diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b54256d446a..770886f8c66 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -5,10 +5,14 @@ Thanks for contributing to Orca. ## Before You Start - Keep changes scoped to a clear user-facing improvement, bug fix, or refactor. -- Orca targets macOS, Linux, and Windows. Avoid platform-specific assumptions in shortcuts, labels, and file paths. +- Orca targets macOS, Linux, and Windows. Every change must stay compatible with all three platforms unless the code is explicitly guarded by a runtime platform check. - For keyboard shortcuts, use runtime platform checks in renderer code and `CmdOrCtrl` in Electron menu accelerators. - For shortcut labels, show `⌘` and `⇧` on macOS, and `Ctrl+` and `Shift+` on Linux and Windows. - For file paths, use Node or Electron path utilities such as `path.join`. +- Orca must work against local repositories, remote servers, and SSH worktrees. Do not assume a process, file, credential, shell, or network path exists only on the local machine. +- Orca supports many CLI agents, integrations, and git providers. Keep generic behavior provider-neutral; guard integration-specific logic behind explicit checks. +- Keep changes well-engineered and performant: follow existing architecture, avoid unnecessary work in hot paths, clean up owned resources, and use concrete module names. +- For UI work, follow [`docs/STYLEGUIDE.md`](../docs/STYLEGUIDE.md), use the tokens and shadcn primitives it specifies, and verify polished behavior across platforms, light/dark mode, and SSH latency. ## Local Setup @@ -58,8 +62,8 @@ Each pull request should: - stay focused on a single topic when possible - include screenshots or screen recordings for new UI or behavior changes - include high-quality tests when behavior changes or bug fixes warrant them -- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, plus a basic security audit summary -- mention any platform-specific behavior or testing notes +- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk +- mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes - **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build). If there is no visual change, say that explicitly in the PR description. diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 33645a3c253..efcdf2b521e 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -31,11 +31,11 @@ jobs: os: [ubuntu-22.04, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: package.json - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: run_install: false - if: runner.os == 'Linux' @@ -60,11 +60,11 @@ jobs: env: ORCA_COMPUTER_E2E: '1' steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: package.json - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: run_install: false - run: pnpm install --frozen-lockfile @@ -80,11 +80,11 @@ jobs: ORCA_COMPUTER_E2E: '1' ACCESSIBILITY_ENABLED: '1' steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: package.json - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: run_install: false - run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool @@ -105,11 +105,11 @@ jobs: env: ORCA_COMPUTER_E2E: '1' steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: package.json - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: run_install: false - run: pnpm install --frozen-lockfile diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b1fcaf1f36f..c07a2cc32e0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ inputs.ref || github.ref }} @@ -37,12 +37,12 @@ jobs: run: sudo apt-get update && sudo apt-get install -y build-essential python3 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false @@ -65,7 +65,7 @@ jobs: run: npx electron-vite build --mode e2e - name: Upload E2E build output - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: e2e-build-out path: out/ @@ -94,7 +94,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ inputs.ref || github.ref }} @@ -111,12 +111,12 @@ jobs: run: sudo apt-get install -y xvfb - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false @@ -136,7 +136,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Download E2E build output - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: e2e-build-out path: out/ @@ -156,7 +156,7 @@ jobs: # re-running locally. - name: Upload Playwright traces if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-traces-${{ matrix.shard_name }} path: test-results/ diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index 28dac408130..db1970ae5ed 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -48,7 +48,7 @@ jobs: contents: read steps: - name: Checkout orca - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Resolve tag and version id: tag @@ -113,7 +113,7 @@ jobs: # installation token can. - name: Generate buf0-bot token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: 2590194 private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }} @@ -121,7 +121,7 @@ jobs: repositories: homebrew-orca - name: Checkout tap - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: stablyai/homebrew-orca path: tap diff --git a/.github/workflows/issue-os-labeler.yaml b/.github/workflows/issue-os-labeler.yaml index d281293318b..c437db7f21a 100644 --- a/.github/workflows/issue-os-labeler.yaml +++ b/.github/workflows/issue-os-labeler.yaml @@ -16,7 +16,7 @@ jobs: timeout-minutes: 5 steps: - name: Apply OS label from issue form - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index 9e7d88666aa..f24bd922ea3 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -23,15 +23,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false @@ -39,7 +39,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Setup JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 @@ -51,7 +51,7 @@ jobs: run: cd android && ./gradlew assembleRelease - name: Upload APK artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: orca-mobile-apk path: mobile/android/app/build/outputs/apk/release/*.apk diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 53b91e5b519..f65ca2f8006 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -20,15 +20,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c2647d83188..1887a494b63 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -14,18 +14,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 + run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false @@ -86,6 +86,11 @@ jobs: - name: Rebuild better-sqlite3 for Node run: pnpm rebuild better-sqlite3 + # Why: install intentionally blocks Electron's package postinstall, but + # some unit tests import `electron` under Node and require path.txt. + - name: Install Electron package binary for tests + run: node config/scripts/install-electron-package-binary.mjs + - name: Test run: pnpm test diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index a3ceb86627f..d115007a09d 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -64,13 +64,13 @@ jobs: should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }} steps: - name: Checkout ref - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }} fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json @@ -388,7 +388,14 @@ jobs: if [[ "$EVENT_NAME" == "schedule" ]]; then commit_message="$commit_message [rc-slot:$SLOT]" fi - git commit -m "$commit_message" + if git diff --cached --quiet; then + # Why: a failed cut can push the version bump to main before the + # release is published. Re-cutting then needs a fresh taggable + # release commit even though package.json is already at VERSION. + git commit --allow-empty -m "$commit_message" + else + git commit -m "$commit_message" + fi git tag -a "v$VERSION" -m "v$VERSION" echo "tag=v$VERSION" >>"$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" @@ -417,7 +424,7 @@ jobs: contents: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: refs/tags/${{ needs.cut.outputs.tag }} @@ -461,19 +468,19 @@ jobs: include: - os: macos-15 platform: mac - release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always eb_cache_path: | ~/Library/Caches/electron ~/Library/Caches/electron-builder - os: windows-latest platform: win - release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always + release_command: 'node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always' eb_cache_path: | ~\AppData\Local\electron\Cache ~\AppData\Local\electron-builder\Cache - os: ubuntu-latest platform: linux - release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always + release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always eb_cache_path: | ~/.cache/electron ~/.cache/electron-builder @@ -485,18 +492,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: refs/tags/${{ needs.cut.outputs.tag }} # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json cache: pnpm @@ -519,7 +526,7 @@ jobs: # Cache the Electron binary + electron-builder tool downloads (notarytool, # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. - name: Cache electron-builder downloads - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ matrix.eb_cache_path }} key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -531,7 +538,7 @@ jobs: # occasionally returns 504s that fail the whole release. Retry on # failure so transient network errors don't require a manual re-run. - name: Install dependencies - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 @@ -622,7 +629,7 @@ jobs: # don't require a manual re-run. - name: Publish release artifacts (macOS) if: matrix.platform == 'mac' - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 45 max_attempts: 3 @@ -638,7 +645,7 @@ jobs: - name: Publish release artifacts if: matrix.platform != 'mac' - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 30 max_attempts: 3 @@ -698,10 +705,10 @@ jobs: contents: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json diff --git a/.github/workflows/track-community-prs.yaml b/.github/workflows/track-community-prs.yaml index dedf48cb432..5a5dcb670c2 100644 --- a/.github/workflows/track-community-prs.yaml +++ b/.github/workflows/track-community-prs.yaml @@ -20,14 +20,14 @@ jobs: steps: - name: Generate bufo-bot token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: 2590194 private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }} owner: stablyai - name: Add PR to project - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: PROJECT_OWNER: stablyai PROJECT_NUMBER: '13' diff --git a/README.md b/README.md index 839e8a9512a..3bedf42646d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Orca supports any CLI agent (*not just this list*). Gemini   Antigravity   Pi   + oh-my-pi   Hermes Agent   OpenCode   Goose   @@ -44,6 +45,7 @@ Orca supports any CLI agent (*not just this list*). Charm   Cline   Codebuff   + Command Code   Continue   Cursor   Droid   @@ -106,7 +108,7 @@ Control your agents from your phone.

- **iOS:** [Download from App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Download from GH release](https://github.com/stablyai/orca/releases/tag/mobile-v0.0.9) +- **Android:** [Download from GH release](https://github.com/stablyai/orca/releases/tag/mobile-v0.0.10) --- diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index e541a5212db..5177a7a7a6e 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -35,7 +35,7 @@ module.exports = { '!resources/onboarding/feature-wall/**' ], // Why: the CLI entry-point lives in out/cli/ but imports shared modules - // from out/shared/ (e.g. runtime-bootstrap). Both directories must be + // from out/shared/ and local hook mutators from out/main/. These paths must be // unpacked so that Node's require() can resolve the cross-directory imports // when the CLI runs outside the asar archive. // Why: daemon-entry.js is forked as a separate Node.js process and must be @@ -54,6 +54,17 @@ module.exports = { asarUnpack: [ 'out/cli/**', 'out/shared/**', + 'out/main/agent-hooks/**', + 'out/main/antigravity/**', + 'out/main/claude/**', + 'out/main/codex/**', + 'out/main/copilot/**', + 'out/main/cursor/**', + 'out/main/droid/**', + 'out/main/gemini/**', + 'out/main/grok/**', + 'out/main/hermes/**', + 'out/main/win32-utils.js', 'out/main/daemon-entry.js', 'out/main/computer-sidecar.js', 'out/main/chunks/**', @@ -61,6 +72,7 @@ module.exports = { 'node_modules/ws/**', 'node_modules/tweetnacl/**', 'node_modules/zod/**', + 'node_modules/yaml/**', 'node_modules/sherpa-onnx*/**' ], afterPack: async (context) => { diff --git a/config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch b/config/patches/@xterm__addon-ligatures@0.11.0-beta.220.patch similarity index 100% rename from config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch rename to config/patches/@xterm__addon-ligatures@0.11.0-beta.220.patch diff --git a/config/patches/@xterm__addon-webgl@0.20.0-beta.215.patch b/config/patches/@xterm__addon-webgl@0.20.0-beta.215.patch deleted file mode 100644 index 146715f0b8d..00000000000 --- a/config/patches/@xterm__addon-webgl@0.20.0-beta.215.patch +++ /dev/null @@ -1,40 +0,0 @@ -diff --git a/lib/addon-webgl.js b/lib/addon-webgl.js -index fb36c5ef32cfdf11a325825354ecd42592318014..5ac18bda75e9205c398c7e3082ca1a1fee123c70 100644 ---- a/lib/addon-webgl.js -+++ b/lib/addon-webgl.js -@@ -1,2 +1,2 @@ --!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.WebglAddon=e():t.WebglAddon=e()}(globalThis,()=>(()=>{"use strict";var t={0(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TEXT_BASELINE=e.DIM_OPACITY=void 0;const s=i(95);e.DIM_OPACITY=.5,e.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},28(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GlyphRenderer=void 0;const s=i(115),r=i(564),a=i(426);i(816);const o=i(719),n=i(959);let L,T=0,h=0,l=0;class d extends a.Disposable{_terminal;_gl;_dimensions;_optionsService;_program;_vertexArrayObject;_projectionLocation;_resolutionLocation;_textureLocation;_atlasTextures;_attributesBuffer;_atlas;_activeBuffer=0;_vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};constructor(t,e,i,r){super(),this._terminal=t,this._gl=e,this._dimensions=i,this._optionsService=r;const L=this._gl;void 0===s.TextureAtlas.maxAtlasPages&&(s.TextureAtlas.maxAtlasPages=Math.min(32,(0,n.throwIfFalsy)(L.getParameter(L.MAX_TEXTURE_IMAGE_UNITS))),s.TextureAtlas.maxTextureSize=(0,n.throwIfFalsy)(L.getParameter(L.MAX_TEXTURE_SIZE))),this._program=(0,n.throwIfFalsy)((0,o.createProgram)(L,"#version 300 es\nlayout (location = 0) in vec2 a_unitquad;\nlayout (location = 1) in vec2 a_cellpos;\nlayout (location = 2) in vec2 a_offset;\nlayout (location = 3) in vec2 a_size;\nlayout (location = 4) in float a_texpage;\nlayout (location = 5) in vec2 a_texcoord;\nlayout (location = 6) in vec2 a_texsize;\n\nuniform mat4 u_projection;\nuniform vec2 u_resolution;\n\nout vec2 v_texcoord;\nflat out int v_texpage;\n\nvoid main() {\n vec2 zeroToOne = (a_offset / u_resolution) + a_cellpos + (a_unitquad * a_size);\n gl_Position = u_projection * vec4(zeroToOne, 0.0, 1.0);\n v_texpage = int(a_texpage);\n v_texcoord = a_texcoord + a_unitquad * a_texsize;\n}",function(t){let e="";for(let i=1;iL.deleteProgram(this._program))),this._projectionLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_resolution")),this._textureLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_texture")),this._vertexArrayObject=L.createVertexArray(),L.bindVertexArray(this._vertexArrayObject);const T=new Float32Array([0,0,1,0,0,1,1,1]),h=L.createBuffer();this._register((0,a.toDisposable)(()=>L.deleteBuffer(h))),L.bindBuffer(L.ARRAY_BUFFER,h),L.bufferData(L.ARRAY_BUFFER,T,L.STATIC_DRAW),L.enableVertexAttribArray(0),L.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);const l=new Uint8Array([0,1,2,3]),d=L.createBuffer();this._register((0,a.toDisposable)(()=>L.deleteBuffer(d))),L.bindBuffer(L.ELEMENT_ARRAY_BUFFER,d),L.bufferData(L.ELEMENT_ARRAY_BUFFER,l,L.STATIC_DRAW),this._attributesBuffer=(0,n.throwIfFalsy)(L.createBuffer()),this._register((0,a.toDisposable)(()=>L.deleteBuffer(this._attributesBuffer))),L.bindBuffer(L.ARRAY_BUFFER,this._attributesBuffer),L.enableVertexAttribArray(2),L.vertexAttribPointer(2,2,L.FLOAT,!1,44,0),L.vertexAttribDivisor(2,1),L.enableVertexAttribArray(3),L.vertexAttribPointer(3,2,L.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(3,1),L.enableVertexAttribArray(4),L.vertexAttribPointer(4,1,L.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(4,1),L.enableVertexAttribArray(5),L.vertexAttribPointer(5,2,L.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(5,1),L.enableVertexAttribArray(6),L.vertexAttribPointer(6,2,L.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(6,1),L.enableVertexAttribArray(1),L.vertexAttribPointer(1,2,L.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(1,1),L.useProgram(this._program);const c=new Int32Array(s.TextureAtlas.maxAtlasPages);for(let t=0;tL.deleteTexture(e.texture))),L.activeTexture(L.TEXTURE0+t),L.bindTexture(L.TEXTURE_2D,e.texture),L.texParameteri(L.TEXTURE_2D,L.TEXTURE_WRAP_S,L.CLAMP_TO_EDGE),L.texParameteri(L.TEXTURE_2D,L.TEXTURE_WRAP_T,L.CLAMP_TO_EDGE),L.texImage2D(L.TEXTURE_2D,0,L.RGBA,1,1,0,L.RGBA,L.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[t]=e}L.enable(L.BLEND),L.blendFunc(L.SRC_ALPHA,L.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return!this._atlas||this._atlas.beginFrame()}updateCell(t,e,i,s,r,a,o,n,L){this._updateCell(this._vertices.attributes,t,e,i,s,r,a,o,n,L)}_updateCell(t,e,i,s,a,o,d,c,_,Q){T=11*(i*this._terminal.cols+e),s!==r.NULL_CELL_CODE&&void 0!==s?this._atlas&&(L=c&&c.length>1?this._atlas.getRasterizedGlyphCombinedChar(c,a,o,d,!1,this._terminal.element):this._atlas.getRasterizedGlyph(s,a,o,d,!1,this._terminal.element),h=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),a!==Q&&L.offset.x>h?(l=L.offset.x-h,t[T]=-(L.offset.x-l)+this._dimensions.device.char.left,t[T+1]=-L.offset.y+this._dimensions.device.char.top,t[T+2]=(L.size.x-l)/this._dimensions.device.canvas.width,t[T+3]=L.size.y/this._dimensions.device.canvas.height,t[T+4]=L.texturePage,t[T+5]=L.texturePositionClipSpace.x+l/this._atlas.pages[L.texturePage].canvas.width,t[T+6]=L.texturePositionClipSpace.y,t[T+7]=L.sizeClipSpace.x-l/this._atlas.pages[L.texturePage].canvas.width,t[T+8]=L.sizeClipSpace.y):(t[T]=-L.offset.x+this._dimensions.device.char.left,t[T+1]=-L.offset.y+this._dimensions.device.char.top,t[T+2]=L.size.x/this._dimensions.device.canvas.width,t[T+3]=L.size.y/this._dimensions.device.canvas.height,t[T+4]=L.texturePage,t[T+5]=L.texturePositionClipSpace.x,t[T+6]=L.texturePositionClipSpace.y,t[T+7]=L.sizeClipSpace.x,t[T+8]=L.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&(0,n.allowRescaling)(s,_,L.size.x,this._dimensions.device.cell.width)&&(t[T+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width)):t.fill(0,T,T+11-1-2)}clear(){const t=this._terminal,e=t.cols*t.rows*11;this._vertices.count!==e?this._vertices.attributes=new Float32Array(e):this._vertices.attributes.fill(0);let i=0;for(;i=0,e.isChromeOS=/\bCrOS\b/.test(i)},105(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.WindowIntervalTimer=e.eventType=void 0,e.getWindow=r,e.addDisposableListener=o,e.addStandardDisposableListener=function(t,e,i,s){return o(t,e,i,s)},e.getDomNodePagePosition=function(t){const e=t.getBoundingClientRect(),i=r(t);return{left:e.left+i.scrollX,top:e.top+i.scrollY,width:e.width,height:e.height}},e.scheduleAtNextAnimationFrame=function(t,e,i=0){const s=T(t),r=new n(e,i);return s.next.push(r),s.animFrameRequested||(s.animFrameRequested=!0,t.requestAnimationFrame(()=>function(t){const e=T(t);for(e.animFrameRequested=!1,e.current=e.next,e.next=[],e.inAnimationFrameRunner=!0;e.current.length>0;)e.current.sort(n.sort),e.current.shift().execute();e.inAnimationFrameRunner=!1}(t))),r};const s=i(578);function r(t){const e=t;if(e?.ownerDocument?.defaultView)return e.ownerDocument.defaultView;const i=t;return i?.view?i.view:window}class a{_handler;_node;_type;_options;constructor(t,e,i,s){this._node=t,this._type=e,this._handler=i,this._options=s,t.addEventListener(e,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function o(t,e,i,s){return new a(t,e,i,s)}e.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class n{_runner;priority;_canceled=!1;constructor(t,e){this._runner=t,this.priority=e}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(t){console.error(t)}}static sort(t,e){return e.priority-t.priority}}const L=new Map;function T(t){let e=L.get(t);return e||(e={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},L.set(t,e)),e}class h extends s.IntervalTimer{_defaultTarget;constructor(t){super(),this._defaultTarget=t?r(t):void 0}cancelAndSet(t,e,i){super.cancelAndSet(t,e,i??this._defaultTarget??window)}}e.WindowIntervalTimer=h},115(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextureAtlas=void 0;const s=i(0),r=i(183),a=i(959),o=i(993),n=i(836),L=i(930),T=i(917),h=i(564),l=i(414),d={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}};let c;class _{_document;_config;_unicodeService;_logService;_didWarmUp=!1;_cacheMap=new n.FourKeyMap;_cacheMapCombined=new n.FourKeyMap;_pages=[];get pages(){return this._pages}_activePages=[];_overflowSizePage;_tmpCanvas;_tmpCtx;_workBoundingBox={top:0,left:0,bottom:0,right:0};_workAttributeData=new T.AttributeData;_textureSize=512;static maxAtlasPages;static maxTextureSize;_onAddTextureAtlasCanvas=new l.Emitter;onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=new l.Emitter;onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;constructor(t,e,i,s){this._document=t,this._config=e,this._unicodeService=i,this._logService=s,this._createNewPage(),this._tmpCanvas=p(t,4*this._config.deviceCellWidth+4,this._config.deviceCellHeight+4),this._tmpCtx=(0,a.throwIfFalsy)(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:!0}))}dispose(){this._tmpCanvas.remove();for(const t of this.pages)t.canvas.remove();this._onAddTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)}_doWarmUp(){const t=new L.IdleTaskQueue(this._logService);for(let e=33;e<126;e++)t.enqueue(()=>{if(!this._cacheMap.get(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT)){const t=this._drawToCache(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT,!1,void 0);this._cacheMap.set(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT,t)}})}_requestClearModel=!1;beginFrame(){return this._requestClearModel}clearTexture(){if(0!==this._pages[0].currentRow.x||0!==this._pages[0].currentRow.y){for(const t of this._pages)t.clear();this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1}}_createNewPage(){if(_.maxAtlasPages&&this._pages.length>=Math.max(4,_.maxAtlasPages)){const t=this._pages.filter(t=>2*t.canvas.width<=(_.maxTextureSize||4096)).sort((t,e)=>e.canvas.width!==t.canvas.width?e.canvas.width-t.canvas.width:e.percentageUsed-t.percentageUsed);let e=-1,i=0;for(let s=0;st.canvas.width!==s[0].canvas.width)){const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}const r=s.map(t=>t.glyphs[0].texturePage).sort((t,e)=>t>e?1:-1),a=this.pages.length-s.length,o=this._mergePages(s,a);o.version++;for(let t=r.length-1;t>=0;t--)this._deletePage(r[t]);this.pages.push(o),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(o.canvas)}const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}_mergePages(t,e){const i=2*t[0].canvas.width,s=new Q(this._document,i,t);for(const[r,a]of t.entries()){const t=r*a.canvas.width%i,o=Math.floor(r/2)*a.canvas.height;s.ctx.drawImage(a.canvas,t,o);for(const s of a.glyphs)s.texturePage=e,s.sizeClipSpace.x=s.size.x/i,s.sizeClipSpace.y=s.size.y/i,s.texturePosition.x+=t,s.texturePosition.y+=o,s.texturePositionClipSpace.x=s.texturePosition.x/i,s.texturePositionClipSpace.y=s.texturePosition.y/i;this._onRemoveTextureAtlasCanvas.fire(a.canvas);const n=this._activePages.indexOf(a);-1!==n&&this._activePages.splice(n,1)}return s}_deletePage(t){this._pages.splice(t,1);for(let e=t;e=this._config.colors.ansi.length)throw new Error("No color found for idx "+t);return this._config.colors.ansi[t]}_getBackgroundColor(t,e,i,s){if(this._config.allowTransparency)return o.NULL_COLOR;let r;switch(t){case 16777216:case 33554432:r=this._getColorFromAnsiIndex(e);break;case 50331648:const t=T.AttributeData.toColorRGB(e);r=o.channels.toColor(t[0],t[1],t[2]);break;default:r=i?o.color.opaque(this._config.colors.foreground):this._config.colors.background}return this._config.allowTransparency||(r=o.color.opaque(r)),r}_getForegroundColor(t,e,i,r,a,n,L,h,l,d){const c=this._getMinimumContrastColor(t,e,i,r,a,n,L,l,h,d);if(c)return c;let _;switch(a){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&l&&n<8&&(n+=8),_=this._getColorFromAnsiIndex(n);break;case 50331648:const t=T.AttributeData.toColorRGB(n);_=o.channels.toColor(t[0],t[1],t[2]);break;default:_=L?this._config.colors.background:this._config.colors.foreground}return this._config.allowTransparency&&(_=o.color.opaque(_)),h&&(_=o.color.multiplyOpacity(_,s.DIM_OPACITY)),_}_resolveBackgroundRgba(t,e,i){switch(t){case 16777216:case 33554432:return this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(t,e,i,s){switch(t){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&e<8&&(e+=8),this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(t,e,i,s,r,a,n,L,T,h){if(1===this._config.minimumContrastRatio||h)return;const l=this._getContrastCache(T),d=l.getColor(t,s);if(void 0!==d)return d??void 0;const c=this._resolveBackgroundRgba(e,i,n),_=this._resolveForegroundRgba(r,a,n,L),Q=o.rgba.ensureContrastRatio(c,_,this._config.minimumContrastRatio/(T?2:1));if(!Q)return void l.setColor(t,s,null);const u=o.channels.toColor(Q>>24&255,Q>>16&255,Q>>8&255);return l.setColor(t,s,u),u}_getContrastCache(t){return t?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(t,e,i,o,n,L){const h="number"==typeof t?String.fromCharCode(t):t;L&&this._tmpCanvas.parentElement!==L&&(this._tmpCanvas.style.display="none",L.append(this._tmpCanvas));const l=Math.min(this._config.deviceCellWidth*Math.max(h.length,2)+4,this._config.deviceMaxTextureSize);this._tmpCanvas.width=t?2*t-o:t-o;!1==!(o>=t)||0===_?(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(T+_,s),this._tmpCtx.lineTo(h,s)):(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(T+_,s),this._tmpCtx.moveTo(T+_+t,s),this._tmpCtx.lineTo(h,s)),o=(0,a.computeNextVariantOffset)(h-T,t,o);break;case 5:const Q=.6,u=.3,p=h-T,g=Math.floor(Q*p),M=Math.floor(u*p),y=p-g-M;this._tmpCtx.setLineDash([g,M,y]),this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(h,s);break;default:this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(h,s)}n||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!O&&this._config.fontSize>=12&&!this._config.allowTransparency&&" "!==h){this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";const e=this._tmpCtx.measureText(h);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in e&&e.actualBoundingBoxDescent>0){this._tmpCtx.save();const e=new Path2D;e.rect(i,L-Math.ceil(t/2),this._config.deviceCellWidth*I,l-L+Math.ceil(t/2)),this._tmpCtx.clip(e),this._tmpCtx.lineWidth=3*this._config.devicePixelRatio,this._tmpCtx.strokeStyle=E.css,this._tmpCtx.strokeText(h,D,D+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(C){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),e=t%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(D,D+e),this._tmpCtx.lineTo(D+this._config.deviceCharWidth*I,D+e),this._tmpCtx.stroke()}if(O||this._tmpCtx.fillText(h,D,D+this._config.deviceCharHeight),"_"===h&&!this._config.allowTransparency){let t=u(this._tmpCtx.getImageData(D,D,this._config.deviceCellWidth,this._config.deviceCellHeight),E,S,W);if(t)for(let e=1;e<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=E.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(h,D,D+this._config.deviceCharHeight-e),t=u(this._tmpCtx.getImageData(D,D,this._config.deviceCellWidth,this._config.deviceCellHeight),E,S,W),t);e++);}if(f){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),e=this._tmpCtx.lineWidth%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(D,D+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.lineTo(D+this._config.deviceCharWidth*I,D+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.stroke()}this._tmpCtx.restore();const N=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height);let P;if(P=this._config.allowTransparency?function(t){for(let e=0;e0)return!1;return!0}(N):u(N,E,S,W),P)return d;const B=this._findGlyphBoundingBox(N,this._workBoundingBox,l,Z,O,D);let $,F;for(;;){if(0===this._activePages.length){const t=this._createNewPage();$=t,F=t.currentRow,F.height=B.size.y;break}$=this._activePages[this._activePages.length-1],F=$.currentRow;for(const t of this._activePages)B.size.y<=t.currentRow.height&&($=t,F=t.currentRow);for(let t=this._activePages.length-1;t>=0;t--)for(const e of this._activePages[t].fixedRows)e.height<=F.height&&B.size.y<=e.height&&($=this._activePages[t],F=e);if(B.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new Q(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),$=this._overflowSizePage,F=this._overflowSizePage.currentRow,F.x+B.size.x>=$.canvas.width&&(F.x=0,F.y+=F.height,F.height=0);break}if(F.y+B.size.y>=$.canvas.height||F.height>B.size.y+2){let t=!1;if($.currentRow.y+$.currentRow.height+B.size.y>=$.canvas.height){let e;for(const t of this._activePages)if(t.currentRow.y+t.currentRow.height+B.size.y=_.maxAtlasPages&&F.y+B.size.y<=$.canvas.height&&F.height>=B.size.y&&F.x+B.size.x<=$.canvas.width)t=!0;else{const e=this._createNewPage();$=e,F=e.currentRow,F.height=B.size.y,t=!0}}t||($.currentRow.height>0&&$.fixedRows.push($.currentRow),F={x:0,y:$.currentRow.y+$.currentRow.height,height:B.size.y},$.fixedRows.push(F),$.currentRow={x:0,y:F.y+F.height,height:0})}if(F.x+B.size.x<=$.canvas.width)break;F===$.currentRow?(F.x=0,F.y+=F.height,F.height=0):$.fixedRows.splice($.fixedRows.indexOf(F),1)}return B.texturePage=this._pages.indexOf($),B.texturePosition.x=F.x,B.texturePosition.y=F.y,B.texturePositionClipSpace.x=F.x/$.canvas.width,B.texturePositionClipSpace.y=F.y/$.canvas.height,B.sizeClipSpace.x/=$.canvas.width,B.sizeClipSpace.y/=$.canvas.height,F.height=Math.max(F.height,B.size.y),F.x+=B.size.x,$.ctx.putImageData(N,B.texturePosition.x-this._workBoundingBox.left,B.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,B.size.x,B.size.y),$.addGlyph(B),$.version++,B}_findGlyphBoundingBox(t,e,i,s,r,a){e.top=0;const o=s?this._config.deviceCellHeight:this._tmpCanvas.height,n=s?this._config.deviceCellWidth:i;let L=!1;for(let i=0;i=a;i--){for(let s=0;s=0;i--){for(let s=0;s>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=i.rgba>>>24,L=i.rgba>>>16&255,T=i.rgba>>>8&255,h=Math.floor((Math.abs(r-n)+Math.abs(a-L)+Math.abs(o-T))/12);let l=!0;for(let e=0;e{this._refreshCharAtlas(t,e),this.reset(t)})),this._register((0,r.toDisposable)(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=(0,a.throwIfFalsy)(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,e,i){}handleSelectionChanged(t,e,i,s=!1){}_setTransparency(t,e){if(e===this._alpha)return;const i=this._canvas;this._alpha=e,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,i),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,e){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=(0,s.acquireTextureAtlas)(t,this._optionsService.rawOptions,e,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,e){this._deviceCellWidth=e.device.cell.width,this._deviceCellHeight=e.device.cell.height,this._deviceCharWidth=e.device.char.width,this._deviceCharHeight=e.device.char.height,this._deviceCharLeft=e.device.char.left,this._deviceCharTop=e.device.char.top,this._canvas.width=e.device.canvas.width,this._canvas.height=e.device.canvas.height,this._canvas.style.width=`${e.css.canvas.width}px`,this._canvas.style.height=`${e.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,e,i=1){this._ctx.fillRect(t*this._deviceCellWidth,(e+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,i*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,e,i,s){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight))}_fillCharTrueColor(t,e,i,s){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=o.TEXT_BASELINE,this._clipCell(i,s,e.getWidth()),this._ctx.fillText(e.getChars(),i*this._deviceCellWidth+this._deviceCharLeft,s*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,e,i){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,e,i){return`${i?"italic":""} ${e?t.options.fontWeightBold:t.options.fontWeight} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}}e.BaseRenderLayer=n},183(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.tryDrawCustomGlyph=function(t,e,i,s,o,n,L,T,h,l,d,c=0){const _=r.customGlyphDefinitions[e];if(_){const e=Array.isArray(_)?_:[_];for(const r of e)a(t,r,i,s,o,n,L,T,h,l,d,c);return!0}return!1};const s=i(959),r=i(915);function a(t,e,i,r,a,T,d,c,_,Q,u,p=0){let g=a,M=T,y=i,v=r;switch(1===e.scaleType&&(g=d,M=c,y=i+(a-d)/2,v=r+(T-c)/2),e.clipPath&&(t.save(),function(t,e,i,s,r,a){t.beginPath();for(const o of e.split(" ")){const e=o[0];if("Z"===e){t.closePath();continue}const n=o.substring(1).split(",");if(!n[0]||!n[1])continue;const L=i+parseFloat(n[0])*r,T=s+parseFloat(n[1])*a;"M"===e?t.moveTo(L,T):"L"===e&&t.lineTo(L,T)}t.clip()}(t,e.clipPath,y,v,g,M)),e.type){case 0:!function(t,e,i,s,r,a){for(let o=0;o7&&parseInt(h.slice(7,9),16)||1;else{if(!h.startsWith("rgba"))throw new Error(`Unexpected fillStyle color format "${h}" when drawing pattern glyph`);[L,d,c,_]=h.substring(5,h.length-1).split(",").map(t=>parseFloat(t))}for(let t=0;t>1&1,c=1&n;0!==d||0!==c?l.setTransform((new DOMMatrix).translateSelf(-d,-c)):l.setTransform(new DOMMatrix),t.fillStyle=l,t.fillRect(i,r,a,o)}(t,e.data,y,v,g,M,p);break;case 2:!function(t,e,i,s,r,a,o,n){let L;t.save(),t.beginPath(),t.rect(i,s,r,a),t.clip(),t.beginPath(),L="function"==typeof e?e(.15,.15/a*r):e;const T={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const e of L.split(" ")){const n=e[0];if("Z"===n){t.closePath(),T.lastCommand=n;continue}const L=h[n];if(!L){console.error(`Could not find drawing instructions for "${n}"`);continue}const d=e.substring(1).split(",");d[0]&&d[1]&&(L(t,l(d,r,a,i,s,!0,o,0,0,!1),T),T.lastCommand=n)}void 0!==n?(t.strokeStyle=t.fillStyle,t.lineWidth=o*n,t.stroke()):t.fill(),t.closePath(),t.restore()}(t,e.data,y,v,g,M,Q,e.strokeWidth);break;case 3:!function(t,e,i,s,r,a,o,L){const T="string"==typeof e?e:e(0,0);t.beginPath();let h=0,l=0,d=0,c=0,_="";for(const e of T.split(" ")){const o=e[0],L=e.substring(1).split(",");if("Z"===o){t.closePath(),_=o;continue}if("V"===o){const e=s+parseFloat(L[0])*a;t.lineTo(h,e),l=e,d=h,c=l,_=o;continue}if("H"===o){const e=i+parseFloat(L[0])*r;t.lineTo(e,l),h=e,d=h,c=l,_=o;continue}if(!L[0]||!L[1])continue;if("A"===o){const e=parseFloat(L[0])*r,o=parseFloat(L[1])*a,T=parseFloat(L[2])*Math.PI/180,d=parseInt(L[3]),c=parseInt(L[4]),_=i+parseFloat(L[5])*r,Q=s+parseFloat(L[6])*a;n(t,h,l,e,o,T,d,c,_,Q),h=_,l=Q;continue}const T=L.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});if("M"===o)t.moveTo(T[0],T[1]),h=T[0],l=T[1],d=h,c=l;else if("L"===o)t.lineTo(T[0],T[1]),h=T[0],l=T[1],d=h,c=l;else if("Q"===o)t.quadraticCurveTo(T[0],T[1],T[2],T[3]),d=T[0],c=T[1],h=T[2],l=T[3];else if("T"===o){let e,i;"Q"===_||"T"===_?(e=2*h-d,i=2*l-c):(e=h,i=l),t.quadraticCurveTo(e,i,T[0],T[1]),d=e,c=i,h=T[0],l=T[1]}else"C"===o&&(t.bezierCurveTo(T[0],T[1],T[2],T[3],T[4],T[5]),d=T[2],c=T[3],h=T[4],l=T[5]);_=o}void 0!==L?(t.strokeStyle=t.fillStyle,t.lineWidth=o*L,t.stroke()):t.fill()}(t,e.data,y,v,g,M,Q,e.strokeWidth);break;case 4:!function(t,e,i,s,r,a,o,n){t.save(),t.fillRect(i,s,r,a),n&&(t.fillStyle=n,t.strokeStyle=n),t.lineWidth=o,t.lineCap="square",t.beginPath();for(const o of e.d.split(" ")){const e=o[0],n=o.substring(1).split(",");if(!n[0]||!n[1]){"Z"===e&&t.closePath();continue}const L=n.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});"M"===e?t.moveTo(L[0],L[1]):"L"===e&&t.lineTo(L[0],L[1])}1===e.type?t.stroke():t.fill(),t.restore()}(t,e.data,y,v,g,M,Q,u);break;case 5:!function(t,e,i,s,r,a,o,n){const L=new Path2D;L.rect(i,s,r,a),t.clip(L),t.beginPath();const T=o/12;t.lineWidth=n*T;const d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const o of e.d.split(" ")){const L=o[0];if("Z"===L){t.closePath(),d.lastCommand=L;continue}const c=h[L];if(!c){console.error(`Could not find drawing instructions for "${L}"`);continue}const _=o.substring(1).split(",");_[0]&&_[1]&&(c(t,l(_,r,a,i,s,!1,n,(e.leftPadding??0)*(T/2),(e.rightPadding??0)*(T/2)),d),d.lastCommand=L)}1===e.type?(t.strokeStyle=t.fillStyle,t.stroke()):t.fill(),t.closePath()}(t,e.data,y,v,g,M,_,Q);break;case 6:!function(t,e,i,s,r,a){const n=r/8,L=.1*a,T=.8*a/8,h=Math.min(n,T);for(let r=0;r<8;r++)if(e&1<1){const t=Math.sqrt(y);u=(s*=t)*s,p=(r*=t)*r}let v=(u*p-u*M-p*g)/(u*M+p*g);v<0&&(v=0);const f=(o===n?-1:1)*Math.sqrt(v),C=f*(s*Q/r),m=f*(-r*_/s),w=h*C-l*m+(e+L)/2,x=l*C+h*m+(i+T)/2,b=(_-C)/s,E=(Q-m)/r,R=(-_-C)/s,k=(-Q-m)/r,A=Math.atan2(E,b);let Z=Math.atan2(k,R)-A;0===n&&Z>0?Z-=2*Math.PI:1===n&&Z<0&&(Z+=2*Math.PI);const S=A+Z;t.ellipse(w,x,s,r,a,A,S,0===n)}const L=new Map;function T(t,e,i=0){return Math.max(Math.min(t,e),i)}const h={C:(t,e,i)=>{t.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),i.lastControlX=e[2],i.lastControlY=e[3],i.currentX=e[4],i.currentY=e[5]},L:(t,e,i)=>{t.lineTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},M:(t,e,i)=>{t.moveTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},Q:(t,e,i)=>{t.quadraticCurveTo(e[0],e[1],e[2],e[3]),i.lastControlX=e[0],i.lastControlY=e[1],i.currentX=e[2],i.currentY=e[3]},T:(t,e,i)=>{let s,r;"Q"===i.lastCommand||"T"===i.lastCommand?(s=2*i.currentX-i.lastControlX,r=2*i.currentY-i.lastControlY):(s=i.currentX,r=i.currentY),t.quadraticCurveTo(s,r,e[0],e[1]),i.lastControlX=s,i.lastControlY=r,i.currentX=e[0],i.currentY=e[1]}};function l(t,e,i,s,r,a,o,n=0,L=0,h=!0){const l=t.map(t=>parseFloat(t)||parseInt(t));if(l.length<2)throw new Error("Too few arguments for instruction");for(let t=0;tL.deleteProgram(this._program))),this._projectionLocation=(0,a.throwIfFalsy)(L.getUniformLocation(this._program,"u_projection")),this._vertexArrayObject=L.createVertexArray(),L.bindVertexArray(this._vertexArrayObject);const T=new Float32Array([0,0,1,0,0,1,1,1]),h=L.createBuffer();this._register((0,s.toDisposable)(()=>L.deleteBuffer(h))),L.bindBuffer(L.ARRAY_BUFFER,h),L.bufferData(L.ARRAY_BUFFER,T,L.STATIC_DRAW),L.enableVertexAttribArray(3),L.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);const l=new Uint8Array([0,1,2,3]),d=L.createBuffer();this._register((0,s.toDisposable)(()=>L.deleteBuffer(d))),L.bindBuffer(L.ELEMENT_ARRAY_BUFFER,d),L.bufferData(L.ELEMENT_ARRAY_BUFFER,l,L.STATIC_DRAW),this._attributesBuffer=(0,a.throwIfFalsy)(L.createBuffer()),this._register((0,s.toDisposable)(()=>L.deleteBuffer(this._attributesBuffer))),L.bindBuffer(L.ARRAY_BUFFER,this._attributesBuffer),L.enableVertexAttribArray(0),L.vertexAttribPointer(0,2,L.FLOAT,!1,o,0),L.vertexAttribDivisor(0,1),L.enableVertexAttribArray(1),L.vertexAttribPointer(1,2,L.FLOAT,!1,o,2*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(1,1),L.enableVertexAttribArray(2),L.vertexAttribPointer(2,4,L.FLOAT,!1,o,4*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(2,1),this._updateCachedColors(n.colors),this._register(this._themeService.onChangeColors(t=>{this._updateCachedColors(t),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){const e=this._gl;e.useProgram(this._program),e.bindVertexArray(this._vertexArrayObject),e.uniformMatrix4fv(this._projectionLocation,!1,r.PROJECTION_MATRIX),e.bindBuffer(e.ARRAY_BUFFER,this._attributesBuffer),e.bufferData(e.ARRAY_BUFFER,t.attributes,e.DYNAMIC_DRAW),e.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,e.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){const e=this._terminal,i=this._vertices;let s,r,a,o,n,L,T,h,l,d,c,_=1;for(s=0;s>24&255)/255,d=(L>>16&255)/255,c=(L>>8&255)/255,_=1,this._addRectangle(t.attributes,e,T,h,(o-a)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,l,d,c,_)}_addRectangle(t,e,i,s,r,a,o,n,L,T){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o,t[e+5]=n,t[e+6]=L,t[e+7]=T}_addRectangleFloat(t,e,i,s,r,a,o){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o[0],t[e+5]=o[1],t[e+6]=o[2],t[e+7]=o[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(255&t.rgba)/255])}}e.RectangleRenderer=Q},208(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.RenderModel=e.COMBINED_CHAR_BIT_MASK=void 0;const s=i(948);e.COMBINED_CHAR_BIT_MASK=2147483648,e.RenderModel=class{cells;lineLengths;selection;cursor;constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=(0,s.createSelectionRenderModel)()}resize(t,e){const i=t*e*4;i!==this.cells.length&&(this.cells=new Uint32Array(i),this.lineLengths=new Uint32Array(e))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}}},215(t,e){Object.defineProperty(e,"__esModule",{value:!0})},267(t,e){Object.defineProperty(e,"__esModule",{value:!0})},306(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.LinkRenderLayer=void 0;const s=i(657),r=i(814),a=i(133);class o extends a.BaseRenderLayer{_state;constructor(t,e,i,s,r,a,o){super(i,t,"link",e,!0,r,a,o),this._register(s.onShowLinkUnderline(t=>this._handleShowLinkUnderline(t))),this._register(s.onHideLinkUnderline(t=>this._handleHideLinkUnderline(t)))}resize(t,e){super.resize(t,e),this._state=void 0}reset(t){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);const t=this._state.y2-this._state.y1-1;t>0&&this._clearCells(0,this._state.y1+1,this._state.cols,t),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(t){if(t.fg===r.INVERTED_DEFAULT_COLOR?this._ctx.fillStyle=this._themeService.colors.background.css:void 0!==t.fg&&(0,s.is256Color)(t.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[t.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,t.y1===t.y2)this._fillBottomLineAtCells(t.x1,t.y1,t.x2-t.x1);else{this._fillBottomLineAtCells(t.x1,t.y1,t.cols-t.x1);for(let e=t.y1+1;e65535?(t-=65536,String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):String.fromCharCode(t)},e.utf32ToString=function(t,e=0,i=t.length){let s="";for(let r=e;r65535?(e-=65536,s+=String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):s+=String.fromCharCode(e)}return s},e.StringToUtf32=class{_interim=0;clear(){this._interim=0}decode(t,e){const i=t.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=t.charCodeAt(r++);56320<=i&&i<=57343?e[s++]=1024*(this._interim-55296)+i-56320+65536:(e[s++]=this._interim,e[s++]=i),this._interim=0}for(let a=r;a=i)return this._interim=r,s;const o=t.charCodeAt(a);56320<=o&&o<=57343?e[s++]=1024*(r-55296)+o-56320+65536:(e[s++]=r,e[s++]=o);continue}65279!==r&&(e[s++]=r)}return s}},e.Utf8ToUtf32=class{interim=new Uint8Array(3);clear(){this.interim.fill(0)}decode(t,e){const i=t.length;if(!i)return 0;let s,r,a,o,n=0,L=0,T=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let a,o=0;for(;(a=63&this.interim[++o])&&o<4;)r<<=6,r|=a;const L=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,h=L-o;for(;T=i)return 0;if(a=t[T++],128!=(192&a)){T--,s=!0;break}this.interim[o++]=a,r<<=6,r|=63&a}s||(2===L?r<128?T--:e[n++]=r:3===L?r<2048||r>=55296&&r<=57343||65279===r||(e[n++]=r):r<65536||r>1114111||(e[n++]=r)),this.interim.fill(0)}const h=i-4;let l=T;for(;l=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(L=(31&s)<<6|63&r,L<128){l--;continue}e[n++]=L}else if(224==(240&s)){if(l>=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,n;if(a=t[l++],128!=(192&a)){l--;continue}if(L=(15&s)<<12|(63&r)<<6|63&a,L<2048||L>=55296&&L<=57343||65279===L)continue;e[n++]=L}else if(240==(248&s)){if(l>=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,n;if(a=t[l++],128!=(192&a)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=a,n;if(o=t[l++],128!=(192&o)){l--;continue}if(L=(7&s)<<18|(63&r)<<12|(63&a)<<6|63&o,L<65536||L>1114111)continue;e[n++]=L}}return n}}},399(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.JoinedCellData=e.WebglRenderer=void 0;const s=i(864),r=i(670),a=i(773),o=i(697),n=i(917),L=i(721),T=i(564),h=i(536),l=i(28),d=i(203),c=i(208);i(816);const _=i(306),Q=i(414),u=i(105),p=i(426),g=i(959);class M extends p.Disposable{_terminal;_characterJoinerService;_charSizeService;_coreBrowserService;_coreService;_decorationService;_optionsService;_themeService;_customGlyphs;_renderLayers;_cursorBlinkStateManager=this._register(new p.MutableDisposable);_textBlinkStateManager;_charAtlasDisposable=this._register(new p.MutableDisposable);_charAtlas;_devicePixelRatio;_deviceMaxTextureSize;_observerDisposable=this._register(new p.MutableDisposable);_model=new c.RenderModel;_rowHasBlinkingCells=[];_rowHasBlinkingCellsCount=0;_workCell=new L.CellData;_cellColorResolver;_canvas;_gl;_rectangleRenderer=this._register(new p.MutableDisposable);_glyphRenderer=this._register(new p.MutableDisposable);dimensions;_core;_isAttached;_contextRestorationTimeout;_onChangeTextureAtlas=this._register(new Q.Emitter);onChangeTextureAtlas=this._onChangeTextureAtlas.event;_onAddTextureAtlasCanvas=this._register(new Q.Emitter);onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=this._register(new Q.Emitter);onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;_onRequestRedraw=this._register(new Q.Emitter);onRequestRedraw=this._onRequestRedraw.event;_onContextLoss=this._register(new Q.Emitter);onContextLoss=this._onContextLoss.event;constructor(t,e,i,a,n,L,T,l,d=!0,c){super(),this._terminal=t,this._characterJoinerService=e,this._charSizeService=i,this._coreBrowserService=a,this._coreService=n,this._decorationService=L,this._optionsService=T,this._themeService=l,this._customGlyphs=d,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas");const Q={antialias:!1,depth:!1,preserveDrawingBuffer:c};if(this._gl=this._canvas.getContext("webgl2",Q),!this._gl)throw new Error("WebGL2 not supported "+this._gl);this._register(this._themeService.onChangeColors(()=>this._handleColorChange())),this._cellColorResolver=new s.CellColorResolver(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new _.LinkRenderLayer(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,T,this._themeService)],this.dimensions=(0,g.createRenderDimensions)(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(T.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new h.TextBlinkStateManager(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextlost",t=>{console.log("webglcontextlost event received"),t.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,console.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(t)},3e3)})),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextrestored",t=>{console.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,(0,r.removeTerminalFromCache)(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,this._coreBrowserService.window,(t,e)=>this._setCanvasDevicePixelDimensions(t,e)),this._register(this._coreBrowserService.onWindowChange(t=>{this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,t,(t,e)=>this._setCanvasDevicePixelDimensions(t,e))})),this._register((0,u.addDisposableListener)(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register((0,p.toDisposable)(()=>{for(const t of this._renderLayers)t.dispose();this._canvas.parentElement?.removeChild(this._canvas),(0,r.removeTerminalFromCache)(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,e){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(const t of this._renderLayers)t.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(const t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(const t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,e,i){for(const s of this._renderLayers)s.handleSelectionChanged(this._terminal,t,e,i);this._model.selection.update(this._core,t,e,i),this._requestRedrawViewport()}handleCursorMove(){for(const t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new d.RectangleRenderer(this._terminal,this._gl,this.dimensions,this._themeService),this._glyphRenderer.value=new l.GlyphRenderer(this._terminal,this._gl,this.dimensions,this._optionsService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0)return void(this._isAttached=!1);const t=(0,r.acquireTextureAtlas)(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=(0,p.combinedDisposable)(Q.EventUtils.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),Q.EventUtils.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(const t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,e){if(!this._isAttached){if(!(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height))return;this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0}for(const i of this._renderLayers)i.handleGridChanged(this._terminal,t,e);this._glyphRenderer.value&&this._rectangleRenderer.value&&(this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,e),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),this._cursorBlinkStateManager.value&&!this._cursorBlinkStateManager.value.isCursorVisible||this._rectangleRenderer.value.renderCursor())}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new a.CursorBlinkStateManager(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,e){const i=this._core;let s,r,a,o,n,L,h,l,d,_,Q,u,p,g,M,f=this._workCell,C=0,m=!0;t=v(t,i.rows-1,0),e=v(e,i.rows-1,0);const w=this._coreService.decPrivateModes.cursorStyle??i.options.cursorStyle??"block",x=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,b=x-i.buffer.ydisp,E=Math.min(this._terminal.buffer.active.cursorX,i.cols-1);let R=-1;const k=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let A=!1;for(r=t;r<=e;r++){a=r+i.buffer.ydisp,o=i.buffer.lines.get(a);let t=!1;for(this._model.lineLengths[r]=0,d=x===a,C=0,n=this._characterJoinerService.getJoinedCharacters(a),g=0;g=C,h=g,n.length>0&&g===n[0][0]&&m){l=n.shift();const t=this._model.selection.isCellSelected(this._terminal,l[0],a);for(p=l[0]+1;p=l[1],m?(L=!0,f=new y(f,o.translateToString(!0,l[0],l[1]),l[1]-l[0]),h=l[1]-1):C=l[1]}if(_=f.getChars(),Q=f.getCode(),p=4*(r*i.cols+g),!t&&f.isBlink()&&(t=!0),this._cellColorResolver.resolve(f,g,a,this.dimensions.device.cell.width,this.dimensions.device.cell.height),k&&a===x&&(g===E&&(this._model.cursor={x:E,y:b,width:f.getWidth(),style:this._coreBrowserService.isFocused?w:i.options.cursorInactiveStyle,cursorWidth:i.options.cursorWidth,dpr:this._devicePixelRatio},R=E+f.getWidth()-1),g>=E&&g<=R&&(this._coreBrowserService.isFocused&&"block"===w||!1===this._coreBrowserService.isFocused&&"block"===i.options.cursorInactiveStyle)&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&f.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),Q!==T.NULL_CELL_CODE&&(this._model.lineLengths[r]=g+1),(this._model.cells[p]!==Q||this._model.cells[p+1]!==this._cellColorResolver.result.bg||this._model.cells[p+2]!==this._cellColorResolver.result.fg||this._model.cells[p+3]!==this._cellColorResolver.result.ext)&&(A=!0,_.length>1&&(Q|=c.COMBINED_CHAR_BIT_MASK),this._model.cells[p]=Q,this._model.cells[p+1]=this._cellColorResolver.result.bg,this._model.cells[p+2]=this._cellColorResolver.result.fg,this._model.cells[p+3]=this._cellColorResolver.result.ext,u=f.getWidth(),this._glyphRenderer.value.updateCell(g,r,Q,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,_,u,s),L)){for(f=this._workCell,g++;g<=h;g++)M=4*(r*i.cols+g),this._glyphRenderer.value.updateCell(g,r,T.NULL_CELL_CODE,0,0,0,T.NULL_CELL_CHAR,0,0),this._model.cells[M]=T.NULL_CELL_CODE,this._model.cells[M+1]=this._cellColorResolver.result.bg,this._model.cells[M+2]=this._cellColorResolver.result.fg,this._model.cells[M+3]=this._cellColorResolver.result.ext;g--}}this._setRowBlinkState(r,t)}A&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,e){this._rowHasBlinkingCells[t]!==e&&(this._rowHasBlinkingCells[t]=e,this._rowHasBlinkingCellsCount+=e?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){this._charSizeService.width&&this._charSizeService.height&&(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=1===this._optionsService.rawOptions.lineHeight?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,e){this._canvas.width===t&&this._canvas.height===e||(this._canvas.width=t,this._canvas.height=e,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){const t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}}e.WebglRenderer=M;class y extends n.AttributeData{_width;content=0;fg;bg;combinedData="";constructor(t,e,i){super(),this.fg=t.fg,this.bg=t.bg,this.combinedData=e,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}function v(t,e,i=0){return Math.max(Math.min(t,e),i)}e.JoinedCellData=y},414(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.EventUtils=e.Emitter=void 0;const s=i(426);var r;e.Emitter=class{_listeners=[];_disposed=!1;_event;get event(){return this._event||(this._event=(t,e,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:t,thisArgs:e};this._listeners.push(r);const a=(0,s.toDisposable)(()=>{const t=this._listeners.indexOf(r);-1!==t&&this._listeners.splice(t,1)});return i&&(Array.isArray(i)?i.push(a):i.add(a)),a}),this._event}fire(t){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:e,thisArgs:i}=this._listeners[0];return void e.call(i,t)}default:{const e=this._listeners.slice();for(const{fn:i,thisArgs:s}of e)i.call(s,t)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(t){t.forward=function(t,e){return t(t=>e.fire(t))},t.map=function(t,e){return(i,s,r)=>t(t=>i.call(s,e(t)),void 0,r)},t.any=function(...t){return(e,i,r)=>{const a=new s.DisposableStore;for(const s of t)a.add(s(t=>e.call(i,t)));return r&&(Array.isArray(r)?r.push(a):r.add(a)),a}},t.runAndSubscribe=function(t,e,i){return e(i),t(t=>e(t))}}(r||(e.EventUtils=r={}))},426(t,e){function i(t){return{dispose:t}}function s(t){if(!t)return t;if(Array.isArray(t)){for(const e of t)e.dispose();return[]}return t.dispose(),t}Object.defineProperty(e,"__esModule",{value:!0}),e.MutableDisposable=e.Disposable=e.DisposableStore=void 0,e.toDisposable=i,e.dispose=s,e.combinedDisposable=function(...t){return i(()=>s(t))};class r{_disposables=new Set;_isDisposed=!1;get isDisposed(){return this._isDisposed}add(t){return this._isDisposed?t.dispose():this._disposables.add(t),t}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const t of this._disposables)t.dispose();this._disposables.clear()}}clear(){for(const t of this._disposables)t.dispose();this._disposables.clear()}}e.DisposableStore=r;class a{static None=Object.freeze({dispose(){}});_store=new r;dispose(){this._store.dispose()}_register(t){return this._store.add(t)}}e.Disposable=a,e.MutableDisposable=class{_value;_isDisposed=!1;get value(){return this._isDisposed?void 0:this._value}set value(t){this._isDisposed||t===this._value||(this._value?.dispose(),this._value=t)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},536(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextBlinkStateManager=void 0;const s=i(426);class r extends s.Disposable{_renderCallback;_coreBrowserService;_optionsService;_intervalDuration=0;_interval;_blinkOn=!0;_needsBlinkInViewport=!1;_isViewportVisible=!0;constructor(t,e,i){super(),this._renderCallback=t,this._coreBrowserService=e,this._optionsService=i,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",t=>{this.setIntervalDuration(t)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const t=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(t||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}e.TextBlinkStateManager=r},564(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.WHITESPACE_CELL_CODE=e.WHITESPACE_CELL_WIDTH=e.WHITESPACE_CELL_CHAR=e.NULL_CELL_CODE=e.NULL_CELL_WIDTH=e.NULL_CELL_CHAR=e.CHAR_DATA_CODE_INDEX=e.CHAR_DATA_WIDTH_INDEX=e.CHAR_DATA_CHAR_INDEX=e.CHAR_DATA_ATTR_INDEX=e.DEFAULT_EXT=e.DEFAULT_ATTR=e.DEFAULT_COLOR=void 0,e.DEFAULT_COLOR=0,e.DEFAULT_ATTR=e.DEFAULT_COLOR<<9|256,e.DEFAULT_EXT=0,e.CHAR_DATA_ATTR_INDEX=0,e.CHAR_DATA_CHAR_INDEX=1,e.CHAR_DATA_WIDTH_INDEX=2,e.CHAR_DATA_CODE_INDEX=3,e.NULL_CELL_CHAR="",e.NULL_CELL_WIDTH=1,e.NULL_CELL_CODE=0,e.WHITESPACE_CELL_CHAR=" ",e.WHITESPACE_CELL_WIDTH=1,e.WHITESPACE_CELL_CODE=32},578(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.IntervalTimer=e.TimeoutTimer=void 0,e.timeout=function(t){return new Promise(e=>setTimeout(e,t))},e.disposableTimeout=function(t,e=0,i){const r=setTimeout(()=>{t(),i&&a.dispose()},e),a=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(a),a};const s=i(426);e.TimeoutTimer=class{_token=-1;_isDisposed=!1;dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(t,e){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,t()},e)}setIfNotSet(t,e){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,t()},e))}},e.IntervalTimer=class{_disposable;_isDisposed=!1;cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(t,e,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{t()},e);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},657(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.generateConfig=function(t,e,i,r,a,o,n,L,T=!0){const h={foreground:o.foreground,background:o.background,cursor:s.NULL_COLOR,cursorAccent:s.NULL_COLOR,selectionForeground:s.NULL_COLOR,selectionBackgroundTransparent:s.NULL_COLOR,selectionBackgroundOpaque:s.NULL_COLOR,selectionInactiveBackgroundTransparent:s.NULL_COLOR,selectionInactiveBackgroundOpaque:s.NULL_COLOR,overviewRulerBorder:s.NULL_COLOR,scrollbarSliderBackground:s.NULL_COLOR,scrollbarSliderHoverBackground:s.NULL_COLOR,scrollbarSliderActiveBackground:s.NULL_COLOR,ansi:o.ansi.slice(),contrastCache:o.contrastCache,halfContrastCache:o.halfContrastCache};return{customGlyphs:T,devicePixelRatio:n,deviceMaxTextureSize:L,letterSpacing:a.letterSpacing,lineHeight:a.lineHeight,deviceCellWidth:t,deviceCellHeight:e,deviceCharWidth:i,deviceCharHeight:r,fontFamily:a.fontFamily,fontSize:a.fontSize,fontWeight:a.fontWeight,fontWeightBold:a.fontWeightBold,allowTransparency:a.allowTransparency,drawBoldTextInBrightColors:a.drawBoldTextInBrightColors,minimumContrastRatio:a.minimumContrastRatio,colors:h}},e.configEquals=function(t,e){for(let i=0;i=0){if((0,r.configEquals)(i.config,c))return i.atlas;1===i.ownedBy.length?(i.atlas.dispose(),a.splice(e,1)):i.ownedBy.splice(s,1);break}}for(let e=0;e{const s=e.find(e=>e.target===t);if(!s)return;if(!("devicePixelContentBoxSize"in s))return r?.disconnect(),void(r=void 0);const a=s.devicePixelContentBoxSize[0].inlineSize,o=s.devicePixelContentBoxSize[0].blockSize;a>0&&o>0&&i(a,o)});try{r.observe(t,{box:["device-pixel-content-box"]})}catch{r.disconnect(),r=void 0}return(0,s.toDisposable)(()=>r?.disconnect())};const s=i(426)},719(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GLTexture=e.PROJECTION_MATRIX=void 0,e.createProgram=function(t,e,i){const a=(0,s.throwIfFalsy)(t.createProgram());if(t.attachShader(a,(0,s.throwIfFalsy)(r(t,t.VERTEX_SHADER,e))),t.attachShader(a,(0,s.throwIfFalsy)(r(t,t.FRAGMENT_SHADER,i))),t.linkProgram(a),t.getProgramParameter(a,t.LINK_STATUS))return a;console.error(t.getProgramInfoLog(a)),t.deleteProgram(a)},e.createShader=r,e.expandFloat32Array=function(t,e){const i=Math.min(2*t.length,e),s=new Float32Array(i);for(let e=0;e>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(t){this.fg=t[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let e=!1;if(t[r.CHAR_DATA_CHAR_INDEX].length>2)e=!0;else if(2===t[r.CHAR_DATA_CHAR_INDEX].length){const i=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|t[r.CHAR_DATA_WIDTH_INDEX]<<22:e=!0}else e=!0}else this.content=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[r.CHAR_DATA_WIDTH_INDEX]<<22;e&&(this.combinedData=t[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|t[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor())return!1;if(this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor())return!1;if(this.isInverse()!==t.isInverse())return!1;if(this.isBold()!==t.isBold())return!1;if(this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;const e=this.isUnderlineColorDefault(),i=t.isUnderlineColorDefault();if(!e||!i){if(e!==i)return!1;if(this.getUnderlineColor()!==t.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==t.getUnderlineColorMode())return!1}}return this.isOverline()===t.isOverline()&&this.isBlink()===t.isBlink()&&this.isInvisible()===t.isInvisible()&&this.isItalic()===t.isItalic()&&this.isDim()===t.isDim()&&this.isStrikethrough()===t.isStrikethrough()}}e.CellData=o},773(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.CursorBlinkStateManager=void 0,e.CursorBlinkStateManager=class{_renderCallback;_coreBrowserService;isCursorVisible;_animationFrame;_blinkStartTimeout;_blinkInterval;_idleTimeout;_isIdlePaused=!1;_animationTimeRestarted;constructor(t,e){this._renderCallback=t,this._coreBrowserService=e,this.isCursorVisible=!0,this._coreBrowserService.isFocused&&(this._restartInterval(),this._resetIdleTimer())}get isPaused(){return!(this._blinkStartTimeout||this._blinkInterval)}dispose(){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}restartBlinkAnimation(){this._isIdlePaused&&this._resetIdleTimer(),this.isPaused||(this._animationTimeRestarted=Date.now(),this.isCursorVisible=!0,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(t=600){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0)return void this._restartInterval(t)}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);return this._animationTimeRestarted=void 0,void this._restartInterval(t)}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},600)},t)}pause(){this.isCursorVisible=!0,this._isIdlePaused=!1,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){this._isIdlePaused=!1,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){this.isCursorVisible=!0,this._isIdlePaused=!0,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}}},814(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.INVERTED_DEFAULT_COLOR=void 0,e.INVERTED_DEFAULT_COLOR=257},816(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),i(267)},836(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.FourKeyMap=e.TwoKeyMap=void 0;class i{_data={};set(t,e,i){this._data[t]||(this._data[t]={}),this._data[t][e]=i}get(t,e){return this._data[t]?this._data[t][e]:void 0}clear(){this._data={}}}e.TwoKeyMap=i,e.FourKeyMap=class{_data=new i;set(t,e,s,r,a){this._data.get(t,e)||this._data.set(t,e,new i),this._data.get(t,e).set(s,r,a)}get(t,e,i,s){return this._data.get(t,e)?.get(i,s)}clear(){this._data.clear()}}},864(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.CellColorResolver=void 0;const s=i(564),r=i(993),a=i(959),o=i(915);let n,L=0,T=0,h=!1,l=!1,d=!1,c=0;e.CellColorResolver=class{_terminal;_optionService;_selectionRenderModel;_decorationService;_coreBrowserService;_themeService;result={fg:0,bg:0,ext:0};constructor(t,e,i,s,r,a){this._terminal=t,this._optionService=e,this._selectionRenderModel=i,this._decorationService=s,this._coreBrowserService=r,this._themeService=a}resolve(t,e,i,_,Q){this.result.bg=t.bg,this.result.fg=t.fg,this.result.ext=268435456&t.bg?t.extended.ext:0,T=0,L=0,l=!1,h=!1,d=!1,n=this._themeService.colors,c=0;const u=t.getCode();if(u!==s.NULL_CELL_CODE&&4===t.extended.underlineStyle){const t=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));c=e*_%(2*Math.round(t))}if(0===c&&o.blockPatternCodepoints.has(u)&&(c=e*_%2*2+i*Q%2),this._decorationService.forEachDecorationAtCell(e,i,"bottom",t=>{t.backgroundColorRGB&&(T=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,h=!0)}),d=this._selectionRenderModel.isCellSelected(this._terminal,e,i),d){if(67108864&this.result.fg||50331648&this.result.bg){if(67108864&this.result.fg)switch(50331648&this.result.fg){case 16777216:case 33554432:T=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:T=(16777215&this.result.fg)<<8|255;break;default:T=this._themeService.colors.foreground.rgba}else switch(50331648&this.result.bg){case 16777216:case 33554432:T=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:T=(16777215&this.result.bg)<<8|255}T=r.rgba.blend(T,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}else T=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(l=!0,n.selectionForeground&&(L=n.selectionForeground.rgba>>8&16777215,h=!0),(0,a.treatGlyphAsBackgroundColor)(t.getCode())){if(67108864&this.result.fg&&!(50331648&this.result.bg))L=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(67108864&this.result.fg)switch(50331648&this.result.bg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:L=(16777215&this.result.bg)<<8|255}else switch(50331648&this.result.fg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:L=(16777215&this.result.fg)<<8|255;break;default:L=this._themeService.colors.foreground.rgba}L=r.rgba.blend(L,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}h=!0}}this._decorationService.forEachDecorationAtCell(e,i,"top",t=>{t.backgroundColorRGB&&(T=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,h=!0)}),l&&(T=d?-16777216&t.bg&-134217729|T|50331648:-16777216&t.bg|T|50331648),h&&(L=-16777216&t.fg&-67108865|L|50331648),67108864&this.result.fg&&(l&&!h&&(L=50331648&this.result.bg?-134217728&this.result.fg|67108863&this.result.bg:-134217728&this.result.fg|16777215&n.background.rgba>>8|50331648,h=!0),!l&&h&&(T=50331648&this.result.fg?-67108864&this.result.bg|67108863&this.result.fg:-67108864&this.result.bg|16777215&n.foreground.rgba>>8|50331648,l=!0)),n=void 0,this.result.bg=l?T:this.result.bg,this.result.fg=h?L:this.result.fg,this.result.ext&=536870911,this.result.ext|=c<<29&3758096384}}},915(t,e,i){var s;function r(t){return{type:2,data:()=>{const e=[],i=[3/8,2/8,3/8],s=[0,3/8,5/8];for(let r=0;r<3;r++){const a=t>>2*r&1,o=t>>2*r+1&1;a&&o?e.push(`M0,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):a?e.push(`M0,${s[r]} L0.5,${s[r]} L0.5,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):o&&e.push(`M0.5,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0.5,${s[r]+i[r]} Z`)}return e.join(" ")}}}function a(t){const e=[],i=.15,s=.015,r=.075,a=.0375,o=.95;if(64&t){const t=.175,a=.2125,n=.25,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}if(32&t){const t=o-i,s=.875,r=o,n=.265,L=.485;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(16&t){const t=o-i,s=.875,r=o,n=.515,L=.735;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(8&t){const t=.75,a=.7875,n=.825,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}if(4&t){const t=.05,i=.125,s=.2,r=.515,o=.735;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(2&t){const t=.05,i=.125,s=.2,r=.265,o=.485;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(1&t){const t=.4625,a=.5,n=.5375,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}return e.join(" ")}Object.defineProperty(e,"__esModule",{value:!0}),e.blockPatternCodepoints=e.customGlyphDefinitions=void 0,i(215),function(t){t.LINE_H=Object.freeze({type:2,data:"M0,.5 L1,.5",strokeWidth:1}),t.LINE_V=Object.freeze({type:2,data:"M.5,0 L.5,1",strokeWidth:1}),t.FADE_RIGHT=Object.freeze({type:2,data:"M0,.5 L.28,.5 M.32,.5 L.52,.5 M.60,.5 L.72,.5 M.84,.5 L.90,.5",strokeWidth:1}),t.FADE_LEFT=Object.freeze({type:2,data:"M.10,.5 L.16,.5 M.28,.5 L.40,.5 M.48,.5 L.68,.5 M.72,.5 L1,.5",strokeWidth:1}),t.FADE_DOWN=Object.freeze({type:2,data:"M.5,0 L.5,.28 M.5,.32 L.5,.52 M.5,.60 L.5,.72 M.5,.84 L.5,.90",strokeWidth:1}),t.FADE_UP=Object.freeze({type:2,data:"M.5,.10 L.5,.16 M.5,.28 L.5,.40 M.5,.48 L.5,.68 M.5,.72 L.5,1",strokeWidth:1}),t.CURVE_DOWN_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_DOWN_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.CURVE_UP_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_UP_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.NODE_FILL=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5"}),t.NODE_STROKE=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5",strokeWidth:1}),t.NODE_LINE_RIGHT=Object.freeze({type:3,data:"M1,.5 L.85,.5",strokeWidth:1}),t.NODE_LINE_LEFT=Object.freeze({type:3,data:"M0,.5 L.15,.5",strokeWidth:1}),t.NODE_LINE_DOWN=Object.freeze({type:3,data:"M.5,1 L.5,.7",strokeWidth:1}),t.NODE_LINE_UP=Object.freeze({type:3,data:"M.5,0 L.5,.3",strokeWidth:1})}(s||(s={})),e.customGlyphDefinitions={"─":{type:2,data:"M0,.5 L1,.5",strokeWidth:1},"━":{type:2,data:"M0,.5 L1,.5",strokeWidth:3},"│":{type:2,data:"M.5,0 L.5,1",strokeWidth:1},"┃":{type:2,data:"M.5,0 L.5,1",strokeWidth:3},"┄":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:1},"┅":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:3},"┆":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:1},"┇":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:3},"┈":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:1},"┉":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:3},"┊":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:1},"┋":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:3},"┌":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},"┍":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┎":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┏":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3},"┐":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},"┑":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┒":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┓":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3},"└":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},"┕":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┖":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┗":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3},"┘":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},"┙":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┚":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┛":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3},"├":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},"┝":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┞":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┟":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┠":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┡":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┢":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┣":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3},"┤":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},"┥":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┦":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┧":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┨":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┩":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┪":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┫":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3},"┬":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},"┭":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┮":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┯":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┰":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┱":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┲":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┳":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3},"┴":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:1},"┵":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┶":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┷":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┸":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┹":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┺":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┻":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:3},"┼":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:1},"┽":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┾":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┿":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"╀":[{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"╁":[{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╂":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"╃":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"╄":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"╅":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"╆":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"╇":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:3}],"╈":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3}],"╉":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3}],"╊":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3}],"╋":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:3},"╌":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:1},"╍":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:3},"╎":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:1},"╏":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:3},"═":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"║":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╒":{type:2,data:(t,e)=>`M.5,1 L.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╓":{type:2,data:(t,e)=>`M${.5-t},1 L${.5-t},.5 L1,.5 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╔":{type:2,data:(t,e)=>`M1,${.5-e} L${.5-t},${.5-e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╕":{type:2,data:(t,e)=>`M0,${.5-e} L.5,${.5-e} L.5,1 M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╖":{type:2,data:(t,e)=>`M${.5+t},1 L${.5+t},.5 L0,.5 M${.5-t},.5 L${.5-t},1`,strokeWidth:1},"╗":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5+t},${.5-e} L${.5+t},1`,strokeWidth:1},"╘":{type:2,data:(t,e)=>`M.5,0 L.5,${.5+e} L1,${.5+e} M.5,${.5-e} L1,${.5-e}`,strokeWidth:1},"╙":{type:2,data:(t,e)=>`M1,.5 L${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╚":{type:2,data:(t,e)=>`M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0 M1,${.5+e} L${.5-t},${.5+e} L${.5-t},0`,strokeWidth:1},"╛":{type:2,data:(t,e)=>`M0,${.5+e} L.5,${.5+e} L.5,0 M0,${.5-e} L.5,${.5-e}`,strokeWidth:1},"╜":{type:2,data:(t,e)=>`M0,.5 L${.5+t},.5 L${.5+t},0 M${.5-t},.5 L${.5-t},0`,strokeWidth:1},"╝":{type:2,data:(t,e)=>`M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M0,${.5+e} L${.5+t},${.5+e} L${.5+t},0`,strokeWidth:1},"╞":{type:2,data:(t,e)=>`M.5,0 L.5,1 M.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╟":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1 M${.5+t},.5 L1,.5`,strokeWidth:1},"╠":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╡":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L.5,${.5-e} M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╢":{type:2,data:(t,e)=>`M0,.5 L${.5-t},.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╣":{type:2,data:(t,e)=>`M${.5+t},0 L${.5+t},1 M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0`,strokeWidth:1},"╤":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e} M.5,${.5+e} L.5,1`,strokeWidth:1},"╥":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},1 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╦":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╧":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e} M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╨":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╩":{type:2,data:(t,e)=>`M0,${.5+e} L1,${.5+e} M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╪":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╫":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╬":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╭":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╮":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╯":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╰":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╱":{type:2,data:"M1,0 L0,1",strokeWidth:1},"╲":{type:2,data:"M0,0 L1,1",strokeWidth:1},"╳":{type:2,data:"M1,0 L0,1 M0,0 L1,1",strokeWidth:1},"╴":{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},"╵":{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},"╶":{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},"╷":{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},"╸":{type:2,data:"M.5,.5 L0,.5",strokeWidth:3},"╹":{type:2,data:"M.5,.5 L.5,0",strokeWidth:3},"╺":{type:2,data:"M.5,.5 L1,.5",strokeWidth:3},"╻":{type:2,data:"M.5,.5 L.5,1",strokeWidth:3},"╼":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"╽":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╾":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"╿":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"▀":{type:0,data:[{x:0,y:0,w:8,h:4}]},"▁":{type:0,data:[{x:0,y:7,w:8,h:1}]},"▂":{type:0,data:[{x:0,y:6,w:8,h:2}]},"▃":{type:0,data:[{x:0,y:5,w:8,h:3}]},"▄":{type:0,data:[{x:0,y:4,w:8,h:4}]},"▅":{type:0,data:[{x:0,y:3,w:8,h:5}]},"▆":{type:0,data:[{x:0,y:2,w:8,h:6}]},"▇":{type:0,data:[{x:0,y:1,w:8,h:7}]},"█":{type:0,data:[{x:0,y:0,w:8,h:8}]},"▉":{type:0,data:[{x:0,y:0,w:7,h:8}]},"▊":{type:0,data:[{x:0,y:0,w:6,h:8}]},"▋":{type:0,data:[{x:0,y:0,w:5,h:8}]},"▌":{type:0,data:[{x:0,y:0,w:4,h:8}]},"▍":{type:0,data:[{x:0,y:0,w:3,h:8}]},"▎":{type:0,data:[{x:0,y:0,w:2,h:8}]},"▏":{type:0,data:[{x:0,y:0,w:1,h:8}]},"▐":{type:0,data:[{x:4,y:0,w:4,h:8}]},"░":{type:1,data:[[1,0],[0,0]]},"▒":{type:1,data:[[1,0],[0,1]]},"▓":{type:1,data:[[1,1],[1,0]]},"▔":{type:0,data:[{x:0,y:0,w:8,h:1}]},"▕":{type:0,data:[{x:7,y:0,w:1,h:8}]},"▖":{type:0,data:[{x:0,y:4,w:4,h:4}]},"▗":{type:0,data:[{x:4,y:4,w:4,h:4}]},"▘":{type:0,data:[{x:0,y:0,w:4,h:4}]},"▙":{type:0,data:[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"▚":{type:0,data:[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}]},"▛":{type:0,data:[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}]},"▜":{type:0,data:[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}]},"▝":{type:0,data:[{x:4,y:0,w:4,h:4}]},"▞":{type:0,data:[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}]},"▟":{type:0,data:[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"":{type:5,data:{d:"M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.7,.03 L.7,.1 L.355,.1 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M0,0 L1,.5 L0,1",type:0,rightPadding:2}},"":{type:5,data:{d:"M-1,-.5 L1,.5 L-1,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L0,.5 L1,1",type:0,leftPadding:2}},"":{type:5,data:{d:"M2,-.5 L0,.5 L2,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0",type:0,rightPadding:1}},"":{type:5,data:{d:"M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0",type:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0",type:0,leftPadding:1}},"":{type:5,data:{d:"M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0",type:1,leftPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L-.5,1.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L1.5,1.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L-.5,-.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L1.5,-.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0.8069,0.6075 Q0.7991,0.612,0.7815,0.6218 T0.7542,0.6369 T0.7282,0.6508 T0.7016,0.6644 T0.6778,0.6758 T0.6551,0.6854 T0.637,0.6911 T0.622,0.6936 Q0.6186,0.6936,0.6128,0.6942 T0.6018,0.6952 T0.5894,0.6956 T0.5757,0.6952 T0.561,0.693 T0.5451,0.6879 Q0.5342,0.6834,0.5193,0.6756 T0.4925,0.6614 T0.4673,0.6485 T0.4436,0.6389 T0.4239,0.6363 T0.4089,0.6422 Q0.4045,0.6467,0.3958,0.6554 T0.3843,0.6669 T0.3748,0.6746 T0.3613,0.683 T0.3442,0.6903 L0.3499,0.6956 Q0.3584,0.7042,0.3679,0.7177 T0.3836,0.7403 T0.4019,0.7583 T0.4295,0.7703 Q0.4688,0.7785,0.5203,0.7605 Q0.4902,0.7817,0.449,0.7911 T0.3696,0.8001 Q0.3567,0.7997,0.341,0.7942 T0.3111,0.7813 T0.2805,0.766 T0.2493,0.753 T0.218,0.7472 T0.187,0.754 Q0.1762,0.7589,0.1653,0.7681 T0.1453,0.7895 T0.1308,0.818 T0.1264,0.8507 L0.1264,0.858 Q0.1453,0.8462,0.1607,0.8433 T0.1899,0.8452 T0.2226,0.8625 Q0.2344,0.8707,0.2485,0.8752 T0.2813,0.8831 T0.3089,0.889 Q0.2981,0.8915,0.2846,0.8956 T0.2603,0.9037 T0.2368,0.9115 T0.2129,0.9174 T0.1894,0.9194 T0.1653,0.9155 Q0.1504,0.9115,0.1328,0.9196 T0.0986,0.9421 T0.0655,0.9708 T0.0317,0.9943 T0,1 Q0.001,0.94,0.0017,0.6508 T0.0024,0.2322 Q0.002,0.2109,0.0007,0.1379 T0,0.0249 Q0.0058,0.0253,0.009,0.0257 T0.0156,0.0288 T0.0203,0.0333 T0.0264,0.0422 T0.0349,0.0539 Q0.044,0.0657,0.0542,0.072 T0.0752,0.0789 T0.096,0.0775 T0.1175,0.0694 T0.1379,0.0573 T0.1579,0.0432 Q0.2141,0,0.2673,0.0318 Q0.2588,0.0318,0.2517,0.033 T0.239,0.0361 T0.2276,0.0424 T0.218,0.0504 T0.2085,0.0612 T0.1997,0.0732 T0.1899,0.0875 T0.1795,0.1028 Q0.1748,0.1093,0.1507,0.1234 T0.1186,0.1448 Q0.0999,0.1612,0.1108,0.1783 Q0.1162,0.1877,0.1238,0.1928 T0.1404,0.1979 T0.158,0.1967 T0.1775,0.1895 T0.1963,0.1791 T0.2151,0.1663 T0.2314,0.1534 T0.2458,0.141 T0.2561,0.1322 Q0.2815,0.1106,0.3017,0.101 T0.344,0.0926 T0.3919,0.1077 Q0.4099,0.1175,0.4265,0.1206 T0.4588,0.1202 T0.488,0.1098 T0.5176,0.0914 Q0.5071,0.1032,0.4963,0.1122 T0.4722,0.1285 T0.4497,0.1401 T0.4223,0.1518 T0.3943,0.1636 Q0.374,0.1726,0.3623,0.1863 T0.3482,0.2162 Q0.3445,0.2403,0.3608,0.2605 T0.4072,0.2876 Q0.4194,0.2909,0.4326,0.2899 T0.457,0.2856 T0.4805,0.2764 T0.5032,0.264 T0.5256,0.2497 T0.5478,0.2352 T0.5698,0.2222 T0.5921,0.2118 Q0.6982,0.173,0.81,0.2436 Q0.833,0.2583,0.8496,0.2674 T0.887,0.2846 T0.925,0.294 T0.9617,0.2911 T1,0.2746 Q0.9817,0.2946,0.96,0.3056 T0.9175,0.3191 T0.8731,0.3209 T0.8277,0.3164 T0.7818,0.3109 T0.7359,0.3101 T0.6907,0.3197 T0.647,0.3448 Q0.6443,0.3472,0.6333,0.3562 T0.6194,0.3674 T0.6076,0.3766 T0.594,0.386 T0.5815,0.3931 T0.5661,0.4007 T0.5501,0.4064 L0.5556,0.4121 Q0.5627,0.4198,0.5722,0.4251 T0.5926,0.4337 T0.6131,0.439 T0.6343,0.4431 T0.6521,0.4468 Q0.6762,0.4529,0.7056,0.4463 Q0.6772,0.4623,0.6543,0.471 T0.604,0.4814 Q0.5962,0.4818,0.5815,0.4825 T0.5591,0.4835 T0.5393,0.4857 T0.519,0.4908 T0.5007,0.4996 T0.4812,0.5141 T0.4627,0.5353 L0.4549,0.5451 L0.4661,0.543 Q0.4759,0.541,0.4909,0.55 T0.5315,0.5796 T0.5684,0.6083 Q0.5799,0.6161,0.5915,0.6212 T0.6181,0.6308 L0.6331,0.6353 Q0.6514,0.6418,0.7004,0.633 T0.8069,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.011,0.0469 Q0.0152,0.0412,0.02,0.0361 T0.0292,0.0281 T0.0365,0.0253 Q0.0426,0.0257,0.0461,0.0263 T0.0534,0.03 T0.0582,0.0345 T0.0645,0.0434 T0.0723,0.0546 Q0.0807,0.0653,0.0897,0.0712 T0.1081,0.0775 T0.1255,0.0765 T0.1438,0.0695 T0.1605,0.0593 T0.1772,0.0473 Q0.2388,0,0.2927,0.0326 L0.2614,0.0767 L0.2581,0.0767 Q0.2494,0.0767,0.2444,0.0773 T0.2359,0.0789 T0.2291,0.0842 T0.2233,0.0922 T0.2146,0.1054 T0.2026,0.1236 Q0.1943,0.1354,0.1843,0.1444 T0.1675,0.1574 T0.1526,0.1654 T0.1429,0.1701 Q0.1436,0.1717,0.1446,0.1733 Q0.1504,0.1823,0.1581,0.1866 T0.1733,0.1915 T0.1909,0.1884 T0.2085,0.1809 T0.2265,0.169 T0.2427,0.1564 T0.2575,0.1436 T0.2688,0.1338 Q0.2969,0.1101,0.3195,0.0995 T0.3643,0.0901 T0.4114,0.1048 Q0.4279,0.1138,0.4426,0.1168 T0.4705,0.117 T0.4945,0.1089 T0.5186,0.0942 L0.5211,0.0926 L0.5289,0.0922 Q0.5411,0.095,0.5189,0.1203 Q0.505,0.1358,0.4913,0.1474 T0.4634,0.167 T0.4389,0.1796 T0.4121,0.1909 T0.3866,0.2011 Q0.3782,0.2047,0.3727,0.2092 Q0.3717,0.2117,0.3714,0.2141 Q0.3695,0.2263,0.3751,0.2388 T0.394,0.2616 T0.4253,0.2761 Q0.4398,0.2785,0.4513,0.2779 T0.4756,0.2724 T0.4987,0.2616 T0.5216,0.2476 T0.5453,0.2321 T0.5703,0.217 T0.5976,0.2043 Q0.7015,0.166,0.8099,0.2345 Q0.8361,0.2512,0.8572,0.2616 T0.9006,0.2779 T0.9432,0.281 T0.9819,0.2663 L0.9887,0.2671 Q1,0.272,0.9768,0.2969 Q0.9558,0.3193,0.9319,0.333 T0.8858,0.3513 T0.8391,0.3569 T0.793,0.3546 T0.7485,0.3497 T0.7065,0.3473 T0.6681,0.3522 T0.6344,0.3699 Q0.5963,0.4005,0.5847,0.4082 Q0.595,0.4139,0.6079,0.4174 T0.6367,0.4237 T0.6586,0.4278 Q0.6793,0.4331,0.7044,0.4278 Q0.7057,0.4278,0.707,0.4274 Q0.7067,0.429,0.7062,0.4313 T0.7036,0.4398 T0.6993,0.4513 T0.6931,0.4619 T0.6851,0.4698 Q0.6573,0.4857,0.6336,0.4947 T0.5831,0.5053 Q0.577,0.5057,0.5615,0.5065 T0.5394,0.508 T0.5221,0.51 T0.5042,0.5139 T0.4905,0.5204 Q0.5008,0.5232,0.515,0.5328 T0.549,0.5585 T0.5799,0.5824 Q0.5905,0.5897,0.6013,0.5946 T0.6262,0.6036 T0.6409,0.6081 Q0.6567,0.6134,0.7035,0.605 T0.8028,0.5816 L0.7974,0.5922 T0.7867,0.6134 L0.7812,0.624 Q0.7799,0.6248,0.7786,0.6252 Q0.7709,0.6297,0.7573,0.6372 T0.7346,0.6499 T0.7123,0.6621 T0.6897,0.6741 T0.6686,0.6847 T0.648,0.6941 T0.6297,0.7013 T0.6129,0.7062 T0.5995,0.7076 Q0.5992,0.7076,0.5831,0.7092 T0.5529,0.7092 T0.525,0.7019 Q0.5105,0.6958,0.4824,0.6811 T0.4356,0.6586 T0.4079,0.6533 Q0.3872,0.6741,0.3827,0.6782 Q0.3872,0.6835,0.3934,0.6929 T0.4037,0.7078 T0.4142,0.7198 T0.4284,0.7304 T0.4466,0.7365 Q0.4824,0.7443,0.5292,0.728 L0.5292,0.7294 T0.529,0.7333 T0.5284,0.7392 T0.5266,0.7463 T0.5236,0.7539 T0.5186,0.7612 T0.5111,0.7679 Q0.5086,0.77,0.5056,0.7716 Q0.4466,0.8104,0.3591,0.8091 Q0.344,0.8091,0.3246,0.802 T0.2893,0.7863 T0.2543,0.77 T0.2206,0.76 T0.1894,0.7643 Q0.1804,0.7684,0.1733,0.7753 Q0.1591,0.7892,0.1601,0.8169 Q0.1784,0.8067,0.1938,0.8053 T0.2212,0.8087 T0.2507,0.825 Q0.2614,0.8324,0.2744,0.8365 T0.3053,0.844 T0.332,0.8499 L0.303,0.894 L0.2998,0.8948 Q0.2904,0.8968,0.278,0.9009 T0.2554,0.9086 T0.233,0.9158 T0.2099,0.9213 T0.1868,0.9229 T0.1633,0.9192 Q0.1517,0.916,0.1367,0.9241 T0.1065,0.9462 T0.0753,0.9737 T0.041,0.9955 T0.0061,1 Q0,0.9984,0.0068,0.9845 Q0.009,0.9804,0.0123,0.9755 Q0.0187,0.9666,0.0261,0.9606 T0.0374,0.9555 Q0.0481,0.958,0.0629,0.949 T0.0931,0.9262 T0.1247,0.8987 T0.1597,0.8777 T0.1949,0.8752 Q0.213,0.8805,0.2343,0.8772 Q0.2249,0.8736,0.2172,0.8683 Q0.1914,0.8511,0.1738,0.8497 T0.1349,0.8617 Q0.1304,0.865,0.1273,0.8646 T0.1242,0.8605 L0.1239,0.8536 Q0.1226,0.8295,0.1354,0.8016 T0.1723,0.7496 Q0.1914,0.7308,0.2104,0.7219 Q0.2259,0.7145,0.2425,0.7141 T0.273,0.7182 T0.3022,0.7296 T0.3301,0.7439 T0.3569,0.7571 T0.3821,0.7643 Q0.373,0.7561,0.3598,0.7367 T0.3382,0.7088 L0.3327,0.7035 Q0.3282,0.699,0.3353,0.6884 T0.3517,0.6692 L0.3611,0.6607 Q0.3711,0.657,0.3751,0.6554 T0.3837,0.6507 T0.3898,0.6458 T0.3979,0.637 T0.4111,0.6236 Q0.4172,0.6175,0.4234,0.6138 Q0.4327,0.6081,0.4439,0.6077 T0.4655,0.6105 T0.4884,0.6201 T0.5113,0.633 T0.5347,0.6468 T0.5573,0.6578 Q0.5705,0.6635,0.5833,0.665 T0.6112,0.665 T0.6286,0.6635 Q0.6421,0.6631,0.6718,0.6501 Q0.626,0.6578,0.6089,0.6517 Q0.6063,0.6509,0.6002,0.6491 T0.5884,0.6454 T0.5757,0.6409 T0.561,0.6344 T0.5466,0.6256 Q0.536,0.6183,0.5108,0.5989 T0.4722,0.5712 T0.4527,0.564 L0.4418,0.5661 Q0.4308,0.5681,0.4366,0.5563 Q0.4401,0.5485,0.4476,0.5392 L0.455,0.5298 Q0.4631,0.5188,0.4714,0.51 T0.4866,0.4947 T0.5021,0.4831 T0.516,0.4749 T0.53,0.4694 T0.5428,0.4657 T0.5558,0.4637 T0.5674,0.4629 T0.5795,0.4625 Q0.5489,0.4543,0.534,0.4384 L0.5286,0.4327 Q0.5244,0.4282,0.5318,0.4174 T0.5486,0.398 L0.5579,0.3899 Q0.567,0.387,0.5749,0.3834 T0.5878,0.3772 T0.6012,0.3687 T0.6126,0.3603 T0.6268,0.3487 T0.6421,0.3361 Q0.6567,0.3242,0.6712,0.3159 T0.7009,0.3034 T0.7277,0.2973 T0.7556,0.2961 T0.7809,0.2977 T0.8074,0.301 Q0.8096,0.3014,0.8109,0.3016 T0.8143,0.302 T0.818,0.3026 Q0.8012,0.2936,0.7767,0.2781 Q0.6718,0.2117,0.5744,0.2476 Q0.5637,0.2516,0.5492,0.2604 T0.5218,0.2779 T0.4923,0.2965 T0.461,0.3124 T0.4282,0.3216 T0.394,0.3206 Q0.3637,0.3136,0.3474,0.2936 T0.3353,0.2459 Q0.3385,0.2247,0.355,0.2023 Q0.3646,0.1892,0.3763,0.179 Q0.3898,0.1672,0.4063,0.1595 Q0.3927,0.1562,0.3785,0.1485 Q0.3604,0.1387,0.3453,0.1354 T0.3161,0.1354 T0.2899,0.1458 T0.2627,0.166 Q0.262,0.1664,0.254,0.1733 T0.2414,0.1841 T0.2267,0.196 T0.2094,0.2086 T0.1915,0.2196 T0.1725,0.2288 T0.1546,0.2337 T0.1373,0.2343 T0.1223,0.228 T0.1097,0.2145 Q0.0962,0.1929,0.1258,0.1582 Q0.131,0.1517,0.1371,0.1464 Q0.1462,0.1387,0.156,0.1321 T0.1752,0.1205 T0.1878,0.1134 Q0.1891,0.1117,0.1947,0.103 T0.2035,0.0899 T0.2125,0.0781 T0.2243,0.064 Q0.1949,0.0616,0.1672,0.0828 Q0.153,0.0934,0.1412,0.1009 T0.1146,0.1146 T0.0873,0.1213 T0.0616,0.116 T0.0374,0.0962 Q0.0339,0.0909,0.0295,0.0846 T0.024,0.0767 T0.0202,0.0728 T0.0148,0.0704 T0.0068,0.0697 Q0.0003,0.0693,0.0058,0.0567 Q0.0077,0.0522,0.011,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.1934,0.6075 Q0.2012,0.612,0.2188,0.6218 T0.2459,0.6369 T0.2718,0.6508 T0.2983,0.6644 T0.322,0.6758 T0.3447,0.6854 T0.363,0.6911 T0.3783,0.6936 Q0.3796,0.6936,0.396,0.6952 T0.4265,0.6952 T0.4548,0.6879 Q0.4656,0.6834,0.4805,0.6756 T0.5073,0.6614 T0.5327,0.6485 T0.5564,0.6389 T0.5759,0.6363 T0.5909,0.6422 Q0.5953,0.6467,0.604,0.6554 T0.6155,0.6669 T0.6251,0.6746 T0.6387,0.683 T0.6556,0.6903 L0.6498,0.6956 Q0.6414,0.7042,0.6319,0.7177 T0.6162,0.7403 T0.5979,0.7583 T0.5703,0.7703 Q0.5313,0.7785,0.4799,0.7605 Q0.5097,0.7817,0.5508,0.7911 T0.6302,0.8001 Q0.6431,0.7997,0.659,0.7942 T0.6888,0.7813 T0.7194,0.766 T0.7506,0.753 T0.7819,0.7472 T0.8131,0.754 Q0.8239,0.7589,0.8346,0.7681 T0.8544,0.7895 T0.8689,0.818 T0.8737,0.8507 L0.8733,0.858 Q0.8591,0.849,0.8474,0.845 T0.8242,0.8421 T0.8021,0.8482 T0.7772,0.8625 Q0.7653,0.8707,0.7513,0.8752 T0.7184,0.8831 T0.6908,0.889 Q0.7017,0.8915,0.7152,0.8956 T0.7396,0.9037 T0.7633,0.9115 T0.7872,0.9174 T0.8107,0.9194 T0.8347,0.9155 Q0.8493,0.9115,0.8671,0.9196 T0.9015,0.9421 T0.9345,0.9708 T0.9682,0.9943 T0.9997,1 Q0.999,0.94,0.9983,0.6508 T0.9976,0.2322 Q0.9976,0.2109,0.9992,0.1379 T1,0.0249 Q0.9949,0.0249,0.9917,0.0257 T0.9863,0.0273 T0.9817,0.0308 T0.9776,0.0357 T0.9721,0.0437 T0.9651,0.0539 Q0.956,0.0657,0.9456,0.072 T0.9247,0.0789 T0.904,0.0775 T0.8823,0.0694 T0.862,0.0573 T0.8422,0.0432 Q0.7856,0,0.7328,0.0318 Q0.7392,0.0318,0.7452,0.0324 T0.756,0.0345 T0.7657,0.0386 T0.7741,0.0437 T0.7821,0.0506 T0.7894,0.0588 T0.7966,0.0683 T0.8039,0.0787 T0.8119,0.0904 T0.8202,0.1028 Q0.8249,0.1093,0.849,0.1234 T0.8811,0.1448 Q0.8998,0.1612,0.8893,0.1783 Q0.8835,0.1877,0.8759,0.1928 T0.8593,0.1979 T0.8417,0.1967 T0.8222,0.1895 T0.8034,0.1791 T0.7848,0.1663 T0.7687,0.1534 T0.7543,0.141 T0.744,0.1322 Q0.7186,0.1106,0.6984,0.101 T0.6559,0.0926 T0.6082,0.1077 Q0.5899,0.1175,0.5733,0.1206 T0.5411,0.1202 T0.5122,0.1098 T0.4822,0.0914 Q0.4927,0.1032,0.5036,0.1122 T0.5276,0.1285 T0.5503,0.1401 T0.5777,0.1518 T0.6055,0.1636 Q0.6258,0.1726,0.6375,0.1863 T0.6519,0.2162 Q0.6556,0.2403,0.6392,0.2605 T0.5926,0.2876 Q0.5804,0.2909,0.5672,0.2899 T0.5428,0.2856 T0.5193,0.2764 T0.4966,0.264 T0.4744,0.2497 T0.4523,0.2352 T0.4301,0.2222 T0.4077,0.2118 Q0.3021,0.173,0.1903,0.2436 Q0.1669,0.2583,0.1504,0.2674 T0.1129,0.2846 T0.075,0.294 T0.0384,0.2911 T0,0.2746 Q0.021,0.297,0.0459,0.3082 T0.0953,0.3207 T0.146,0.3193 T0.1988,0.3131 T0.2508,0.3095 T0.3029,0.3176 T0.3529,0.3448 Q0.3566,0.348,0.3652,0.355 T0.3771,0.3645 T0.3877,0.3731 T0.3993,0.3815 T0.4103,0.3884 T0.4226,0.3953 T0.4352,0.4009 T0.4497,0.4064 L0.4443,0.4121 Q0.4372,0.4198,0.4277,0.4251 T0.4072,0.4337 T0.3867,0.439 T0.3656,0.4431 T0.3481,0.4468 Q0.3241,0.4529,0.2943,0.4463 Q0.3227,0.4623,0.3458,0.471 T0.3959,0.4814 Q0.404,0.4818,0.4187,0.4825 T0.4409,0.4835 T0.4605,0.4857 T0.4809,0.4908 T0.4992,0.4996 T0.5186,0.5141 T0.5371,0.5353 L0.5449,0.5451 L0.5337,0.543 Q0.5242,0.541,0.5091,0.55 T0.4683,0.5796 T0.4314,0.6083 Q0.4203,0.6161,0.4086,0.6212 T0.382,0.6308 T0.3667,0.6353 Q0.3485,0.6418,0.2995,0.633 T0.1934,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.989,0.0469 Q0.9829,0.0379,0.9753,0.0316 T0.9635,0.0253 Q0.9574,0.0257,0.9539,0.0263 T0.9466,0.03 T0.9418,0.0345 T0.9355,0.0434 T0.9277,0.0546 Q0.9193,0.0653,0.9103,0.0712 T0.8919,0.0775 T0.8745,0.0765 T0.8564,0.0695 T0.8396,0.0593 T0.8228,0.0473 Q0.7612,0,0.7073,0.0326 L0.7386,0.0767 L0.7419,0.0767 Q0.7506,0.0767,0.7556,0.0773 T0.7641,0.0789 T0.7709,0.0842 T0.7767,0.0922 T0.7854,0.1054 T0.7974,0.1236 Q0.8057,0.1354,0.8157,0.1444 T0.8325,0.1574 T0.8474,0.1654 T0.8571,0.1701 Q0.8564,0.1717,0.8554,0.1733 Q0.8503,0.1815,0.844,0.186 T0.8301,0.1909 T0.8153,0.1903 T0.7993,0.1845 T0.7836,0.1758 T0.768,0.1648 T0.754,0.1535 T0.741,0.1425 T0.7312,0.1338 Q0.7031,0.1101,0.6805,0.0995 T0.6357,0.0901 T0.5886,0.1048 Q0.5721,0.1138,0.5574,0.1168 T0.5295,0.117 T0.5055,0.1089 T0.4814,0.0942 L0.4789,0.0926 L0.4711,0.0922 Q0.4589,0.095,0.4811,0.1203 Q0.495,0.1358,0.5087,0.1474 T0.5366,0.167 T0.5611,0.1796 T0.5879,0.1909 T0.6134,0.2011 Q0.6218,0.2047,0.6273,0.2092 Q0.6283,0.2117,0.6286,0.2141 Q0.6305,0.2263,0.6249,0.2388 T0.606,0.2616 T0.5747,0.2761 Q0.5602,0.2785,0.5487,0.2779 T0.5245,0.2724 T0.5015,0.2616 T0.4784,0.2476 T0.4547,0.2321 T0.4297,0.217 T0.4024,0.2043 Q0.2985,0.166,0.1901,0.2345 Q0.1694,0.248,0.1523,0.2569 T0.1165,0.2726 T0.0818,0.2812 T0.0494,0.2796 T0.0181,0.2663 L0.0116,0.2671 Q0,0.272,0.0232,0.2969 Q0.0468,0.3222,0.0742,0.3365 T0.1278,0.3542 T0.1807,0.3564 T0.233,0.352 T0.2815,0.3473 T0.3269,0.3509 T0.3656,0.3699 Q0.4037,0.4005,0.4153,0.4082 Q0.405,0.4139,0.3921,0.4174 T0.3633,0.4237 T0.3414,0.4278 Q0.3207,0.4331,0.2956,0.4278 Q0.2943,0.4278,0.293,0.4274 Q0.2933,0.429,0.2938,0.4313 T0.2964,0.4398 T0.3007,0.4513 T0.3069,0.4619 T0.3149,0.4698 Q0.3427,0.4857,0.3664,0.4947 T0.4169,0.5053 Q0.423,0.5057,0.4385,0.5065 T0.4606,0.508 T0.4779,0.51 T0.4958,0.5139 T0.5095,0.5204 Q0.4992,0.5232,0.485,0.5328 T0.451,0.5585 T0.4201,0.5824 Q0.4095,0.5897,0.3987,0.5946 T0.3738,0.6036 T0.3591,0.6081 Q0.3433,0.6134,0.2965,0.605 T0.1972,0.5816 L0.2026,0.5922 T0.2133,0.6134 L0.2188,0.624 Q0.2201,0.6248,0.2214,0.6252 Q0.2291,0.6297,0.2486,0.6405 T0.2786,0.657 T0.3066,0.6721 T0.3353,0.6866 T0.3598,0.6972 T0.383,0.7051 T0.4005,0.7076 Q0.4008,0.7076,0.4169,0.7092 T0.4471,0.7092 T0.475,0.7019 Q0.4895,0.6958,0.5176,0.6811 T0.5644,0.6586 T0.5921,0.6533 Q0.6128,0.6741,0.6173,0.6782 Q0.6131,0.6835,0.6068,0.6929 T0.5963,0.7078 T0.5858,0.7198 T0.5716,0.7304 T0.5534,0.7365 Q0.5179,0.7443,0.4708,0.728 L0.4708,0.7294 T0.471,0.7333 T0.4716,0.7392 T0.4734,0.7463 T0.4764,0.7539 T0.4814,0.7612 T0.4889,0.7679 Q0.4914,0.77,0.4944,0.7716 Q0.5534,0.8104,0.6409,0.8091 Q0.6521,0.8091,0.6654,0.8053 T0.6907,0.7959 T0.7157,0.7838 T0.741,0.772 T0.7652,0.7631 T0.7888,0.7594 T0.8106,0.7643 Q0.8196,0.7684,0.8267,0.7753 Q0.8409,0.7892,0.8399,0.8169 Q0.8216,0.8067,0.8062,0.8053 T0.7788,0.8087 T0.7493,0.825 Q0.7386,0.8324,0.7256,0.8365 T0.6947,0.844 T0.668,0.8499 L0.697,0.894 L0.7002,0.8948 Q0.7099,0.8968,0.7222,0.9009 T0.7446,0.9086 T0.767,0.9158 T0.7901,0.9213 T0.8132,0.9229 T0.8367,0.9192 Q0.8483,0.916,0.8633,0.9241 T0.8937,0.9462 T0.9248,0.9737 T0.959,0.9955 T0.9939,1 Q1,0.9984,0.9932,0.9845 Q0.991,0.9804,0.9877,0.9755 Q0.9813,0.9666,0.9739,0.9606 T0.9626,0.9555 Q0.9519,0.958,0.9372,0.949 T0.9071,0.9262 T0.8753,0.8987 T0.8403,0.8777 T0.8051,0.8752 Q0.787,0.8805,0.7657,0.8772 Q0.7751,0.8736,0.7828,0.8683 Q0.8086,0.8511,0.8262,0.8497 T0.8651,0.8617 Q0.8696,0.865,0.8727,0.8646 T0.8758,0.8605 L0.8761,0.8536 Q0.8774,0.8295,0.8646,0.8016 T0.8277,0.7496 Q0.8086,0.7308,0.7896,0.7219 Q0.7741,0.7145,0.7575,0.7141 T0.727,0.7182 T0.6978,0.7296 T0.6699,0.7439 T0.6433,0.7571 T0.6179,0.7643 Q0.627,0.7561,0.6402,0.7367 T0.6618,0.7088 L0.6673,0.7035 Q0.6718,0.699,0.6647,0.6884 T0.6483,0.6692 L0.6389,0.6607 Q0.6289,0.657,0.6249,0.6554 T0.6163,0.6507 T0.6102,0.6458 T0.6021,0.637 T0.5889,0.6236 Q0.5828,0.6175,0.5766,0.6138 Q0.5673,0.6081,0.5561,0.6077 T0.5345,0.6105 T0.5116,0.6201 T0.4887,0.633 T0.4653,0.6468 T0.4427,0.6578 Q0.4295,0.6635,0.4167,0.665 T0.3888,0.665 T0.3714,0.6635 Q0.3579,0.6631,0.3282,0.6501 Q0.374,0.6578,0.3911,0.6517 Q0.3937,0.6509,0.4024,0.6482 T0.4179,0.6434 T0.4348,0.6364 T0.4534,0.6256 Q0.464,0.6183,0.4892,0.5989 T0.5278,0.5712 T0.5476,0.564 L0.5582,0.5661 Q0.5692,0.5681,0.5634,0.5563 Q0.5599,0.5485,0.5524,0.5392 L0.545,0.5298 Q0.5369,0.5188,0.5286,0.51 T0.5134,0.4947 T0.4979,0.4831 T0.484,0.4749 T0.47,0.4694 T0.4572,0.4657 T0.4442,0.4637 T0.4326,0.4629 T0.4205,0.4625 Q0.4511,0.4543,0.466,0.4384 L0.4714,0.4327 Q0.4795,0.4237,0.4469,0.394 L0.4421,0.3899 Q0.433,0.387,0.4251,0.3834 T0.4122,0.3772 T0.3988,0.3687 T0.3874,0.3603 T0.3732,0.3487 T0.3579,0.3361 Q0.3433,0.3242,0.3288,0.3159 T0.2993,0.3034 T0.2725,0.2973 T0.2446,0.2961 T0.2193,0.2977 T0.1926,0.301 Q0.1855,0.3022,0.182,0.3026 Q0.1991,0.2936,0.2233,0.2781 Q0.3282,0.2117,0.4256,0.2476 Q0.4363,0.2516,0.451,0.2604 T0.4784,0.2779 T0.5077,0.2965 T0.539,0.3124 T0.5718,0.3216 T0.606,0.3206 Q0.6363,0.3136,0.6526,0.2936 T0.6647,0.2459 Q0.6615,0.2247,0.645,0.2023 Q0.6354,0.1892,0.6237,0.179 Q0.6102,0.1672,0.5937,0.1595 Q0.6073,0.1562,0.6215,0.1485 Q0.6396,0.1387,0.6547,0.1354 T0.6839,0.1354 T0.7101,0.1458 T0.7373,0.166 Q0.738,0.1664,0.746,0.1733 T0.7586,0.1841 T0.7733,0.196 T0.7907,0.2086 T0.8086,0.2196 T0.8275,0.2288 T0.8454,0.2337 T0.8627,0.2343 T0.8777,0.228 T0.8903,0.2145 Q0.9038,0.1929,0.8742,0.1582 Q0.869,0.1517,0.8629,0.1464 Q0.8538,0.1387,0.844,0.1321 T0.8248,0.1205 T0.8122,0.1134 Q0.8109,0.1117,0.8053,0.103 T0.7965,0.0899 T0.7875,0.0781 T0.7757,0.064 Q0.8051,0.0616,0.8328,0.0828 Q0.847,0.0934,0.8588,0.1009 T0.8854,0.1146 T0.9127,0.1213 T0.9384,0.116 T0.9626,0.0962 Q0.9664,0.0909,0.9706,0.0846 T0.976,0.0767 T0.9798,0.0728 T0.9852,0.0704 T0.9932,0.0697 Q0.9997,0.0693,0.9942,0.0567 Q0.9923,0.0522,0.989,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0.9099,0.1158 L0.9099,0.1888 L0.9951,0.1888 L0.9951,0.1158 L0.9099,0.1158 Z M0.9075,0.1116 L0.9976,0.1116 Q1,0.1116,1,0.1137 L1,0.1909 Q1,0.193,0.9976,0.193 L0.9075,0.193 Q0.905,0.193,0.905,0.1909 L0.905,0.1137 Q0.905,0.1116,0.9075,0.1116 Z M0.7852,0.6086 L0.7852,0.682 L0.8704,0.682 L0.8704,0.6086 L0.7852,0.6086 Z M0.7828,0.6044 L0.8729,0.6044 Q0.8753,0.6044,0.8753,0.6065 L0.8753,0.6841 Q0.8753,0.6862,0.8729,0.6862 L0.7828,0.6862 Q0.7803,0.6862,0.7803,0.6841 L0.7803,0.6065 Q0.7803,0.6044,0.7828,0.6044 Z M0.66,0.1137 L0.7501,0.1137 L0.7501,0.1909 L0.66,0.1909 L0.66,0.1137 Z M0.6624,0.4102 L0.6624,0.4836 L0.7477,0.4836 L0.7477,0.4102 L0.6624,0.4102 Z M0.66,0.406 L0.7501,0.406 Q0.7526,0.406,0.7526,0.4081 L0.7526,0.4857 Q0.7526,0.4878,0.7501,0.4878 L0.66,0.4878 Q0.6576,0.4878,0.6576,0.4857 L0.6576,0.4081 Q0.6576,0.406,0.66,0.406 Z M0.6624,0.7131 L0.6624,0.7865 L0.7477,0.7865 L0.7477,0.7131 L0.6624,0.7131 Z M0.66,0.7089 L0.7501,0.7089 Q0.7526,0.7089,0.7526,0.711 L0.7526,0.7886 Q0.7526,0.7907,0.7501,0.7907 L0.66,0.7907 Q0.6576,0.7907,0.6576,0.7886 L0.6576,0.711 Q0.6576,0.7089,0.66,0.7089 Z M0.5407,0.2227 L0.5407,0.2836 L0.6113,0.2836 L0.6113,0.2227 L0.5407,0.2227 Z M0.5309,0.206 L0.621,0.206 Q0.623,0.206,0.6249,0.2068 T0.6281,0.2089 T0.6301,0.2116 T0.6308,0.2143 L0.6308,0.2919 Q0.6308,0.2957,0.6276,0.298 T0.621,0.3003 L0.5309,0.3003 Q0.5265,0.3003,0.5239,0.2976 T0.5212,0.2919 L0.5212,0.2143 Q0.5212,0.211,0.5244,0.2085 T0.5309,0.206 Z M0.5407,0.3184 L0.5407,0.3792 L0.6113,0.3792 L0.6113,0.3184 L0.5407,0.3184 Z M0.5309,0.3016 L0.621,0.3016 Q0.6254,0.3016,0.6281,0.3043 T0.6308,0.31 L0.6308,0.3876 Q0.6308,0.3914,0.6276,0.3937 T0.621,0.396 L0.5309,0.396 Q0.5265,0.396,0.5239,0.3932 T0.5212,0.3876 L0.5212,0.31 Q0.5212,0.3062,0.5244,0.3039 T0.5309,0.3016 Z M0.5309,0.4081 L0.621,0.4081 L0.621,0.4857 L0.5309,0.4857 L0.5309,0.4081 Z M0.5309,0.5059 L0.621,0.5059 L0.621,0.5835 L0.5309,0.5835 L0.5309,0.5059 Z M0.5407,0.6149 L0.5407,0.6758 L0.6113,0.6758 L0.6113,0.6149 L0.5407,0.6149 Z M0.5309,0.5982 L0.621,0.5982 Q0.6254,0.5982,0.6281,0.6009 T0.6308,0.6065 L0.6308,0.6841 Q0.6308,0.6879,0.6276,0.6902 T0.621,0.6925 L0.5309,0.6925 Q0.5265,0.6925,0.5239,0.6898 T0.5212,0.6841 L0.5212,0.6065 Q0.5212,0.6028,0.5244,0.6005 T0.5309,0.5982 Z M0.4062,0.1137 L0.4963,0.1137 L0.4963,0.1909 L0.4062,0.1909 L0.4062,0.1137 Z M0.4062,0.2143 L0.4963,0.2143 L0.4963,0.2919 L0.4062,0.2919 L0.4062,0.2143 Z M0.4184,0.4186 L0.4184,0.4753 L0.4842,0.4753 L0.4842,0.4186 L0.4184,0.4186 Z M0.4062,0.3977 L0.4963,0.3977 Q0.5017,0.3977,0.5051,0.401 T0.5085,0.4081 L0.5085,0.4857 Q0.5085,0.4904,0.5046,0.4933 T0.4963,0.4962 L0.4062,0.4962 Q0.4009,0.4962,0.3975,0.4929 T0.3941,0.4857 L0.3941,0.4081 Q0.3941,0.4035,0.398,0.4006 T0.4062,0.3977 Z M0.4062,0.5059 L0.4963,0.5059 L0.4963,0.5835 L0.4062,0.5835 L0.4062,0.5059 Z M0.4087,0.7131 L0.4087,0.7865 L0.4939,0.7865 L0.4939,0.7131 L0.4087,0.7131 Z M0.4062,0.7089 L0.4963,0.7089 Q0.4988,0.7089,0.4988,0.711 L0.4988,0.7886 Q0.4988,0.7907,0.4963,0.7907 L0.4062,0.7907 Q0.4038,0.7907,0.4038,0.7886 L0.4038,0.711 Q0.4038,0.7089,0.4062,0.7089 Z M0.2689,0.1137 L0.359,0.1137 L0.359,0.1909 L0.2689,0.1909 L0.2689,0.1137 Z M0.2689,0.2143 L0.359,0.2143 L0.359,0.2919 L0.2689,0.2919 L0.2689,0.2143 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.31 L0.359,0.31 L0.359,0.3876 L0.2689,0.3876 L0.2689,0.31 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.4081 L0.359,0.4081 L0.359,0.4857 L0.2689,0.4857 L0.2689,0.4081 Z M0.2689,0.5059 L0.359,0.5059 L0.359,0.5835 L0.2689,0.5835 L0.2689,0.5059 Z M0.2689,0.711 L0.359,0.711 L0.359,0.7886 L0.2689,0.7886 L0.2689,0.711 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.1374,0.1137 L0.2275,0.1137 L0.2275,0.1909 L0.1374,0.1909 L0.1374,0.1137 Z M0.1374,0.2143 L0.2275,0.2143 L0.2275,0.2919 L0.1374,0.2919 L0.1374,0.2143 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.31 L0.2275,0.31 L0.2275,0.3876 L0.1374,0.3876 L0.1374,0.31 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.4081 L0.2275,0.4081 L0.2275,0.4857 L0.1374,0.4857 L0.1374,0.4081 Z M0.1374,0.5059 L0.2275,0.5059 L0.2275,0.5835 L0.1374,0.5835 L0.1374,0.5059 Z M0.1374,0.6065 L0.2275,0.6065 L0.2275,0.6841 L0.1374,0.6841 L0.1374,0.6065 Z M0.1374,0.711 L0.2275,0.711 L0.2275,0.7886 L0.1374,0.7886 L0.1374,0.711 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0,0.1137 L0.0901,0.1137 L0.0901,0.1909 L0,0.1909 L0,0.1137 Z M0,0.2143 L0.0901,0.2143 L0.0901,0.2919 L0,0.2919 L0,0.2143 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.31 L0.0901,0.31 L0.0901,0.3876 L0,0.3876 L0,0.31 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.4081 L0.0901,0.4081 L0.0901,0.4857 L0,0.4857 L0,0.4081 Z M0,0.5059 L0.0901,0.5059 L0.0901,0.5835 L0,0.5835 L0,0.5059 Z M0,0.6065 L0.0901,0.6065 L0.0901,0.6841 L0,0.6841 L0,0.6065 Z M0,0.711 L0.0901,0.711 L0.0901,0.7886 L0,0.7886 L0,0.711 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M0.0901,0.1158 L0.0901,0.1888 L0.0049,0.1888 L0.0049,0.1158 L0.0901,0.1158 Z M0.0925,0.1116 L0.0024,0.1116 Q0,0.1116,0,0.1137 L0,0.1909 Q0,0.193,0.0024,0.193 L0.0925,0.193 Q0.095,0.193,0.095,0.1909 L0.095,0.1137 Q0.095,0.1116,0.0925,0.1116 Z M0.2148,0.6086 L0.2148,0.682 L0.1296,0.682 L0.1296,0.6086 L0.2148,0.6086 Z M0.2172,0.6044 L0.1271,0.6044 Q0.1247,0.6044,0.1247,0.6065 L0.1247,0.6841 Q0.1247,0.6862,0.1271,0.6862 L0.2172,0.6862 Q0.2197,0.6862,0.2197,0.6841 L0.2197,0.6065 Q0.2197,0.6044,0.2172,0.6044 Z M0.34,0.1137 L0.2499,0.1137 L0.2499,0.1909 L0.34,0.1909 L0.34,0.1137 Z M0.3376,0.4102 L0.3376,0.4836 L0.2523,0.4836 L0.2523,0.4102 L0.3376,0.4102 Z M0.34,0.406 L0.2499,0.406 Q0.2474,0.406,0.2474,0.4081 L0.2474,0.4857 Q0.2474,0.4878,0.2499,0.4878 L0.34,0.4878 Q0.3424,0.4878,0.3424,0.4857 L0.3424,0.4081 Q0.3424,0.406,0.34,0.406 Z M0.3376,0.7131 L0.3376,0.7865 L0.2523,0.7865 L0.2523,0.7131 L0.3376,0.7131 Z M0.34,0.7089 L0.2499,0.7089 Q0.2474,0.7089,0.2474,0.711 L0.2474,0.7886 Q0.2474,0.7907,0.2499,0.7907 L0.34,0.7907 Q0.3424,0.7907,0.3424,0.7886 L0.3424,0.711 Q0.3424,0.7089,0.34,0.7089 Z M0.4593,0.2227 L0.4593,0.2836 L0.3887,0.2836 L0.3887,0.2227 L0.4593,0.2227 Z M0.4691,0.206 L0.379,0.206 Q0.3746,0.206,0.3719,0.2087 T0.3692,0.2143 L0.3692,0.2919 Q0.3692,0.2957,0.3724,0.298 T0.379,0.3003 L0.4691,0.3003 Q0.4735,0.3003,0.4761,0.2976 T0.4788,0.2919 L0.4788,0.2143 Q0.4788,0.211,0.4756,0.2085 T0.4691,0.206 Z M0.4593,0.3184 L0.4593,0.3792 L0.3887,0.3792 L0.3887,0.3184 L0.4593,0.3184 Z M0.4691,0.3016 L0.379,0.3016 Q0.3746,0.3016,0.3719,0.3043 T0.3692,0.31 L0.3692,0.3876 Q0.3692,0.3914,0.3724,0.3937 T0.379,0.396 L0.4691,0.396 Q0.4735,0.396,0.4761,0.3932 T0.4788,0.3876 L0.4788,0.31 Q0.4788,0.3062,0.4756,0.3039 T0.4691,0.3016 Z M0.4691,0.4081 L0.379,0.4081 L0.379,0.4857 L0.4691,0.4857 L0.4691,0.4081 Z M0.4691,0.5059 L0.379,0.5059 L0.379,0.5835 L0.4691,0.5835 L0.4691,0.5059 Z M0.4593,0.6149 L0.4593,0.6758 L0.3887,0.6758 L0.3887,0.6149 L0.4593,0.6149 Z M0.4691,0.5982 L0.379,0.5982 Q0.3746,0.5982,0.3719,0.6009 T0.3692,0.6065 L0.3692,0.6841 Q0.3692,0.6879,0.3724,0.6902 T0.379,0.6925 L0.4691,0.6925 Q0.4735,0.6925,0.4761,0.6898 T0.4788,0.6841 L0.4788,0.6065 Q0.4788,0.6028,0.4756,0.6005 T0.4691,0.5982 Z M0.5938,0.1137 L0.5037,0.1137 L0.5037,0.1909 L0.5938,0.1909 L0.5938,0.1137 Z M0.5938,0.2143 L0.5037,0.2143 L0.5037,0.2919 L0.5938,0.2919 L0.5938,0.2143 Z M0.5816,0.4186 L0.5816,0.4753 L0.5158,0.4753 L0.5158,0.4186 L0.5816,0.4186 Z M0.5938,0.3977 L0.5037,0.3977 Q0.4983,0.3977,0.4949,0.401 T0.4915,0.4081 L0.4915,0.4857 Q0.4915,0.4904,0.4954,0.4933 T0.5037,0.4962 L0.5938,0.4962 Q0.5991,0.4962,0.6025,0.4929 T0.6059,0.4857 L0.6059,0.4081 Q0.6059,0.4035,0.602,0.4006 T0.5938,0.3977 Z M0.5938,0.5059 L0.5037,0.5059 L0.5037,0.5835 L0.5938,0.5835 L0.5938,0.5059 Z M0.5913,0.7131 L0.5913,0.7865 L0.5061,0.7865 L0.5061,0.7131 L0.5913,0.7131 Z M0.5938,0.7089 L0.5037,0.7089 Q0.5012,0.7089,0.5012,0.711 L0.5012,0.7886 Q0.5012,0.7907,0.5037,0.7907 L0.5938,0.7907 Q0.5962,0.7907,0.5962,0.7886 L0.5962,0.711 Q0.5962,0.7089,0.5938,0.7089 Z M0.7311,0.1137 L0.641,0.1137 L0.641,0.1909 L0.7311,0.1909 L0.7311,0.1137 Z M0.7311,0.2143 L0.641,0.2143 L0.641,0.2919 L0.7311,0.2919 L0.7311,0.2143 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.31 L0.641,0.31 L0.641,0.3876 L0.7311,0.3876 L0.7311,0.31 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.4081 L0.641,0.4081 L0.641,0.4857 L0.7311,0.4857 L0.7311,0.4081 Z M0.7311,0.5059 L0.641,0.5059 L0.641,0.5835 L0.7311,0.5835 L0.7311,0.5059 Z M0.7311,0.711 L0.641,0.711 L0.641,0.7886 L0.7311,0.7886 L0.7311,0.711 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.8626,0.1137 L0.7725,0.1137 L0.7725,0.1909 L0.8626,0.1909 L0.8626,0.1137 Z M0.8626,0.2143 L0.7725,0.2143 L0.7725,0.2919 L0.8626,0.2919 L0.8626,0.2143 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.31 L0.7725,0.31 L0.7725,0.3876 L0.8626,0.3876 L0.8626,0.31 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.4081 L0.7725,0.4081 L0.7725,0.4857 L0.8626,0.4857 L0.8626,0.4081 Z M0.8626,0.5059 L0.7725,0.5059 L0.7725,0.5835 L0.8626,0.5835 L0.8626,0.5059 Z M0.8626,0.6065 L0.7725,0.6065 L0.7725,0.6841 L0.8626,0.6841 L0.8626,0.6065 Z M0.8626,0.711 L0.7725,0.711 L0.7725,0.7886 L0.8626,0.7886 L0.8626,0.711 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M1,0.1137 L0.9099,0.1137 L0.9099,0.1909 L1,0.1909 L1,0.1137 Z M1,0.2143 L0.9099,0.2143 L0.9099,0.2919 L1,0.2919 L1,0.2143 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.31 L0.9099,0.31 L0.9099,0.3876 L1,0.3876 L1,0.31 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.4081 L0.9099,0.4081 L0.9099,0.4857 L1,0.4857 L1,0.4081 Z M1,0.5059 L0.9099,0.5059 L0.9099,0.5835 L1,0.5835 L1,0.5059 Z M1,0.6065 L0.9099,0.6065 L0.9099,0.6841 L1,0.6841 L1,0.6065 Z M1,0.711 L0.9099,0.711 L0.9099,0.7886 L1,0.7886 L1,0.711 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0.8574,0.3217 L0.8574,0.4098 L0.9691,0.4098 L0.9691,0.3217 L0.8574,0.3217 Z M0.8419,0.2978 L0.9846,0.2978 Q0.9892,0.2978,0.9928,0.2997 T0.9982,0.3043 T1,0.31 L1,0.4216 Q1,0.427,0.9951,0.4304 T0.9846,0.4337 L0.8419,0.4337 Q0.8352,0.4337,0.8308,0.4299 T0.8265,0.4216 L0.8265,0.31 Q0.8265,0.3062,0.829,0.3035 T0.835,0.2993 T0.8419,0.2978 Z M0.8574,0.888 L0.8574,0.9757 L0.9691,0.9757 L0.9691,0.888 L0.8574,0.888 Z M0.8419,0.8637 L0.9846,0.8637 Q0.9892,0.8637,0.9928,0.8658 T0.9982,0.8706 T1,0.8758 L1,0.9878 Q1,0.9929,0.9951,0.9964 T0.9846,1 L0.8419,1 Q0.8352,1,0.8308,0.996 T0.8265,0.9878 L0.8265,0.8758 Q0.8265,0.8708,0.8314,0.8672 T0.8419,0.8637 Z M0.6509,0.169 L0.6509,0.2706 L0.7806,0.2706 L0.7806,0.169 L0.6509,0.169 Z M0.6442,0.159 L0.7868,0.159 Q0.7899,0.159,0.7917,0.1604 T0.7935,0.164 L0.7935,0.2756 Q0.7935,0.2781,0.7915,0.2796 T0.7868,0.281 L0.6442,0.281 Q0.6416,0.281,0.6395,0.2794 T0.6375,0.2756 L0.6375,0.164 Q0.6375,0.1619,0.6398,0.1604 T0.6442,0.159 Z M0.6632,0.6044 L0.6632,0.6858 L0.7678,0.6858 L0.7678,0.6044 L0.6632,0.6044 Z M0.6442,0.5742 L0.7868,0.5742 Q0.7951,0.5742,0.8007,0.5791 T0.8064,0.5893 L0.8064,0.7013 Q0.8064,0.7076,0.8002,0.712 T0.7868,0.7164 L0.6442,0.7164 Q0.6359,0.7164,0.6305,0.7114 T0.6251,0.7013 L0.6251,0.5893 Q0.6251,0.5847,0.6282,0.5812 T0.6357,0.5759 T0.6442,0.5742 Z M0.4264,0.164 L0.5695,0.164 L0.5695,0.2756 L0.4264,0.2756 L0.4264,0.164 Z M0.4325,0.315 L0.4325,0.4165 L0.5628,0.4165 L0.5628,0.315 L0.4325,0.315 Z M0.4264,0.3049 L0.5695,0.3049 Q0.5721,0.3049,0.5739,0.3064 T0.5757,0.31 L0.5757,0.4216 Q0.5757,0.4237,0.5736,0.4251 T0.5695,0.4266 L0.4264,0.4266 Q0.4238,0.4266,0.422,0.4251 T0.4202,0.4216 L0.4202,0.31 Q0.4202,0.3087,0.4207,0.3077 T0.4222,0.306 T0.4243,0.3052 T0.4264,0.3049 Z M0.4264,0.4476 L0.5695,0.4476 L0.5695,0.5596 L0.4264,0.5596 L0.4264,0.4476 Z M0.4392,0.7408 L0.4392,0.8326 L0.5561,0.8326 L0.5561,0.7408 L0.4392,0.7408 Z M0.4264,0.7206 L0.5695,0.7206 Q0.5747,0.7206,0.5783,0.724 T0.5819,0.7307 L0.5819,0.8427 Q0.5819,0.8469,0.5778,0.8498 T0.5695,0.8528 L0.4264,0.8528 Q0.4207,0.8528,0.4171,0.8496 T0.4135,0.8427 L0.4135,0.7307 Q0.4135,0.7265,0.4176,0.7236 T0.4264,0.7206 Z M0.2178,0.164 L0.3605,0.164 L0.3605,0.2756 L0.2178,0.2756 L0.2178,0.164 Z M0.2178,0.31 L0.3605,0.31 L0.3605,0.4216 L0.2178,0.4216 L0.2178,0.31 Z M0.2178,0.5893 L0.3605,0.5893 L0.3605,0.7013 L0.2178,0.7013 L0.2178,0.5893 Z M0.2178,0.7307 L0.3605,0.7307 L0.3605,0.8427 L0.2178,0.8427 L0.2178,0.7307 Z M0.2178,0.8758 L0.3605,0.8758 L0.3605,0.9878 L0.2178,0.9878 L0.2178,0.8758 Z M0,0.164 L0.1432,0.164 L0.1432,0.2756 L0,0.2756 L0,0.164 Z M0,0.31 L0.1432,0.31 L0.1432,0.4216 L0,0.4216 L0,0.31 Z M0,0.4476 L0.1432,0.4476 L0.1432,0.5596 L0,0.5596 L0,0.4476 Z M0,0.5893 L0.1432,0.5893 L0.1432,0.7013 L0,0.7013 L0,0.5893 Z M0,0.7307 L0.1432,0.7307 L0.1432,0.8427 L0,0.8427 L0,0.7307 Z M0,0.8758 L0.1432,0.8758 L0.1432,0.9878 L0,0.9878 L0,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M0.1432,0.3217 L0.1432,0.4098 L0.0309,0.4098 L0.0309,0.3217 L0.1432,0.3217 Z M0.1586,0.2978 L0.0154,0.2978 Q0.0088,0.2978,0.0044,0.3016 T0,0.31 L0,0.4216 Q0,0.427,0.0049,0.4304 T0.0154,0.4337 L0.1586,0.4337 Q0.1648,0.4337,0.1694,0.4299 T0.174,0.4216 L0.174,0.31 Q0.174,0.3062,0.1715,0.3035 T0.1653,0.2993 T0.1586,0.2978 Z M0.1432,0.888 L0.1432,0.9757 L0.0309,0.9757 L0.0309,0.888 L0.1432,0.888 Z M0.1586,0.8637 L0.0154,0.8637 Q0.0108,0.8637,0.0072,0.8658 T0.0018,0.8706 T0,0.8758 L0,0.9878 Q0,0.9929,0.0049,0.9964 T0.0154,1 L0.1586,1 Q0.1648,1,0.1694,0.996 T0.174,0.9878 L0.174,0.8758 Q0.174,0.8725,0.1715,0.8695 T0.1653,0.8651 T0.1586,0.8637 Z M0.3496,0.169 L0.3496,0.2706 L0.2194,0.2706 L0.2194,0.169 L0.3496,0.169 Z M0.3563,0.159 L0.2132,0.159 Q0.2106,0.159,0.2088,0.1604 T0.207,0.164 L0.207,0.2756 Q0.207,0.2781,0.2091,0.2796 T0.2132,0.281 L0.3563,0.281 Q0.3589,0.281,0.3607,0.2794 T0.3625,0.2756 L0.3625,0.164 Q0.3625,0.1619,0.3605,0.1604 T0.3563,0.159 Z M0.3368,0.6044 L0.3368,0.6858 L0.2327,0.6858 L0.2327,0.6044 L0.3368,0.6044 Z M0.3563,0.5742 L0.2132,0.5742 Q0.2049,0.5742,0.1993,0.5791 T0.1936,0.5893 L0.1936,0.7013 Q0.1936,0.7076,0.2001,0.712 T0.2132,0.7164 L0.3563,0.7164 Q0.3641,0.7164,0.3697,0.7114 T0.3754,0.7013 L0.3754,0.5893 Q0.3754,0.5847,0.3723,0.5812 T0.3648,0.5759 T0.3563,0.5742 Z M0.5736,0.164 L0.431,0.164 L0.431,0.2756 L0.5736,0.2756 L0.5736,0.164 Z M0.5675,0.315 L0.5675,0.4165 L0.4377,0.4165 L0.4377,0.315 L0.5675,0.315 Z M0.5736,0.3049 L0.431,0.3049 Q0.4284,0.3049,0.4264,0.3064 T0.4243,0.31 L0.4243,0.4216 Q0.4243,0.4237,0.4266,0.4251 T0.431,0.4266 L0.5736,0.4266 Q0.5767,0.4266,0.5785,0.4251 T0.5803,0.4216 L0.5803,0.31 Q0.5803,0.3087,0.5798,0.3077 T0.5783,0.306 T0.5762,0.3052 T0.5736,0.3049 Z M0.5736,0.4476 L0.431,0.4476 L0.431,0.5596 L0.5736,0.5596 L0.5736,0.4476 Z M0.5613,0.7408 L0.5613,0.8326 L0.4439,0.8326 L0.4439,0.7408 L0.5613,0.7408 Z M0.5736,0.7206 L0.431,0.7206 Q0.4253,0.7206,0.4217,0.724 T0.4181,0.7307 L0.4181,0.8427 Q0.4181,0.8469,0.4222,0.8498 T0.431,0.8528 L0.5736,0.8528 Q0.5778,0.8528,0.5808,0.8511 T0.5855,0.8471 T0.587,0.8427 L0.587,0.7307 Q0.587,0.7265,0.5826,0.7236 T0.5736,0.7206 Z M0.7827,0.164 L0.6395,0.164 L0.6395,0.2756 L0.7827,0.2756 L0.7827,0.164 Z M0.7827,0.31 L0.6395,0.31 L0.6395,0.4216 L0.7827,0.4216 L0.7827,0.31 Z M0.7827,0.5893 L0.6395,0.5893 L0.6395,0.7013 L0.7827,0.7013 L0.7827,0.5893 Z M0.7827,0.7307 L0.6395,0.7307 L0.6395,0.8427 L0.7827,0.8427 L0.7827,0.7307 Z M0.7827,0.8758 L0.6395,0.8758 L0.6395,0.9878 L0.7827,0.9878 L0.7827,0.8758 Z M1,0.164 L0.8574,0.164 L0.8574,0.2756 L1,0.2756 L1,0.164 Z M1,0.31 L0.8574,0.31 L0.8574,0.4216 L1,0.4216 L1,0.31 Z M1,0.4476 L0.8574,0.4476 L0.8574,0.5596 L1,0.5596 L1,0.4476 Z M1,0.5893 L0.8574,0.5893 L0.8574,0.7013 L1,0.7013 L1,0.5893 Z M1,0.7307 L0.8574,0.7307 L0.8574,0.8427 L1,0.8427 L1,0.7307 Z M1,0.8758 L0.8574,0.8758 L0.8574,0.9878 L1,0.9878 L1,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.0008,0.9979 Q0.0008,1,0.0032,1 Q0.0482,1,0.0602,0.9975 Q0.0667,0.9963,0.0847,0.9917 T0.1084,0.9859 Q0.1124,0.9851,0.1221,0.9851 L0.1253,0.9851 L0.1285,0.9851 Q0.1518,0.9851,0.1655,0.9809 Q0.1695,0.9801,0.1731,0.9788 T0.1791,0.9766 T0.1839,0.9747 T0.1888,0.9732 L0.1936,0.972 T0.1988,0.9712 T0.2048,0.971 L0.2096,0.971 Q0.2201,0.9714,0.2233,0.9714 T0.2345,0.9693 Q0.2386,0.9685,0.2502,0.9674 T0.2715,0.966 L0.2811,0.9656 Q0.2916,0.9647,0.2916,0.9573 Q0.2916,0.954,0.2892,0.9515 T0.2819,0.9486 Q0.2755,0.9482,0.2683,0.9482 Q0.2643,0.9482,0.2582,0.9484 T0.2498,0.9486 Q0.2426,0.9486,0.2402,0.9473 Q0.2386,0.9469,0.2317,0.943 T0.2161,0.937 Q0.2104,0.9357,0.1992,0.933 T0.1831,0.9291 Q0.1807,0.9287,0.1783,0.9285 T0.1739,0.928 T0.1703,0.9276 T0.1675,0.9272 T0.1655,0.9266 T0.1639,0.9253 Q0.1598,0.9216,0.1462,0.9216 L0.143,0.9216 L0.1398,0.9216 L0.1365,0.9216 Q0.1197,0.9195,0.1197,0.9154 L0.1197,0.9154 Q0.1205,0.9121,0.1373,0.9088 T0.1703,0.9054 Q0.1791,0.9054,0.1863,0.904 T0.1984,0.9011 T0.2064,0.8992 Q0.2402,0.8967,0.2506,0.8934 Q0.2538,0.8926,0.2602,0.8899 T0.2707,0.8872 Q0.2715,0.8872,0.2731,0.8876 Q0.2795,0.8884,0.2884,0.8884 Q0.3068,0.8884,0.3092,0.8826 L0.3092,0.8797 Q0.3092,0.8772,0.3076,0.8747 T0.3044,0.8706 T0.3028,0.8681 Q0.3076,0.8656,0.3237,0.8656 Q0.3317,0.8656,0.3622,0.8623 Q0.3655,0.8623,0.3867,0.8617 T0.4217,0.8602 Q0.4241,0.8598,0.4289,0.8598 L0.4345,0.8598 L0.441,0.8598 Q0.4578,0.8598,0.4667,0.8586 Q0.4739,0.8573,0.4851,0.8567 T0.5004,0.8552 Q0.5012,0.8552,0.5108,0.855 T0.5269,0.853 T0.5333,0.8461 Q0.5333,0.8445,0.5325,0.8432 T0.5309,0.8409 T0.5289,0.8391 T0.5261,0.8378 L0.5229,0.837 T0.5185,0.8362 T0.5133,0.8355 T0.5076,0.8349 T0.502,0.8341 Q0.4932,0.8324,0.4859,0.8324 Q0.4827,0.8324,0.4787,0.8328 T0.4731,0.8333 Q0.4707,0.8333,0.4683,0.8324 Q0.4482,0.825,0.4394,0.825 Q0.4369,0.825,0.4345,0.8254 Q0.4225,0.827,0.4153,0.827 Q0.4088,0.827,0.4064,0.8258 Q0.3976,0.8204,0.3751,0.8204 Q0.3703,0.8204,0.3667,0.8214 T0.3622,0.8225 Q0.3598,0.8225,0.3558,0.82 Q0.351,0.8175,0.3402,0.8167 T0.3177,0.8156 T0.3028,0.8146 L0.2956,0.8121 T0.2871,0.8094 T0.2771,0.8073 T0.2635,0.8063 Q0.261,0.8063,0.2586,0.8067 Q0.2378,0.8071,0.2273,0.8071 Q0.2072,0.8071,0.2024,0.8051 Q0.1976,0.8034,0.1932,0.8026 T0.1867,0.8013 T0.1847,0.8001 L0.1847,0.7993 Q0.1855,0.7984,0.1855,0.7978 T0.1859,0.7966 T0.1867,0.7959 T0.188,0.7955 T0.1896,0.7951 T0.1916,0.7949 T0.1944,0.7945 T0.1984,0.7941 T0.2032,0.7939 Q0.212,0.7926,0.2325,0.7918 T0.2562,0.7905 Q0.2691,0.7893,0.2787,0.7847 Q0.2835,0.7827,0.2968,0.7791 T0.3149,0.7756 Q0.3189,0.7756,0.3305,0.7733 T0.3446,0.771 L0.3454,0.771 Q0.3462,0.771,0.3498,0.7721 T0.3574,0.774 T0.3655,0.7748 Q0.3703,0.7748,0.3743,0.7731 T0.3823,0.7708 T0.3916,0.77 T0.3992,0.7694 Q0.4008,0.7694,0.4076,0.7688 T0.4205,0.7675 L0.4305,0.7665 Q0.4345,0.7657,0.4402,0.7617 T0.4458,0.7545 Q0.4458,0.7503,0.4353,0.7487 Q0.4297,0.7478,0.4209,0.7462 T0.4044,0.7433 T0.3904,0.7416 L0.3847,0.7416 Q0.3807,0.7416,0.3747,0.7422 T0.3663,0.7428 T0.3606,0.7424 Q0.3582,0.742,0.3562,0.7418 T0.3522,0.7412 T0.3482,0.7406 T0.3446,0.7401 T0.341,0.7397 T0.3373,0.7393 T0.3341,0.7391 L0.3293,0.7391 Q0.3277,0.7395,0.3253,0.7395 Q0.3205,0.7395,0.3052,0.7387 T0.2835,0.7379 Q0.2755,0.7379,0.253,0.7325 Q0.2506,0.7316,0.2462,0.731 T0.2382,0.7296 T0.2305,0.7271 T0.2181,0.7234 T0.2048,0.7196 T0.2,0.7155 Q0.2,0.7142,0.2016,0.713 Q0.208,0.7059,0.2285,0.6978 T0.2618,0.6898 Q0.2739,0.6898,0.2831,0.6848 T0.2956,0.6794 Q0.3028,0.6786,0.3289,0.6765 T0.3566,0.674 Q0.3582,0.674,0.3855,0.6723 T0.4406,0.6688 T0.4755,0.6653 Q0.5004,0.6607,0.5124,0.6599 L0.5165,0.6599 Q0.5213,0.6599,0.5277,0.6605 T0.5373,0.6611 Q0.543,0.6611,0.5494,0.6599 Q0.592,0.6524,0.6,0.6524 Q0.6064,0.6524,0.61,0.6499 T0.6161,0.6474 Q0.6177,0.6474,0.6261,0.6495 T0.6418,0.6516 L0.6482,0.6516 Q0.653,0.6512,0.6651,0.6506 T0.6876,0.6493 T0.7084,0.6477 T0.7229,0.645 Q0.7269,0.6437,0.7333,0.6437 Q0.7373,0.6437,0.7458,0.6443 T0.7574,0.645 Q0.7614,0.645,0.7631,0.6445 T0.7655,0.6441 Q0.7671,0.6441,0.7723,0.6448 T0.7823,0.6454 Q0.788,0.6454,0.7952,0.6445 Q0.8233,0.6408,0.8723,0.6367 Q0.9012,0.6342,0.9092,0.6342 L0.9165,0.6342 L0.9205,0.6342 Q0.9277,0.6342,0.9382,0.6323 T0.9526,0.6304 Q0.9534,0.6304,0.9594,0.6307 T0.9695,0.6309 Q0.9976,0.6309,0.9992,0.6234 Q1,0.6201,1,0.618 Q1,0.6089,0.9775,0.608 Q0.9703,0.6076,0.9582,0.6076 Q0.9518,0.6076,0.9394,0.6078 T0.9213,0.608 Q0.9036,0.608,0.8972,0.6072 Q0.894,0.6068,0.8871,0.6058 T0.8751,0.6039 T0.8627,0.6024 T0.8506,0.6018 L0.8474,0.6018 Q0.841,0.6022,0.8281,0.6041 T0.8112,0.606 Q0.8096,0.606,0.808,0.6056 Q0.7783,0.6002,0.7647,0.6002 Q0.7598,0.6002,0.7454,0.6006 T0.7261,0.601 Q0.7221,0.601,0.7145,0.6022 T0.7036,0.6035 Q0.6996,0.6035,0.6964,0.6022 Q0.6723,0.5902,0.6442,0.5902 Q0.6426,0.5902,0.6386,0.5904 T0.6337,0.5906 Q0.6273,0.5906,0.6249,0.5894 L0.6177,0.5856 T0.6116,0.5838 T0.6016,0.5825 T0.5855,0.5823 Q0.5719,0.5823,0.5574,0.5798 T0.5373,0.5774 L0.5341,0.5774 Q0.5293,0.5778,0.5213,0.5786 T0.51,0.5794 Q0.5036,0.5794,0.4988,0.5769 Q0.4932,0.574,0.4771,0.574 Q0.4723,0.574,0.4631,0.5745 T0.4498,0.5749 Q0.4418,0.5749,0.4394,0.5736 Q0.4353,0.5724,0.4305,0.5715 T0.4225,0.5707 T0.4145,0.5705 T0.4056,0.5699 Q0.4008,0.5691,0.398,0.5689 T0.394,0.5689 T0.3908,0.5686 T0.3855,0.5674 Q0.3831,0.567,0.3811,0.5662 T0.3779,0.5645 T0.3743,0.563 T0.3695,0.5624 Q0.3655,0.5624,0.3594,0.5635 T0.3494,0.5645 T0.3406,0.5635 T0.3325,0.5624 Q0.3309,0.5624,0.3301,0.5628 L0.3141,0.5628 Q0.2635,0.5628,0.2506,0.5612 Q0.2474,0.5608,0.2434,0.5608 Q0.2402,0.5608,0.2349,0.561 T0.2273,0.5612 Q0.2209,0.5612,0.2177,0.5595 Q0.2145,0.557,0.2145,0.5558 Q0.2169,0.5537,0.2345,0.5521 Q0.2876,0.5475,0.3028,0.5425 Q0.3068,0.5413,0.3149,0.5396 T0.3309,0.5367 T0.3478,0.5338 T0.3606,0.5309 Q0.3631,0.5305,0.3755,0.5259 T0.3968,0.5214 Q0.3992,0.5214,0.4024,0.5218 T0.4096,0.5222 Q0.4161,0.5222,0.4213,0.5209 T0.4313,0.5185 T0.4402,0.5172 Q0.4418,0.5168,0.447,0.5168 L0.4558,0.5168 T0.4639,0.5162 T0.4707,0.5141 T0.4731,0.5102 Q0.4731,0.5089,0.4723,0.5081 Q0.4699,0.5019,0.4627,0.4994 T0.4369,0.4965 Q0.4273,0.4961,0.4193,0.4946 T0.4048,0.4915 T0.3952,0.4894 Q0.392,0.4886,0.388,0.4886 Q0.3847,0.4886,0.3775,0.4892 T0.3647,0.4898 Q0.3598,0.4898,0.355,0.4894 Q0.3165,0.4861,0.3141,0.4853 Q0.2779,0.4774,0.2715,0.4774 Q0.2699,0.4774,0.2683,0.4776 T0.2627,0.4778 Q0.2498,0.4778,0.2281,0.4762 Q0.2241,0.4749,0.2233,0.4737 Q0.2249,0.472,0.2321,0.4708 Q0.2402,0.4695,0.2554,0.4695 L0.2675,0.4695 L0.2795,0.4695 Q0.294,0.4695,0.2996,0.4679 Q0.3141,0.4629,0.3373,0.4616 Q0.3414,0.4612,0.3466,0.4606 T0.3582,0.4589 T0.3687,0.4562 T0.3727,0.4525 Q0.3727,0.4509,0.3703,0.4492 Q0.3639,0.4455,0.3474,0.4419 T0.3213,0.4384 L0.3157,0.4384 L0.31,0.4384 Q0.294,0.4384,0.2859,0.4363 Q0.2795,0.4347,0.2691,0.4347 L0.2618,0.4347 L0.2546,0.4347 Q0.2458,0.4347,0.2394,0.4338 Q0.2257,0.4318,0.2193,0.4303 T0.2092,0.4266 T0.2052,0.4224 T0.2024,0.4154 T0.196,0.4052 Q0.1912,0.3994,0.1867,0.3949 T0.1807,0.3884 T0.1791,0.3849 Q0.1791,0.3824,0.1839,0.3745 Q0.1871,0.3687,0.1944,0.3637 T0.2072,0.3561 T0.2221,0.3501 T0.2321,0.3463 Q0.2353,0.3455,0.241,0.3445 T0.2522,0.3428 T0.2639,0.3411 T0.2731,0.3384 T0.2956,0.3351 T0.3197,0.3326 Q0.3213,0.3322,0.3237,0.3322 T0.3325,0.3331 T0.3414,0.3339 Q0.3478,0.3339,0.3518,0.3314 Q0.3606,0.3264,0.3703,0.3264 Q0.3775,0.3264,0.3847,0.3293 Q0.3888,0.331,0.396,0.331 Q0.4024,0.331,0.4116,0.3295 T0.4265,0.3266 T0.4414,0.3227 T0.4514,0.3202 Q0.4578,0.3181,0.4839,0.3152 T0.5112,0.3123 T0.5177,0.3119 T0.5285,0.3111 T0.5398,0.3096 T0.549,0.3065 T0.5526,0.3015 Q0.5526,0.2999,0.5518,0.2982 Q0.547,0.2903,0.5386,0.2883 T0.5165,0.2862 Q0.5116,0.2862,0.5056,0.2866 T0.4972,0.287 Q0.4884,0.287,0.4787,0.2833 Q0.4594,0.2758,0.4265,0.2758 Q0.4169,0.2758,0.4129,0.2766 Q0.4112,0.2771,0.4104,0.2771 Q0.4088,0.2771,0.4056,0.2748 T0.3968,0.2721 Q0.3695,0.2696,0.359,0.2659 Q0.355,0.2646,0.351,0.2646 T0.3426,0.2661 T0.3365,0.2675 T0.3333,0.2659 Q0.3277,0.2605,0.3116,0.2553 T0.2859,0.2501 L0.2843,0.2501 L0.2819,0.2501 Q0.2779,0.2501,0.2723,0.2493 T0.2586,0.2472 T0.2474,0.2455 Q0.1799,0.236,0.1719,0.236 Q0.1663,0.236,0.1606,0.235 T0.1502,0.2316 T0.1454,0.2265 Q0.1454,0.2223,0.1558,0.2173 Q0.1679,0.2115,0.1859,0.2092 T0.2185,0.207 T0.2353,0.2057 Q0.2369,0.2049,0.2502,0.2041 T0.2799,0.2022 T0.302,0.2007 Q0.3084,0.1999,0.3245,0.1954 T0.347,0.1908 Q0.3502,0.1908,0.3534,0.1916 Q0.3663,0.197,0.388,0.197 Q0.404,0.197,0.4088,0.1929 Q0.4096,0.192,0.4108,0.192 T0.4177,0.1945 T0.4277,0.197 T0.4369,0.1949 Q0.4442,0.1908,0.4843,0.1875 Q0.4892,0.1871,0.4968,0.1839 T0.5084,0.1808 L0.5108,0.1808 Q0.5213,0.1825,0.5454,0.1825 Q0.5823,0.1825,0.5992,0.1779 Q0.6096,0.1746,0.6217,0.1746 Q0.6249,0.1746,0.6309,0.1748 T0.6394,0.175 Q0.6434,0.175,0.6466,0.1746 Q0.6474,0.1746,0.6594,0.1738 T0.6787,0.1713 T0.6892,0.1659 Q0.6924,0.1634,0.6924,0.1593 Q0.6924,0.1547,0.6888,0.151 T0.6795,0.1472 Q0.6779,0.1472,0.6763,0.1477 Q0.6699,0.1493,0.6667,0.1493 Q0.661,0.1493,0.6506,0.1456 Q0.6329,0.1394,0.6129,0.1394 Q0.6048,0.1394,0.5992,0.1377 T0.5888,0.136 T0.5803,0.1377 T0.5751,0.1394 Q0.5711,0.1394,0.5639,0.1369 Q0.5365,0.1273,0.5116,0.1273 Q0.4876,0.1273,0.4667,0.1253 Q0.4627,0.1248,0.4542,0.1211 T0.4337,0.1174 L0.4305,0.1174 Q0.4217,0.1178,0.4153,0.119 T0.408,0.1203 Q0.4056,0.1203,0.4,0.117 Q0.3976,0.1153,0.396,0.1136 T0.3932,0.1112 T0.39,0.1099 T0.3847,0.1095 Q0.3799,0.1095,0.3687,0.1103 Q0.355,0.1112,0.3434,0.1141 T0.3301,0.117 Q0.3261,0.117,0.3229,0.1145 Q0.3181,0.1116,0.3052,0.1087 T0.2787,0.1043 T0.2546,0.1029 Q0.2506,0.1029,0.2474,0.1033 Q0.2418,0.1037,0.2349,0.1054 T0.2249,0.107 Q0.2225,0.107,0.2193,0.1066 Q0.2024,0.1024,0.1863,0.1024 Q0.1823,0.1024,0.1562,0.0971 T0.1269,0.0917 T0.1161,0.0896 T0.106,0.0854 Q0.1076,0.0846,0.11,0.0842 Q0.1173,0.0821,0.1261,0.0821 Q0.1301,0.0821,0.1369,0.0825 T0.1462,0.083 Q0.1494,0.083,0.151,0.0825 T0.1831,0.0813 T0.2201,0.0784 T0.243,0.074 T0.2667,0.0718 Q0.2707,0.0718,0.2755,0.0724 T0.2843,0.073 T0.2936,0.0724 T0.3024,0.0705 T0.3072,0.068 T0.3092,0.0657 T0.3108,0.0647 Q0.3116,0.0643,0.3133,0.0643 L0.3157,0.0643 L0.3181,0.0643 Q0.3446,0.0643,0.3446,0.0556 T0.3205,0.0469 L0.3189,0.0469 Q0.3157,0.0469,0.3157,0.0465 Q0.3084,0.0427,0.2835,0.0386 Q0.2739,0.0369,0.2675,0.0342 T0.2538,0.0299 Q0.249,0.029,0.245,0.029 T0.2378,0.0303 T0.2321,0.0315 Q0.2305,0.0315,0.2273,0.0307 Q0.2201,0.0286,0.2004,0.0272 T0.1775,0.0245 Q0.1735,0.0228,0.1663,0.0226 T0.1522,0.0197 T0.1345,0.0145 T0.1124,0.0079 Q0.0996,0.0033,0.0671,0.0044 T0.0297,0.0041 Q0.0169,0,0.0024,0.0017 Q0,0.0021,0,0.0037 Z M0.0064,0.9954 L0.0056,0.0058 Q0.0177,0.005,0.0281,0.0083 Q0.0345,0.01,0.0675,0.0089 T0.1108,0.012 Q0.1221,0.0162,0.1329,0.0187 T0.1498,0.0236 T0.1606,0.0268 T0.1691,0.0274 T0.1759,0.0286 Q0.1807,0.0303,0.2,0.0315 T0.2257,0.0348 Q0.2305,0.0361,0.2337,0.0361 Q0.2361,0.0361,0.2398,0.0348 T0.2466,0.0336 Q0.249,0.0336,0.2522,0.034 Q0.2586,0.0353,0.2655,0.0382 T0.2827,0.0427 Q0.3068,0.0469,0.3124,0.0502 Q0.3157,0.0523,0.3237,0.0523 L0.3277,0.0523 L0.3309,0.0523 Q0.3398,0.0523,0.3398,0.0556 Q0.3398,0.0581,0.3341,0.0587 T0.3205,0.0593 T0.3092,0.0603 T0.3036,0.0643 T0.2932,0.068 Q0.2884,0.0684,0.2843,0.0684 Q0.2811,0.0684,0.2763,0.0678 T0.2667,0.0672 Q0.2586,0.0672,0.2422,0.0697 T0.2177,0.0747 Q0.2137,0.0759,0.1831,0.0767 T0.1494,0.0784 L0.1474,0.0784 T0.1398,0.078 T0.1285,0.0776 Q0.1173,0.0776,0.1084,0.08 Q0.1004,0.0817,0.1004,0.0846 Q0.1004,0.0867,0.104,0.0888 T0.1124,0.0923 T0.1213,0.0948 T0.1269,0.0958 T0.155,0.1014 T0.1863,0.107 Q0.2016,0.107,0.2185,0.1107 Q0.2217,0.1116,0.2257,0.1116 Q0.2289,0.1116,0.2365,0.1099 T0.2482,0.1074 L0.2538,0.1074 Q0.2683,0.1074,0.2912,0.1107 T0.3197,0.1182 Q0.3245,0.1215,0.3301,0.1215 Q0.3333,0.1215,0.3454,0.1184 T0.3687,0.1145 Q0.3735,0.1141,0.3779,0.1139 T0.3843,0.1134 T0.3871,0.1132 Q0.3896,0.1132,0.39,0.1136 T0.392,0.1161 T0.3968,0.1203 Q0.404,0.1248,0.408,0.1248 Q0.4096,0.1248,0.4165,0.1234 T0.4305,0.1215 L0.4337,0.1215 Q0.4442,0.1215,0.4518,0.1251 T0.4659,0.1294 Q0.4876,0.1319,0.5116,0.1319 T0.5622,0.141 Q0.5703,0.1439,0.5751,0.1439 Q0.5791,0.1439,0.5831,0.1423 T0.5888,0.1406 Q0.592,0.1406,0.5944,0.1412 T0.6012,0.1427 T0.6129,0.1435 Q0.6313,0.1435,0.649,0.1497 Q0.6602,0.1535,0.6667,0.1535 Q0.6715,0.1535,0.6779,0.1522 Q0.6787,0.1518,0.6795,0.1518 Q0.6827,0.1518,0.6847,0.1541 T0.6867,0.1593 Q0.6867,0.1618,0.6851,0.1634 Q0.6835,0.1655,0.6775,0.1667 T0.6598,0.169 T0.6458,0.1705 T0.6402,0.1709 Q0.6378,0.1709,0.6321,0.1705 T0.6225,0.1701 Q0.6096,0.1701,0.5968,0.1738 Q0.5823,0.1779,0.5446,0.1779 Q0.5205,0.1779,0.5116,0.1767 Q0.51,0.1763,0.5084,0.1763 Q0.5028,0.1763,0.4944,0.1796 T0.4835,0.1829 L0.4771,0.1833 T0.4659,0.1844 L0.4546,0.1858 T0.443,0.1881 T0.4337,0.1912 Q0.4321,0.1916,0.4305,0.1916 Q0.4273,0.1916,0.4209,0.1895 T0.412,0.1875 Q0.408,0.1875,0.4056,0.19 Q0.4024,0.1925,0.388,0.1925 Q0.3663,0.1925,0.3558,0.1879 Q0.3518,0.1862,0.3462,0.1862 Q0.3373,0.1862,0.3213,0.191 T0.3012,0.1962 Q0.2964,0.1966,0.2799,0.1976 T0.2494,0.1997 T0.2313,0.2024 L0.2313,0.2024 L0.2293,0.2024 L0.2261,0.2024 L0.2217,0.2024 Q0.204,0.2024,0.1851,0.2047 T0.1526,0.2136 Q0.1406,0.2198,0.1406,0.226 Q0.1406,0.2319,0.1518,0.2362 T0.1719,0.2406 Q0.1791,0.2406,0.2466,0.2497 Q0.2498,0.2501,0.2574,0.2516 T0.2711,0.2538 T0.2819,0.2547 L0.2851,0.2547 Q0.2851,0.2543,0.2859,0.2543 Q0.294,0.2543,0.3096,0.2592 T0.3285,0.2684 Q0.3317,0.2721,0.3373,0.2721 Q0.3406,0.2721,0.3462,0.2708 T0.3542,0.2696 Q0.3558,0.2696,0.3574,0.27 Q0.3679,0.2737,0.3968,0.2762 Q0.3984,0.2766,0.4004,0.2779 L0.4044,0.2804 T0.4096,0.2816 Q0.412,0.2816,0.4145,0.2808 Q0.4177,0.2804,0.4249,0.2804 Q0.4586,0.2804,0.4763,0.287 Q0.4867,0.2912,0.4972,0.2912 Q0.5004,0.2912,0.5064,0.2908 T0.5165,0.2903 Q0.5293,0.2903,0.5361,0.2922 T0.547,0.3003 L0.547,0.3011 Q0.547,0.3036,0.5414,0.3049 T0.5249,0.3067 T0.51,0.3082 Q0.5092,0.3082,0.4831,0.3111 T0.4498,0.3161 Q0.408,0.3264,0.3952,0.3264 Q0.3896,0.3264,0.3863,0.3252 Q0.3783,0.3223,0.3703,0.3223 Q0.359,0.3223,0.3494,0.3277 Q0.347,0.3293,0.343,0.3293 Q0.3414,0.3293,0.3353,0.3285 T0.3253,0.3277 T0.3181,0.3285 Q0.3149,0.3289,0.2952,0.3306 T0.2699,0.3347 Q0.2675,0.3364,0.2598,0.3372 T0.2434,0.3395 T0.2297,0.3426 L0.2205,0.3461 T0.2048,0.3523 T0.1908,0.3606 T0.1791,0.3725 Q0.1743,0.3803,0.1743,0.3845 Q0.1743,0.3874,0.1763,0.3901 T0.1827,0.3976 T0.1912,0.4073 T0.198,0.4189 T0.2032,0.4276 T0.2137,0.4326 T0.2386,0.438 Q0.2458,0.4392,0.2562,0.4392 L0.2639,0.4392 L0.2699,0.4392 Q0.2795,0.4392,0.2843,0.4405 Q0.2932,0.443,0.3092,0.443 L0.3157,0.443 L0.3213,0.443 Q0.3293,0.443,0.3458,0.4463 T0.3671,0.4525 Q0.3598,0.4558,0.3373,0.4571 Q0.3124,0.4587,0.298,0.4637 Q0.294,0.465,0.2811,0.465 L0.2703,0.465 L0.2586,0.465 Q0.241,0.465,0.2305,0.4666 Q0.2185,0.4687,0.2185,0.4728 Q0.2185,0.4745,0.2205,0.4764 T0.2241,0.4791 L0.2265,0.4803 L0.2273,0.4803 Q0.2506,0.4824,0.261,0.4824 Q0.2675,0.4824,0.2699,0.482 Q0.2699,0.4815,0.2707,0.4815 Q0.2771,0.4815,0.3124,0.4898 Q0.3173,0.4907,0.3229,0.4913 T0.3386,0.4925 T0.3542,0.4936 T0.3663,0.494 Q0.3711,0.494,0.3787,0.4936 T0.3888,0.4932 Q0.392,0.4932,0.3944,0.4936 T0.4032,0.4959 T0.4181,0.499 T0.4369,0.5006 Q0.449,0.501,0.455,0.5021 T0.4639,0.5048 T0.4675,0.5093 L0.4675,0.5097 Q0.4675,0.5106,0.4663,0.511 T0.4622,0.5116 L0.4566,0.512 T0.4486,0.5124 T0.4402,0.5127 Q0.4345,0.5131,0.4257,0.5153 T0.4096,0.5176 Q0.4064,0.5176,0.4024,0.5172 L0.3968,0.5172 Q0.3863,0.5172,0.3731,0.522 T0.359,0.5268 Q0.355,0.528,0.3317,0.5321 T0.3004,0.5388 Q0.2867,0.5433,0.2337,0.5479 Q0.2096,0.55,0.2096,0.5562 Q0.2096,0.5591,0.2145,0.5628 Q0.2193,0.5662,0.2289,0.5662 Q0.2313,0.5662,0.2373,0.5657 T0.2458,0.5653 L0.2498,0.5653 Q0.2635,0.5674,0.3124,0.5674 Q0.3261,0.5674,0.3301,0.567 L0.3317,0.567 Q0.3341,0.567,0.3394,0.568 T0.3486,0.5691 Q0.3542,0.5691,0.3606,0.5678 T0.3699,0.5666 T0.3759,0.5686 T0.3839,0.5718 T0.3908,0.573 T0.3964,0.5734 T0.4048,0.5742 T0.4165,0.5751 T0.4277,0.5757 T0.4369,0.5778 Q0.4426,0.5794,0.4538,0.5794 Q0.4586,0.5794,0.4679,0.5792 T0.4811,0.579 Q0.4924,0.579,0.4956,0.5807 Q0.502,0.584,0.5108,0.584 L0.5137,0.584 T0.5173,0.5838 L0.5205,0.5834 L0.5237,0.583 L0.5269,0.5825 T0.5305,0.5821 T0.5341,0.5819 L0.5365,0.5819 Q0.5414,0.5819,0.5562,0.5842 T0.5855,0.5865 Q0.5912,0.5865,0.5948,0.5867 T0.6012,0.5869 T0.6064,0.5873 T0.61,0.5877 T0.6133,0.5886 T0.6157,0.5896 L0.6185,0.591 T0.6217,0.5931 Q0.6265,0.5952,0.6369,0.5952 L0.6418,0.5952 L0.6466,0.5952 Q0.6723,0.5952,0.6932,0.606 Q0.698,0.608,0.7044,0.608 Q0.7084,0.608,0.7161,0.6068 T0.7261,0.6056 Q0.7309,0.6056,0.7454,0.6051 T0.7647,0.6047 Q0.7775,0.6047,0.8072,0.6101 Q0.8096,0.6105,0.812,0.6105 Q0.8177,0.6105,0.8297,0.6085 T0.8474,0.606 L0.8506,0.606 Q0.8562,0.606,0.8647,0.607 T0.8831,0.6095 T0.8964,0.6118 Q0.9036,0.6122,0.9221,0.6122 L0.9406,0.6122 L0.959,0.6122 L0.9767,0.6122 Q0.9952,0.613,0.9952,0.6172 Q0.9952,0.6176,0.9944,0.6221 Q0.9936,0.6259,0.9574,0.6259 L0.955,0.6259 L0.9526,0.6259 Q0.9478,0.6259,0.9373,0.628 T0.9205,0.63 L0.9173,0.63 Q0.9133,0.6296,0.9084,0.6296 Q0.9004,0.6296,0.8715,0.6321 Q0.8233,0.6367,0.7944,0.64 Q0.7888,0.6408,0.7839,0.6408 T0.7739,0.6402 T0.7671,0.6396 Q0.7639,0.6396,0.7622,0.64 Q0.7614,0.6404,0.759,0.6404 Q0.7574,0.6404,0.749,0.6398 T0.7357,0.6392 Q0.7261,0.6392,0.7213,0.6412 Q0.7173,0.6425,0.7032,0.6437 T0.6711,0.6458 T0.6474,0.647 Q0.645,0.6474,0.6418,0.6474 Q0.6353,0.6474,0.6277,0.6454 T0.6161,0.6433 Q0.6104,0.6433,0.6072,0.6456 T0.6,0.6479 Q0.5912,0.6479,0.5486,0.6557 Q0.5422,0.657,0.5373,0.657 Q0.5349,0.657,0.5289,0.6564 T0.5173,0.6557 L0.5124,0.6557 Q0.4996,0.6562,0.4739,0.6611 Q0.4675,0.6624,0.4398,0.6645 T0.3851,0.668 T0.3558,0.6698 Q0.355,0.6698,0.3285,0.6721 T0.2948,0.6752 Q0.2892,0.6761,0.2843,0.6786 T0.2747,0.6831 T0.2618,0.6852 Q0.253,0.6852,0.2394,0.6893 T0.2141,0.6993 T0.1968,0.7105 Q0.1944,0.713,0.1944,0.7155 Q0.1944,0.7196,0.2012,0.7229 T0.2177,0.7285 T0.2281,0.7308 Q0.2321,0.7329,0.2365,0.7339 T0.245,0.7356 T0.2514,0.7366 Q0.2522,0.737,0.2554,0.7379 T0.2614,0.7393 T0.2679,0.7406 T0.2755,0.7416 T0.2835,0.742 Q0.29,0.742,0.3048,0.7428 T0.3253,0.7437 L0.3301,0.7437 L0.3333,0.7437 Q0.3341,0.7437,0.3598,0.7466 Q0.3639,0.7474,0.3671,0.7474 T0.3759,0.7466 T0.3855,0.7457 Q0.388,0.7457,0.3904,0.7462 Q0.3984,0.7466,0.4137,0.7495 T0.4349,0.7532 T0.441,0.7557 Q0.441,0.7599,0.4289,0.7619 Q0.4257,0.7628,0.4197,0.7634 T0.4072,0.7644 T0.3984,0.7652 Q0.3968,0.7652,0.3912,0.7655 T0.3811,0.7665 T0.3719,0.7694 Q0.3695,0.7702,0.3655,0.7702 T0.3546,0.7686 T0.3462,0.7665 L0.3446,0.7665 Q0.3406,0.7665,0.3293,0.769 T0.3149,0.7715 Q0.3084,0.7715,0.2952,0.7748 T0.2763,0.781 Q0.2675,0.7847,0.2554,0.7864 Q0.253,0.7864,0.2325,0.7874 T0.2024,0.7893 Q0.2008,0.7893,0.1984,0.7897 T0.1948,0.7901 T0.1916,0.7903 T0.1888,0.7908 T0.1867,0.7914 T0.1847,0.7922 L0.1831,0.793 T0.1819,0.7943 L0.1811,0.7959 T0.1799,0.7976 Q0.1791,0.7993,0.1791,0.8001 Q0.1791,0.8034,0.1831,0.8051 T0.1932,0.8078 T0.2,0.8092 Q0.2072,0.8117,0.2257,0.8117 Q0.2337,0.8117,0.2586,0.8109 L0.2635,0.8109 Q0.2707,0.8109,0.2767,0.8117 T0.2859,0.8136 T0.2936,0.8163 T0.3012,0.8187 Q0.3068,0.82,0.3277,0.8206 T0.3526,0.8237 Q0.3582,0.8266,0.3622,0.8266 Q0.3647,0.8266,0.3687,0.8258 T0.3751,0.825 Q0.396,0.825,0.404,0.8291 Q0.408,0.8316,0.4161,0.8316 Q0.4217,0.8316,0.4353,0.8295 L0.4394,0.8295 Q0.4466,0.8295,0.4659,0.8366 Q0.4699,0.8378,0.4747,0.8378 Q0.4763,0.8378,0.4807,0.8374 T0.4884,0.837 Q0.494,0.837,0.5004,0.8382 Q0.5052,0.8391,0.5124,0.8399 Q0.5221,0.8411,0.5249,0.8422 T0.5277,0.8461 Q0.5277,0.8482,0.5233,0.849 T0.5112,0.8501 T0.4996,0.8511 Q0.4964,0.8519,0.4851,0.8523 T0.4659,0.8544 Q0.4578,0.8557,0.4402,0.8557 L0.4349,0.8557 L0.4297,0.8557 L0.4209,0.8557 Q0.408,0.8565,0.3867,0.8571 T0.3614,0.8582 Q0.3317,0.8611,0.3237,0.8611 L0.3225,0.8611 L0.3213,0.8611 Q0.2972,0.8611,0.2972,0.8681 Q0.2972,0.8714,0.3008,0.8747 T0.3044,0.8805 Q0.3044,0.881,0.3036,0.8814 Q0.3012,0.8839,0.2851,0.8839 Q0.2779,0.8839,0.2739,0.883 Q0.2723,0.8826,0.2699,0.8826 T0.2643,0.8835 T0.259,0.8853 T0.2542,0.8876 T0.249,0.8893 Q0.2386,0.8922,0.2056,0.8946 Q0.2016,0.8951,0.1916,0.898 T0.1703,0.9009 Q0.1518,0.9009,0.1337,0.9044 T0.1149,0.9146 L0.1149,0.9158 Q0.1149,0.9175,0.1157,0.9187 T0.1173,0.921 T0.1201,0.9226 T0.1233,0.9237 T0.1269,0.9245 L0.1305,0.9251 T0.1337,0.9255 L0.1357,0.9258 Q0.139,0.9262,0.1446,0.9262 T0.1542,0.9266 T0.1598,0.9282 Q0.1614,0.9303,0.1647,0.9314 T0.1739,0.9328 T0.1815,0.9336 Q0.1863,0.9345,0.1976,0.9372 T0.2145,0.9415 Q0.2225,0.9432,0.2285,0.9467 T0.2378,0.9515 Q0.2426,0.9531,0.2514,0.9531 Q0.2538,0.9531,0.2602,0.9529 T0.2707,0.9527 Q0.2763,0.9527,0.2815,0.9531 T0.2867,0.9569 T0.2811,0.9614 Q0.2434,0.9627,0.2329,0.9652 Q0.2249,0.9672,0.2241,0.9672 L0.2096,0.9668 L0.2056,0.9668 Q0.2,0.9668,0.196,0.9672 T0.1884,0.9687 T0.1815,0.9708 T0.1731,0.9737 T0.1639,0.9772 Q0.1518,0.9805,0.1285,0.9805 L0.1253,0.9805 Q0.1124,0.9805,0.1076,0.9818 Q0.1012,0.983,0.0831,0.9876 T0.0594,0.9934 Q0.049,0.9954,0.0064,0.9954 Z M0.0032,0.9979 L0.0024,0.0037 Q0.0161,0.0021,0.0289,0.0062 Q0.0337,0.0075,0.0671,0.0066 T0.1116,0.01 T0.1337,0.0166 T0.151,0.022 Q0.1582,0.0249,0.1659,0.0251 T0.1767,0.0265 Q0.1807,0.0278,0.2,0.0292 T0.2265,0.0328 Q0.2305,0.034,0.2329,0.034 T0.239,0.0326 T0.2458,0.0311 T0.253,0.0319 Q0.2578,0.0332,0.2614,0.0342 T0.2667,0.0363 T0.2723,0.0384 T0.2827,0.0406 Q0.3084,0.0448,0.3141,0.0485 Q0.3149,0.0494,0.3225,0.0494 T0.3361,0.0504 T0.3422,0.0554 T0.3357,0.0606 T0.3205,0.0618 T0.31,0.0626 Q0.3092,0.0626,0.3056,0.0659 T0.2932,0.0701 Q0.2884,0.0709,0.2843,0.0709 Q0.2811,0.0709,0.2759,0.0701 T0.2667,0.0693 Q0.2586,0.0693,0.2422,0.0718 T0.2193,0.0763 Q0.2137,0.0784,0.1831,0.079 T0.1502,0.0805 Q0.1494,0.0809,0.147,0.0809 Q0.1454,0.0809,0.1386,0.0803 T0.1277,0.0796 Q0.1173,0.0796,0.1092,0.0821 Q0.1036,0.0834,0.1036,0.0854 Q0.1036,0.0879,0.1129,0.0908 T0.1269,0.0937 Q0.1293,0.0937,0.1558,0.0991 T0.1863,0.1045 Q0.2016,0.1045,0.2193,0.1087 Q0.2225,0.1091,0.2249,0.1091 Q0.2289,0.1091,0.2357,0.1076 T0.2482,0.1054 Q0.2506,0.1049,0.2546,0.1049 Q0.2699,0.1049,0.2924,0.1085 T0.3213,0.1165 Q0.3253,0.119,0.3301,0.119 Q0.3325,0.119,0.3446,0.1161 T0.3687,0.1124 Q0.3831,0.1112,0.3863,0.1112 T0.3908,0.1118 T0.3936,0.1145 T0.3984,0.1186 Q0.4048,0.1228,0.408,0.1228 Q0.4096,0.1228,0.4161,0.1213 T0.4305,0.1195 L0.4337,0.1195 Q0.4426,0.1195,0.4474,0.1211 T0.4566,0.1246 T0.4667,0.1273 Q0.4876,0.1298,0.5116,0.1298 Q0.5365,0.1298,0.5631,0.1389 Q0.5703,0.1414,0.5751,0.1414 Q0.5783,0.1414,0.5819,0.1398 T0.5888,0.1381 Q0.5928,0.1381,0.5984,0.1398 T0.6129,0.1414 Q0.6321,0.1414,0.6498,0.1477 Q0.661,0.1514,0.6667,0.1514 Q0.6707,0.1514,0.6771,0.1501 Q0.6787,0.1497,0.6795,0.1497 Q0.6835,0.1497,0.6863,0.1526 T0.6892,0.1593 Q0.6892,0.1626,0.6876,0.1647 Q0.6851,0.1672,0.6799,0.1686 T0.6695,0.1705 T0.6566,0.1715 T0.6458,0.1725 Q0.6434,0.173,0.6402,0.173 Q0.6378,0.173,0.6317,0.1727 T0.6225,0.1725 Q0.6096,0.1725,0.5984,0.1759 Q0.5823,0.1804,0.5454,0.1804 Q0.5205,0.1804,0.5116,0.1788 L0.5084,0.1788 Q0.5036,0.1788,0.4956,0.1819 T0.4843,0.185 Q0.4434,0.1887,0.4353,0.1929 Q0.4321,0.1945,0.4289,0.1945 T0.4197,0.1922 T0.4112,0.19 T0.4072,0.1912 Q0.4032,0.1949,0.388,0.1949 Q0.3663,0.1949,0.3542,0.19 Q0.351,0.1883,0.3462,0.1883 Q0.3406,0.1883,0.3321,0.1906 T0.3153,0.1954 T0.302,0.1983 Q0.2972,0.1991,0.2859,0.1997 T0.2643,0.201 T0.245,0.2024 T0.2337,0.2041 Q0.2321,0.2045,0.2181,0.2045 T0.1851,0.207 T0.1542,0.2157 Q0.143,0.2211,0.143,0.226 Q0.143,0.2314,0.153,0.2348 T0.1719,0.2381 Q0.1791,0.2381,0.2474,0.2476 Q0.2506,0.248,0.2558,0.2489 T0.2651,0.2505 T0.2739,0.252 T0.2819,0.2526 Q0.2835,0.2526,0.2851,0.2522 L0.2859,0.2522 Q0.2948,0.2522,0.3104,0.2574 T0.3309,0.2671 Q0.3333,0.2696,0.3365,0.2696 Q0.339,0.2696,0.3442,0.2684 T0.3526,0.2671 T0.3582,0.2679 Q0.3687,0.2717,0.3968,0.2742 Q0.4,0.2746,0.4036,0.2771 T0.4096,0.2796 Q0.4112,0.2796,0.4137,0.2787 Q0.4169,0.2779,0.4257,0.2779 Q0.4594,0.2779,0.4779,0.2849 Q0.4876,0.2891,0.4972,0.2891 Q0.4996,0.2891,0.506,0.2887 T0.5165,0.2883 Q0.5253,0.2883,0.5305,0.2889 T0.541,0.292 T0.5494,0.299 L0.5494,0.3015 Q0.5494,0.3036,0.5466,0.3051 T0.5386,0.3073 T0.5285,0.3086 T0.5181,0.3094 T0.5108,0.3102 Q0.5068,0.3107,0.494,0.3121 L0.4683,0.315 T0.4506,0.3181 Q0.449,0.3181,0.4426,0.32 T0.4313,0.3229 T0.4197,0.3256 T0.4064,0.3281 T0.3952,0.3289 Q0.3888,0.3289,0.3855,0.3273 Q0.3783,0.3243,0.3703,0.3243 Q0.3598,0.3243,0.351,0.3297 Q0.3478,0.3314,0.3422,0.3314 Q0.3398,0.3314,0.3337,0.3306 T0.3245,0.3297 T0.3189,0.3306 Q0.3149,0.3314,0.2956,0.3328 T0.2715,0.3368 Q0.2691,0.338,0.2635,0.3391 T0.2522,0.3407 T0.2406,0.3424 T0.2313,0.3447 Q0.2305,0.3447,0.2213,0.348 T0.206,0.354 T0.1924,0.3621 T0.1815,0.3733 Q0.1767,0.3816,0.1767,0.3849 Q0.1767,0.387,0.1783,0.3893 T0.1847,0.3961 T0.1936,0.4063 T0.2004,0.4181 T0.2052,0.4264 T0.2145,0.4309 T0.2386,0.4359 Q0.2458,0.4372,0.2554,0.4372 Q0.2578,0.4372,0.2627,0.437 T0.2699,0.4367 Q0.2795,0.4367,0.2851,0.4384 Q0.294,0.4409,0.3092,0.4409 Q0.3116,0.4409,0.3157,0.4407 T0.3213,0.4405 Q0.3301,0.4405,0.3466,0.444 T0.3687,0.4509 Q0.3703,0.4517,0.3703,0.4525 Q0.3703,0.4575,0.3373,0.4596 Q0.3133,0.4608,0.2988,0.4658 Q0.294,0.4674,0.2803,0.4674 Q0.2763,0.4674,0.2687,0.4672 T0.257,0.467 Q0.2402,0.467,0.2313,0.4687 Q0.2209,0.4708,0.2209,0.4737 Q0.2209,0.4741,0.2213,0.4749 T0.2229,0.4764 L0.2249,0.4774 T0.2265,0.4778 L0.2273,0.4782 Q0.2498,0.4803,0.2618,0.4803 Q0.2667,0.4803,0.2691,0.4799 Q0.2699,0.4795,0.2715,0.4795 Q0.2779,0.4795,0.3133,0.4873 Q0.3181,0.4886,0.3233,0.4892 T0.3386,0.4905 T0.3542,0.4915 T0.3655,0.4919 Q0.3703,0.4919,0.3779,0.4915 T0.3888,0.4911 T0.3952,0.4915 Q0.3976,0.4919,0.4008,0.4927 L0.4072,0.4944 T0.4149,0.4961 T0.4249,0.4975 T0.4369,0.4985 Q0.449,0.499,0.4554,0.5 T0.4651,0.5031 T0.4699,0.5085 Q0.4707,0.5093,0.4707,0.5097 Q0.4707,0.5114,0.4687,0.5124 T0.4631,0.5139 T0.4562,0.5145 T0.4478,0.5147 L0.4402,0.5147 Q0.4353,0.5151,0.4265,0.5174 T0.4096,0.5197 L0.4024,0.5197 Q0.4,0.5193,0.3968,0.5193 Q0.3896,0.5193,0.3831,0.5209 T0.3703,0.5253 T0.3598,0.5288 Q0.3558,0.5301,0.3321,0.5342 T0.302,0.5409 Q0.2876,0.5454,0.2337,0.55 Q0.2112,0.5521,0.2112,0.5558 Q0.2112,0.5574,0.2161,0.5612 Q0.2201,0.5637,0.2281,0.5637 Q0.2305,0.5637,0.2361,0.5633 T0.2442,0.5628 Q0.2474,0.5628,0.2498,0.5633 Q0.2635,0.5653,0.3133,0.5653 Q0.3261,0.5653,0.3301,0.5649 L0.3325,0.5649 Q0.3349,0.5649,0.3402,0.5657 T0.3494,0.5666 Q0.3542,0.5666,0.3598,0.5657 Q0.3663,0.5645,0.3695,0.5645 Q0.3735,0.5645,0.3775,0.5666 T0.3847,0.5695 Q0.3888,0.5707,0.3912,0.5709 T0.3968,0.5711 T0.4048,0.572 Q0.4096,0.5724,0.4165,0.5726 T0.4281,0.5734 T0.4378,0.5757 Q0.4418,0.5774,0.4522,0.5774 Q0.4562,0.5774,0.4655,0.5769 T0.4795,0.5765 Q0.4932,0.5765,0.4972,0.579 Q0.5028,0.5819,0.51,0.5819 Q0.5141,0.5819,0.5221,0.5809 T0.5341,0.5798 L0.5373,0.5798 Q0.5422,0.5798,0.557,0.5821 T0.5855,0.5844 L0.5936,0.5844 L0.5992,0.5844 T0.604,0.5848 T0.6076,0.5852 T0.6108,0.5859 T0.6137,0.5865 T0.6161,0.5873 T0.6181,0.5881 T0.6209,0.5896 L0.6233,0.591 Q0.6265,0.5927,0.6353,0.5927 L0.6402,0.5927 L0.645,0.5927 Q0.6723,0.5927,0.6948,0.6039 Q0.6988,0.606,0.7036,0.606 Q0.7076,0.606,0.7153,0.6045 T0.7261,0.6031 Q0.7309,0.6031,0.7454,0.6027 T0.7647,0.6022 Q0.7783,0.6022,0.808,0.608 L0.812,0.608 Q0.8169,0.608,0.8293,0.6062 T0.8474,0.6039 L0.8506,0.6039 Q0.857,0.6039,0.8655,0.6049 T0.8835,0.6074 T0.8972,0.6093 Q0.9036,0.6101,0.9213,0.6101 L0.9398,0.6101 L0.959,0.6101 L0.9767,0.6101 Q0.9976,0.6109,0.9976,0.6176 Q0.9976,0.6188,0.9968,0.6226 Q0.9952,0.6284,0.9671,0.6284 L0.959,0.6284 L0.9526,0.6284 Q0.9486,0.6284,0.9382,0.6302 T0.9205,0.6321 L0.9173,0.6321 Q0.9133,0.6317,0.9092,0.6317 Q0.9012,0.6317,0.8715,0.6346 Q0.8233,0.6387,0.7944,0.6421 Q0.7888,0.6429,0.7831,0.6429 Q0.7783,0.6429,0.7731,0.6425 T0.7663,0.6421 L0.7622,0.6421 Q0.7614,0.6425,0.7586,0.6425 T0.7474,0.6421 T0.7349,0.6416 Q0.7261,0.6416,0.7221,0.6429 Q0.7173,0.645,0.7032,0.646 T0.6711,0.6479 T0.6482,0.6491 Q0.645,0.6495,0.6418,0.6495 Q0.6353,0.6495,0.6273,0.6474 T0.6161,0.6454 Q0.612,0.6454,0.6088,0.6477 T0.6,0.6499 Q0.5912,0.6499,0.5494,0.6578 Q0.5422,0.6591,0.5373,0.6591 Q0.5341,0.6591,0.5281,0.6584 T0.5173,0.6578 L0.5124,0.6578 Q0.5004,0.6582,0.4747,0.6632 Q0.4675,0.6649,0.4398,0.6667 T0.3851,0.6703 T0.3566,0.6719 Q0.3534,0.6723,0.3406,0.6734 T0.3141,0.6757 T0.2948,0.6773 Q0.2908,0.6781,0.2819,0.6829 T0.2618,0.6877 Q0.2482,0.6877,0.2273,0.6958 T0.1992,0.7117 Q0.1976,0.7138,0.1976,0.7155 Q0.1976,0.7188,0.2032,0.7213 T0.2181,0.7258 T0.2289,0.7292 Q0.2337,0.7308,0.2378,0.7316 L0.2458,0.7333 T0.2522,0.7345 Q0.2747,0.7399,0.2835,0.7399 Q0.29,0.7399,0.3048,0.7408 T0.3253,0.7416 Q0.3277,0.7416,0.3297,0.7414 T0.3341,0.7412 L0.3598,0.7445 Q0.3639,0.7449,0.3663,0.7449 Q0.3695,0.7449,0.3751,0.7443 T0.3855,0.7437 L0.3904,0.7437 Q0.3984,0.7441,0.4141,0.7472 T0.4353,0.7507 Q0.4434,0.752,0.4434,0.7553 Q0.4434,0.7578,0.439,0.7605 T0.4297,0.764 Q0.4257,0.7648,0.4201,0.7655 T0.4076,0.7665 T0.3984,0.7673 Q0.3968,0.7673,0.3912,0.7677 T0.3815,0.7688 T0.3735,0.771 T0.3655,0.7727 Q0.3606,0.7727,0.3534,0.7708 T0.3458,0.769 L0.3446,0.769 Q0.3414,0.769,0.3297,0.7713 T0.3149,0.7735 Q0.3092,0.7735,0.296,0.7771 T0.2771,0.7831 Q0.2683,0.7872,0.2554,0.7885 Q0.253,0.7889,0.2325,0.7897 T0.2032,0.7914 Q0.2008,0.7918,0.198,0.792 T0.194,0.7924 T0.1912,0.7928 T0.1888,0.793 T0.1867,0.7934 T0.1851,0.7943 T0.1843,0.7953 T0.1835,0.7968 T0.1823,0.7984 T0.1815,0.8001 Q0.1815,0.8022,0.1847,0.8032 T0.1932,0.8053 T0.2008,0.8071 Q0.2072,0.8092,0.2265,0.8092 Q0.2361,0.8092,0.2586,0.8088 L0.2635,0.8088 Q0.2731,0.8088,0.2795,0.8098 T0.2928,0.8134 T0.302,0.8167 Q0.306,0.8179,0.3277,0.8183 T0.3542,0.8221 Q0.359,0.8246,0.3622,0.8246 Q0.3639,0.8246,0.3675,0.8237 T0.3751,0.8229 Q0.3968,0.8229,0.4048,0.8275 Q0.4088,0.8291,0.4161,0.8291 Q0.4225,0.8291,0.4353,0.8275 Q0.4369,0.827,0.4394,0.827 Q0.4474,0.827,0.4675,0.8345 Q0.4707,0.8358,0.4739,0.8358 Q0.4755,0.8358,0.4799,0.8353 T0.4876,0.8349 Q0.494,0.8349,0.5012,0.8362 L0.5076,0.837 T0.5133,0.8376 T0.5181,0.8382 T0.5221,0.8391 L0.5253,0.8399 T0.5277,0.8409 L0.5293,0.8422 T0.5301,0.8438 L0.5301,0.8461 Q0.5301,0.8499,0.5249,0.8511 T0.5112,0.8526 T0.5004,0.8532 Q0.4964,0.854,0.4851,0.8546 T0.4667,0.8565 Q0.4578,0.8577,0.441,0.8577 L0.4345,0.8577 L0.4289,0.8577 L0.4209,0.8577 Q0.4153,0.8582,0.4088,0.8586 T0.3968,0.8592 T0.3855,0.8594 T0.3755,0.8596 T0.3675,0.86 T0.3622,0.8602 Q0.3317,0.8635,0.3237,0.8635 Q0.2996,0.8635,0.2996,0.8681 Q0.2996,0.8698,0.3032,0.8733 T0.3068,0.8797 L0.3068,0.8822 Q0.3052,0.8859,0.2859,0.8859 Q0.2787,0.8859,0.2739,0.8851 L0.2699,0.8851 Q0.2659,0.8851,0.259,0.888 T0.2498,0.8913 Q0.2394,0.8946,0.2056,0.8971 Q0.2032,0.8971,0.1928,0.9002 T0.1703,0.9034 Q0.1526,0.9034,0.1353,0.9067 T0.1173,0.915 L0.1173,0.9158 Q0.1173,0.9166,0.1177,0.9175 T0.1189,0.9191 T0.1205,0.9204 T0.1229,0.9212 T0.1257,0.922 T0.1285,0.9226 T0.1309,0.9231 T0.1337,0.9235 T0.1365,0.9237 L0.1446,0.9237 T0.1554,0.9243 T0.1614,0.9268 T0.1659,0.9293 T0.1743,0.9303 T0.1823,0.9316 Q0.1863,0.932,0.1912,0.9332 T0.2028,0.9361 T0.2153,0.939 Q0.2233,0.9411,0.2297,0.9448 T0.2386,0.9494 Q0.2426,0.9506,0.2506,0.9506 Q0.253,0.9506,0.2594,0.9504 T0.2699,0.9502 Q0.2763,0.9502,0.2819,0.9511 Q0.2851,0.9511,0.2871,0.9529 T0.2892,0.9573 Q0.2892,0.9627,0.2811,0.9635 Q0.2434,0.9652,0.2337,0.9672 Q0.2257,0.9693,0.2241,0.9693 Q0.2217,0.9693,0.2096,0.9689 L0.2056,0.9689 Q0.1976,0.9689,0.1924,0.9699 T0.1787,0.9741 L0.1647,0.9793 Q0.1518,0.983,0.1285,0.983 L0.1261,0.983 L0.1237,0.983 Q0.1124,0.983,0.1076,0.9838 Q0.102,0.9851,0.0839,0.9896 T0.0602,0.9954 T0.0406,0.9971 T0.0153,0.9979 L0.0032,0.9979 Z",type:0}},"":{type:5,data:{d:"M0.9992,0.998 Q0.9992,1,0.9968,1 Q0.9521,1,0.9392,0.998 Q0.9335,0.997,0.9152,0.9921 T0.8913,0.9862 Q0.8873,0.9852,0.8783,0.9852 L0.8747,0.9852 L0.871,0.9852 Q0.8483,0.9852,0.8345,0.9812 Q0.8289,0.9792,0.8228,0.9773 T0.8135,0.9743 T0.8054,0.9723 T0.7948,0.9713 L0.7908,0.9713 L0.777,0.9713 T0.7656,0.9693 Q0.7616,0.9683,0.7498,0.9674 T0.7283,0.9664 L0.7186,0.9654 Q0.708,0.9644,0.708,0.9575 Q0.708,0.9545,0.7109,0.952 T0.7178,0.9486 L0.7315,0.9486 L0.7417,0.9486 L0.7502,0.9486 Q0.7575,0.9486,0.7599,0.9476 Q0.7616,0.9466,0.7685,0.9426 T0.7843,0.9377 Q0.7899,0.9357,0.8009,0.9332 T0.8175,0.9298 Q0.82,0.9288,0.8228,0.9283 T0.8277,0.9278 T0.8313,0.9273 T0.8341,0.9263 T0.8362,0.9258 Q0.8402,0.9219,0.854,0.9219 L0.8569,0.9219 L0.8597,0.9219 L0.8637,0.9219 Q0.88,0.9199,0.88,0.9159 L0.88,0.9149 Q0.88,0.913,0.8719,0.911 T0.852,0.9075 T0.8297,0.906 Q0.8224,0.906,0.8167,0.905 T0.8074,0.9031 T0.8001,0.9006 T0.794,0.8991 Q0.7599,0.8971,0.7494,0.8942 Q0.7461,0.8932,0.7397,0.8902 T0.7299,0.8872 L0.7267,0.8872 Q0.721,0.8882,0.7121,0.8882 Q0.6926,0.8882,0.691,0.8833 L0.691,0.8803 Q0.691,0.8773,0.6922,0.8749 T0.6955,0.8704 L0.6975,0.8684 Q0.6926,0.8655,0.6764,0.8655 Q0.6683,0.8655,0.6375,0.8625 Q0.6358,0.8625,0.629,0.862 T0.6148,0.8615 T0.5973,0.861 T0.5791,0.8605 L0.5718,0.8605 L0.5661,0.8605 L0.5596,0.8605 Q0.5426,0.8605,0.5337,0.8586 Q0.5264,0.8576,0.515,0.8571 T0.4996,0.8556 Q0.498,0.8556,0.4947,0.8551 T0.4882,0.8546 T0.4809,0.8541 T0.474,0.8531 T0.4692,0.8506 T0.4672,0.8467 Q0.4672,0.8437,0.468,0.8422 T0.4704,0.8398 T0.4736,0.8383 T0.4781,0.8368 T0.4838,0.8358 T0.4911,0.8353 T0.4988,0.8338 Q0.5069,0.8328,0.5142,0.8328 Q0.5174,0.8328,0.5215,0.8333 T0.5264,0.8338 Q0.5296,0.8338,0.532,0.8328 Q0.5523,0.8249,0.5604,0.8249 L0.5653,0.8249 Q0.5775,0.8269,0.5848,0.8269 Q0.5912,0.8269,0.5937,0.8259 Q0.6026,0.821,0.6253,0.821 Q0.6294,0.821,0.6334,0.822 T0.6383,0.8229 Q0.6399,0.8229,0.644,0.82 Q0.6504,0.817,0.6723,0.816 T0.6975,0.814 Q0.6999,0.814,0.7048,0.8121 T0.7129,0.8091 T0.7226,0.8071 T0.7372,0.8061 L0.7421,0.8061 Q0.7624,0.8071,0.7721,0.8071 Q0.7924,0.8071,0.7981,0.8051 Q0.8021,0.8032,0.807,0.8027 T0.8135,0.8017 T0.8151,0.8002 L0.8151,0.7992 Q0.8143,0.7962,0.8127,0.7957 T0.8045,0.7943 Q0.7997,0.7943,0.7964,0.7933 Q0.7883,0.7933,0.7676,0.7923 T0.7437,0.7903 Q0.7307,0.7893,0.7218,0.7844 Q0.7194,0.7834,0.7141,0.7819 L0.7036,0.7789 T0.693,0.7765 T0.6853,0.7755 Q0.6805,0.7755,0.6691,0.773 T0.6553,0.7705 Q0.6545,0.7705,0.6545,0.7715 Q0.6537,0.7715,0.65,0.7725 T0.6423,0.774 T0.635,0.7745 Q0.6294,0.7745,0.6257,0.773 T0.618,0.771 T0.6083,0.77 T0.601,0.7695 Q0.5985,0.7695,0.5921,0.769 T0.5795,0.7676 T0.5702,0.7666 Q0.5653,0.7656,0.5596,0.7616 T0.5539,0.7547 Q0.5539,0.7498,0.5645,0.7488 Q0.5693,0.7478,0.5852,0.7448 T0.6099,0.7418 L0.6148,0.7418 Q0.6196,0.7418,0.6253,0.7423 T0.6338,0.7428 L0.6399,0.7428 Q0.6431,0.7418,0.6488,0.7409 T0.6586,0.7394 T0.6659,0.7389 L0.6707,0.7389 Q0.6723,0.7399,0.6748,0.7399 Q0.6796,0.7399,0.6946,0.7389 T0.7161,0.7379 Q0.7251,0.7379,0.7478,0.7319 Q0.7494,0.7319,0.7539,0.7315 T0.762,0.73 T0.7697,0.727 T0.7818,0.723 T0.7948,0.7196 T0.7997,0.7151 Q0.7997,0.7141,0.7989,0.7132 Q0.7924,0.7062,0.7717,0.6978 T0.738,0.6894 Q0.7267,0.6894,0.717,0.6845 T0.7048,0.6795 Q0.6975,0.6785,0.6711,0.6766 T0.6431,0.6746 Q0.6415,0.6736,0.6144,0.6721 T0.5596,0.6686 T0.5247,0.6657 Q0.4996,0.6607,0.4874,0.6597 L0.4834,0.6597 Q0.4785,0.6597,0.4724,0.6607 T0.4631,0.6617 Q0.4574,0.6617,0.4501,0.6597 Q0.4088,0.6518,0.4006,0.6518 Q0.3933,0.6518,0.3901,0.6499 T0.3844,0.6479 T0.3739,0.6499 T0.3585,0.6518 L0.352,0.6518 Q0.3471,0.6508,0.335,0.6503 T0.3122,0.6494 T0.2912,0.6479 T0.2766,0.6449 Q0.2733,0.6439,0.2668,0.6439 Q0.2628,0.6439,0.2543,0.6444 T0.2425,0.6449 T0.2368,0.6439 L0.2344,0.6439 Q0.2336,0.6439,0.2283,0.6444 T0.2182,0.6449 Q0.2117,0.6449,0.2052,0.6439 Q0.1768,0.6409,0.1281,0.637 Q0.0989,0.634,0.0916,0.634 L0.0835,0.634 L0.0795,0.634 Q0.0722,0.634,0.0616,0.632 T0.0479,0.6301 Q0.0462,0.6301,0.0406,0.6306 T0.0308,0.6311 Q0.0032,0.6311,0.0008,0.6231 Q0,0.6202,0,0.6182 Q0,0.6083,0.0227,0.6083 Q0.03,0.6073,0.0422,0.6073 Q0.0487,0.6073,0.0608,0.6078 T0.0787,0.6083 Q0.0973,0.6083,0.103,0.6073 Q0.1062,0.6063,0.1131,0.6053 T0.1253,0.6039 T0.1375,0.6024 T0.1492,0.6014 L0.1533,0.6014 Q0.1598,0.6024,0.1723,0.6044 T0.189,0.6063 Q0.1906,0.6063,0.1922,0.6053 Q0.2214,0.6004,0.236,0.6004 Q0.2409,0.6004,0.2551,0.6009 T0.2741,0.6014 Q0.2782,0.6014,0.2859,0.6024 T0.2968,0.6034 Q0.3009,0.6034,0.3041,0.6024 Q0.3277,0.5905,0.356,0.5905 L0.3613,0.5905 L0.3666,0.5905 Q0.3731,0.5905,0.3755,0.5895 Q0.382,0.5856,0.3844,0.5846 T0.3938,0.5831 T0.4144,0.5826 T0.4428,0.5801 T0.4631,0.5776 L0.4663,0.5776 Q0.4704,0.5776,0.4785,0.5786 T0.4899,0.5796 Q0.4964,0.5796,0.5012,0.5767 Q0.5069,0.5737,0.5223,0.5737 Q0.5272,0.5737,0.5369,0.5742 T0.5499,0.5747 Q0.558,0.5747,0.5612,0.5737 Q0.5661,0.5717,0.5714,0.5712 T0.5835,0.5702 T0.5945,0.5692 T0.6018,0.5687 L0.6062,0.5687 T0.6095,0.5682 T0.6148,0.5678 Q0.6172,0.5668,0.6196,0.5653 T0.6245,0.5633 T0.631,0.5628 T0.6407,0.5638 T0.6504,0.5648 T0.6594,0.5638 T0.6675,0.5628 L0.6707,0.5628 L0.6861,0.5628 Q0.7364,0.5628,0.7494,0.5608 L0.7567,0.5608 L0.7652,0.5608 L0.7729,0.5608 Q0.7794,0.5608,0.7818,0.5589 Q0.7851,0.5569,0.7859,0.5559 Q0.7835,0.5539,0.7656,0.5519 Q0.7121,0.547,0.6975,0.543 Q0.6934,0.541,0.6853,0.5396 L0.6691,0.5366 T0.6525,0.5336 T0.6391,0.5312 Q0.6367,0.5302,0.6245,0.5257 T0.6034,0.5213 L0.5977,0.5213 Q0.5945,0.5223,0.5904,0.5223 Q0.5839,0.5223,0.5787,0.5208 T0.5685,0.5183 T0.5596,0.5173 Q0.558,0.5173,0.5552,0.5168 T0.5495,0.5163 L0.5434,0.5163 T0.5373,0.5158 T0.532,0.5148 T0.5284,0.5129 T0.5272,0.5104 L0.5272,0.5074 Q0.5304,0.5015,0.5377,0.499 T0.5629,0.4965 Q0.5734,0.4955,0.5811,0.4946 T0.5953,0.4916 T0.605,0.4896 Q0.6083,0.4886,0.6123,0.4886 Q0.6148,0.4886,0.6225,0.4891 T0.635,0.4896 L0.6456,0.4896 Q0.6837,0.4857,0.6861,0.4857 Q0.7226,0.4768,0.7283,0.4768 Q0.7307,0.4768,0.732,0.4773 T0.7372,0.4777 Q0.7502,0.4777,0.7721,0.4758 Q0.7753,0.4748,0.777,0.4738 Q0.7745,0.4718,0.768,0.4708 Q0.7599,0.4688,0.7445,0.4688 Q0.7405,0.4688,0.7324,0.4693 T0.721,0.4698 Q0.7056,0.4698,0.6999,0.4679 Q0.6861,0.4629,0.6626,0.4619 Q0.6586,0.4609,0.6533,0.4604 T0.6419,0.459 T0.6314,0.456 T0.6269,0.452 Q0.6269,0.451,0.6302,0.4491 Q0.6358,0.4451,0.6525,0.4416 T0.6788,0.4382 L0.6845,0.4382 L0.6902,0.4382 Q0.7064,0.4382,0.7145,0.4362 Q0.7202,0.4342,0.7307,0.4342 L0.7356,0.4342 L0.7409,0.4342 L0.7453,0.4342 Q0.7543,0.4342,0.7607,0.4332 Q0.7778,0.4313,0.7847,0.4288 T0.7932,0.4248 T0.7972,0.4169 T0.8045,0.4045 L0.8094,0.3986 T0.8135,0.3942 L0.8167,0.3912 T0.8191,0.3887 T0.8204,0.3867 T0.8208,0.3848 Q0.8208,0.3818,0.8167,0.3739 Q0.8127,0.3689,0.8054,0.3635 T0.7924,0.3556 T0.7778,0.3497 T0.768,0.3462 Q0.764,0.3442,0.7478,0.3427 T0.7267,0.3383 Q0.7234,0.3363,0.7044,0.3348 T0.6805,0.3323 L0.6764,0.3323 T0.6679,0.3328 T0.6586,0.3333 Q0.6521,0.3333,0.648,0.3314 Q0.6391,0.3264,0.6294,0.3264 Q0.6229,0.3264,0.6156,0.3294 Q0.6115,0.3304,0.6042,0.3304 Q0.5977,0.3304,0.5888,0.3289 T0.5738,0.3264 T0.5584,0.3225 L0.5491,0.3195 Q0.5418,0.3185,0.5162,0.3155 T0.4895,0.3121 T0.4826,0.3116 T0.4716,0.3111 T0.4607,0.3096 T0.4513,0.3066 T0.4477,0.3017 Q0.4477,0.2997,0.4485,0.2977 Q0.4526,0.2898,0.4611,0.2878 T0.4842,0.2859 Q0.4882,0.2859,0.4943,0.2864 T0.5028,0.2868 Q0.5118,0.2868,0.5215,0.2829 Q0.5401,0.276,0.5734,0.276 Q0.5831,0.276,0.5872,0.277 L0.5904,0.277 T0.5949,0.2745 T0.6034,0.272 Q0.631,0.269,0.6407,0.2661 Q0.6448,0.2641,0.6488,0.2641 T0.6573,0.2656 T0.6634,0.2671 Q0.6659,0.2671,0.6675,0.2651 Q0.6723,0.2601,0.6886,0.2552 T0.7137,0.2502 L0.7153,0.2502 L0.7178,0.2502 Q0.7218,0.2502,0.7279,0.2493 T0.7417,0.2468 T0.7526,0.2453 Q0.8208,0.2354,0.8281,0.2354 Q0.8337,0.2354,0.8394,0.2344 T0.8496,0.2315 T0.854,0.2265 Q0.854,0.2216,0.8443,0.2176 Q0.8354,0.2127,0.8216,0.2102 T0.7972,0.2072 T0.777,0.2067 T0.7648,0.2057 Q0.7632,0.2047,0.7498,0.2038 T0.7202,0.2018 T0.6983,0.1998 Q0.6918,0.1998,0.676,0.1949 T0.6537,0.1899 Q0.6496,0.1899,0.6472,0.1919 Q0.6342,0.1968,0.6123,0.1968 Q0.5961,0.1968,0.5912,0.1929 Q0.5904,0.1919,0.5892,0.1919 T0.5823,0.1944 T0.5722,0.1968 T0.5637,0.1949 Q0.5556,0.1909,0.5158,0.1869 Q0.5109,0.1869,0.5032,0.1835 T0.4915,0.18 Q0.4907,0.18,0.4891,0.181 Q0.4793,0.182,0.455,0.182 Q0.4177,0.182,0.4015,0.1771 Q0.3901,0.1741,0.3779,0.1741 Q0.3747,0.1741,0.369,0.1746 T0.3609,0.1751 Q0.3569,0.1751,0.3536,0.1741 Q0.3512,0.1741,0.3459,0.1736 T0.3358,0.1726 T0.3256,0.1716 T0.3167,0.1696 T0.3106,0.1662 Q0.3082,0.1632,0.3082,0.1592 Q0.3082,0.1543,0.3114,0.1508 T0.3204,0.1474 L0.3236,0.1474 Q0.3301,0.1494,0.3333,0.1494 Q0.339,0.1494,0.3496,0.1454 Q0.3674,0.1385,0.3869,0.1385 Q0.395,0.1385,0.4011,0.137 T0.4116,0.1355 T0.4197,0.1375 T0.425,0.1395 Q0.429,0.1395,0.4355,0.1365 Q0.4631,0.1266,0.4882,0.1266 Q0.5126,0.1266,0.5337,0.1246 Q0.5369,0.1246,0.5458,0.1207 T0.5661,0.1167 L0.5693,0.1167 Q0.5791,0.1177,0.5852,0.1187 T0.5921,0.1197 Q0.5945,0.1197,0.6002,0.1167 Q0.6026,0.1147,0.605,0.1123 T0.6091,0.1093 T0.6156,0.1088 T0.6318,0.1098 Q0.6456,0.1108,0.6569,0.1137 T0.6707,0.1167 Q0.674,0.1167,0.6772,0.1147 Q0.6821,0.1108,0.6946,0.1083 T0.721,0.1044 T0.7453,0.1029 L0.7526,0.1029 Q0.7583,0.1039,0.7652,0.1053 T0.7753,0.1068 Q0.7778,0.1068,0.7802,0.1058 Q0.7981,0.1019,0.8143,0.1019 Q0.8175,0.1019,0.8439,0.0964 T0.8735,0.091 Q0.8759,0.091,0.8836,0.089 T0.8938,0.0851 Q0.8929,0.0841,0.8905,0.0841 Q0.8832,0.0821,0.8735,0.0821 Q0.8694,0.0821,0.8629,0.0826 T0.854,0.0831 Q0.8508,0.0831,0.8491,0.0821 Q0.8475,0.0821,0.8171,0.0811 T0.7802,0.0781 T0.7571,0.0737 T0.7332,0.0712 Q0.7299,0.0712,0.7247,0.0717 T0.7161,0.0722 L0.7064,0.0722 Q0.7032,0.0712,0.7003,0.0707 T0.6959,0.0692 T0.693,0.0678 T0.6914,0.0663 T0.6902,0.0648 T0.689,0.0643 L0.6869,0.0643 L0.6845,0.0643 L0.6821,0.0643 Q0.6553,0.0643,0.6553,0.0554 T0.6796,0.0465 L0.6813,0.0465 L0.6845,0.0465 Q0.6918,0.0425,0.717,0.0376 Q0.7226,0.0366,0.7267,0.0356 T0.7324,0.0336 T0.7376,0.0317 T0.747,0.0297 Q0.7518,0.0287,0.7551,0.0287 T0.762,0.0297 T0.768,0.0307 L0.7729,0.0307 Q0.7802,0.0277,0.7997,0.0267 T0.8224,0.0237 Q0.8273,0.0227,0.8341,0.0223 T0.8475,0.0193 T0.8654,0.0143 T0.8873,0.0079 Q0.9002,0.003,0.9331,0.004 T0.97,0.004 Q0.9838,0,0.9976,0.001 Q1,0.001,1,0.003 Z M0.9935,0.996 L0.9951,0.0049 Q0.9822,0.0049,0.9716,0.0079 Q0.9659,0.0099,0.9327,0.0089 T0.8897,0.0119 Q0.8775,0.0158,0.8666,0.0183 T0.85,0.0237 Q0.8418,0.0267,0.8341,0.0272 T0.8248,0.0277 Q0.82,0.0297,0.8001,0.0312 T0.7745,0.0346 Q0.7697,0.0356,0.7664,0.0356 Q0.764,0.0356,0.7603,0.0341 T0.7534,0.0326 Q0.751,0.0326,0.7478,0.0336 Q0.7429,0.0346,0.7397,0.0356 T0.7344,0.0376 T0.7283,0.0401 T0.7178,0.0425 Q0.6926,0.0465,0.6878,0.0495 Q0.6845,0.0514,0.6764,0.0514 L0.6723,0.0514 L0.6691,0.0514 Q0.6602,0.0514,0.6602,0.0554 Q0.6602,0.0574,0.6659,0.0579 T0.6796,0.0589 T0.691,0.0603 T0.6967,0.0643 T0.7072,0.0673 Q0.7121,0.0682,0.7153,0.0682 T0.7238,0.0678 T0.7332,0.0673 Q0.7413,0.0673,0.7579,0.0692 T0.7818,0.0742 Q0.7867,0.0752,0.8171,0.0762 T0.8508,0.0781 L0.852,0.0781 T0.8601,0.0776 T0.8719,0.0772 Q0.8832,0.0772,0.8921,0.0791 Q0.8994,0.0811,0.8994,0.0841 T0.8933,0.0895 T0.8812,0.094 T0.8735,0.0959 T0.8451,0.1014 T0.8143,0.1068 Q0.7989,0.1068,0.7818,0.1098 Q0.7778,0.1108,0.7745,0.1108 T0.768,0.1103 T0.7603,0.1088 T0.7518,0.1068 L0.7461,0.1068 Q0.7315,0.1068,0.7088,0.1103 T0.6805,0.1177 Q0.6756,0.1207,0.6699,0.1207 Q0.6667,0.1207,0.6545,0.1177 T0.631,0.1147 Q0.6269,0.1137,0.6225,0.1133 T0.616,0.1128 L0.6123,0.1128 T0.6103,0.1133 L0.6083,0.1157 T0.6034,0.1197 Q0.5961,0.1246,0.5921,0.1246 Q0.5904,0.1246,0.5835,0.1231 T0.5693,0.1217 L0.5661,0.1217 Q0.558,0.1217,0.5535,0.1231 T0.5446,0.1266 T0.5345,0.1296 Q0.5126,0.1316,0.4882,0.1316 T0.438,0.1405 Q0.4298,0.1434,0.425,0.1434 Q0.4209,0.1434,0.4173,0.1419 T0.4112,0.1405 T0.4027,0.1419 T0.3869,0.1434 Q0.3682,0.1434,0.3512,0.1494 Q0.3398,0.1533,0.3333,0.1533 Q0.3285,0.1533,0.3228,0.1513 L0.3212,0.1513 Q0.3179,0.1513,0.3155,0.1538 T0.3131,0.1592 Q0.3131,0.1612,0.3147,0.1632 Q0.3171,0.1652,0.3232,0.1667 T0.3406,0.1691 T0.3544,0.1701 L0.3601,0.1701 L0.3678,0.1701 L0.3771,0.1701 Q0.3909,0.1701,0.4031,0.1731 Q0.4177,0.178,0.4558,0.178 Q0.4793,0.178,0.4882,0.1761 L0.4915,0.1761 Q0.498,0.1761,0.5061,0.179 T0.5158,0.183 Q0.5166,0.183,0.5231,0.1835 T0.5341,0.1845 T0.545,0.1855 T0.5572,0.1874 T0.5661,0.1909 Q0.5677,0.1919,0.5702,0.1919 T0.5791,0.1894 T0.588,0.1869 Q0.5921,0.1869,0.5945,0.1899 Q0.5977,0.1919,0.6123,0.1919 Q0.6334,0.1919,0.6448,0.1879 Q0.6488,0.186,0.6537,0.186 Q0.6626,0.186,0.6784,0.1909 T0.6983,0.1958 Q0.704,0.1968,0.7206,0.1973 T0.7506,0.1993 T0.7689,0.2018 L0.7705,0.2018 L0.7741,0.2018 L0.7786,0.2018 Q0.8248,0.2018,0.8475,0.2136 Q0.8597,0.2196,0.8597,0.2255 T0.8483,0.2359 T0.8281,0.2404 Q0.8208,0.2404,0.7534,0.2493 Q0.7502,0.2502,0.7425,0.2512 T0.7287,0.2532 T0.7178,0.2542 L0.7153,0.2542 L0.7137,0.2542 Q0.7064,0.2542,0.6906,0.2591 T0.6715,0.2681 T0.6626,0.272 Q0.6594,0.272,0.6541,0.2705 T0.6464,0.269 T0.6431,0.27 Q0.6318,0.273,0.6034,0.276 Q0.6018,0.276,0.5998,0.2774 T0.5957,0.2799 T0.5904,0.2809 L0.5856,0.2809 Q0.5823,0.2799,0.575,0.2799 Q0.541,0.2799,0.5239,0.2868 Q0.5134,0.2908,0.5028,0.2908 Q0.4996,0.2908,0.4935,0.2903 T0.4842,0.2898 Q0.4704,0.2898,0.4635,0.2918 T0.4534,0.2997 L0.4534,0.3007 Q0.4534,0.3037,0.459,0.3046 T0.4753,0.3066 T0.4899,0.3076 Q0.4907,0.3076,0.5166,0.3106 T0.5507,0.3155 Q0.5921,0.3264,0.605,0.3264 Q0.6107,0.3264,0.6131,0.3254 Q0.6212,0.3225,0.6294,0.3225 Q0.6407,0.3225,0.6513,0.3274 Q0.6529,0.3294,0.6569,0.3294 Q0.6594,0.3294,0.665,0.3284 T0.6748,0.3274 T0.6821,0.3284 Q0.6861,0.3294,0.6967,0.3299 T0.7165,0.3314 T0.7299,0.3343 Q0.7324,0.3363,0.7486,0.3383 T0.7697,0.3422 Q0.7713,0.3432,0.7774,0.3452 T0.7887,0.3492 T0.8005,0.3546 T0.8127,0.3625 T0.8208,0.3719 Q0.8256,0.3798,0.8256,0.3848 Q0.8256,0.3877,0.8236,0.3902 T0.8171,0.3976 T0.809,0.4075 T0.8021,0.4189 T0.7968,0.4273 T0.7867,0.4322 T0.7616,0.4382 Q0.7543,0.4392,0.7437,0.4392 L0.7364,0.4392 L0.7299,0.4392 Q0.7202,0.4392,0.7161,0.4402 Q0.7072,0.4431,0.691,0.4431 L0.6845,0.4431 L0.6788,0.4431 Q0.6707,0.4431,0.6545,0.4461 T0.6334,0.452 Q0.6399,0.456,0.6626,0.457 Q0.6878,0.458,0.7024,0.4639 Q0.7064,0.4649,0.7186,0.4649 L0.7295,0.4649 L0.7413,0.4649 Q0.7591,0.4649,0.7697,0.4669 Q0.7818,0.4688,0.7818,0.4728 Q0.7818,0.4748,0.7798,0.4763 T0.7753,0.4787 L0.7737,0.4797 L0.7729,0.4797 Q0.7494,0.4827,0.7388,0.4827 Q0.7332,0.4827,0.7307,0.4817 L0.7291,0.4817 Q0.7226,0.4817,0.6878,0.4896 Q0.6829,0.4906,0.6772,0.4911 T0.6618,0.4921 T0.646,0.4931 T0.6342,0.4936 T0.6212,0.4931 T0.6115,0.4926 Q0.6083,0.4926,0.6058,0.4931 T0.5969,0.4955 T0.5819,0.499 T0.5629,0.5005 Q0.5572,0.5005,0.5527,0.501 T0.545,0.502 T0.5397,0.503 T0.5361,0.5045 T0.5337,0.5064 T0.5328,0.5094 L0.532,0.5094 Q0.532,0.5104,0.5337,0.5109 T0.5381,0.5114 T0.5438,0.5119 T0.5515,0.5124 L0.5604,0.5124 Q0.5661,0.5134,0.5746,0.5153 T0.5912,0.5173 L0.5977,0.5173 L0.6026,0.5173 Q0.6139,0.5173,0.6273,0.5218 T0.6407,0.5272 Q0.6448,0.5282,0.6683,0.5321 T0.6991,0.5381 Q0.7137,0.543,0.7664,0.548 Q0.7908,0.55,0.7908,0.5559 Q0.7908,0.5589,0.7859,0.5628 Q0.781,0.5658,0.7713,0.5658 Q0.7689,0.5658,0.7628,0.5653 T0.7543,0.5648 T0.7502,0.5658 Q0.7364,0.5678,0.6878,0.5678 Q0.674,0.5678,0.6699,0.5668 L0.6683,0.5668 Q0.6659,0.5668,0.6606,0.5678 T0.6513,0.5687 Q0.6464,0.5687,0.6391,0.5678 Q0.6334,0.5668,0.6302,0.5668 T0.6241,0.5687 T0.6164,0.5717 Q0.6123,0.5727,0.6099,0.5727 L0.6062,0.5727 T0.6022,0.5732 T0.5953,0.5737 Q0.5904,0.5747,0.5839,0.5747 T0.5726,0.5752 T0.5629,0.5776 T0.5458,0.5796 Q0.5418,0.5796,0.5324,0.5791 T0.5191,0.5786 Q0.5077,0.5786,0.5045,0.5806 Q0.498,0.5836,0.4899,0.5836 Q0.4858,0.5836,0.4777,0.5826 T0.4655,0.5816 L0.4631,0.5816 Q0.4582,0.5816,0.4436,0.5841 T0.4144,0.5865 L0.4051,0.5865 L0.3986,0.5865 T0.3933,0.587 T0.3897,0.5875 T0.3869,0.5885 T0.3844,0.5895 T0.3812,0.591 T0.3779,0.5925 Q0.3739,0.5955,0.3633,0.5955 L0.3585,0.5955 L0.3536,0.5955 Q0.3277,0.5955,0.3066,0.6053 Q0.3017,0.6083,0.296,0.6083 Q0.2912,0.6083,0.2839,0.6068 T0.2741,0.6053 Q0.2693,0.6053,0.2547,0.6048 T0.236,0.6044 Q0.2222,0.6044,0.193,0.6103 L0.1882,0.6103 Q0.1825,0.6103,0.1703,0.6083 T0.1525,0.6063 L0.15,0.6063 Q0.1436,0.6063,0.135,0.6073 T0.1168,0.6098 T0.1038,0.6113 Q0.0973,0.6123,0.0787,0.6123 L0.06,0.6123 L0.0414,0.6123 L0.0235,0.6123 Q0.0049,0.6133,0.0049,0.6172 L0.0057,0.6222 Q0.0073,0.6261,0.043,0.6261 L0.0454,0.6261 L0.0479,0.6261 Q0.0527,0.6261,0.0633,0.6281 T0.0803,0.6301 L0.0835,0.6301 L0.0916,0.6301 Q0.0998,0.6301,0.129,0.632 Q0.1768,0.636,0.206,0.64 Q0.2117,0.6409,0.2165,0.6409 Q0.2206,0.6409,0.2263,0.6405 T0.2336,0.64 L0.2384,0.64 L0.2409,0.64 T0.251,0.6395 T0.2644,0.639 Q0.2741,0.639,0.279,0.6409 Q0.283,0.6429,0.2972,0.6439 T0.3293,0.6459 T0.3528,0.6469 L0.3577,0.6469 Q0.3642,0.6469,0.3723,0.6449 T0.3844,0.6429 Q0.3901,0.6429,0.3933,0.6454 T0.4006,0.6479 Q0.4088,0.6479,0.4517,0.6558 Q0.4582,0.6568,0.4623,0.6568 Q0.4655,0.6568,0.4716,0.6563 T0.4826,0.6558 L0.4882,0.6558 Q0.5004,0.6558,0.5255,0.6607 Q0.5328,0.6627,0.5604,0.6647 T0.6152,0.6682 T0.644,0.6696 T0.6719,0.6721 T0.7056,0.6756 Q0.7113,0.6756,0.7198,0.6805 T0.738,0.6855 Q0.7453,0.6855,0.7551,0.6879 T0.7741,0.6939 T0.7912,0.7018 T0.8029,0.7102 Q0.8054,0.7132,0.8054,0.7151 Q0.8054,0.7191,0.8009,0.7216 T0.7908,0.7255 T0.779,0.7285 T0.7721,0.731 Q0.7672,0.7329,0.7632,0.7339 T0.7551,0.7354 T0.7486,0.7369 Q0.7478,0.7369,0.7445,0.7379 T0.7388,0.7394 T0.7324,0.7404 L0.7242,0.7413 T0.7161,0.7418 Q0.7105,0.7418,0.6955,0.7428 T0.6748,0.7438 L0.6699,0.7438 L0.6667,0.7438 Q0.6659,0.7438,0.6407,0.7468 Q0.6367,0.7478,0.6334,0.7478 T0.6245,0.7468 T0.6148,0.7458 L0.6099,0.7458 Q0.6018,0.7468,0.5864,0.7498 T0.5653,0.7532 T0.5596,0.7557 Q0.5596,0.7596,0.571,0.7616 Q0.5742,0.7626,0.5803,0.7631 T0.5929,0.7641 T0.6018,0.7656 L0.6087,0.7656 T0.6192,0.7666 T0.6277,0.7695 Q0.631,0.7705,0.6342,0.7705 Q0.6383,0.7705,0.6452,0.7685 T0.6537,0.7666 L0.6553,0.7666 Q0.6602,0.7666,0.6711,0.769 T0.6853,0.7715 Q0.691,0.7715,0.7048,0.775 T0.7242,0.7814 Q0.7324,0.7854,0.7445,0.7864 Q0.7478,0.7864,0.768,0.7873 T0.7972,0.7893 Q0.7989,0.7893,0.8017,0.7898 T0.8054,0.7903 L0.8082,0.7903 T0.811,0.7908 T0.8131,0.7913 T0.8151,0.7918 L0.8167,0.7928 T0.8179,0.7943 T0.8191,0.7957 T0.8204,0.7977 T0.8208,0.8002 Q0.8208,0.8032,0.8171,0.8051 T0.8074,0.8081 T0.7997,0.8091 Q0.7932,0.8121,0.7737,0.8121 Q0.7664,0.8121,0.7413,0.8111 L0.7364,0.8111 Q0.7275,0.8111,0.7214,0.8121 T0.7084,0.8155 T0.6991,0.819 Q0.6934,0.82,0.6723,0.8205 T0.6472,0.8239 Q0.6423,0.8269,0.6375,0.8269 Q0.6358,0.8269,0.6338,0.8264 T0.6294,0.8254 T0.6253,0.8249 Q0.6042,0.8249,0.5961,0.8289 Q0.5921,0.8318,0.5839,0.8318 Q0.5783,0.8318,0.5645,0.8299 L0.5604,0.8299 Q0.5531,0.8299,0.5337,0.8368 Q0.5296,0.8378,0.5255,0.8378 Q0.5239,0.8378,0.5195,0.8373 T0.5118,0.8368 Q0.5061,0.8368,0.4996,0.8388 Q0.4947,0.8388,0.4882,0.8398 Q0.4777,0.8417,0.4753,0.8427 T0.4728,0.8467 Q0.4728,0.8487,0.4769,0.8492 T0.4886,0.8501 T0.5004,0.8516 Q0.5036,0.8516,0.515,0.8521 T0.5345,0.8546 Q0.5426,0.8556,0.5596,0.8556 L0.5649,0.8556 L0.5702,0.8556 L0.5791,0.8556 Q0.5921,0.8566,0.6135,0.8571 T0.6383,0.8586 Q0.6683,0.8615,0.6764,0.8615 L0.6776,0.8615 L0.6788,0.8615 Q0.7032,0.8615,0.7032,0.8684 Q0.7032,0.8714,0.6995,0.8749 T0.6959,0.8803 L0.6959,0.8813 Q0.6991,0.8843,0.7153,0.8843 Q0.7218,0.8843,0.7259,0.8833 L0.7299,0.8833 Q0.7324,0.8833,0.7348,0.8838 T0.7393,0.8848 T0.7433,0.8863 L0.7474,0.8882 T0.751,0.8892 Q0.7616,0.8922,0.7948,0.8952 Q0.7981,0.8952,0.8082,0.8981 T0.8297,0.9011 T0.8528,0.9026 T0.8747,0.907 T0.8856,0.9149 L0.8856,0.9159 Q0.8856,0.9179,0.8848,0.9189 T0.8828,0.9209 T0.88,0.9228 T0.8767,0.9243 T0.8731,0.9248 T0.8698,0.9253 T0.8666,0.9258 L0.8646,0.9258 Q0.8621,0.9258,0.8577,0.9263 T0.85,0.9268 T0.8439,0.9273 T0.8402,0.9278 Q0.8386,0.9298,0.8358,0.9308 T0.8305,0.9322 T0.8244,0.9332 T0.8183,0.9337 Q0.8135,0.9347,0.8025,0.9372 T0.7859,0.9416 Q0.7778,0.9436,0.7717,0.9471 T0.7624,0.9515 Q0.7575,0.9535,0.7486,0.9535 Q0.7461,0.9535,0.7397,0.953 T0.7291,0.9525 Q0.7234,0.9525,0.7186,0.953 T0.7137,0.957 T0.7194,0.9614 Q0.7567,0.9634,0.7664,0.9654 Q0.7745,0.9674,0.7762,0.9674 L0.7908,0.9674 Q0.7924,0.9664,0.7948,0.9664 Q0.8029,0.9664,0.8082,0.9679 T0.8224,0.9723 T0.8362,0.9773 Q0.8483,0.9812,0.871,0.9812 L0.8751,0.9812 Q0.8873,0.9812,0.8929,0.9822 T0.9165,0.9876 T0.9408,0.9931 Q0.9513,0.996,0.9935,0.996 Z M0.9968,0.998 L0.9976,0.003 Q0.9838,0.002,0.9708,0.0059 Q0.9659,0.0069,0.9327,0.0059 T0.8881,0.0099 Q0.8767,0.0138,0.8658,0.0163 T0.8491,0.0218 Q0.8418,0.0247,0.8341,0.0247 T0.8232,0.0257 Q0.8191,0.0277,0.7997,0.0292 T0.7737,0.0326 Q0.7697,0.0336,0.7672,0.0336 T0.7612,0.0321 T0.7543,0.0307 T0.747,0.0317 Q0.7397,0.0326,0.7336,0.0356 T0.717,0.0406 Q0.6918,0.0445,0.6861,0.0475 Q0.6845,0.0485,0.6772,0.0485 T0.6638,0.05 T0.6577,0.0549 T0.6642,0.0598 T0.6796,0.0613 T0.6902,0.0623 Q0.691,0.0623,0.6946,0.0658 T0.7064,0.0702 L0.7161,0.0702 Q0.7194,0.0702,0.7242,0.0697 T0.7332,0.0692 Q0.7413,0.0692,0.7579,0.0717 T0.781,0.0762 Q0.7867,0.0781,0.8171,0.0786 T0.85,0.0801 L0.8532,0.0801 Q0.8548,0.0801,0.8613,0.0796 T0.8727,0.0791 Q0.8832,0.0791,0.8913,0.0811 Q0.897,0.0831,0.897,0.0851 Q0.897,0.088,0.8873,0.0905 T0.8735,0.093 Q0.871,0.093,0.8447,0.0984 T0.8143,0.1039 Q0.7981,0.1039,0.781,0.1078 Q0.7778,0.1088,0.7745,0.1088 T0.7644,0.1073 T0.7518,0.1048 L0.7453,0.1048 Q0.7299,0.1048,0.7076,0.1083 T0.6788,0.1157 Q0.6748,0.1187,0.6699,0.1187 Q0.6675,0.1187,0.6557,0.1157 T0.6318,0.1118 Q0.6164,0.1108,0.6135,0.1108 T0.6095,0.1113 T0.6067,0.1142 T0.6018,0.1187 Q0.5953,0.1227,0.5921,0.1227 Q0.5904,0.1227,0.5839,0.1212 T0.5693,0.1187 L0.5661,0.1187 Q0.558,0.1187,0.5527,0.1207 T0.5434,0.1246 T0.5337,0.1266 Q0.5126,0.1296,0.4882,0.1296 T0.4371,0.1385 Q0.4298,0.1414,0.425,0.1414 Q0.4225,0.1414,0.4189,0.1395 T0.4112,0.1375 Q0.4079,0.1375,0.4019,0.1395 T0.3869,0.1414 Q0.3682,0.1414,0.3504,0.1474 Q0.3398,0.1513,0.3333,0.1513 Q0.3293,0.1513,0.3228,0.1494 L0.3204,0.1494 Q0.3179,0.1494,0.3155,0.1508 T0.3118,0.1543 T0.3106,0.1592 T0.3127,0.1642 T0.32,0.1677 T0.3305,0.1701 T0.3435,0.1716 T0.3544,0.1721 Q0.3569,0.1731,0.3601,0.1731 Q0.3625,0.1731,0.3686,0.1726 T0.3779,0.1721 Q0.3901,0.1721,0.4023,0.1751 Q0.4177,0.18,0.455,0.18 Q0.4793,0.18,0.4882,0.178 L0.4915,0.178 Q0.4964,0.178,0.5045,0.1815 T0.5158,0.185 Q0.5564,0.1889,0.5645,0.1929 Q0.5677,0.1939,0.571,0.1939 Q0.575,0.1939,0.5807,0.1919 T0.5888,0.1899 T0.5929,0.1909 Q0.5969,0.1949,0.6123,0.1949 Q0.6334,0.1949,0.6456,0.1899 Q0.6488,0.1879,0.6537,0.1879 Q0.661,0.1879,0.6772,0.1929 T0.6983,0.1978 Q0.704,0.1988,0.7206,0.1998 T0.7502,0.2018 T0.7664,0.2038 Q0.7672,0.2038,0.7818,0.2043 T0.8151,0.2067 T0.8459,0.2156 Q0.8573,0.2206,0.8573,0.2255 Q0.8573,0.2295,0.8516,0.2324 T0.8394,0.2369 T0.8281,0.2384 Q0.8208,0.2384,0.7526,0.2473 Q0.7494,0.2483,0.7421,0.2493 T0.7287,0.2512 T0.7178,0.2522 L0.7153,0.2522 L0.7137,0.2522 Q0.7056,0.2522,0.6898,0.2572 T0.6691,0.2671 Q0.6667,0.269,0.6634,0.269 Q0.661,0.269,0.6557,0.2681 T0.6472,0.2671 Q0.6448,0.2671,0.6415,0.2681 Q0.631,0.271,0.6034,0.274 Q0.5994,0.274,0.5961,0.2765 T0.5904,0.2789 L0.5864,0.2789 Q0.5831,0.2779,0.5742,0.2779 Q0.541,0.2779,0.5223,0.2849 Q0.5126,0.2888,0.5028,0.2888 Q0.5004,0.2888,0.4943,0.2883 T0.4842,0.2878 Q0.4704,0.2878,0.4627,0.2898 T0.4509,0.2987 Q0.4501,0.2997,0.4501,0.3007 Q0.4501,0.3046,0.4574,0.3066 T0.4765,0.3091 T0.4899,0.3096 Q0.4931,0.3106,0.5061,0.3121 T0.5316,0.315 T0.5499,0.3175 Q0.5507,0.3175,0.5572,0.3195 T0.5685,0.3225 T0.5803,0.3254 T0.5933,0.3279 T0.605,0.3284 T0.6148,0.3274 Q0.6221,0.3244,0.6294,0.3244 Q0.6399,0.3244,0.6496,0.3294 Q0.6529,0.3314,0.6577,0.3314 Q0.6602,0.3314,0.6663,0.3304 T0.6756,0.3294 T0.6813,0.3304 Q0.6837,0.3304,0.6873,0.3309 L0.6946,0.3318 T0.7024,0.3323 T0.7105,0.3328 T0.7178,0.3338 T0.7238,0.3348 T0.7283,0.3363 Q0.7324,0.3383,0.7482,0.3403 T0.7689,0.3442 Q0.7697,0.3442,0.779,0.3477 T0.794,0.3536 T0.8074,0.3615 T0.8183,0.3729 Q0.8232,0.3808,0.8232,0.3848 Q0.8232,0.3867,0.8216,0.3892 T0.8151,0.3961 T0.8062,0.406 T0.7997,0.4179 T0.7952,0.4263 T0.7855,0.4308 T0.7616,0.4362 Q0.7543,0.4372,0.7445,0.4372 L0.7372,0.4372 L0.7307,0.4372 Q0.7202,0.4372,0.7153,0.4382 Q0.7064,0.4402,0.691,0.4402 L0.6845,0.4402 L0.6788,0.4402 Q0.6699,0.4402,0.6533,0.4436 T0.6318,0.451 Q0.6302,0.451,0.6302,0.452 Q0.6302,0.457,0.6626,0.459 Q0.6869,0.4609,0.7015,0.4659 Q0.7064,0.4669,0.7202,0.4669 L0.7311,0.4669 L0.7429,0.4669 Q0.7599,0.4669,0.7689,0.4688 Q0.7794,0.4708,0.7794,0.4738 L0.7786,0.4748 T0.777,0.4763 T0.7749,0.4773 L0.7737,0.4777 L0.7729,0.4777 Q0.7502,0.4797,0.738,0.4797 L0.7315,0.4797 L0.7291,0.4797 Q0.7226,0.4797,0.6869,0.4876 Q0.6821,0.4886,0.6768,0.4891 T0.6614,0.4901 T0.6456,0.4916 L0.6342,0.4916 Q0.6294,0.4916,0.6217,0.4911 T0.6115,0.4906 Q0.6083,0.4906,0.6054,0.4911 T0.5961,0.4936 T0.5815,0.4965 T0.5629,0.4985 Q0.5507,0.4985,0.5442,0.4995 T0.5345,0.5025 T0.5304,0.5084 L0.5296,0.5094 Q0.5296,0.5114,0.5316,0.5124 T0.5373,0.5138 T0.5442,0.5143 L0.5523,0.5143 L0.5604,0.5143 Q0.5637,0.5153,0.5677,0.5158 T0.5742,0.5173 T0.5811,0.5188 T0.5904,0.5193 L0.5977,0.5193 L0.6026,0.5193 Q0.6107,0.5193,0.6168,0.5208 T0.6294,0.5252 T0.6399,0.5292 Q0.6448,0.5302,0.6679,0.5341 T0.6983,0.541 Q0.7129,0.545,0.7664,0.55 Q0.7883,0.5519,0.7883,0.5559 Q0.7883,0.5579,0.7843,0.5608 T0.7721,0.5638 Q0.7697,0.5638,0.764,0.5633 T0.7559,0.5628 L0.7502,0.5628 Q0.7364,0.5648,0.6869,0.5648 L0.6699,0.5648 L0.6675,0.5648 Q0.665,0.5648,0.6602,0.5658 T0.6513,0.5668 Q0.6464,0.5668,0.6399,0.5658 Q0.6342,0.5648,0.6302,0.5648 T0.6225,0.5668 T0.6156,0.5697 Q0.6107,0.5707,0.6087,0.5707 T0.6034,0.5712 T0.5953,0.5717 Q0.5904,0.5727,0.5835,0.5727 T0.5718,0.5732 T0.562,0.5757 Q0.558,0.5767,0.5483,0.5767 L0.5349,0.5767 L0.5207,0.5767 Q0.5069,0.5767,0.5028,0.5786 Q0.4972,0.5816,0.4899,0.5816 Q0.4858,0.5816,0.4781,0.5806 T0.4663,0.5796 L0.4631,0.5796 Q0.4582,0.5796,0.4432,0.5821 T0.4144,0.5846 L0.4071,0.5846 L0.4011,0.5846 T0.3958,0.5851 T0.3921,0.5856 T0.3889,0.5861 T0.3865,0.5865 T0.384,0.5875 L0.382,0.5885 L0.3796,0.5895 L0.3771,0.5905 Q0.3731,0.5925,0.365,0.5925 L0.3601,0.5925 L0.3552,0.5925 Q0.3277,0.5925,0.3049,0.6044 Q0.3017,0.6053,0.296,0.6053 Q0.292,0.6053,0.2847,0.6044 T0.2741,0.6034 Q0.2693,0.6034,0.2547,0.6029 T0.236,0.6024 Q0.2222,0.6024,0.1922,0.6073 Q0.1906,0.6083,0.1882,0.6083 Q0.1833,0.6083,0.1711,0.6063 T0.1533,0.6044 Q0.1517,0.6034,0.1492,0.6034 Q0.1436,0.6034,0.135,0.6048 T0.1168,0.6078 T0.103,0.6093 Q0.0973,0.6103,0.0787,0.6103 L0.0604,0.6103 L0.0414,0.6103 L0.0235,0.6103 Q0.0024,0.6113,0.0024,0.6172 Q0.0024,0.6192,0.0032,0.6231 Q0.0049,0.6281,0.0333,0.6281 L0.0414,0.6281 L0.0479,0.6281 Q0.0519,0.6281,0.0624,0.6301 T0.0803,0.632 L0.0835,0.632 L0.0916,0.632 Q0.0989,0.632,0.1281,0.634 L0.206,0.6419 Q0.2117,0.6429,0.2174,0.6429 Q0.2214,0.6429,0.2271,0.6424 T0.2344,0.6419 L0.2376,0.6419 Q0.2393,0.6429,0.2417,0.6429 T0.2526,0.6419 T0.2652,0.6409 Q0.2741,0.6409,0.2782,0.6429 T0.2968,0.6459 T0.3293,0.6479 T0.352,0.6489 Q0.3552,0.6499,0.3585,0.6499 Q0.365,0.6499,0.3731,0.6474 T0.3844,0.6449 Q0.3885,0.6449,0.3917,0.6474 T0.4006,0.6499 Q0.4088,0.6499,0.4509,0.6578 Q0.4574,0.6588,0.4631,0.6588 Q0.4655,0.6588,0.4716,0.6583 T0.4834,0.6578 L0.4874,0.6578 Q0.5004,0.6588,0.5255,0.6637 Q0.532,0.6647,0.56,0.6667 T0.6148,0.6701 T0.644,0.6716 Q0.6464,0.6726,0.6594,0.6736 T0.6861,0.6756 T0.7048,0.6775 Q0.7097,0.6775,0.7186,0.6825 T0.738,0.6874 Q0.7518,0.6874,0.7729,0.6958 T0.8005,0.7122 Q0.8029,0.7141,0.8029,0.7151 Q0.8029,0.7191,0.7968,0.7216 T0.7818,0.726 T0.7705,0.729 L0.764,0.731 T0.7583,0.7324 T0.753,0.7334 T0.7486,0.7349 Q0.7251,0.7399,0.7161,0.7399 Q0.7097,0.7399,0.6951,0.7409 T0.6748,0.7418 L0.6703,0.7418 L0.6667,0.7418 Q0.6659,0.7418,0.6399,0.7448 L0.6334,0.7448 Q0.631,0.7448,0.6249,0.7443 T0.6148,0.7438 L0.6099,0.7438 Q0.601,0.7448,0.5856,0.7473 T0.5645,0.7507 Q0.5572,0.7517,0.5572,0.7547 T0.5616,0.7606 T0.5702,0.7646 Q0.5734,0.7646,0.5779,0.7651 T0.5864,0.7661 T0.5949,0.7671 T0.6018,0.7676 L0.6071,0.7676 T0.6135,0.7681 T0.62,0.769 T0.6269,0.771 T0.6342,0.7725 Q0.6391,0.7725,0.6464,0.7705 T0.6541,0.7685 L0.6553,0.7685 Q0.6586,0.7685,0.6699,0.771 T0.6853,0.7735 Q0.6902,0.7735,0.704,0.777 T0.7226,0.7834 Q0.7315,0.7873,0.7445,0.7883 Q0.747,0.7883,0.7676,0.7893 T0.7972,0.7913 Q0.7989,0.7913,0.8017,0.7918 T0.8058,0.7923 T0.809,0.7928 T0.8114,0.7933 T0.8131,0.7938 T0.8147,0.7943 T0.8159,0.7953 L0.8171,0.7967 T0.8175,0.7982 Q0.8183,0.7992,0.8183,0.8002 Q0.8183,0.8022,0.8155,0.8032 T0.807,0.8051 T0.7989,0.8071 Q0.7932,0.8091,0.7737,0.8091 L0.7413,0.8091 L0.7372,0.8091 Q0.7275,0.8091,0.721,0.8101 T0.7072,0.8136 T0.6983,0.817 Q0.6942,0.818,0.6825,0.818 T0.6602,0.819 T0.6456,0.822 Q0.6415,0.8249,0.6375,0.8249 Q0.6367,0.8249,0.6326,0.8239 T0.6253,0.8229 Q0.6034,0.8229,0.5953,0.8279 Q0.5912,0.8289,0.5848,0.8289 Q0.5775,0.8289,0.5645,0.8279 Q0.5629,0.8269,0.5604,0.8269 Q0.5523,0.8269,0.5328,0.8348 Q0.5296,0.8358,0.5264,0.8358 Q0.5247,0.8358,0.5203,0.8353 T0.5126,0.8348 Q0.5061,0.8348,0.4988,0.8358 Q0.4947,0.8368,0.4882,0.8378 Q0.4826,0.8388,0.4797,0.8388 T0.474,0.8398 T0.4704,0.8422 T0.4696,0.8462 T0.472,0.8501 T0.4781,0.8521 T0.4854,0.8526 L0.4935,0.8526 T0.4996,0.8536 Q0.5036,0.8536,0.515,0.8546 T0.5337,0.8566 Q0.5426,0.8576,0.5596,0.8576 L0.5657,0.8576 L0.571,0.8576 L0.5791,0.8576 Q0.5921,0.8586,0.6135,0.8591 T0.6383,0.8605 Q0.6683,0.8635,0.6764,0.8635 Q0.6999,0.8635,0.6999,0.8684 Q0.6999,0.8694,0.6967,0.8734 T0.6934,0.8803 L0.6934,0.8823 Q0.6951,0.8863,0.7137,0.8863 Q0.7218,0.8863,0.7267,0.8853 L0.7299,0.8853 Q0.734,0.8853,0.7409,0.8882 T0.7502,0.8912 Q0.7607,0.8942,0.794,0.8971 Q0.7972,0.8971,0.8078,0.9001 T0.8297,0.9031 Q0.8475,0.9031,0.8646,0.9065 T0.8824,0.9149 L0.8824,0.9159 Q0.8824,0.9169,0.882,0.9179 T0.8808,0.9194 L0.8792,0.9204 T0.8767,0.9214 T0.8743,0.9224 T0.8715,0.9228 L0.8686,0.9228 T0.8662,0.9233 T0.8637,0.9238 L0.8556,0.9238 T0.8447,0.9243 T0.8386,0.9268 Q0.837,0.9278,0.835,0.9288 T0.8305,0.9303 T0.8244,0.9308 T0.8175,0.9318 Q0.8127,0.9327,0.8017,0.9352 T0.7851,0.9397 Q0.777,0.9416,0.7705,0.9451 T0.7616,0.9496 Q0.7575,0.9505,0.7494,0.9505 L0.7405,0.9505 L0.7307,0.9505 Q0.7234,0.9505,0.7178,0.9515 Q0.7145,0.9515,0.7125,0.9535 T0.7105,0.9575 Q0.7105,0.9624,0.7194,0.9634 Q0.7567,0.9654,0.7664,0.9674 Q0.7745,0.9693,0.7762,0.9693 L0.7908,0.9693 L0.7948,0.9693 Q0.8005,0.9693,0.8058,0.9698 T0.8143,0.9718 T0.824,0.9753 T0.8354,0.9792 Q0.8483,0.9832,0.871,0.9832 L0.8739,0.9832 L0.8767,0.9832 Q0.8873,0.9832,0.8921,0.9842 Q0.8978,0.9852,0.9157,0.9896 T0.94,0.996 Q0.9424,0.996,0.9469,0.9965 T0.9566,0.997 T0.9676,0.9975 T0.9781,0.998 L0.9874,0.998 L0.9943,0.998 L0.9968,0.998 Z",type:0}},"":{type:5,data:{d:"M1,0.5071 L0.8623,0.7102 L0.5865,0.7102 L0.4489,0.5071 L0.5865,0.3045 L0.8623,0.3045 Z M0.4135,0.7974 L0.2758,1 L0,1 L0,0.5944 L0.2758,0.5944 Z M0.4135,0.2026 L0.2758,0.4056 L0,0.4056 L0,0 L0.2758,0 Z",type:0}},"":{type:5,data:{d:"M0.9726,0.5071 L0.8451,0.3184 L0.59,0.3184 L0.4624,0.5071 L0.59,0.6955 L0.8451,0.6955 Z M1,0.5071 L0.8588,0.716 L0.5768,0.716 L0.4355,0.5071 L0.5768,0.2982 L0.8588,0.2982 Z M0.401,0.7911 L0.2735,0.6028 L0.0236,0.6028 L0.0236,0.9799 L0.2735,0.9799 Z M0.4284,0.7911 L0.2872,1 L0,1 L0,0.5826 L0.2872,0.5826 Z M0.401,0.2089 L0.2735,0.0201 L0.0236,0.0201 L0.0236,0.3972 L0.2735,0.3972 Z M0.4284,0.2089 L0.2872,0.4174 L0,0.4174 L0,0 L0.2872,0 Z",type:0}},"":{type:5,data:{d:"M1,0.4996 Q1,0.5365,0.9924,0.568 T0.9718,0.6177 T0.9433,0.6363 L0.9425,0.6367 Q0.9355,0.6367,0.9285,0.6326 L0.9308,0.6326 Q0.9129,0.6195,0.9013,0.587 T0.8871,0.5164 T0.8943,0.4373 T0.9254,0.3687 L0.9254,0.3691 Q0.9339,0.3628,0.9425,0.3628 Q0.9584,0.3628,0.9716,0.3811 T0.9924,0.431 T1,0.4996 Z M0.9234,0.6321 L0.8776,0.7169 Q0.8791,0.732,0.8791,0.7441 Q0.8791,0.781,0.8716,0.8123 T0.8508,0.862 T0.8224,0.8809 L0.8216,0.8809 Q0.8154,0.8809,0.8092,0.8775 L0.8088,0.8775 Q0.7905,0.8633,0.7792,0.8297 T0.766,0.7571 T0.775,0.6768 T0.8088,0.6086 L0.7431,0.6065 Q0.719,0.6313,0.7072,0.6705 T0.6965,0.7487 T0.7097,0.823 T0.7423,0.8763 L0.7913,0.8775 L0.7314,0.9895 L0.4648,0.5038 L0.7299,0.0176 L0.7882,0.1229 L0.7404,0.1212 Q0.7244,0.138,0.7136,0.1617 T0.6982,0.2108 T0.6943,0.2632 T0.7003,0.3144 T0.7159,0.3586 T0.7396,0.3909 L0.8072,0.3926 Q0.7894,0.3796,0.7777,0.3479 T0.7635,0.2789 T0.7697,0.2011 T0.7987,0.1326 Q0.8092,0.1225,0.8204,0.1225 Q0.8321,0.1225,0.8426,0.1332 T0.8609,0.1623 T0.8731,0.2062 T0.8776,0.2592 Q0.8776,0.268,0.8768,0.2815 L0.9219,0.3628 L0.8644,0.3612 Q0.8403,0.3855,0.8284,0.4247 T0.8177,0.5029 T0.8309,0.5772 T0.8632,0.6305 Z M0.6759,0.6384 Q0.6576,0.625,0.6457,0.5914 T0.6319,0.5185 T0.6407,0.4375 T0.6751,0.3691 L0.609,0.367 Q0.5849,0.3918,0.5731,0.431 T0.5626,0.509 T0.5758,0.5833 T0.6082,0.6367 Z M0.6459,0.0025 L0.391,0.4551 L0.0035,0.4551 L0.246,0 Z M0.6553,1 L0.2421,1 L0,0.5449 L0.3875,0.5449 Z",type:0}},"":{type:5,data:{d:"M0.4996,0 Q0.5562,0,0.5961,0.0166 T0.6364,0.0566 L0.6368,0.0575 Q0.6368,0.0646,0.6324,0.0713 L0.6324,0.0692 Q0.6196,0.0868,0.5872,0.0986 T0.5166,0.1128 T0.4373,0.1057 T0.3689,0.0747 L0.3694,0.0747 Q0.3627,0.0663,0.3627,0.0575 Q0.3627,0.0336,0.4028,0.0168 T0.4996,0 Z M0.6324,0.0768 L0.717,0.1225 Q0.7321,0.1212,0.744,0.1212 Q0.8007,0.1212,0.8406,0.1378 T0.8809,0.1779 L0.8813,0.1783 Q0.8813,0.1846,0.8778,0.1909 Q0.8636,0.2093,0.8299,0.2208 T0.7573,0.2343 T0.6769,0.2253 T0.6089,0.1913 L0.6067,0.2567 Q0.6315,0.281,0.6707,0.2928 T0.7489,0.3035 T0.8231,0.2903 T0.8764,0.258 L0.8778,0.2085 L0.9898,0.2685 L0.504,0.5352 L0.0177,0.2701 L0.1227,0.2118 L0.1213,0.2596 Q0.1457,0.2836,0.1851,0.2955 T0.2635,0.3062 T0.3377,0.293 T0.3911,0.2605 L0.3928,0.193 Q0.38,0.2106,0.3483,0.2221 T0.2792,0.2364 T0.2013,0.2303 T0.1324,0.2013 Q0.1222,0.1909,0.1222,0.1795 Q0.1222,0.156,0.1623,0.1393 T0.2591,0.1225 Q0.2679,0.1225,0.2817,0.1233 L0.3627,0.078 L0.3609,0.1359 Q0.3857,0.1598,0.4249,0.1718 T0.5031,0.1825 T0.5773,0.1693 T0.6306,0.1367 Z M0.6386,0.3242 Q0.6253,0.3427,0.5917,0.3544 T0.5186,0.3683 T0.4376,0.3595 T0.3689,0.3251 L0.3671,0.3909 Q0.3915,0.4148,0.4309,0.4268 T0.5093,0.4375 T0.5835,0.4243 T0.6368,0.3918 Z M0.0027,0.354 L0.4553,0.6091 L0.4553,0.9962 L0,0.7542 Z M1,0.3448 L1,0.7576 L0.5447,1 L0.5447,0.6128 Z",type:0}},"":{type:5,data:{d:"M0,0.7699 Q0,0.7172,0.0277,0.6731 T0.103,0.6032 T0.2066,0.5774 Q0.2487,0.5774,0.2865,0.5922 T0.3526,0.6339 L0.3935,0.6715 Q0.4218,0.6984,0.438,0.7336 T0.4541,0.8075 Q0.4541,0.8602,0.4264,0.9046 T0.3511,0.9745 T0.2475,1 Q0.2054,1,0.1676,0.9852 T0.1016,0.9435 L0.0606,0.9059 Q0.0317,0.879,0.0159,0.8438 T0,0.7699 Z M0.5459,0.7699 Q0.5459,0.7172,0.5736,0.6731 T0.6489,0.6032 T0.7525,0.5774 Q0.7946,0.5774,0.8324,0.5922 T0.8984,0.6339 L0.9394,0.6715 Q0.9579,0.6892,0.9714,0.7105 T0.9925,0.7567 T1,0.8075 Q1,0.8602,0.9723,0.9046 T0.897,0.9745 T0.7934,1 Q0.7513,1,0.7135,0.9852 T0.6474,0.9435 L0.6065,0.9059 Q0.5776,0.879,0.5617,0.8438 T0.5459,0.7699 Z M0,0.1925 Q0,0.1398,0.0277,0.0957 T0.103,0.0258 T0.2066,0 Q0.2487,0,0.2865,0.0148 T0.3526,0.0565 L0.3935,0.0941 Q0.4218,0.121,0.438,0.1562 T0.4541,0.2301 Q0.4541,0.2828,0.4264,0.3269 T0.3511,0.3968 T0.2475,0.4226 Q0.2054,0.4226,0.1676,0.4078 T0.1016,0.3661 L0.0606,0.3285 Q0.0317,0.3016,0.0159,0.2664 T0,0.1925 Z M0.5459,0.1925 Q0.5459,0.1398,0.5736,0.0957 T0.6489,0.0258 T0.7525,0 Q0.7946,0,0.8324,0.0148 T0.8984,0.0565 L0.9394,0.0941 Q0.9677,0.121,0.9838,0.1562 T1,0.2301 Q1,0.2828,0.9723,0.3269 T0.897,0.3968 T0.7934,0.4226 Q0.7513,0.4226,0.7135,0.4078 T0.6474,0.3661 L0.6065,0.3285 Q0.5874,0.3108,0.5741,0.2895 T0.5534,0.2433 T0.5459,0.1925 Z M0.3301,0.6403 Q0.3306,0.6403,0.3312,0.6409 L0.3272,0.6371 L0.3272,0.6371 Z M0.8759,0.6403 Q0.8765,0.6403,0.8771,0.6409 Q0.8748,0.6387,0.8725,0.6371 L0.8731,0.6371 Z M0.3301,0.0629 Q0.3306,0.0629,0.3312,0.0634 L0.3272,0.0597 L0.3272,0.0602 Z M0.8759,0.0629 Q0.8765,0.0629,0.8771,0.0634 Q0.8748,0.0613,0.8725,0.0597 Q0.8731,0.0597,0.8731,0.0602 Z M0.779,0.4113 L0.7501,0.3849 Q0.7046,0.3844,0.6636,0.3661 Q0.7132,0.407,0.779,0.4113 Z M0.7957,0.4118 Q0.8477,0.4113,0.8918,0.3874 T0.9619,0.3223 T0.9885,0.2323 L0.9585,0.2048 Q0.9538,0.2769,0.8987,0.3282 T0.7657,0.3844 Z M0.9879,0.2167 Q0.9827,0.1554,0.9388,0.1097 Q0.9585,0.1478,0.959,0.1898 Z M0.2331,0.4113 L0.2043,0.3849 Q0.1587,0.3844,0.1177,0.3661 Q0.1673,0.407,0.2331,0.4113 Z M0.2499,0.4118 Q0.2885,0.4118,0.324,0.3973 T0.3852,0.3589 T0.4264,0.3019 T0.4426,0.2323 L0.4126,0.2048 Q0.408,0.2769,0.3529,0.3282 T0.2198,0.3844 Z M0.442,0.2167 Q0.4368,0.1554,0.393,0.1097 Q0.4126,0.1478,0.4132,0.1903 Z M0.779,0.9887 L0.7501,0.9624 Q0.7046,0.9618,0.6636,0.9435 Q0.7132,0.9844,0.779,0.9887 Z M0.7957,0.9892 Q0.8477,0.9887,0.8918,0.9648 T0.9619,0.8997 T0.9885,0.8097 L0.9585,0.7823 Q0.9538,0.8543,0.8987,0.9056 T0.7657,0.9618 Z M0.9879,0.7941 Q0.9827,0.7328,0.9388,0.6871 Q0.9585,0.7253,0.959,0.7672 Z M0.2331,0.9887 L0.2043,0.9624 Q0.1587,0.9618,0.1177,0.9435 Q0.1673,0.9844,0.2331,0.9887 Z M0.2499,0.9892 Q0.3018,0.9887,0.3459,0.9648 T0.416,0.8997 T0.4426,0.8102 L0.4126,0.7823 Q0.408,0.8543,0.3529,0.9056 T0.2198,0.9618 Z M0.442,0.7941 Q0.4368,0.7328,0.393,0.6871 Q0.4126,0.7253,0.4132,0.7672 Z M0.3191,0.6452 Q0.2943,0.622,0.2643,0.6091 T0.2066,0.5962 Q0.1795,0.5962,0.1497,0.6091 T0.0949,0.6441 T0.0537,0.6995 T0.0375,0.7699 Q0.0375,0.8419,0.0941,0.8941 Q0.1189,0.9172,0.1489,0.9304 T0.2066,0.9435 Q0.2337,0.9435,0.2634,0.9304 T0.3182,0.8952 T0.3595,0.8398 T0.3756,0.7699 Q0.3756,0.6978,0.3191,0.6452 Z M0.026,0.7731 L0.026,0.7699 Q0.026,0.6935,0.0854,0.6376 Q0.0658,0.6538,0.0514,0.6739 T0.0289,0.718 T0.0202,0.7677 Z M0.0271,0.7892 L0.0208,0.7833 Q0.0254,0.8419,0.0681,0.886 Q0.0664,0.8828,0.0646,0.879 Q0.0329,0.8382,0.0271,0.7892 Z M0.3866,0.7823 Q0.382,0.8511,0.3278,0.9016 Q0.3843,0.8565,0.3918,0.7871 Z M0.3872,0.7677 L0.393,0.7726 L0.393,0.7699 Q0.393,0.7349,0.3791,0.7038 Q0.3739,0.6973,0.3681,0.6914 Q0.3866,0.7274,0.3872,0.7677 Z M0.865,0.6452 Q0.8402,0.622,0.8102,0.6091 T0.7525,0.5962 Q0.7305,0.5962,0.7072,0.6043 T0.6619,0.628 T0.6223,0.6642 T0.5941,0.7124 T0.5834,0.7699 Q0.5834,0.8419,0.6399,0.8941 Q0.6561,0.9097,0.6757,0.9207 T0.7149,0.9376 T0.7525,0.9435 Q0.7796,0.9435,0.8093,0.9304 T0.8641,0.8952 T0.9054,0.8398 T0.9215,0.7699 Q0.9215,0.6978,0.865,0.6452 Z M0.5718,0.7731 L0.5718,0.7699 Q0.5718,0.6935,0.6313,0.6376 Q0.6013,0.6618,0.584,0.6954 T0.5661,0.7677 Z M0.573,0.7892 L0.5666,0.7833 Q0.5713,0.8419,0.614,0.886 Q0.6122,0.8828,0.6105,0.879 Q0.5788,0.8382,0.573,0.7892 Z M0.9325,0.7823 Q0.9279,0.8511,0.8736,0.9016 Q0.9308,0.8565,0.9377,0.7871 Z M0.9331,0.7672 L0.9388,0.7726 L0.9388,0.7699 Q0.9388,0.7349,0.925,0.7038 Q0.9198,0.6973,0.914,0.6914 Q0.9325,0.7274,0.9331,0.7672 Z M0.3191,0.0677 Q0.2943,0.0446,0.2643,0.0317 T0.2066,0.0188 Q0.1795,0.0188,0.1497,0.0317 T0.0949,0.0667 T0.0537,0.122 T0.0375,0.1925 Q0.0375,0.2645,0.0941,0.3167 Q0.1189,0.3398,0.1489,0.353 T0.2066,0.3661 Q0.2337,0.3661,0.2634,0.353 T0.3182,0.3177 T0.3595,0.2624 T0.3756,0.1925 Q0.3756,0.1204,0.3191,0.0677 Z M0.026,0.1957 L0.026,0.1925 Q0.026,0.1161,0.0854,0.0602 Q0.0554,0.0844,0.0381,0.118 T0.0202,0.1903 Z M0.0271,0.2118 L0.0208,0.2059 Q0.0254,0.2645,0.0681,0.3086 Q0.0664,0.3054,0.0646,0.3016 Q0.0329,0.2608,0.0271,0.2118 Z M0.3866,0.2048 Q0.382,0.2737,0.3278,0.3242 Q0.3849,0.279,0.3918,0.2097 Z M0.3872,0.1903 L0.393,0.1952 L0.393,0.1925 Q0.393,0.1581,0.3791,0.1263 Q0.3739,0.1199,0.3681,0.114 Q0.3866,0.15,0.3872,0.1903 Z M0.865,0.0677 Q0.8402,0.0446,0.8102,0.0317 T0.7525,0.0188 Q0.7253,0.0188,0.6956,0.0317 T0.6408,0.0667 T0.5995,0.122 T0.5834,0.1925 Q0.5834,0.2645,0.6399,0.3167 Q0.6647,0.3398,0.6947,0.353 T0.7525,0.3661 Q0.7796,0.3661,0.8093,0.353 T0.8641,0.3177 T0.9054,0.2624 T0.9215,0.1925 Q0.9215,0.1204,0.865,0.0677 Z M0.5718,0.1957 L0.5718,0.1925 Q0.5718,0.1161,0.6313,0.0602 Q0.6013,0.0844,0.584,0.118 T0.5661,0.1903 Z M0.573,0.2118 L0.5666,0.2059 Q0.5713,0.2645,0.614,0.3086 Q0.6122,0.3054,0.6105,0.3016 Q0.5788,0.2608,0.573,0.2118 Z M0.9325,0.2048 Q0.9279,0.2737,0.8736,0.3242 Q0.9308,0.279,0.9377,0.2097 Z M0.9331,0.1898 L0.9388,0.1952 L0.9388,0.1925 Q0.9388,0.1575,0.925,0.1263 Q0.9198,0.1199,0.914,0.114 Q0.9325,0.15,0.9331,0.1898 Z",type:0}},"":{type:5,data:{d:"M0.7639,0.1974 L0.9593,0.1974 L0.9593,0.1443 L0.7639,0.1443 L0.7639,0.1974 Z M0.7586,0.109 L0.9727,0.109 Q0.9839,0.109,0.992,0.1153 T1,0.1291 L1,0.382 Q1,0.3906,0.9912,0.3964 T0.9727,0.4021 L0.7586,0.4021 Q0.7468,0.4021,0.739,0.3956 T0.7313,0.382 L0.7313,0.1291 Q0.7313,0.1246,0.7339,0.1207 T0.7406,0.1143 T0.7495,0.1104 T0.7586,0.109 Z M0.7639,0.6575 L0.9593,0.6575 L0.9593,0.6044 L0.7639,0.6044 L0.7639,0.6575 Z M0.7313,0.8347 L0.7313,0.5843 Q0.7313,0.5757,0.7401,0.5699 T0.7586,0.5641 L0.9706,0.5641 Q0.9818,0.5641,0.9898,0.5705 T0.9979,0.5843 L0.9979,0.8347 Q0.9979,0.8433,0.989,0.8491 T0.9706,0.8549 L0.7586,0.8549 Q0.7468,0.8549,0.739,0.8483 T0.7313,0.8347 Z M0,1 L0,0 L0.7607,0 L0.7607,1 L0,1 Z",type:0}},"":{type:5,data:{d:"M0.9764,0.0029 L0.3179,0.4552 L0,0.4552 L0,0 Z M1,1 L0,1 L0,0.5448 L0.3091,0.5448 Z",type:0}},"":{type:5,data:{d:"M0.0236,0.0029 L0.6824,0.4552 L1,0.4552 L1,0 Z M0,1 L1,1 L1,0.5448 L0.6912,0.5448 Z",type:0}},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z M.25,.15 L1,.15 L1,.85 L.25,.85 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z M0,.15 L1,.15 L1,.85 L0,.85 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z M0,.15 L.75,.15 L.75,.85 L0,.85 Z"},"":{type:5,data:{d:"M0.6574,0.303 Q0.623,0.291,0.5852,0.285 T0.5082,0.279 T0.4311,0.285 T0.359,0.303 L0.3082,0.248 Q0.3525,0.232,0.4041,0.2235 T0.5082,0.215 T0.6123,0.2235 T0.7082,0.248 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.8557,0.582 L0.7656,0.551 Q0.7852,0.53,0.7951,0.507 T0.8049,0.46 Q0.8049,0.424,0.782,0.3905 T0.718,0.332 T0.6221,0.293 T0.5082,0.279 L0.5082,0.215 Q0.5607,0.215,0.6123,0.2235 T0.709,0.248 T0.7918,0.287 T0.8557,0.3375 T0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.582 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.482,0.705,0.4557,0.703 T0.4049,0.697 L0.4311,0.635 Q0.4508,0.638,0.4697,0.6395 T0.5082,0.641 Q0.5672,0.641,0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 Q0.8049,0.436,0.7951,0.413 T0.7656,0.369 L0.8557,0.338 Q0.882,0.365,0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 L0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 T0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 L0.9098,0.46 Q0.9098,0.492,0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.338 L0.2508,0.369 Q0.2311,0.39,0.2213,0.413 T0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 Q0.5279,0.641,0.5467,0.6395 T0.5852,0.635 L0.6115,0.697 Q0.5869,0.701,0.5607,0.703 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.1607,0.582 Q0.1344,0.555,0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.3375 T0.2246,0.287 T0.3074,0.248 T0.4041,0.2235 T0.5082,0.215 L0.5082,0.279 Q0.4492,0.279,0.3943,0.293 T0.2984,0.332 T0.2344,0.3905 T0.2115,0.46 Q0.2115,0.484,0.2213,0.507 T0.2508,0.551 Z",type:0},scaleType:1},"":s.LINE_H,"":s.LINE_V,"":s.FADE_RIGHT,"":s.FADE_LEFT,"":s.FADE_DOWN,"":s.FADE_UP,"":s.CURVE_DOWN_RIGHT,"":s.CURVE_DOWN_LEFT,"":s.CURVE_UP_RIGHT,"":s.CURVE_UP_LEFT,"":[s.LINE_V,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_RIGHT],"":[s.CURVE_DOWN_RIGHT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_UP_LEFT],"":[s.LINE_V,s.CURVE_DOWN_LEFT],"":[s.CURVE_DOWN_LEFT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_LEFT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT],"":[s.CURVE_DOWN_LEFT,s.CURVE_DOWN_RIGHT],"":[s.LINE_H,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_UP_RIGHT],"":[s.CURVE_UP_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_UP_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_LEFT,s.CURVE_DOWN_RIGHT],"":[s.LINE_H,s.CURVE_DOWN_LEFT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_RIGHT,s.CURVE_UP_LEFT],"":[s.LINE_V,s.CURVE_DOWN_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_LEFT,s.CURVE_UP_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE],"":s.NODE_STROKE,"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN],"":[s.NODE_STROKE,s.NODE_LINE_DOWN],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP],"":[s.NODE_STROKE,s.NODE_LINE_UP],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_UP],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_UP],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"🬀":r(1),"🬁":r(2),"🬂":r(3),"🬃":r(4),"🬄":r(5),"🬅":r(6),"🬆":r(7),"🬇":r(8),"🬈":r(9),"🬉":r(10),"🬊":r(11),"🬋":r(12),"🬌":r(13),"🬍":r(14),"🬎":r(15),"🬏":r(16),"🬐":r(17),"🬑":r(18),"🬒":r(19),"🬓":r(20),"🬔":r(22),"🬕":r(23),"🬖":r(24),"🬗":r(25),"🬘":r(26),"🬙":r(27),"🬚":r(28),"🬛":r(29),"🬜":r(30),"🬝":r(31),"🬞":r(32),"🬟":r(33),"🬠":r(34),"🬡":r(35),"🬢":r(36),"🬣":r(37),"🬤":r(38),"🬥":r(39),"🬦":r(40),"🬧":r(41),"🬨":r(43),"🬩":r(44),"🬪":r(45),"🬫":r(46),"🬬":r(47),"🬭":r(48),"🬮":r(49),"🬯":r(50),"🬰":r(51),"🬱":r(52),"🬲":r(53),"🬳":r(54),"🬴":r(55),"🬵":r(56),"🬶":r(57),"🬷":r(58),"🬸":r(59),"🬹":r(60),"🬺":r(61),"🬻":r(62),"🬼":{type:3,data:"M0,0.6667 L0,1 L0.5,1 Z"},"🬽":{type:3,data:"M0,0.6667 L0,1 L1,1 Z"},"🬾":{type:3,data:"M0,0.3333 L0,1 L0.5,1 Z"},"🬿":{type:3,data:"M0,0.3333 L0,1 L1,1 Z"},"🭀":{type:3,data:"M0,0 L0,1 L0.5,1 Z"},"🭁":{type:3,data:"M0,0.3333 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭂":{type:3,data:"M0,0.3333 L1,0 L1,1 L0,1 Z"},"🭃":{type:3,data:"M0,0.6667 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭄":{type:3,data:"M0,0.6667 L1,0 L1,1 L0,1 Z"},"🭅":{type:3,data:"M0,1 L0.5,0 L1,0 L1,1 Z"},"🭆":{type:3,data:"M0,0.6667 L1,0.3333 L1,1 L0,1 Z"},"🭇":{type:3,data:"M0.5,1 L1,0.6667 L1,1 Z"},"🭈":{type:3,data:"M0,1 L1,0.6667 L1,1 Z"},"🭉":{type:3,data:"M0.5,1 L1,0.3333 L1,1 Z"},"🭊":{type:3,data:"M0,1 L1,0.3333 L1,1 Z"},"🭋":{type:3,data:"M0.5,1 L1,0 L1,1 Z"},"🭌":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.3333 Z"},"🭍":{type:3,data:"M0,0 L0,1 L1,1 L1,0.3333 Z"},"🭎":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.6667 Z"},"🭏":{type:3,data:"M0,0 L0,1 L1,1 L1,0.6667 Z"},"🭐":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 Z"},"🭑":{type:3,data:"M0,0.3333 L0,1 L1,1 L1,0.6667 Z"},"🭒":{type:3,data:"M0,0.6667 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭓":{type:3,data:"M0,0.6667 L1,1 L1,0 L0,0 Z"},"🭔":{type:3,data:"M0,0.3333 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭕":{type:3,data:"M0,0.3333 L1,1 L1,0 L0,0 Z"},"🭖":{type:3,data:"M0,0 L0.5,1 L1,1 L1,0 Z"},"🭗":{type:3,data:"M0,0.3333 L0,0 L0.5,0 Z"},"🭘":{type:3,data:"M0,0.3333 L0,0 L1,0 Z"},"🭙":{type:3,data:"M0,0.6667 L0,0 L0.5,0 Z"},"🭚":{type:3,data:"M0,0.6667 L0,0 L1,0 Z"},"🭛":{type:3,data:"M0,1 L0,0 L0.5,0 Z"},"🭜":{type:3,data:"M0,0.6667 L0,0 L1,0 L1,0.3333 Z"},"🭝":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.6667 Z"},"🭞":{type:3,data:"M0,1 L0,0 L1,0 L1,0.6667 Z"},"🭟":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.3333 Z"},"🭠":{type:3,data:"M0,1 L0,0 L1,0 L1,0.3333 Z"},"🭡":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 Z"},"🭢":{type:3,data:"M0.5,0 L1,0 L1,0.3333 Z"},"🭣":{type:3,data:"M0,0 L1,0 L1,0.3333 Z"},"🭤":{type:3,data:"M0.5,0 L1,0 L1,0.6667 Z"},"🭥":{type:3,data:"M0,0 L1,0 L1,0.6667 Z"},"🭦":{type:3,data:"M0.5,0 L1,0 L1,1 Z"},"🭧":{type:3,data:"M0,0.3333 L1,0.6667 L1,0 L0,0 Z"},"🭨":{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0.5,0.5 Z"},"🭩":{type:3,data:"M0,0 L0.5,0.5 L1,0 L1,1 L0,1 Z"},"🭪":{type:3,data:"M0,0 L1,0 L0.5,0.5 L1,1 L0,1 Z"},"🭫":{type:3,data:"M0,0 L1,0 L1,1 L0.5,0.5 L0,1 Z"},"🭬":{type:3,data:"M0,0 L0.5,0.5 L0,1 Z"},"🭭":{type:3,data:"M0,0 L1,0 L0.5,0.5 Z"},"🭮":{type:3,data:"M1,0 L1,1 L0.5,0.5 Z"},"🭯":{type:3,data:"M0,1 L1,1 L0.5,0.5 Z"},"🭰":{type:0,data:[{x:1,y:0,w:1,h:8}]},"🭱":{type:0,data:[{x:2,y:0,w:1,h:8}]},"🭲":{type:0,data:[{x:3,y:0,w:1,h:8}]},"🭳":{type:0,data:[{x:4,y:0,w:1,h:8}]},"🭴":{type:0,data:[{x:5,y:0,w:1,h:8}]},"🭵":{type:0,data:[{x:6,y:0,w:1,h:8}]},"🭶":{type:0,data:[{x:0,y:1,w:8,h:1}]},"🭷":{type:0,data:[{x:0,y:2,w:8,h:1}]},"🭸":{type:0,data:[{x:0,y:3,w:8,h:1}]},"🭹":{type:0,data:[{x:0,y:4,w:8,h:1}]},"🭺":{type:0,data:[{x:0,y:5,w:8,h:1}]},"🭻":{type:0,data:[{x:0,y:6,w:8,h:1}]},"🭼":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🭽":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭾":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭿":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🮀":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮁":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮂":{type:0,data:[{x:0,y:0,w:8,h:2}]},"🮃":{type:0,data:[{x:0,y:0,w:8,h:3}]},"🮄":{type:0,data:[{x:0,y:0,w:8,h:5}]},"🮅":{type:0,data:[{x:0,y:0,w:8,h:6}]},"🮆":{type:0,data:[{x:0,y:0,w:8,h:7}]},"🮇":{type:0,data:[{x:6,y:0,w:2,h:8}]},"🮈":{type:0,data:[{x:5,y:0,w:3,h:8}]},"🮉":{type:0,data:[{x:3,y:0,w:5,h:8}]},"🮊":{type:0,data:[{x:2,y:0,w:6,h:8}]},"🮋":{type:0,data:[{x:1,y:0,w:7,h:8}]},"🮌":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},"🮍":{type:1,data:[[1,0],[0,1]],clipPath:"M0.5,0 L1,0 L1,1 L0.5,1 Z"},"🮎":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},"🮏":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},"🮐":{type:1,data:[[0,1],[1,0]]},"🮑":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},{type:0,data:[{x:0,y:0,w:8,h:4}]}],"🮒":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},{type:0,data:[{x:0,y:4,w:8,h:4}]}],"🮔":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},{type:0,data:[{x:4,y:0,w:4,h:8}]}],"🮕":{type:0,data:[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}]},"🮖":{type:0,data:[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}]},"🮗":{type:0,data:[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},"🮘":{type:2,data:"M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25",strokeWidth:1},"🮙":{type:2,data:"M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75",strokeWidth:1},"🮚":{type:5,data:{d:"M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0",type:0}},"🮛":{type:5,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1",type:0}},"🮜":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L0,1 Z"},"🮝":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,1 Z"},"🮞":{type:1,data:[[1,0],[0,1]],clipPath:"M1,0 L1,1 L0,1 Z"},"🮟":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,1 L0,1 Z"},"🮠":{type:2,data:"M.5,0 L0,.5",strokeWidth:1},"🮡":{type:2,data:"M.5,0 L1,.5",strokeWidth:1},"🮢":{type:2,data:"M0,.5 L.5,1",strokeWidth:1},"🮣":{type:2,data:"M1,.5 L.5,1",strokeWidth:1},"🮤":{type:2,data:"M.5,0 L0,.5 L.5,1",strokeWidth:1},"🮥":{type:2,data:"M.5,0 L1,.5 L.5,1",strokeWidth:1},"🮦":{type:2,data:"M0,.5 L.5,1 L1,.5",strokeWidth:1},"🮧":{type:2,data:"M0,.5 L.5,0 L1,.5",strokeWidth:1},"🮨":{type:2,data:"M.5,0 L0,.5 M1,.5 L.5,1",strokeWidth:1},"🮩":{type:2,data:"M.5,0 L1,.5 M0,.5 L.5,1",strokeWidth:1},"🮪":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5",strokeWidth:1},"🮫":{type:2,data:"M.5,0 L0,.5 L.5,1 L1,.5",strokeWidth:1},"🮬":{type:2,data:"M0,.5 L.5,0 L1,.5 L.5,1",strokeWidth:1},"🮭":{type:2,data:"M1,.5 L.5,0 L0,.5 L.5,1",strokeWidth:1},"🮮":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5 Z",strokeWidth:1},"🮯":{type:2,data:"M0,.5 L1,.5 M.5,.35 L.5,.65",strokeWidth:1},"🮰":{type:3,data:"M0.1,0.2 L0.1,.8 L.4,.6 L.9,0.6 Z",scaleType:1},"🮱":{type:4,data:{d:"M.1,.525 L.35,.675 L.9,.35",type:1},scaleType:1},"🮲":{type:3,data:"M.29,.27 L0.13,.56 L.22,.59 L.35,0.35 L.67,.35 L.57,.57 L.71,.76 L.22,.76 L.42,1 L.53,.98 L.43,.86 L.9,.86 L.71,.6 L1,.6 L1,.52 L.83,.52 L.92,.36 L1,.36 L1,.27Z M.99,.13 A.12,.12,0,1,1,.75,.13 A.12,.12,0,1,1,.99,.13"},"🮳":{type:3,data:"M0,.27 L.3,.27 L.55,.12 L.63,.18 L.33,.36 L0,.36 M0,.52 L.33,.52 L.59,.89 L.73,.89 L.73,.98 L.53,.98 L.28,.6 L0,.6"},"🮴":{type:4,data:{d:"M.15,.55 L.5,.4 L.5,.45 L.65,.45 L.65,.35 L.85,.35 L.85,.625 L.5,.625 L.5,.7 Z",type:0},scaleType:1},"🮵":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.15,.5 L.5,.35 L.5,.425 L.85,.425 L.85,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮶":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.85,.5 L.5,.35 L.5,.425 L.15,.425 L.15,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮷":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.675 L.2,.5 L.35,.5 L.35,.325 L.65,.325 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮸":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.325 L.2,.5 L.35,.5 L.35,.675 L.65,.675 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮹":{type:5,data:{d:"M1,.89 L.11,.89 L.11,.37 L.36,.12 L.74,.12 L.96,.34 L1,.34 L1,.405 L.92,.405 L.69,.185 L.41,.185 L.21,.42 L.21,.825 L1,.825 Z",type:0}},"🮺":{type:5,data:{d:"M0,.89 L0,.825 L.78,.825 L.78,.53 L.7,.415 L0,.415 L0,.35 L.75,.35 L.88,.48 L.88,.89 Z",type:0}},"🮻":{type:5,data:{d:"M.31,.275 L.44,.275 L.44,.47 L.05,.47 L.05,.405 L.31,.405 Z M.56,.275 L.69,.275 L.69,.405 L.95,.405 L.95,.47 L.56,.47 Z M.05,.53 L.44,.53 L.44,.725 L.31,.725 L.31,.595 L.05,.595 Z M.56,.53 L.95,.53 L.95,.595 L.69,.595 L.69,.725 L.56,.725 Z",type:0},scaleType:1},"🮼":[{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0,.935 L.885,.935 L.885,.065 L0,.065 Z"},{type:3,data:"M.67,.5 A.17,.085,0,1,1,.33,.5 A.17,.085,0,1,1,.67,.5",scaleType:1}],"🮽":{type:4,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1 Z",type:1}},"🮾":{type:4,data:{d:"M1,.5 L.5,1",type:1}},"🮿":{type:4,data:{d:"M.5,0 L1,.5 L.5,1 L0,.5 Z",type:1}},"🯀":{type:3,data:"M.16,.445 A.02,.01,0,0,1,.39,.33 L.5,.375 L.61,.33 A.02,.01,0,0,1,.84,.445 L.75,.5 L.84,.555 A.02,.01,0,0,1,.61,.67 L.5,.625 L.39,.67 A.02,.01,0,0,1,.16,.555 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",scaleType:1},"🯁":{type:3,data:"M0.142308,0.924 Q0.130770,0.924,0.120193,0.922 T0.101924,0.916 T0.089424,0.9065 T0.084616,0.895 V0.025 Q0.084616,0.019,0.089424,0.0135 T0.101924,0.004 T0.120193,-0.002 T0.142308,-0.004 Q0.150000,-0.004,0.157693,-0.003 T0.173078,0.000 L0.430770,0.085 Q0.451924,0.059,0.494232,0.041 T0.587501,0.013 T0.692309,-0.0005 T0.800001,-0.004 H1 V0.055 H0.800001 Q0.771155,0.055,0.742309,0.056 T0.685578,0.060 T0.630770,0.0685 T0.580770,0.0825 T0.543270,0.1045 T0.528847,0.133 V0.647 H0.530770 Q0.596155,0.645,0.659616,0.638 T0.782693,0.6165 T0.893270,0.5805 T1,0.531 V0.623 Q0.934616,0.644,0.880770,0.6595 T0.769232,0.685 T0.650963,0.700 T0.528847,0.707 V0.787 Q0.528847,0.802,0.543270,0.8155 T0.580770,0.8375 T0.630770,0.8515 T0.685578,0.860 T0.742309,0.864 T0.800001,0.865 H1 V0.924 H0.800001 Q0.746155,0.924,0.692309,0.9205 T0.587501,0.907 T0.494232,0.879 T0.430770,0.835 L0.173078,0.920 Q0.165386,0.922,0.157693,0.923 T0.142308,0.924 Z M0.2,0.841 L0.415385,0.770 V0.150 L0.2,0.079 V0.841 Z M1,0.698 Q0.973077,0.694,0.969231,0.6885 T0.965385,0.677 Q0.965385,0.672,0.969231,0.6665 T1,0.657 V0.698 Z"},"🯂":{type:3,data:"M0,0.623 V0.531 Q0.044231,0.512,0.0625,0.4905 T0.092308,0.4465 T0.108654,0.4005 T0.113462,0.354 V0.351 Q0.113462,0.345,0.117308,0.3395 T0.129808,0.3305 T0.148846,0.3245 T0.171154,0.323 H0.501923 Q0.532692,0.323,0.563462,0.3185 T0.617308,0.303 T0.651923,0.2765 T0.663462,0.2435 V0.241 Q0.663462,0.219,0.657692,0.197 T0.639423,0.1545 T0.604808,0.115 T0.549615,0.082 T0.475577,0.0615 T0.392308,0.055 H0 V-0.004 H1 V0.055 H0.667308 Q0.694231,0.071,0.713462,0.09 T0.746154,0.129 T0.766346,0.1705 T0.775,0.213 H1 V0.316 Q0.971154,0.305,0.955769,0.2965 T0.920192,0.2825 T0.876923,0.2745 T0.830769,0.272 H0.773077 Q0.767308,0.297,0.743269,0.319 T0.680769,0.355 T0.595192,0.375 T0.501923,0.381 H0.227308 Q0.223462,0.415,0.211,0.4485 T0.172692,0.5135 T0.108269,0.573 T0,0.623 Z M0.611538,0.924 H0 V0.865 H0.611538 Q0.642308,0.865,0.673077,0.8605 T0.727885,0.845 T0.762308,0.8185 T0.773077,0.787 V0.785 Q0.773077,0.769,0.762308,0.7535 T0.727885,0.727 T0.673077,0.7115 T0.611538,0.707 H0.059615 Q0.048077,0.707,0.037115,0.7045 T0,0.694 V0.653 Q0.026923,0.648,0.037115,0.646 T0.059615,0.644 H0.722692 Q0.753462,0.644,0.784231,0.6395 T0.839038,0.624 T0.873846,0.5975 T0.884615,0.566 V0.564 Q0.884615,0.548,0.873846,0.532 T0.839038,0.5055 T0.784231,0.49 T0.722692,0.4855 H0.392308 Q0.380769,0.4855,0.370192,0.483 T0.351923,0.4765 T0.339423,0.467 T0.334615,0.4555 T0.339423,0.444 T0.351923,0.4345 T0.370192,0.428 T0.392308,0.4255 H0.832692 Q0.855769,0.4255,0.878846,0.4235 T0.922115,0.416 T0.957692,0.4015 T1,0.3815 V0.4685 Q0.975,0.4705,0.966346,0.4725 T0.95,0.4765 Q0.961538,0.4835,0.969231,0.4915 T1,0.5075 V0.6205 Q0.965385,0.6455,0.926923,0.665 T0.84,0.6935 Q0.866923,0.7115,0.877596,0.7345 T0.894231,0.7855 V0.787 Q0.894231,0.815,0.876923,0.8425 T0.820192,0.8895 T0.726923,0.916 T0.611538,0.924 Z"},"🯃":{type:3,data:"M0,0.473 V0.386 Q0.025490,0.378,0.028431,0.3695 T0.031373,0.352 V0.35 Q0.031373,0.342,0.028431,0.333 T0,0.316 V0.213 H0.652941 Q0.684314,0.213,0.715686,0.2085 T0.770588,0.193 T0.804902,0.1665 T0.815686,0.135 V0.133 Q0.815686,0.117,0.804902,0.101 T0.770588,0.0745 T0.715686,0.0595 T0.652941,0.055 H0 V-0.004 H0.652941 Q0.707843,-0.004,0.761765,0.004 T0.856863,0.031 T0.915686,0.0775 T0.933333,0.133 V0.135 Q0.933333,0.163,0.915686,0.1905 T0.856863,0.237 T0.761765,0.264 T0.652941,0.272 H0.109804 Q0.131373,0.29,0.140196,0.31 T0.149020,0.35 V0.352 Q0.149020,0.37,0.142157,0.388 T0.118627,0.4225 T0.076471,0.452 T0,0.473 Z M0,0.625 V0.513 Q0.029412,0.526,0.032353,0.54 T0.035294,0.568 V0.57 Q0.035294,0.584,0.032353,0.598 T0,0.625 Z"},"🯄":{type:3,data:"M0.019231,1.085 V-0.165 H0.980769 V1.085 H0.019231 Z M0.446154,0.527 H0.553846 Q0.553846,0.511,0.5625,0.495 T0.591346,0.466 T0.631731,0.4405 T0.666346,0.4135 T0.688462,0.3825 T0.696154,0.35 Q0.696154,0.33,0.682692,0.311 T0.641346,0.2785 T0.575,0.259 T0.498077,0.253 T0.421154,0.259 T0.356731,0.279 T0.315385,0.3125 T0.301923,0.352 V0.357 H0.409615 V0.354 Q0.409615,0.345,0.414423,0.335 T0.431731,0.318 T0.4625,0.3075 T0.498077,0.304 Q0.517308,0.304,0.534615,0.307 T0.564423,0.3165 T0.582692,0.332 T0.588462,0.35 Q0.588462,0.366,0.572115,0.3805 T0.535577,0.4075 T0.496154,0.4335 T0.465385,0.4625 T0.45,0.4945 T0.446154,0.527 Z M0.5,0.667 Q0.519231,0.667,0.5375,0.664 T0.569231,0.654 T0.588462,0.637 T0.594231,0.617 T0.588462,0.5975 T0.569231,0.581 T0.5375,0.571 T0.5,0.568 T0.4625,0.571 T0.430769,0.581 T0.411538,0.5975 T0.405769,0.617 T0.411538,0.637 T0.430769,0.654 T0.4625,0.664 T0.5,0.667 Z",scaleType:1},"🯅":{type:5,data:{d:"M.44,.425 L.44,.51 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.51 L.56,.425 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯆":{type:5,data:{d:"M.44,.425 L.44,.46 L.23,.385 L.19,.43 L.44,.51 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.61,.735 L.7,.71 L.56,.56 L.56,.51 L.81,.43 L.77,.385 L.56,.46 L.56,.425 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯇":{type:5,data:{d:"M.44,.425 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.425 Z M.18,.53 L.23,.575 L.81,.43 L.77,.385 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯈":{type:5,data:{d:"M.44,.425 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.62,.735 L.71,.71 L.56,.56 L.56,.425 Z M.23,.385 L.18,.43 L.77,.575 L.81,.53 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯉":{type:5,data:{d:"M.44,.425 L.45,.495 L.15,.645 L.34,.645 L.34,.7 L.44,.7 L.44,.645 L.56,.645 L.56,.7 L.66,.7 L.66,.645 L.84,.645 L.54,.495 L.56,.425 Z M.39,.6 L.5,.55 L.60,.6 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯊":{type:5,data:{d:"M.26,.375 L.5,.255 L.74,.375 L.74,.665 L.5,.55 L.26,.665 Z M.37,.395 L.37,.54 L.5,.475 L.63,.54 L.63,.395 L.5,.33 Z",type:0},scaleType:1},"🯋":{type:5,data:{d:"M.09,.41 L.32,.295 L.5,.375 L.68,.295 L.91,.41 L.75,.5 L.91,.59 L.68,.705 L.5,.625 L.32,.705 L.09,.59 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",type:0},scaleType:1},"🯌":{type:5,data:{d:"M.55,.305 L.88,.305 L.88,.355 L.65,.355 L.65,.47 L.88,.47 L.88,.52 L.55,.52 Z",type:0},scaleType:1},"🯍":{type:5,data:{d:"M.38,.39 L.5,.33 L.62,.39 L.62,.53 L.5,.47 L.38,.53 Z",type:0},scaleType:1},"🯎":{type:3,data:"M0,0 L0.6667,0 L0.6667,1 L0,1 Z"},"🯏":{type:3,data:"M0,0 L0.3333,0 L0.3333,1 L0,1 Z"},"🯐":{type:2,data:"M1,.5 L0,1",strokeWidth:1},"🯑":{type:2,data:"M1,0 L0,.5",strokeWidth:1},"🯒":{type:2,data:"M0,0 L1,.5",strokeWidth:1},"🯓":{type:2,data:"M0,.5 L1,1",strokeWidth:1},"🯔":{type:2,data:"M0,0 L.5,1",strokeWidth:1},"🯕":{type:2,data:"M.5,0 L1,1",strokeWidth:1},"🯖":{type:2,data:"M1,0 L.5,1",strokeWidth:1},"🯗":{type:2,data:"M.5,0 L0,1",strokeWidth:1},"🯘":{type:2,data:"M0,0 L.5,.5 L1,0",strokeWidth:1},"🯙":{type:2,data:"M1,0 L.5,.5 L1,1",strokeWidth:1},"🯚":{type:2,data:"M0,1 L.5,.5 L1,1",strokeWidth:1},"🯛":{type:2,data:"M0,0 L.5,.5 L0,1",strokeWidth:1},"🯜":{type:2,data:"M0,0 L.5,1 L1,0",strokeWidth:1},"🯝":{type:2,data:"M1,0 L0,.5 L1,1",strokeWidth:1},"🯞":{type:2,data:"M0,1 L.5,0 L1,1",strokeWidth:1},"🯟":{type:2,data:"M0,0 L1,.5 L0,1",strokeWidth:1},"🯠":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0",type:1}},"🯡":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1",type:1}},"🯢":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1",type:1}},"🯣":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1",type:1}},"🯤":{type:0,data:[{x:2,y:0,w:4,h:4}]},"🯥":{type:0,data:[{x:2,y:4,w:4,h:4}]},"🯦":{type:0,data:[{x:0,y:2,w:4,h:4}]},"🯧":{type:0,data:[{x:4,y:2,w:4,h:4}]},"🯨":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0 Z",type:0}},"🯩":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1 Z",type:0}},"🯪":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1 Z",type:0}},"🯫":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1 Z",type:0}},"🯬":{type:5,data:{d:"M1,0 L.5,0 C.5,.276,.724,.5,1,.5 Z",type:0}},"🯭":{type:5,data:{d:"M0,1 L.5,1 C.5,.724,.276,.5,0,.5 Z",type:0}},"🯮":{type:5,data:{d:"M1,1 L1,.5 C.724,.5,.5,.724,.5,1 Z",type:0}},"🯯":{type:5,data:{d:"M0,0 L0,.5 C.276,.5,.5,.276,.5,0 Z",type:0}},"🯰":{type:5,data:{d:a(126),type:0},scaleType:1},"🯱":{type:5,data:{d:a(48),type:0},scaleType:1},"🯲":{type:5,data:{d:a(109),type:0},scaleType:1},"🯳":{type:5,data:{d:a(121),type:0},scaleType:1},"🯴":{type:5,data:{d:a(51),type:0},scaleType:1},"🯵":{type:5,data:{d:a(91),type:0},scaleType:1},"🯶":{type:5,data:{d:a(95),type:0},scaleType:1},"🯷":{type:5,data:{d:a(114),type:0},scaleType:1},"🯸":{type:5,data:{d:a(127),type:0},scaleType:1},"🯹":{type:5,data:{d:a(123),type:0},scaleType:1},"🯺":{type:5,data:{d:"M.5,.175 C.2,.175,.15,.305,.15,.435 L.05,.63 L.35,.63 C.35,.682,.42,.76,.5,.76 C.58,.76,.65,.682,.65,.63 L.95,.63 L.85,.435 C.85,.305,.8,.175,.5,.175 Z",type:0},scaleType:1},...Object.fromEntries(Array.from({length:256},(t,e)=>[String.fromCodePoint(10240+e),{type:6,data:e}]))},e.blockPatternCodepoints=new Set([9617,9618,9619,129932,129933,129934,129935,129936,129937,129938,129940,129948,129949,129950,129951])},917(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.ExtendedAttrs=e.AttributeData=void 0;class i{static toColorRGB(t){return[t>>>16&255,t>>>8&255,255&t]}static fromColorRGB(t){return(255&t[0])<<16|(255&t[1])<<8|255&t[2]}clone(){const t=new i;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}fg=0;bg=0;extended=new s;isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}e.AttributeData=i;class s{_ext=0;get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(t){this._ext=t}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(t){this._ext&=-469762049,this._ext|=t<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(t){this._ext&=-67108864,this._ext|=67108863&t}_urlId=0;get urlId(){return this._urlId}set urlId(t){this._urlId=t}get underlineVariantOffset(){const t=(3758096384&this._ext)>>29;return t<0?4294967288^t:t}set underlineVariantOffset(t){this._ext&=536870911,this._ext|=t<<29&3758096384}constructor(t=0,e=0){this._ext=t,this._urlId=e}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}e.ExtendedAttrs=s},930(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.DebouncedIdleTask=e.IdleTaskQueue=e.PriorityTaskQueue=void 0;class i{_tasks=[];_idleCallback;_i=0;_logService;constructor(t){this._logService=t}enqueue(t){this._tasks.push(t),this._start()}flush(){for(;this._ir)return s-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-e))}ms`),void this._start();s=r}this.clear()}}class s extends i{_requestCallback(t){return setTimeout(()=>t(this._createDeadline(16)))}_cancelCallback(t){clearTimeout(t)}_createDeadline(t){const e=performance.now()+t;return{timeRemaining:()=>Math.max(0,e-performance.now())}}}e.PriorityTaskQueue=s,e.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(t){return requestIdleCallback(t)}_cancelCallback(t){cancelIdleCallback(t)}}:s,e.DebouncedIdleTask=class{_queue;constructor(t){this._queue=new e.IdleTaskQueue(t)}set(t){this._queue.clear(),this._queue.enqueue(t)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},948(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.createSelectionRenderModel=function(){return new i};class i{hasSelection;columnSelectMode;viewportStartRow;viewportEndRow;viewportCappedStartRow;viewportCappedEndRow;startCol;endCol;selectionStart;selectionEnd;constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(t,e,i,s=!1){if(this.selectionStart=e,this.selectionEnd=i,!e||!i||e[0]===i[0]&&e[1]===i[1])return void this.clear();const r=t.buffers.active.ydisp,a=e[1]-r,o=i[1]-r,n=Math.max(a,0),L=Math.min(o,t.rows-1);n>=t.rows||L<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=a,this.viewportEndRow=o,this.viewportCappedStartRow=n,this.viewportCappedEndRow=L,this.startCol=e[0],this.endCol=i[0])}isCellSelected(t,e,i){return!!this.hasSelection&&(i-=t.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&i>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&e=this.startCol)}}},959(t,e){function i(t){return 57508<=t&&t<=57558}function s(t){return t>=128512&&t<=128591||t>=127744&&t<=128511||t>=128640&&t<=128767||t>=9728&&t<=9983||t>=9984&&t<=10175||t>=65024&&t<=65039||t>=129280&&t<=129535||t>=127462&&t<=127487}Object.defineProperty(e,"__esModule",{value:!0}),e.throwIfFalsy=function(t){if(!t)throw new Error("value must not be falsy");return t},e.isPowerlineGlyph=i,e.isRestrictedPowerlineGlyph=function(t){return 57520<=t&&t<=57527},e.isEmoji=s,e.allowRescaling=function(t,e,r,a){return 1===e&&r>Math.ceil(1.5*a)&&void 0!==t&&t>255&&!s(t)&&!i(t)&&!function(t){return 57344<=t&&t<=63743}(t)},e.treatGlyphAsBackgroundColor=function(t){return i(t)||function(t){return 9472<=t&&t<=9631}(t)},e.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},e.computeNextVariantOffset=function(t,e,i=0){return(t-(2*Math.round(e)-i))%(2*Math.round(e))}},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=l,e.contrastRatio=d;let i=0,s=0,r=0,a=0;var o,n,L,T,h;function l(t){const e=t.toString(16);return e.length<2?"0"+e:e}function d(t,e){return t>>0},t.toColor=function(e,i,s,r){return{css:t.toCss(e,i,s,r),rgba:t.toRgba(e,i,s,r)}}}(o||(e.channels=o={})),function(t){function e(t,e){return a=Math.round(255*e),[i,s,r]=h.toChannels(t.rgba),{css:o.toCss(i,s,r,a),rgba:o.toRgba(i,s,r,a)}}t.blend=function(t,e){if(a=(255&e.rgba)/255,1===a)return{css:e.css,rgba:e.rgba};const n=e.rgba>>24&255,L=e.rgba>>16&255,T=e.rgba>>8&255,h=t.rgba>>24&255,l=t.rgba>>16&255,d=t.rgba>>8&255;return i=h+Math.round((n-h)*a),s=l+Math.round((L-l)*a),r=d+Math.round((T-d)*a),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,i){const s=h.ensureContrastRatio(t.rgba,e.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[i,s,r]=h.toChannels(e),{css:o.toCss(i,s,r),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,i){return a=255&t.rgba,e(t,a*i/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(n||(e.color=n={})),function(t){let e,n;try{const t=document.createElement("canvas");t.width=1,t.height=1;const i=t.getContext("2d",{willReadFrequently:!0});i&&(e=i,e.globalCompositeOperation="copy",n=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),a=parseInt(t.slice(4,5).repeat(2),16),o.toColor(i,s,r,a);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const L=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(L)return i=parseInt(L[1]),s=parseInt(L[2]),r=parseInt(L[3]),a=Math.round(255*(void 0===L[5]?1:parseFloat(L[5]))),o.toColor(i,s,r,a);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!n)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=n,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[i,s,r,a]=e.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,a),css:t}}}(L||(e.css=L={})),function(t){function e(t,e,i){const s=t/255,r=e/255,a=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(T||(e.rgb=T={})),function(t){function e(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));for(;h0||n>0||L>0);)o-=Math.max(0,Math.ceil(.1*o)),n-=Math.max(0,Math.ceil(.1*n)),L-=Math.max(0,Math.ceil(.1*L)),h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));return(o<<24|n<<16|L<<8|255)>>>0}function n(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));for(;h>>0}t.blend=function(t,e){if(a=(255&e)/255,1===a)return e;const n=e>>24&255,L=e>>16&255,T=e>>8&255,h=t>>24&255,l=t>>16&255,d=t>>8&255;return i=h+Math.round((n-h)*a),s=l+Math.round((L-l)*a),r=d+Math.round((T-d)*a),o.toRgba(i,s,r)},t.ensureContrastRatio=function(t,i,s){const r=T.relativeLuminance(t>>8),a=T.relativeLuminance(i>>8);if(d(r,a)>8));if(od(r,T.relativeLuminance(e>>8))?a:e}return a}const o=n(t,i,s),L=d(r,T.relativeLuminance(o>>8));if(Ld(r,T.relativeLuminance(a>>8))?o:a}return o}},t.reduceLuminance=e,t.increaseLuminance=n,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function i(s){var r=e[s];if(void 0!==r)return r.exports;var a=e[s]={exports:{}};return t[s](a,a.exports,i),a.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.WebglAddon=void 0;const e=i(426),r=i(95),a=i(399),o=i(414);class n extends e.Disposable{_terminal;_renderer;_onChangeTextureAtlas=this._register(new o.Emitter);onChangeTextureAtlas=this._onChangeTextureAtlas.event;_onAddTextureAtlasCanvas=this._register(new o.Emitter);onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=this._register(new o.Emitter);onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;_onContextLoss=this._register(new o.Emitter);onContextLoss=this._onContextLoss.event;_customGlyphs;_preserveDrawingBuffer;constructor(t){if(r.isSafari&&(0,r.getSafariVersion)()<16){const t={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",t))throw new Error("Webgl2 is only supported on Safari 16 and above")}super(),this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){const i=t._core;if(!t.element)return void this._register(i.onWillOpen(()=>this.activate(t)));this._terminal=t;const s=i.coreService,r=i.optionsService,n=i,L=n._renderService,T=n._characterJoinerService,h=n._charSizeService,l=n._coreBrowserService,d=n._decorationService,c=n._themeService;this._renderer=this._register(new a.WebglRenderer(t,T,h,l,s,d,r,c,this._customGlyphs,this._preserveDrawingBuffer)),this._register(o.EventUtils.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(o.EventUtils.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(o.EventUtils.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(o.EventUtils.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),L.setRenderer(this._renderer),this._register((0,e.toDisposable)(()=>{if(this._terminal._core._store._isDisposed)return;const e=this._terminal._core._renderService;e.setRenderer(this._terminal._core._createRenderer()),e.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}}t.WebglAddon=n})(),s})()); -+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.WebglAddon=e():t.WebglAddon=e()}(globalThis,()=>(()=>{"use strict";var t={0(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TEXT_BASELINE=e.DIM_OPACITY=void 0;const s=i(95);e.DIM_OPACITY=.5,e.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},28(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GlyphRenderer=void 0;const s=i(115),r=i(564),a=i(426);i(816);const o=i(719),n=i(959);let L,T=0,h=0,l=0;class d extends a.Disposable{_terminal;_gl;_dimensions;_optionsService;_program;_vertexArrayObject;_projectionLocation;_resolutionLocation;_textureLocation;_atlasTextures;_attributesBuffer;_atlas;_activeBuffer=0;_vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};constructor(t,e,i,r){super(),this._terminal=t,this._gl=e,this._dimensions=i,this._optionsService=r;const L=this._gl;void 0===s.TextureAtlas.maxAtlasPages&&(s.TextureAtlas.maxAtlasPages=Math.min(32,(0,n.throwIfFalsy)(L.getParameter(L.MAX_TEXTURE_IMAGE_UNITS))),s.TextureAtlas.maxTextureSize=(0,n.throwIfFalsy)(L.getParameter(L.MAX_TEXTURE_SIZE))),this._program=(0,n.throwIfFalsy)((0,o.createProgram)(L,"#version 300 es\nlayout (location = 0) in vec2 a_unitquad;\nlayout (location = 1) in vec2 a_cellpos;\nlayout (location = 2) in vec2 a_offset;\nlayout (location = 3) in vec2 a_size;\nlayout (location = 4) in float a_texpage;\nlayout (location = 5) in vec2 a_texcoord;\nlayout (location = 6) in vec2 a_texsize;\n\nuniform mat4 u_projection;\nuniform vec2 u_resolution;\n\nout vec2 v_texcoord;\nflat out int v_texpage;\n\nvoid main() {\n vec2 zeroToOne = (a_offset / u_resolution) + a_cellpos + (a_unitquad * a_size);\n gl_Position = u_projection * vec4(zeroToOne, 0.0, 1.0);\n v_texpage = int(a_texpage);\n v_texcoord = a_texcoord + a_unitquad * a_texsize;\n}",function(t){let e="";for(let i=1;iL.deleteProgram(this._program))),this._projectionLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_resolution")),this._textureLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_texture")),this._vertexArrayObject=L.createVertexArray(),L.bindVertexArray(this._vertexArrayObject);const T=new Float32Array([0,0,1,0,0,1,1,1]),h=L.createBuffer();this._register((0,a.toDisposable)(()=>L.deleteBuffer(h))),L.bindBuffer(L.ARRAY_BUFFER,h),L.bufferData(L.ARRAY_BUFFER,T,L.STATIC_DRAW),L.enableVertexAttribArray(0),L.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);const l=new Uint8Array([0,1,2,3]),d=L.createBuffer();this._register((0,a.toDisposable)(()=>L.deleteBuffer(d))),L.bindBuffer(L.ELEMENT_ARRAY_BUFFER,d),L.bufferData(L.ELEMENT_ARRAY_BUFFER,l,L.STATIC_DRAW),this._attributesBuffer=(0,n.throwIfFalsy)(L.createBuffer()),this._register((0,a.toDisposable)(()=>L.deleteBuffer(this._attributesBuffer))),L.bindBuffer(L.ARRAY_BUFFER,this._attributesBuffer),L.enableVertexAttribArray(2),L.vertexAttribPointer(2,2,L.FLOAT,!1,44,0),L.vertexAttribDivisor(2,1),L.enableVertexAttribArray(3),L.vertexAttribPointer(3,2,L.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(3,1),L.enableVertexAttribArray(4),L.vertexAttribPointer(4,1,L.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(4,1),L.enableVertexAttribArray(5),L.vertexAttribPointer(5,2,L.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(5,1),L.enableVertexAttribArray(6),L.vertexAttribPointer(6,2,L.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(6,1),L.enableVertexAttribArray(1),L.vertexAttribPointer(1,2,L.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(1,1),L.useProgram(this._program);const c=new Int32Array(s.TextureAtlas.maxAtlasPages);for(let t=0;tL.deleteTexture(e.texture))),L.activeTexture(L.TEXTURE0+t),L.bindTexture(L.TEXTURE_2D,e.texture),L.texParameteri(L.TEXTURE_2D,L.TEXTURE_WRAP_S,L.CLAMP_TO_EDGE),L.texParameteri(L.TEXTURE_2D,L.TEXTURE_WRAP_T,L.CLAMP_TO_EDGE),L.texImage2D(L.TEXTURE_2D,0,L.RGBA,1,1,0,L.RGBA,L.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[t]=e}L.enable(L.BLEND),L.blendFunc(L.SRC_ALPHA,L.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return!this._atlas||this._atlas.beginFrame()}updateCell(t,e,i,s,r,a,o,n,L){this._updateCell(this._vertices.attributes,t,e,i,s,r,a,o,n,L)}_updateCell(t,e,i,s,a,o,d,c,_,Q){T=11*(i*this._terminal.cols+e),s!==r.NULL_CELL_CODE&&void 0!==s?this._atlas&&(L=c&&c.length>1?this._atlas.getRasterizedGlyphCombinedChar(c,a,o,d,!1,this._terminal.element):this._atlas.getRasterizedGlyph(s,a,o,d,!1,this._terminal.element),h=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),a!==Q&&L.offset.x>h?(l=L.offset.x-h,t[T]=-(L.offset.x-l)+this._dimensions.device.char.left,t[T+1]=-L.offset.y+this._dimensions.device.char.top,t[T+2]=(L.size.x-l)/this._dimensions.device.canvas.width,t[T+3]=L.size.y/this._dimensions.device.canvas.height,t[T+4]=L.texturePage,t[T+5]=L.texturePositionClipSpace.x+l/this._atlas.pages[L.texturePage].canvas.width,t[T+6]=L.texturePositionClipSpace.y,t[T+7]=L.sizeClipSpace.x-l/this._atlas.pages[L.texturePage].canvas.width,t[T+8]=L.sizeClipSpace.y):(t[T]=-L.offset.x+this._dimensions.device.char.left,t[T+1]=-L.offset.y+this._dimensions.device.char.top,t[T+2]=L.size.x/this._dimensions.device.canvas.width,t[T+3]=L.size.y/this._dimensions.device.canvas.height,t[T+4]=L.texturePage,t[T+5]=L.texturePositionClipSpace.x,t[T+6]=L.texturePositionClipSpace.y,t[T+7]=L.sizeClipSpace.x,t[T+8]=L.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&(0,n.allowRescaling)(s,_,L.size.x,this._dimensions.device.cell.width)&&(t[T+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width)):t.fill(0,T,T+11-1-2)}clear(){const t=this._terminal,e=t.cols*t.rows*11;this._vertices.count!==e?this._vertices.attributes=new Float32Array(e):this._vertices.attributes.fill(0);let i=0;for(;i=0,e.isChromeOS=/\bCrOS\b/.test(i)},105(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.WindowIntervalTimer=e.eventType=void 0,e.getWindow=r,e.addDisposableListener=o,e.addStandardDisposableListener=function(t,e,i,s){return o(t,e,i,s)},e.getDomNodePagePosition=function(t){const e=t.getBoundingClientRect(),i=r(t);return{left:e.left+i.scrollX,top:e.top+i.scrollY,width:e.width,height:e.height}},e.scheduleAtNextAnimationFrame=function(t,e,i=0){const s=T(t),r=new n(e,i);return s.next.push(r),s.animFrameRequested||(s.animFrameRequested=!0,t.requestAnimationFrame(()=>function(t){const e=T(t);for(e.animFrameRequested=!1,e.current=e.next,e.next=[],e.inAnimationFrameRunner=!0;e.current.length>0;)e.current.sort(n.sort),e.current.shift().execute();e.inAnimationFrameRunner=!1}(t))),r};const s=i(578);function r(t){const e=t;if(e?.ownerDocument?.defaultView)return e.ownerDocument.defaultView;const i=t;return i?.view?i.view:window}class a{_handler;_node;_type;_options;constructor(t,e,i,s){this._node=t,this._type=e,this._handler=i,this._options=s,t.addEventListener(e,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function o(t,e,i,s){return new a(t,e,i,s)}e.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class n{_runner;priority;_canceled=!1;constructor(t,e){this._runner=t,this.priority=e}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(t){console.error(t)}}static sort(t,e){return e.priority-t.priority}}const L=new Map;function T(t){let e=L.get(t);return e||(e={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},L.set(t,e)),e}class h extends s.IntervalTimer{_defaultTarget;constructor(t){super(),this._defaultTarget=t?r(t):void 0}cancelAndSet(t,e,i){super.cancelAndSet(t,e,i??this._defaultTarget??window)}}e.WindowIntervalTimer=h},115(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextureAtlas=void 0;const s=i(0),r=i(183),a=i(959),o=i(993),n=i(836),L=i(930),T=i(917),h=i(564),l=i(414),d={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}};let c;class _{_document;_config;_unicodeService;_logService;_didWarmUp=!1;_cacheMap=new n.FourKeyMap;_cacheMapCombined=new n.FourKeyMap;_pages=[];get pages(){return this._pages}_activePages=[];_overflowSizePage;_tmpCanvas;_tmpCtx;_workBoundingBox={top:0,left:0,bottom:0,right:0};_workAttributeData=new T.AttributeData;_textureSize=512;static maxAtlasPages;static maxTextureSize;_onAddTextureAtlasCanvas=new l.Emitter;onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=new l.Emitter;onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;constructor(t,e,i,s){this._document=t,this._config=e,this._unicodeService=i,this._logService=s,this._createNewPage(),this._tmpCanvas=p(t,4*this._config.deviceCellWidth+4,this._config.deviceCellHeight+4),this._tmpCtx=(0,a.throwIfFalsy)(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:!0}))}dispose(){this._tmpCanvas.remove();for(const t of this.pages)t.canvas.remove();this._onAddTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)}_doWarmUp(){const t=new L.IdleTaskQueue(this._logService);for(let e=33;e<126;e++)t.enqueue(()=>{if(!this._cacheMap.get(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT)){const t=this._drawToCache(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT,!1,void 0);this._cacheMap.set(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT,t)}})}_requestClearModel=!1;beginFrame(){return this._requestClearModel}clearTexture(){if(0!==this._pages[0].currentRow.x||0!==this._pages[0].currentRow.y){for(const t of this._pages)t.clear();this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1}}_createNewPage(){if(_.maxAtlasPages&&this._pages.length>=Math.max(4,_.maxAtlasPages)){const t=this._pages.filter(t=>2*t.canvas.width<=(_.maxTextureSize||4096)).sort((t,e)=>e.canvas.width!==t.canvas.width?e.canvas.width-t.canvas.width:e.percentageUsed-t.percentageUsed);let e=-1,i=0;for(let s=0;st.canvas.width!==s[0].canvas.width)){const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}const r=s.map(t=>t.glyphs[0].texturePage).sort((t,e)=>t>e?1:-1),a=this.pages.length-s.length,o=this._mergePages(s,a);o.version++;for(let t=r.length-1;t>=0;t--)this._deletePage(r[t]);this.pages.push(o),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(o.canvas)}const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}_mergePages(t,e){const i=2*t[0].canvas.width,s=new Q(this._document,i,t);for(const[r,a]of t.entries()){const t=r*a.canvas.width%i,o=Math.floor(r/2)*a.canvas.height;s.ctx.drawImage(a.canvas,t,o);for(const s of a.glyphs)s.texturePage=e,s.sizeClipSpace.x=s.size.x/i,s.sizeClipSpace.y=s.size.y/i,s.texturePosition.x+=t,s.texturePosition.y+=o,s.texturePositionClipSpace.x=s.texturePosition.x/i,s.texturePositionClipSpace.y=s.texturePosition.y/i;this._onRemoveTextureAtlasCanvas.fire(a.canvas);const n=this._activePages.indexOf(a);-1!==n&&this._activePages.splice(n,1)}return s}_deletePage(t){this._pages.splice(t,1);for(let e=t;e=this._config.colors.ansi.length)throw new Error("No color found for idx "+t);return this._config.colors.ansi[t]}_getBackgroundColor(t,e,i,s){if(this._config.allowTransparency)return o.NULL_COLOR;let r;switch(t){case 16777216:case 33554432:r=this._getColorFromAnsiIndex(e);break;case 50331648:const t=T.AttributeData.toColorRGB(e);r=o.channels.toColor(t[0],t[1],t[2]);break;default:r=i?o.color.opaque(this._config.colors.foreground):this._config.colors.background}return this._config.allowTransparency||(r=o.color.opaque(r)),r}_getForegroundColor(t,e,i,r,a,n,L,h,l,d){const c=this._getMinimumContrastColor(t,e,i,r,a,n,L,l,h,d);if(c)return c;let _;switch(a){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&l&&n<8&&(n+=8),_=this._getColorFromAnsiIndex(n);break;case 50331648:const t=T.AttributeData.toColorRGB(n);_=o.channels.toColor(t[0],t[1],t[2]);break;default:_=L?this._config.colors.background:this._config.colors.foreground}return this._config.allowTransparency&&(_=o.color.opaque(_)),h&&(_=o.color.multiplyOpacity(_,s.DIM_OPACITY)),_}_resolveBackgroundRgba(t,e,i){switch(t){case 16777216:case 33554432:return this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(t,e,i,s){switch(t){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&e<8&&(e+=8),this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(t,e,i,s,r,a,n,L,T,h){if(1===this._config.minimumContrastRatio||h)return;const l=this._getContrastCache(T),d=l.getColor(t,s);if(void 0!==d)return d??void 0;const c=this._resolveBackgroundRgba(e,i,n),_=this._resolveForegroundRgba(r,a,n,L),Q=o.rgba.ensureContrastRatio(c,_,this._config.minimumContrastRatio/(T?2:1));if(!Q)return void l.setColor(t,s,null);const u=o.channels.toColor(Q>>24&255,Q>>16&255,Q>>8&255);return l.setColor(t,s,u),u}_getContrastCache(t){return t?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(t,e,i,o,n,L){const h="number"==typeof t?String.fromCharCode(t):t;L&&this._tmpCanvas.parentElement!==L&&(this._tmpCanvas.style.display="none",L.append(this._tmpCanvas));const l=Math.min(this._config.deviceCellWidth*Math.max(h.length,2)+4,this._config.deviceMaxTextureSize);this._tmpCanvas.width=t?2*t-o:t-o;!1==!(o>=t)||0===_?(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(T+_,s),this._tmpCtx.lineTo(h,s)):(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(T+_,s),this._tmpCtx.moveTo(T+_+t,s),this._tmpCtx.lineTo(h,s)),o=(0,a.computeNextVariantOffset)(h-T,t,o);break;case 5:const Q=.6,u=.3,p=h-T,g=Math.floor(Q*p),M=Math.floor(u*p),y=p-g-M;this._tmpCtx.setLineDash([g,M,y]),this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(h,s);break;default:this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(h,s)}n||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!O&&this._config.fontSize>=12&&!this._config.allowTransparency&&" "!==h){this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";const e=this._tmpCtx.measureText(h);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in e&&e.actualBoundingBoxDescent>0){this._tmpCtx.save();const e=new Path2D;e.rect(i,L-Math.ceil(t/2),this._config.deviceCellWidth*I,l-L+Math.ceil(t/2)),this._tmpCtx.clip(e),this._tmpCtx.lineWidth=3*this._config.devicePixelRatio,this._tmpCtx.strokeStyle=E.css,this._tmpCtx.strokeText(h,D,D+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(C){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),e=t%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(D,D+e),this._tmpCtx.lineTo(D+this._config.deviceCharWidth*I,D+e),this._tmpCtx.stroke()}if(O||this._tmpCtx.fillText(h,D,D+this._config.deviceCharHeight),"_"===h&&!this._config.allowTransparency){let t=u(this._tmpCtx.getImageData(D,D,this._config.deviceCellWidth,this._config.deviceCellHeight),E,S,W);if(t)for(let e=1;e<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=E.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(h,D,D+this._config.deviceCharHeight-e),t=u(this._tmpCtx.getImageData(D,D,this._config.deviceCellWidth,this._config.deviceCellHeight),E,S,W),t);e++);}if(f){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),e=this._tmpCtx.lineWidth%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(D,D+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.lineTo(D+this._config.deviceCharWidth*I,D+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.stroke()}this._tmpCtx.restore();const N=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height);let P;if(P=this._config.allowTransparency?function(t){for(let e=0;e0)return!1;return!0}(N):u(N,E,S,W),P)return d;const B=this._findGlyphBoundingBox(N,this._workBoundingBox,l,Z,O,D);let $,F;for(;;){if(0===this._activePages.length){const t=this._createNewPage();$=t,F=t.currentRow,F.height=B.size.y;break}$=this._activePages[this._activePages.length-1],F=$.currentRow;for(const t of this._activePages)B.size.y<=t.currentRow.height&&($=t,F=t.currentRow);for(let t=this._activePages.length-1;t>=0;t--)for(const e of this._activePages[t].fixedRows)e.height<=F.height&&B.size.y<=e.height&&($=this._activePages[t],F=e);if(B.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new Q(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),$=this._overflowSizePage,F=this._overflowSizePage.currentRow,F.x+B.size.x>=$.canvas.width&&(F.x=0,F.y+=F.height,F.height=0);break}if(F.y+B.size.y>=$.canvas.height||F.height>B.size.y+2){let t=!1;if($.currentRow.y+$.currentRow.height+B.size.y>=$.canvas.height){let e;for(const t of this._activePages)if(t.currentRow.y+t.currentRow.height+B.size.y=_.maxAtlasPages&&F.y+B.size.y<=$.canvas.height&&F.height>=B.size.y&&F.x+B.size.x<=$.canvas.width)t=!0;else{const e=this._createNewPage();$=e,F=e.currentRow,F.height=B.size.y,t=!0}}t||($.currentRow.height>0&&$.fixedRows.push($.currentRow),F={x:0,y:$.currentRow.y+$.currentRow.height,height:B.size.y},$.fixedRows.push(F),$.currentRow={x:0,y:F.y+F.height,height:0})}if(F.x+B.size.x<=$.canvas.width)break;F===$.currentRow?(F.x=0,F.y+=F.height,F.height=0):$.fixedRows.splice($.fixedRows.indexOf(F),1)}return B.texturePage=this._pages.indexOf($),B.texturePosition.x=F.x,B.texturePosition.y=F.y,B.texturePositionClipSpace.x=F.x/$.canvas.width,B.texturePositionClipSpace.y=F.y/$.canvas.height,B.sizeClipSpace.x/=$.canvas.width,B.sizeClipSpace.y/=$.canvas.height,F.height=Math.max(F.height,B.size.y),F.x+=B.size.x,$.ctx.putImageData(N,B.texturePosition.x-this._workBoundingBox.left,B.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,B.size.x,B.size.y),$.addGlyph(B),$.version++,B}_findGlyphBoundingBox(t,e,i,s,r,a){e.top=0;const o=s?this._config.deviceCellHeight:this._tmpCanvas.height,n=s?this._config.deviceCellWidth:i;let L=!1;for(let i=0;i=a;i--){for(let s=0;s=0;i--){for(let s=0;s>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=i.rgba>>>24,L=i.rgba>>>16&255,T=i.rgba>>>8&255,h=Math.floor((Math.abs(r-n)+Math.abs(a-L)+Math.abs(o-T))/12);let l=!0;for(let e=0;e{this._refreshCharAtlas(t,e),this.reset(t)})),this._register((0,r.toDisposable)(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=(0,a.throwIfFalsy)(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,e,i){}handleSelectionChanged(t,e,i,s=!1){}_setTransparency(t,e){if(e===this._alpha)return;const i=this._canvas;this._alpha=e,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,i),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,e){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=(0,s.acquireTextureAtlas)(t,this._optionsService.rawOptions,e,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,e){this._deviceCellWidth=e.device.cell.width,this._deviceCellHeight=e.device.cell.height,this._deviceCharWidth=e.device.char.width,this._deviceCharHeight=e.device.char.height,this._deviceCharLeft=e.device.char.left,this._deviceCharTop=e.device.char.top,this._canvas.width=e.device.canvas.width,this._canvas.height=e.device.canvas.height,this._canvas.style.width=`${e.css.canvas.width}px`,this._canvas.style.height=`${e.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,e,i=1){this._ctx.fillRect(t*this._deviceCellWidth,(e+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,i*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,e,i,s){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight))}_fillCharTrueColor(t,e,i,s){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=o.TEXT_BASELINE,this._clipCell(i,s,e.getWidth()),this._ctx.fillText(e.getChars(),i*this._deviceCellWidth+this._deviceCharLeft,s*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,e,i){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,e,i){return`${i?"italic":""} ${e?t.options.fontWeightBold:t.options.fontWeight} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}}e.BaseRenderLayer=n},183(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.tryDrawCustomGlyph=function(t,e,i,s,o,n,L,T,h,l,d,c=0){const _=r.customGlyphDefinitions[e];if(_){const e=Array.isArray(_)?_:[_];for(const r of e)a(t,r,i,s,o,n,L,T,h,l,d,c);return!0}return!1};const s=i(959),r=i(915);function a(t,e,i,r,a,T,d,c,_,Q,u,p=0){let g=a,M=T,y=i,v=r;switch(1===e.scaleType&&(g=d,M=c,y=i+(a-d)/2,v=r+(T-c)/2),e.clipPath&&(t.save(),function(t,e,i,s,r,a){t.beginPath();for(const o of e.split(" ")){const e=o[0];if("Z"===e){t.closePath();continue}const n=o.substring(1).split(",");if(!n[0]||!n[1])continue;const L=i+parseFloat(n[0])*r,T=s+parseFloat(n[1])*a;"M"===e?t.moveTo(L,T):"L"===e&&t.lineTo(L,T)}t.clip()}(t,e.clipPath,y,v,g,M)),e.type){case 0:!function(t,e,i,s,r,a){for(let o=0;o7&&parseInt(h.slice(7,9),16)||1;else{if(!h.startsWith("rgba"))throw new Error(`Unexpected fillStyle color format "${h}" when drawing pattern glyph`);[L,d,c,_]=h.substring(5,h.length-1).split(",").map(t=>parseFloat(t))}for(let t=0;t>1&1,c=1&n;0!==d||0!==c?l.setTransform((new DOMMatrix).translateSelf(-d,-c)):l.setTransform(new DOMMatrix),t.fillStyle=l,t.fillRect(i,r,a,o)}(t,e.data,y,v,g,M,p);break;case 2:!function(t,e,i,s,r,a,o,n){let L;t.save(),t.beginPath(),t.rect(i,s,r,a),t.clip(),t.beginPath(),L="function"==typeof e?e(.15,.15/a*r):e;const T={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const e of L.split(" ")){const n=e[0];if("Z"===n){t.closePath(),T.lastCommand=n;continue}const L=h[n];if(!L){console.error(`Could not find drawing instructions for "${n}"`);continue}const d=e.substring(1).split(",");d[0]&&d[1]&&(L(t,l(d,r,a,i,s,!0,o,0,0,!1),T),T.lastCommand=n)}void 0!==n?(t.strokeStyle=t.fillStyle,t.lineWidth=o*n,t.stroke()):t.fill(),t.closePath(),t.restore()}(t,e.data,y,v,g,M,Q,e.strokeWidth);break;case 3:!function(t,e,i,s,r,a,o,L){const T="string"==typeof e?e:e(0,0);t.beginPath();let h=0,l=0,d=0,c=0,_="";for(const e of T.split(" ")){const o=e[0],L=e.substring(1).split(",");if("Z"===o){t.closePath(),_=o;continue}if("V"===o){const e=s+parseFloat(L[0])*a;t.lineTo(h,e),l=e,d=h,c=l,_=o;continue}if("H"===o){const e=i+parseFloat(L[0])*r;t.lineTo(e,l),h=e,d=h,c=l,_=o;continue}if(!L[0]||!L[1])continue;if("A"===o){const e=parseFloat(L[0])*r,o=parseFloat(L[1])*a,T=parseFloat(L[2])*Math.PI/180,d=parseInt(L[3]),c=parseInt(L[4]),_=i+parseFloat(L[5])*r,Q=s+parseFloat(L[6])*a;n(t,h,l,e,o,T,d,c,_,Q),h=_,l=Q;continue}const T=L.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});if("M"===o)t.moveTo(T[0],T[1]),h=T[0],l=T[1],d=h,c=l;else if("L"===o)t.lineTo(T[0],T[1]),h=T[0],l=T[1],d=h,c=l;else if("Q"===o)t.quadraticCurveTo(T[0],T[1],T[2],T[3]),d=T[0],c=T[1],h=T[2],l=T[3];else if("T"===o){let e,i;"Q"===_||"T"===_?(e=2*h-d,i=2*l-c):(e=h,i=l),t.quadraticCurveTo(e,i,T[0],T[1]),d=e,c=i,h=T[0],l=T[1]}else"C"===o&&(t.bezierCurveTo(T[0],T[1],T[2],T[3],T[4],T[5]),d=T[2],c=T[3],h=T[4],l=T[5]);_=o}void 0!==L?(t.strokeStyle=t.fillStyle,t.lineWidth=o*L,t.stroke()):t.fill()}(t,e.data,y,v,g,M,Q,e.strokeWidth);break;case 4:!function(t,e,i,s,r,a,o,n){t.save(),t.fillRect(i,s,r,a),n&&(t.fillStyle=n,t.strokeStyle=n),t.lineWidth=o,t.lineCap="square",t.beginPath();for(const o of e.d.split(" ")){const e=o[0],n=o.substring(1).split(",");if(!n[0]||!n[1]){"Z"===e&&t.closePath();continue}const L=n.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});"M"===e?t.moveTo(L[0],L[1]):"L"===e&&t.lineTo(L[0],L[1])}1===e.type?t.stroke():t.fill(),t.restore()}(t,e.data,y,v,g,M,Q,u);break;case 5:!function(t,e,i,s,r,a,o,n){const L=new Path2D;L.rect(i,s,r,a),t.clip(L),t.beginPath();const T=o/12;t.lineWidth=n*T;const d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const o of e.d.split(" ")){const L=o[0];if("Z"===L){t.closePath(),d.lastCommand=L;continue}const c=h[L];if(!c){console.error(`Could not find drawing instructions for "${L}"`);continue}const _=o.substring(1).split(",");_[0]&&_[1]&&(c(t,l(_,r,a,i,s,!1,n,(e.leftPadding??0)*(T/2),(e.rightPadding??0)*(T/2)),d),d.lastCommand=L)}1===e.type?(t.strokeStyle=t.fillStyle,t.stroke()):t.fill(),t.closePath()}(t,e.data,y,v,g,M,_,Q);break;case 6:!function(t,e,i,s,r,a){const n=r/8,L=.1*a,T=.8*a/8,h=Math.min(n,T);for(let r=0;r<8;r++)if(e&1<1){const t=Math.sqrt(y);u=(s*=t)*s,p=(r*=t)*r}let v=(u*p-u*M-p*g)/(u*M+p*g);v<0&&(v=0);const f=(o===n?-1:1)*Math.sqrt(v),C=f*(s*Q/r),m=f*(-r*_/s),w=h*C-l*m+(e+L)/2,x=l*C+h*m+(i+T)/2,b=(_-C)/s,E=(Q-m)/r,R=(-_-C)/s,k=(-Q-m)/r,A=Math.atan2(E,b);let Z=Math.atan2(k,R)-A;0===n&&Z>0?Z-=2*Math.PI:1===n&&Z<0&&(Z+=2*Math.PI);const S=A+Z;t.ellipse(w,x,s,r,a,A,S,0===n)}const L=new Map;function T(t,e,i=0){return Math.max(Math.min(t,e),i)}const h={C:(t,e,i)=>{t.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),i.lastControlX=e[2],i.lastControlY=e[3],i.currentX=e[4],i.currentY=e[5]},L:(t,e,i)=>{t.lineTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},M:(t,e,i)=>{t.moveTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},Q:(t,e,i)=>{t.quadraticCurveTo(e[0],e[1],e[2],e[3]),i.lastControlX=e[0],i.lastControlY=e[1],i.currentX=e[2],i.currentY=e[3]},T:(t,e,i)=>{let s,r;"Q"===i.lastCommand||"T"===i.lastCommand?(s=2*i.currentX-i.lastControlX,r=2*i.currentY-i.lastControlY):(s=i.currentX,r=i.currentY),t.quadraticCurveTo(s,r,e[0],e[1]),i.lastControlX=s,i.lastControlY=r,i.currentX=e[0],i.currentY=e[1]}};function l(t,e,i,s,r,a,o,n=0,L=0,h=!0){const l=t.map(t=>parseFloat(t)||parseInt(t));if(l.length<2)throw new Error("Too few arguments for instruction");for(let t=0;tL.deleteProgram(this._program))),this._projectionLocation=(0,a.throwIfFalsy)(L.getUniformLocation(this._program,"u_projection")),this._vertexArrayObject=L.createVertexArray(),L.bindVertexArray(this._vertexArrayObject);const T=new Float32Array([0,0,1,0,0,1,1,1]),h=L.createBuffer();this._register((0,s.toDisposable)(()=>L.deleteBuffer(h))),L.bindBuffer(L.ARRAY_BUFFER,h),L.bufferData(L.ARRAY_BUFFER,T,L.STATIC_DRAW),L.enableVertexAttribArray(3),L.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);const l=new Uint8Array([0,1,2,3]),d=L.createBuffer();this._register((0,s.toDisposable)(()=>L.deleteBuffer(d))),L.bindBuffer(L.ELEMENT_ARRAY_BUFFER,d),L.bufferData(L.ELEMENT_ARRAY_BUFFER,l,L.STATIC_DRAW),this._attributesBuffer=(0,a.throwIfFalsy)(L.createBuffer()),this._register((0,s.toDisposable)(()=>L.deleteBuffer(this._attributesBuffer))),L.bindBuffer(L.ARRAY_BUFFER,this._attributesBuffer),L.enableVertexAttribArray(0),L.vertexAttribPointer(0,2,L.FLOAT,!1,o,0),L.vertexAttribDivisor(0,1),L.enableVertexAttribArray(1),L.vertexAttribPointer(1,2,L.FLOAT,!1,o,2*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(1,1),L.enableVertexAttribArray(2),L.vertexAttribPointer(2,4,L.FLOAT,!1,o,4*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(2,1),this._updateCachedColors(n.colors),this._register(this._themeService.onChangeColors(t=>{this._updateCachedColors(t),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){const e=this._gl;e.useProgram(this._program),e.bindVertexArray(this._vertexArrayObject),e.uniformMatrix4fv(this._projectionLocation,!1,r.PROJECTION_MATRIX),e.bindBuffer(e.ARRAY_BUFFER,this._attributesBuffer),e.bufferData(e.ARRAY_BUFFER,t.attributes,e.DYNAMIC_DRAW),e.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,e.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){const e=this._terminal,i=this._vertices;let s,r,a,o,n,L,T,h,l,d,c,_=1;for(s=0;s>24&255)/255,d=(L>>16&255)/255,c=(L>>8&255)/255,_=1,this._addRectangle(t.attributes,e,T,h,(o-a)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,l,d,c,_)}_addRectangle(t,e,i,s,r,a,o,n,L,T){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o,t[e+5]=n,t[e+6]=L,t[e+7]=T}_addRectangleFloat(t,e,i,s,r,a,o){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o[0],t[e+5]=o[1],t[e+6]=o[2],t[e+7]=o[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(255&t.rgba)/255])}}e.RectangleRenderer=Q},208(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.RenderModel=e.COMBINED_CHAR_BIT_MASK=void 0;const s=i(948);e.COMBINED_CHAR_BIT_MASK=2147483648,e.RenderModel=class{cells;lineLengths;selection;cursor;constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=(0,s.createSelectionRenderModel)()}resize(t,e){const i=t*e*4;i!==this.cells.length&&(this.cells=new Uint32Array(i),this.lineLengths=new Uint32Array(e))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}}},215(t,e){Object.defineProperty(e,"__esModule",{value:!0})},267(t,e){Object.defineProperty(e,"__esModule",{value:!0})},306(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.LinkRenderLayer=void 0;const s=i(657),r=i(814),a=i(133);class o extends a.BaseRenderLayer{_state;constructor(t,e,i,s,r,a,o){super(i,t,"link",e,!0,r,a,o),this._register(s.onShowLinkUnderline(t=>this._handleShowLinkUnderline(t))),this._register(s.onHideLinkUnderline(t=>this._handleHideLinkUnderline(t)))}resize(t,e){super.resize(t,e),this._state=void 0}reset(t){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);const t=this._state.y2-this._state.y1-1;t>0&&this._clearCells(0,this._state.y1+1,this._state.cols,t),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(t){if(t.fg===r.INVERTED_DEFAULT_COLOR?this._ctx.fillStyle=this._themeService.colors.background.css:void 0!==t.fg&&(0,s.is256Color)(t.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[t.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,t.y1===t.y2)this._fillBottomLineAtCells(t.x1,t.y1,t.x2-t.x1);else{this._fillBottomLineAtCells(t.x1,t.y1,t.cols-t.x1);for(let e=t.y1+1;e65535?(t-=65536,String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):String.fromCharCode(t)},e.utf32ToString=function(t,e=0,i=t.length){let s="";for(let r=e;r65535?(e-=65536,s+=String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):s+=String.fromCharCode(e)}return s},e.StringToUtf32=class{_interim=0;clear(){this._interim=0}decode(t,e){const i=t.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=t.charCodeAt(r++);56320<=i&&i<=57343?e[s++]=1024*(this._interim-55296)+i-56320+65536:(e[s++]=this._interim,e[s++]=i),this._interim=0}for(let a=r;a=i)return this._interim=r,s;const o=t.charCodeAt(a);56320<=o&&o<=57343?e[s++]=1024*(r-55296)+o-56320+65536:(e[s++]=r,e[s++]=o);continue}65279!==r&&(e[s++]=r)}return s}},e.Utf8ToUtf32=class{interim=new Uint8Array(3);clear(){this.interim.fill(0)}decode(t,e){const i=t.length;if(!i)return 0;let s,r,a,o,n=0,L=0,T=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let a,o=0;for(;(a=63&this.interim[++o])&&o<4;)r<<=6,r|=a;const L=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,h=L-o;for(;T=i)return 0;if(a=t[T++],128!=(192&a)){T--,s=!0;break}this.interim[o++]=a,r<<=6,r|=63&a}s||(2===L?r<128?T--:e[n++]=r:3===L?r<2048||r>=55296&&r<=57343||65279===r||(e[n++]=r):r<65536||r>1114111||(e[n++]=r)),this.interim.fill(0)}const h=i-4;let l=T;for(;l=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(L=(31&s)<<6|63&r,L<128){l--;continue}e[n++]=L}else if(224==(240&s)){if(l>=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,n;if(a=t[l++],128!=(192&a)){l--;continue}if(L=(15&s)<<12|(63&r)<<6|63&a,L<2048||L>=55296&&L<=57343||65279===L)continue;e[n++]=L}else if(240==(248&s)){if(l>=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,n;if(a=t[l++],128!=(192&a)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=a,n;if(o=t[l++],128!=(192&o)){l--;continue}if(L=(7&s)<<18|(63&r)<<12|(63&a)<<6|63&o,L<65536||L>1114111)continue;e[n++]=L}}return n}}},399(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.JoinedCellData=e.WebglRenderer=void 0;const s=i(864),r=i(670),a=i(773),o=i(697),n=i(917),L=i(721),T=i(564),h=i(536),l=i(28),d=i(203),c=i(208);i(816);const _=i(306),Q=i(414),u=i(105),p=i(426),g=i(959);class M extends p.Disposable{_terminal;_characterJoinerService;_charSizeService;_coreBrowserService;_coreService;_decorationService;_optionsService;_themeService;_customGlyphs;_renderLayers;_cursorBlinkStateManager=this._register(new p.MutableDisposable);_textBlinkStateManager;_charAtlasDisposable=this._register(new p.MutableDisposable);_charAtlas;_devicePixelRatio;_deviceMaxTextureSize;_observerDisposable=this._register(new p.MutableDisposable);_model=new c.RenderModel;_rowHasBlinkingCells=[];_rowHasBlinkingCellsCount=0;_workCell=new L.CellData;_cellColorResolver;_canvas;_gl;_rectangleRenderer=this._register(new p.MutableDisposable);_glyphRenderer=this._register(new p.MutableDisposable);dimensions;_core;_isAttached;_contextRestorationTimeout;_onChangeTextureAtlas=this._register(new Q.Emitter);onChangeTextureAtlas=this._onChangeTextureAtlas.event;_onAddTextureAtlasCanvas=this._register(new Q.Emitter);onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=this._register(new Q.Emitter);onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;_onRequestRedraw=this._register(new Q.Emitter);onRequestRedraw=this._onRequestRedraw.event;_onContextLoss=this._register(new Q.Emitter);onContextLoss=this._onContextLoss.event;constructor(t,e,i,a,n,L,T,l,d=!0,c){super(),this._terminal=t,this._characterJoinerService=e,this._charSizeService=i,this._coreBrowserService=a,this._coreService=n,this._decorationService=L,this._optionsService=T,this._themeService=l,this._customGlyphs=d,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas");const Q={antialias:!1,depth:!1,preserveDrawingBuffer:c};if(this._gl=this._canvas.getContext("webgl2",Q),!this._gl)throw new Error("WebGL2 not supported "+this._gl);this._register(this._themeService.onChangeColors(()=>this._handleColorChange())),this._cellColorResolver=new s.CellColorResolver(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new _.LinkRenderLayer(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,T,this._themeService)],this.dimensions=(0,g.createRenderDimensions)(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(T.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new h.TextBlinkStateManager(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextlost",t=>{console.log("webglcontextlost event received"),t.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,console.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(t)},3e3)})),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextrestored",t=>{console.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,(0,r.removeTerminalFromCache)(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,this._coreBrowserService.window,(t,e)=>this._setCanvasDevicePixelDimensions(t,e)),this._register(this._coreBrowserService.onWindowChange(t=>{this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,t,(t,e)=>this._setCanvasDevicePixelDimensions(t,e))})),this._register((0,u.addDisposableListener)(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register((0,p.toDisposable)(()=>{for(const t of this._renderLayers)t.dispose();this._canvas.parentElement?.removeChild(this._canvas),(0,r.removeTerminalFromCache)(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,e){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(const t of this._renderLayers)t.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(const t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(const t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,e,i){for(const s of this._renderLayers)s.handleSelectionChanged(this._terminal,t,e,i);this._model.selection.update(this._core,t,e,i),this._requestRedrawViewport()}handleCursorMove(){for(const t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new d.RectangleRenderer(this._terminal,this._gl,this.dimensions,this._themeService),this._glyphRenderer.value=new l.GlyphRenderer(this._terminal,this._gl,this.dimensions,this._optionsService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0)return void(this._isAttached=!1);const t=(0,r.acquireTextureAtlas)(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=(0,p.combinedDisposable)(Q.EventUtils.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),Q.EventUtils.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(const t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,e){if(!this._isAttached){if(!(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height))return;this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0}for(const i of this._renderLayers)i.handleGridChanged(this._terminal,t,e);this._glyphRenderer.value&&this._rectangleRenderer.value&&(this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,e),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),this._cursorBlinkStateManager.value&&!this._cursorBlinkStateManager.value.isCursorVisible||this._rectangleRenderer.value.renderCursor())}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new a.CursorBlinkStateManager(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,e){const i=this._core;let s,r,a,o,n,L,h,l,d,_,Q,u,p,g,M,f=this._workCell,C=0,m=!0;t=v(t,i.rows-1,0),e=v(e,i.rows-1,0);const w=this._coreService.decPrivateModes.cursorStyle??i.options.cursorStyle??"block",x=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,b=x-i.buffer.ydisp,E=Math.min(this._terminal.buffer.active.cursorX,i.cols-1);let R=-1;const k=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let A=!1;for(r=t;r<=e;r++){a=r+i.buffer.ydisp,o=i.buffer.lines.get(a);let t=!1;for(this._model.lineLengths[r]=0,d=x===a,C=0,n=this._characterJoinerService.getJoinedCharacters(a),g=0;g=C,h=g,n.length>0&&g===n[0][0]&&m){l=n.shift();const t=this._model.selection.isCellSelected(this._terminal,l[0],a);for(p=l[0]+1;p=l[1],m?(L=!0,f=new y(f,o.translateToString(!0,l[0],l[1]),l[1]-l[0]),h=l[1]-1):C=l[1]}if(_=f.getChars(),Q=f.getCode(),p=4*(r*i.cols+g),!t&&f.isBlink()&&(t=!0),this._cellColorResolver.resolve(f,g,a,this.dimensions.device.cell.width,this.dimensions.device.cell.height),k&&a===x&&(g===E&&(this._model.cursor={x:E,y:b,width:f.getWidth(),style:this._coreBrowserService.isFocused?w:i.options.cursorInactiveStyle,cursorWidth:i.options.cursorWidth,dpr:this._devicePixelRatio},R=E+f.getWidth()-1),g>=E&&g<=R&&(this._coreBrowserService.isFocused&&"block"===w||!1===this._coreBrowserService.isFocused&&"block"===i.options.cursorInactiveStyle)&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&f.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),Q!==T.NULL_CELL_CODE&&(this._model.lineLengths[r]=g+1),(this._model.cells[p]!==Q||this._model.cells[p+1]!==this._cellColorResolver.result.bg||this._model.cells[p+2]!==this._cellColorResolver.result.fg||this._model.cells[p+3]!==this._cellColorResolver.result.ext)&&(A=!0,_.length>1&&(Q|=c.COMBINED_CHAR_BIT_MASK),this._model.cells[p]=Q,this._model.cells[p+1]=this._cellColorResolver.result.bg,this._model.cells[p+2]=this._cellColorResolver.result.fg,this._model.cells[p+3]=this._cellColorResolver.result.ext,u=f.getWidth(),this._glyphRenderer.value.updateCell(g,r,Q,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,_,u,s),L)){for(f=this._workCell,g++;g<=h;g++)M=4*(r*i.cols+g),this._glyphRenderer.value.updateCell(g,r,T.NULL_CELL_CODE,0,0,0,T.NULL_CELL_CHAR,0,0),this._model.cells[M]=T.NULL_CELL_CODE,this._model.cells[M+1]=this._cellColorResolver.result.bg,this._model.cells[M+2]=this._cellColorResolver.result.fg,this._model.cells[M+3]=this._cellColorResolver.result.ext;g--}}this._setRowBlinkState(r,t)}A&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,e){this._rowHasBlinkingCells[t]!==e&&(this._rowHasBlinkingCells[t]=e,this._rowHasBlinkingCellsCount+=e?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){this._charSizeService.width&&this._charSizeService.height&&(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=1===this._optionsService.rawOptions.lineHeight?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,e){this._canvas.width===t&&this._canvas.height===e||(this._canvas.width=t,this._canvas.height=e,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){const t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}}e.WebglRenderer=M;class y extends n.AttributeData{_width;content=0;fg;bg;combinedData="";constructor(t,e,i){super(),this.fg=t.fg,this.bg=t.bg,this.combinedData=e,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}function v(t,e,i=0){return Math.max(Math.min(t,e),i)}e.JoinedCellData=y},414(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.EventUtils=e.Emitter=void 0;const s=i(426);var r;e.Emitter=class{_listeners=[];_disposed=!1;_event;get event(){return this._event||(this._event=(t,e,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:t,thisArgs:e};this._listeners.push(r);const a=(0,s.toDisposable)(()=>{const t=this._listeners.indexOf(r);-1!==t&&this._listeners.splice(t,1)});return i&&(Array.isArray(i)?i.push(a):i.add(a)),a}),this._event}fire(t){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:e,thisArgs:i}=this._listeners[0];return void e.call(i,t)}default:{const e=this._listeners.slice();for(const{fn:i,thisArgs:s}of e)i.call(s,t)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(t){t.forward=function(t,e){return t(t=>e.fire(t))},t.map=function(t,e){return(i,s,r)=>t(t=>i.call(s,e(t)),void 0,r)},t.any=function(...t){return(e,i,r)=>{const a=new s.DisposableStore;for(const s of t)a.add(s(t=>e.call(i,t)));return r&&(Array.isArray(r)?r.push(a):r.add(a)),a}},t.runAndSubscribe=function(t,e,i){return e(i),t(t=>e(t))}}(r||(e.EventUtils=r={}))},426(t,e){function i(t){return{dispose:t}}function s(t){if(!t)return t;if(Array.isArray(t)){for(const e of t)e.dispose();return[]}return t.dispose(),t}Object.defineProperty(e,"__esModule",{value:!0}),e.MutableDisposable=e.Disposable=e.DisposableStore=void 0,e.toDisposable=i,e.dispose=s,e.combinedDisposable=function(...t){return i(()=>s(t))};class r{_disposables=new Set;_isDisposed=!1;get isDisposed(){return this._isDisposed}add(t){return this._isDisposed?t.dispose():this._disposables.add(t),t}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const t of this._disposables)t.dispose();this._disposables.clear()}}clear(){for(const t of this._disposables)t.dispose();this._disposables.clear()}}e.DisposableStore=r;class a{static None=Object.freeze({dispose(){}});_store=new r;dispose(){this._store.dispose()}_register(t){return this._store.add(t)}}e.Disposable=a,e.MutableDisposable=class{_value;_isDisposed=!1;get value(){return this._isDisposed?void 0:this._value}set value(t){this._isDisposed||t===this._value||(this._value?.dispose(),this._value=t)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},536(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextBlinkStateManager=void 0;const s=i(426);class r extends s.Disposable{_renderCallback;_coreBrowserService;_optionsService;_intervalDuration=0;_interval;_blinkOn=!0;_needsBlinkInViewport=!1;_isViewportVisible=!0;constructor(t,e,i){super(),this._renderCallback=t,this._coreBrowserService=e,this._optionsService=i,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",t=>{this.setIntervalDuration(t)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const t=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(t||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}e.TextBlinkStateManager=r},564(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.WHITESPACE_CELL_CODE=e.WHITESPACE_CELL_WIDTH=e.WHITESPACE_CELL_CHAR=e.NULL_CELL_CODE=e.NULL_CELL_WIDTH=e.NULL_CELL_CHAR=e.CHAR_DATA_CODE_INDEX=e.CHAR_DATA_WIDTH_INDEX=e.CHAR_DATA_CHAR_INDEX=e.CHAR_DATA_ATTR_INDEX=e.DEFAULT_EXT=e.DEFAULT_ATTR=e.DEFAULT_COLOR=void 0,e.DEFAULT_COLOR=0,e.DEFAULT_ATTR=e.DEFAULT_COLOR<<9|256,e.DEFAULT_EXT=0,e.CHAR_DATA_ATTR_INDEX=0,e.CHAR_DATA_CHAR_INDEX=1,e.CHAR_DATA_WIDTH_INDEX=2,e.CHAR_DATA_CODE_INDEX=3,e.NULL_CELL_CHAR="",e.NULL_CELL_WIDTH=1,e.NULL_CELL_CODE=0,e.WHITESPACE_CELL_CHAR=" ",e.WHITESPACE_CELL_WIDTH=1,e.WHITESPACE_CELL_CODE=32},578(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.IntervalTimer=e.TimeoutTimer=void 0,e.timeout=function(t){return new Promise(e=>setTimeout(e,t))},e.disposableTimeout=function(t,e=0,i){const r=setTimeout(()=>{t(),i&&a.dispose()},e),a=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(a),a};const s=i(426);e.TimeoutTimer=class{_token=-1;_isDisposed=!1;dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(t,e){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,t()},e)}setIfNotSet(t,e){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,t()},e))}},e.IntervalTimer=class{_disposable;_isDisposed=!1;cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(t,e,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{t()},e);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},657(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.generateConfig=function(t,e,i,r,a,o,n,L,T=!0){const h={foreground:o.foreground,background:o.background,cursor:s.NULL_COLOR,cursorAccent:s.NULL_COLOR,selectionForeground:s.NULL_COLOR,selectionBackgroundTransparent:s.NULL_COLOR,selectionBackgroundOpaque:s.NULL_COLOR,selectionInactiveBackgroundTransparent:s.NULL_COLOR,selectionInactiveBackgroundOpaque:s.NULL_COLOR,overviewRulerBorder:s.NULL_COLOR,scrollbarSliderBackground:s.NULL_COLOR,scrollbarSliderHoverBackground:s.NULL_COLOR,scrollbarSliderActiveBackground:s.NULL_COLOR,ansi:o.ansi.slice(),contrastCache:o.contrastCache,halfContrastCache:o.halfContrastCache};return{customGlyphs:T,devicePixelRatio:n,deviceMaxTextureSize:L,letterSpacing:a.letterSpacing,lineHeight:a.lineHeight,deviceCellWidth:t,deviceCellHeight:e,deviceCharWidth:i,deviceCharHeight:r,fontFamily:a.fontFamily,fontSize:a.fontSize,fontWeight:a.fontWeight,fontWeightBold:a.fontWeightBold,allowTransparency:a.allowTransparency,drawBoldTextInBrightColors:a.drawBoldTextInBrightColors,minimumContrastRatio:a.minimumContrastRatio,colors:h}},e.configEquals=function(t,e){for(let i=0;i=0){if((0,r.configEquals)(i.config,c))return i.atlas;1===i.ownedBy.length?(i.atlas.dispose(),a.splice(e,1)):i.ownedBy.splice(s,1);break}}for(let e=0;e{const s=e.find(e=>e.target===t);if(!s)return;if(!("devicePixelContentBoxSize"in s))return r?.disconnect(),void(r=void 0);const a=s.devicePixelContentBoxSize[0].inlineSize,o=s.devicePixelContentBoxSize[0].blockSize;a>0&&o>0&&i(a,o)});try{r.observe(t,{box:["device-pixel-content-box"]})}catch{r.disconnect(),r=void 0}return(0,s.toDisposable)(()=>r?.disconnect())};const s=i(426)},719(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GLTexture=e.PROJECTION_MATRIX=void 0,e.createProgram=function(t,e,i){const a=(0,s.throwIfFalsy)(t.createProgram());if(t.attachShader(a,(0,s.throwIfFalsy)(r(t,t.VERTEX_SHADER,e))),t.attachShader(a,(0,s.throwIfFalsy)(r(t,t.FRAGMENT_SHADER,i))),t.linkProgram(a),t.getProgramParameter(a,t.LINK_STATUS))return a;console.error(t.getProgramInfoLog(a)),t.deleteProgram(a)},e.createShader=r,e.expandFloat32Array=function(t,e){const i=Math.min(2*t.length,e),s=new Float32Array(i);for(let e=0;e>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(t){this.fg=t[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let e=!1;if(t[r.CHAR_DATA_CHAR_INDEX].length>2)e=!0;else if(2===t[r.CHAR_DATA_CHAR_INDEX].length){const i=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|t[r.CHAR_DATA_WIDTH_INDEX]<<22:e=!0}else e=!0}else this.content=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[r.CHAR_DATA_WIDTH_INDEX]<<22;e&&(this.combinedData=t[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|t[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor())return!1;if(this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor())return!1;if(this.isInverse()!==t.isInverse())return!1;if(this.isBold()!==t.isBold())return!1;if(this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;const e=this.isUnderlineColorDefault(),i=t.isUnderlineColorDefault();if(!e||!i){if(e!==i)return!1;if(this.getUnderlineColor()!==t.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==t.getUnderlineColorMode())return!1}}return this.isOverline()===t.isOverline()&&this.isBlink()===t.isBlink()&&this.isInvisible()===t.isInvisible()&&this.isItalic()===t.isItalic()&&this.isDim()===t.isDim()&&this.isStrikethrough()===t.isStrikethrough()}}e.CellData=o},773(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.CursorBlinkStateManager=void 0,e.CursorBlinkStateManager=class{_renderCallback;_coreBrowserService;isCursorVisible;_animationFrame;_blinkStartTimeout;_blinkInterval;_idleTimeout;_isIdlePaused=!1;_animationTimeRestarted;constructor(t,e){this._renderCallback=t,this._coreBrowserService=e,this.isCursorVisible=!0,this._coreBrowserService.isFocused&&(this._restartInterval(),this._resetIdleTimer())}get isPaused(){return!(this._blinkStartTimeout||this._blinkInterval)}dispose(){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}restartBlinkAnimation(){this._isIdlePaused&&this._resetIdleTimer(),this.isPaused||(this._animationTimeRestarted=Date.now(),this.isCursorVisible=!0,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(t=600){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0)return void this._restartInterval(t)}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);return this._animationTimeRestarted=void 0,void this._restartInterval(t)}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},600)},t)}pause(){this.isCursorVisible=!0,this._isIdlePaused=!1,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){this._isIdlePaused=!1,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){this.isCursorVisible=!0,this._isIdlePaused=!0,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}}},814(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.INVERTED_DEFAULT_COLOR=void 0,e.INVERTED_DEFAULT_COLOR=257},816(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),i(267)},836(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.FourKeyMap=e.TwoKeyMap=void 0;class i{_data={};set(t,e,i){this._data[t]||(this._data[t]={}),this._data[t][e]=i}get(t,e){return this._data[t]?this._data[t][e]:void 0}clear(){this._data={}}}e.TwoKeyMap=i,e.FourKeyMap=class{_data=new i;set(t,e,s,r,a){this._data.get(t,e)||this._data.set(t,e,new i),this._data.get(t,e).set(s,r,a)}get(t,e,i,s){return this._data.get(t,e)?.get(i,s)}clear(){this._data.clear()}}},864(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.CellColorResolver=void 0;const s=i(564),r=i(993),a=i(959),o=i(915);let n,L=0,T=0,h=!1,l=!1,d=!1,c=0;e.CellColorResolver=class{_terminal;_optionService;_selectionRenderModel;_decorationService;_coreBrowserService;_themeService;result={fg:0,bg:0,ext:0};constructor(t,e,i,s,r,a){this._terminal=t,this._optionService=e,this._selectionRenderModel=i,this._decorationService=s,this._coreBrowserService=r,this._themeService=a}resolve(t,e,i,_,Q){this.result.bg=t.bg,this.result.fg=t.fg,this.result.ext=268435456&t.bg?t.extended.ext:0,T=0,L=0,l=!1,h=!1,d=!1,n=this._themeService.colors,c=0;const u=t.getCode();if(u!==s.NULL_CELL_CODE&&4===t.extended.underlineStyle){const t=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));c=e*_%(2*Math.round(t))}if(0===c&&o.blockPatternCodepoints.has(u)&&(c=e*_%2*2+i*Q%2),this._decorationService.forEachDecorationAtCell(e,i,"bottom",t=>{t.backgroundColorRGB&&(T=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,h=!0)}),d=this._selectionRenderModel.isCellSelected(this._terminal,e,i),d){if(67108864&this.result.fg||50331648&this.result.bg){if(67108864&this.result.fg)switch(50331648&this.result.fg){case 16777216:case 33554432:T=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:T=(16777215&this.result.fg)<<8|255;break;default:T=this._themeService.colors.foreground.rgba}else switch(50331648&this.result.bg){case 16777216:case 33554432:T=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:T=(16777215&this.result.bg)<<8|255}T=r.rgba.blend(T,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}else T=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(l=!0,n.selectionForeground&&(L=n.selectionForeground.rgba>>8&16777215,h=!0),(0,a.treatGlyphAsBackgroundColor)(t.getCode())){if(67108864&this.result.fg&&!(50331648&this.result.bg))L=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(67108864&this.result.fg)switch(50331648&this.result.bg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:L=(16777215&this.result.bg)<<8|255}else switch(50331648&this.result.fg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:L=(16777215&this.result.fg)<<8|255;break;default:L=this._themeService.colors.foreground.rgba}L=r.rgba.blend(L,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}h=!0}}this._decorationService.forEachDecorationAtCell(e,i,"top",t=>{t.backgroundColorRGB&&(T=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,h=!0)}),l&&(T=d?-16777216&t.bg&-134217729|T|50331648:-16777216&t.bg|T|50331648),h&&(L=-16777216&t.fg&-67108865|L|50331648),67108864&this.result.fg&&(l&&!h&&(L=50331648&this.result.bg?-134217728&this.result.fg|67108863&this.result.bg:-134217728&this.result.fg|16777215&n.background.rgba>>8|50331648,h=!0),!l&&h&&(T=50331648&this.result.fg?-67108864&this.result.bg|67108863&this.result.fg:-67108864&this.result.bg|16777215&n.foreground.rgba>>8|50331648,l=!0)),n=void 0,this.result.bg=l?T:this.result.bg,this.result.fg=h?L:this.result.fg,this.result.ext&=536870911,this.result.ext|=c<<29&3758096384}}},915(t,e,i){var s;function r(t){return{type:2,data:()=>{const e=[],i=[3/8,2/8,3/8],s=[0,3/8,5/8];for(let r=0;r<3;r++){const a=t>>2*r&1,o=t>>2*r+1&1;a&&o?e.push(`M0,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):a?e.push(`M0,${s[r]} L0.5,${s[r]} L0.5,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):o&&e.push(`M0.5,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0.5,${s[r]+i[r]} Z`)}return e.join(" ")}}}function a(t){const e=[],i=.15,s=.015,r=.075,a=.0375,o=.95;if(64&t){const t=.175,a=.2125,n=.25,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}if(32&t){const t=o-i,s=.875,r=o,n=.265,L=.485;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(16&t){const t=o-i,s=.875,r=o,n=.515,L=.735;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(8&t){const t=.75,a=.7875,n=.825,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}if(4&t){const t=.05,i=.125,s=.2,r=.515,o=.735;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(2&t){const t=.05,i=.125,s=.2,r=.265,o=.485;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(1&t){const t=.4625,a=.5,n=.5375,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}return e.join(" ")}Object.defineProperty(e,"__esModule",{value:!0}),e.blockPatternCodepoints=e.customGlyphDefinitions=void 0,i(215),function(t){t.LINE_H=Object.freeze({type:2,data:"M0,.5 L1,.5",strokeWidth:1}),t.LINE_V=Object.freeze({type:2,data:"M.5,0 L.5,1",strokeWidth:1}),t.FADE_RIGHT=Object.freeze({type:2,data:"M0,.5 L.28,.5 M.32,.5 L.52,.5 M.60,.5 L.72,.5 M.84,.5 L.90,.5",strokeWidth:1}),t.FADE_LEFT=Object.freeze({type:2,data:"M.10,.5 L.16,.5 M.28,.5 L.40,.5 M.48,.5 L.68,.5 M.72,.5 L1,.5",strokeWidth:1}),t.FADE_DOWN=Object.freeze({type:2,data:"M.5,0 L.5,.28 M.5,.32 L.5,.52 M.5,.60 L.5,.72 M.5,.84 L.5,.90",strokeWidth:1}),t.FADE_UP=Object.freeze({type:2,data:"M.5,.10 L.5,.16 M.5,.28 L.5,.40 M.5,.48 L.5,.68 M.5,.72 L.5,1",strokeWidth:1}),t.CURVE_DOWN_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_DOWN_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.CURVE_UP_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_UP_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.NODE_FILL=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5"}),t.NODE_STROKE=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5",strokeWidth:1}),t.NODE_LINE_RIGHT=Object.freeze({type:3,data:"M1,.5 L.85,.5",strokeWidth:1}),t.NODE_LINE_LEFT=Object.freeze({type:3,data:"M0,.5 L.15,.5",strokeWidth:1}),t.NODE_LINE_DOWN=Object.freeze({type:3,data:"M.5,1 L.5,.7",strokeWidth:1}),t.NODE_LINE_UP=Object.freeze({type:3,data:"M.5,0 L.5,.3",strokeWidth:1})}(s||(s={})),e.customGlyphDefinitions={"─":{type:2,data:"M0,.5 L1,.5",strokeWidth:1},"━":{type:2,data:"M0,.5 L1,.5",strokeWidth:3},"│":{type:2,data:"M.5,0 L.5,1",strokeWidth:1},"┃":{type:2,data:"M.5,0 L.5,1",strokeWidth:3},"┄":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:1},"┅":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:3},"┆":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:1},"┇":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:3},"┈":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:1},"┉":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:3},"┊":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:1},"┋":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:3},"┌":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},"┍":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┎":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┏":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3},"┐":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},"┑":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┒":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┓":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3},"└":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},"┕":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┖":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┗":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3},"┘":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},"┙":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┚":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┛":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3},"├":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},"┝":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┞":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┟":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┠":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┡":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┢":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┣":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3},"┤":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},"┥":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┦":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┧":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┨":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┩":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┪":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┫":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3},"┬":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},"┭":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┮":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┯":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┰":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┱":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┲":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┳":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3},"┴":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:1},"┵":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┶":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┷":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┸":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┹":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┺":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┻":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:3},"┼":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:1},"┽":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┾":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┿":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"╀":[{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"╁":[{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╂":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"╃":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"╄":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"╅":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"╆":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"╇":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:3}],"╈":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3}],"╉":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3}],"╊":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3}],"╋":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:3},"╌":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:1},"╍":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:3},"╎":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:1},"╏":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:3},"═":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"║":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╒":{type:2,data:(t,e)=>`M.5,1 L.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╓":{type:2,data:(t,e)=>`M${.5-t},1 L${.5-t},.5 L1,.5 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╔":{type:2,data:(t,e)=>`M1,${.5-e} L${.5-t},${.5-e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╕":{type:2,data:(t,e)=>`M0,${.5-e} L.5,${.5-e} L.5,1 M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╖":{type:2,data:(t,e)=>`M${.5+t},1 L${.5+t},.5 L0,.5 M${.5-t},.5 L${.5-t},1`,strokeWidth:1},"╗":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5+t},${.5-e} L${.5+t},1`,strokeWidth:1},"╘":{type:2,data:(t,e)=>`M.5,0 L.5,${.5+e} L1,${.5+e} M.5,${.5-e} L1,${.5-e}`,strokeWidth:1},"╙":{type:2,data:(t,e)=>`M1,.5 L${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╚":{type:2,data:(t,e)=>`M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0 M1,${.5+e} L${.5-t},${.5+e} L${.5-t},0`,strokeWidth:1},"╛":{type:2,data:(t,e)=>`M0,${.5+e} L.5,${.5+e} L.5,0 M0,${.5-e} L.5,${.5-e}`,strokeWidth:1},"╜":{type:2,data:(t,e)=>`M0,.5 L${.5+t},.5 L${.5+t},0 M${.5-t},.5 L${.5-t},0`,strokeWidth:1},"╝":{type:2,data:(t,e)=>`M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M0,${.5+e} L${.5+t},${.5+e} L${.5+t},0`,strokeWidth:1},"╞":{type:2,data:(t,e)=>`M.5,0 L.5,1 M.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╟":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1 M${.5+t},.5 L1,.5`,strokeWidth:1},"╠":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╡":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L.5,${.5-e} M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╢":{type:2,data:(t,e)=>`M0,.5 L${.5-t},.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╣":{type:2,data:(t,e)=>`M${.5+t},0 L${.5+t},1 M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0`,strokeWidth:1},"╤":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e} M.5,${.5+e} L.5,1`,strokeWidth:1},"╥":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},1 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╦":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╧":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e} M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╨":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╩":{type:2,data:(t,e)=>`M0,${.5+e} L1,${.5+e} M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╪":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╫":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╬":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╭":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╮":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╯":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╰":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╱":{type:2,data:"M1,0 L0,1",strokeWidth:1},"╲":{type:2,data:"M0,0 L1,1",strokeWidth:1},"╳":{type:2,data:"M1,0 L0,1 M0,0 L1,1",strokeWidth:1},"╴":{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},"╵":{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},"╶":{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},"╷":{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},"╸":{type:2,data:"M.5,.5 L0,.5",strokeWidth:3},"╹":{type:2,data:"M.5,.5 L.5,0",strokeWidth:3},"╺":{type:2,data:"M.5,.5 L1,.5",strokeWidth:3},"╻":{type:2,data:"M.5,.5 L.5,1",strokeWidth:3},"╼":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"╽":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╾":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"╿":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"▀":{type:0,data:[{x:0,y:0,w:8,h:4}]},"▁":{type:0,data:[{x:0,y:7,w:8,h:1}]},"▂":{type:0,data:[{x:0,y:6,w:8,h:2}]},"▃":{type:0,data:[{x:0,y:5,w:8,h:3}]},"▄":{type:0,data:[{x:0,y:4,w:8,h:4}]},"▅":{type:0,data:[{x:0,y:3,w:8,h:5}]},"▆":{type:0,data:[{x:0,y:2,w:8,h:6}]},"▇":{type:0,data:[{x:0,y:1,w:8,h:7}]},"█":{type:0,data:[{x:0,y:0,w:8,h:8}]},"▉":{type:0,data:[{x:0,y:0,w:7,h:8}]},"▊":{type:0,data:[{x:0,y:0,w:6,h:8}]},"▋":{type:0,data:[{x:0,y:0,w:5,h:8}]},"▌":{type:0,data:[{x:0,y:0,w:4,h:8}]},"▍":{type:0,data:[{x:0,y:0,w:3,h:8}]},"▎":{type:0,data:[{x:0,y:0,w:2,h:8}]},"▏":{type:0,data:[{x:0,y:0,w:1,h:8}]},"▐":{type:0,data:[{x:4,y:0,w:4,h:8}]},"░":{type:1,data:[[1,0],[0,0]]},"▒":{type:1,data:[[1,0],[0,1]]},"▓":{type:1,data:[[1,1],[1,0]]},"▔":{type:0,data:[{x:0,y:0,w:8,h:1}]},"▕":{type:0,data:[{x:7,y:0,w:1,h:8}]},"▖":{type:0,data:[{x:0,y:4,w:4,h:4}]},"▗":{type:0,data:[{x:4,y:4,w:4,h:4}]},"▘":{type:0,data:[{x:0,y:0,w:4,h:4}]},"▙":{type:0,data:[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"▚":{type:0,data:[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}]},"▛":{type:0,data:[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}]},"▜":{type:0,data:[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}]},"▝":{type:0,data:[{x:4,y:0,w:4,h:4}]},"▞":{type:0,data:[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}]},"▟":{type:0,data:[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"":{type:5,data:{d:"M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.7,.03 L.7,.1 L.355,.1 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M0,0 L1,.5 L0,1",type:0,rightPadding:2}},"":{type:5,data:{d:"M-1,-.5 L1,.5 L-1,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L0,.5 L1,1",type:0,leftPadding:2}},"":{type:5,data:{d:"M2,-.5 L0,.5 L2,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0",type:0,rightPadding:1}},"":{type:5,data:{d:"M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0",type:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0",type:0,leftPadding:1}},"":{type:5,data:{d:"M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0",type:1,leftPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L-.5,1.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L1.5,1.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L-.5,-.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L1.5,-.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0.8069,0.6075 Q0.7991,0.612,0.7815,0.6218 T0.7542,0.6369 T0.7282,0.6508 T0.7016,0.6644 T0.6778,0.6758 T0.6551,0.6854 T0.637,0.6911 T0.622,0.6936 Q0.6186,0.6936,0.6128,0.6942 T0.6018,0.6952 T0.5894,0.6956 T0.5757,0.6952 T0.561,0.693 T0.5451,0.6879 Q0.5342,0.6834,0.5193,0.6756 T0.4925,0.6614 T0.4673,0.6485 T0.4436,0.6389 T0.4239,0.6363 T0.4089,0.6422 Q0.4045,0.6467,0.3958,0.6554 T0.3843,0.6669 T0.3748,0.6746 T0.3613,0.683 T0.3442,0.6903 L0.3499,0.6956 Q0.3584,0.7042,0.3679,0.7177 T0.3836,0.7403 T0.4019,0.7583 T0.4295,0.7703 Q0.4688,0.7785,0.5203,0.7605 Q0.4902,0.7817,0.449,0.7911 T0.3696,0.8001 Q0.3567,0.7997,0.341,0.7942 T0.3111,0.7813 T0.2805,0.766 T0.2493,0.753 T0.218,0.7472 T0.187,0.754 Q0.1762,0.7589,0.1653,0.7681 T0.1453,0.7895 T0.1308,0.818 T0.1264,0.8507 L0.1264,0.858 Q0.1453,0.8462,0.1607,0.8433 T0.1899,0.8452 T0.2226,0.8625 Q0.2344,0.8707,0.2485,0.8752 T0.2813,0.8831 T0.3089,0.889 Q0.2981,0.8915,0.2846,0.8956 T0.2603,0.9037 T0.2368,0.9115 T0.2129,0.9174 T0.1894,0.9194 T0.1653,0.9155 Q0.1504,0.9115,0.1328,0.9196 T0.0986,0.9421 T0.0655,0.9708 T0.0317,0.9943 T0,1 Q0.001,0.94,0.0017,0.6508 T0.0024,0.2322 Q0.002,0.2109,0.0007,0.1379 T0,0.0249 Q0.0058,0.0253,0.009,0.0257 T0.0156,0.0288 T0.0203,0.0333 T0.0264,0.0422 T0.0349,0.0539 Q0.044,0.0657,0.0542,0.072 T0.0752,0.0789 T0.096,0.0775 T0.1175,0.0694 T0.1379,0.0573 T0.1579,0.0432 Q0.2141,0,0.2673,0.0318 Q0.2588,0.0318,0.2517,0.033 T0.239,0.0361 T0.2276,0.0424 T0.218,0.0504 T0.2085,0.0612 T0.1997,0.0732 T0.1899,0.0875 T0.1795,0.1028 Q0.1748,0.1093,0.1507,0.1234 T0.1186,0.1448 Q0.0999,0.1612,0.1108,0.1783 Q0.1162,0.1877,0.1238,0.1928 T0.1404,0.1979 T0.158,0.1967 T0.1775,0.1895 T0.1963,0.1791 T0.2151,0.1663 T0.2314,0.1534 T0.2458,0.141 T0.2561,0.1322 Q0.2815,0.1106,0.3017,0.101 T0.344,0.0926 T0.3919,0.1077 Q0.4099,0.1175,0.4265,0.1206 T0.4588,0.1202 T0.488,0.1098 T0.5176,0.0914 Q0.5071,0.1032,0.4963,0.1122 T0.4722,0.1285 T0.4497,0.1401 T0.4223,0.1518 T0.3943,0.1636 Q0.374,0.1726,0.3623,0.1863 T0.3482,0.2162 Q0.3445,0.2403,0.3608,0.2605 T0.4072,0.2876 Q0.4194,0.2909,0.4326,0.2899 T0.457,0.2856 T0.4805,0.2764 T0.5032,0.264 T0.5256,0.2497 T0.5478,0.2352 T0.5698,0.2222 T0.5921,0.2118 Q0.6982,0.173,0.81,0.2436 Q0.833,0.2583,0.8496,0.2674 T0.887,0.2846 T0.925,0.294 T0.9617,0.2911 T1,0.2746 Q0.9817,0.2946,0.96,0.3056 T0.9175,0.3191 T0.8731,0.3209 T0.8277,0.3164 T0.7818,0.3109 T0.7359,0.3101 T0.6907,0.3197 T0.647,0.3448 Q0.6443,0.3472,0.6333,0.3562 T0.6194,0.3674 T0.6076,0.3766 T0.594,0.386 T0.5815,0.3931 T0.5661,0.4007 T0.5501,0.4064 L0.5556,0.4121 Q0.5627,0.4198,0.5722,0.4251 T0.5926,0.4337 T0.6131,0.439 T0.6343,0.4431 T0.6521,0.4468 Q0.6762,0.4529,0.7056,0.4463 Q0.6772,0.4623,0.6543,0.471 T0.604,0.4814 Q0.5962,0.4818,0.5815,0.4825 T0.5591,0.4835 T0.5393,0.4857 T0.519,0.4908 T0.5007,0.4996 T0.4812,0.5141 T0.4627,0.5353 L0.4549,0.5451 L0.4661,0.543 Q0.4759,0.541,0.4909,0.55 T0.5315,0.5796 T0.5684,0.6083 Q0.5799,0.6161,0.5915,0.6212 T0.6181,0.6308 L0.6331,0.6353 Q0.6514,0.6418,0.7004,0.633 T0.8069,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.011,0.0469 Q0.0152,0.0412,0.02,0.0361 T0.0292,0.0281 T0.0365,0.0253 Q0.0426,0.0257,0.0461,0.0263 T0.0534,0.03 T0.0582,0.0345 T0.0645,0.0434 T0.0723,0.0546 Q0.0807,0.0653,0.0897,0.0712 T0.1081,0.0775 T0.1255,0.0765 T0.1438,0.0695 T0.1605,0.0593 T0.1772,0.0473 Q0.2388,0,0.2927,0.0326 L0.2614,0.0767 L0.2581,0.0767 Q0.2494,0.0767,0.2444,0.0773 T0.2359,0.0789 T0.2291,0.0842 T0.2233,0.0922 T0.2146,0.1054 T0.2026,0.1236 Q0.1943,0.1354,0.1843,0.1444 T0.1675,0.1574 T0.1526,0.1654 T0.1429,0.1701 Q0.1436,0.1717,0.1446,0.1733 Q0.1504,0.1823,0.1581,0.1866 T0.1733,0.1915 T0.1909,0.1884 T0.2085,0.1809 T0.2265,0.169 T0.2427,0.1564 T0.2575,0.1436 T0.2688,0.1338 Q0.2969,0.1101,0.3195,0.0995 T0.3643,0.0901 T0.4114,0.1048 Q0.4279,0.1138,0.4426,0.1168 T0.4705,0.117 T0.4945,0.1089 T0.5186,0.0942 L0.5211,0.0926 L0.5289,0.0922 Q0.5411,0.095,0.5189,0.1203 Q0.505,0.1358,0.4913,0.1474 T0.4634,0.167 T0.4389,0.1796 T0.4121,0.1909 T0.3866,0.2011 Q0.3782,0.2047,0.3727,0.2092 Q0.3717,0.2117,0.3714,0.2141 Q0.3695,0.2263,0.3751,0.2388 T0.394,0.2616 T0.4253,0.2761 Q0.4398,0.2785,0.4513,0.2779 T0.4756,0.2724 T0.4987,0.2616 T0.5216,0.2476 T0.5453,0.2321 T0.5703,0.217 T0.5976,0.2043 Q0.7015,0.166,0.8099,0.2345 Q0.8361,0.2512,0.8572,0.2616 T0.9006,0.2779 T0.9432,0.281 T0.9819,0.2663 L0.9887,0.2671 Q1,0.272,0.9768,0.2969 Q0.9558,0.3193,0.9319,0.333 T0.8858,0.3513 T0.8391,0.3569 T0.793,0.3546 T0.7485,0.3497 T0.7065,0.3473 T0.6681,0.3522 T0.6344,0.3699 Q0.5963,0.4005,0.5847,0.4082 Q0.595,0.4139,0.6079,0.4174 T0.6367,0.4237 T0.6586,0.4278 Q0.6793,0.4331,0.7044,0.4278 Q0.7057,0.4278,0.707,0.4274 Q0.7067,0.429,0.7062,0.4313 T0.7036,0.4398 T0.6993,0.4513 T0.6931,0.4619 T0.6851,0.4698 Q0.6573,0.4857,0.6336,0.4947 T0.5831,0.5053 Q0.577,0.5057,0.5615,0.5065 T0.5394,0.508 T0.5221,0.51 T0.5042,0.5139 T0.4905,0.5204 Q0.5008,0.5232,0.515,0.5328 T0.549,0.5585 T0.5799,0.5824 Q0.5905,0.5897,0.6013,0.5946 T0.6262,0.6036 T0.6409,0.6081 Q0.6567,0.6134,0.7035,0.605 T0.8028,0.5816 L0.7974,0.5922 T0.7867,0.6134 L0.7812,0.624 Q0.7799,0.6248,0.7786,0.6252 Q0.7709,0.6297,0.7573,0.6372 T0.7346,0.6499 T0.7123,0.6621 T0.6897,0.6741 T0.6686,0.6847 T0.648,0.6941 T0.6297,0.7013 T0.6129,0.7062 T0.5995,0.7076 Q0.5992,0.7076,0.5831,0.7092 T0.5529,0.7092 T0.525,0.7019 Q0.5105,0.6958,0.4824,0.6811 T0.4356,0.6586 T0.4079,0.6533 Q0.3872,0.6741,0.3827,0.6782 Q0.3872,0.6835,0.3934,0.6929 T0.4037,0.7078 T0.4142,0.7198 T0.4284,0.7304 T0.4466,0.7365 Q0.4824,0.7443,0.5292,0.728 L0.5292,0.7294 T0.529,0.7333 T0.5284,0.7392 T0.5266,0.7463 T0.5236,0.7539 T0.5186,0.7612 T0.5111,0.7679 Q0.5086,0.77,0.5056,0.7716 Q0.4466,0.8104,0.3591,0.8091 Q0.344,0.8091,0.3246,0.802 T0.2893,0.7863 T0.2543,0.77 T0.2206,0.76 T0.1894,0.7643 Q0.1804,0.7684,0.1733,0.7753 Q0.1591,0.7892,0.1601,0.8169 Q0.1784,0.8067,0.1938,0.8053 T0.2212,0.8087 T0.2507,0.825 Q0.2614,0.8324,0.2744,0.8365 T0.3053,0.844 T0.332,0.8499 L0.303,0.894 L0.2998,0.8948 Q0.2904,0.8968,0.278,0.9009 T0.2554,0.9086 T0.233,0.9158 T0.2099,0.9213 T0.1868,0.9229 T0.1633,0.9192 Q0.1517,0.916,0.1367,0.9241 T0.1065,0.9462 T0.0753,0.9737 T0.041,0.9955 T0.0061,1 Q0,0.9984,0.0068,0.9845 Q0.009,0.9804,0.0123,0.9755 Q0.0187,0.9666,0.0261,0.9606 T0.0374,0.9555 Q0.0481,0.958,0.0629,0.949 T0.0931,0.9262 T0.1247,0.8987 T0.1597,0.8777 T0.1949,0.8752 Q0.213,0.8805,0.2343,0.8772 Q0.2249,0.8736,0.2172,0.8683 Q0.1914,0.8511,0.1738,0.8497 T0.1349,0.8617 Q0.1304,0.865,0.1273,0.8646 T0.1242,0.8605 L0.1239,0.8536 Q0.1226,0.8295,0.1354,0.8016 T0.1723,0.7496 Q0.1914,0.7308,0.2104,0.7219 Q0.2259,0.7145,0.2425,0.7141 T0.273,0.7182 T0.3022,0.7296 T0.3301,0.7439 T0.3569,0.7571 T0.3821,0.7643 Q0.373,0.7561,0.3598,0.7367 T0.3382,0.7088 L0.3327,0.7035 Q0.3282,0.699,0.3353,0.6884 T0.3517,0.6692 L0.3611,0.6607 Q0.3711,0.657,0.3751,0.6554 T0.3837,0.6507 T0.3898,0.6458 T0.3979,0.637 T0.4111,0.6236 Q0.4172,0.6175,0.4234,0.6138 Q0.4327,0.6081,0.4439,0.6077 T0.4655,0.6105 T0.4884,0.6201 T0.5113,0.633 T0.5347,0.6468 T0.5573,0.6578 Q0.5705,0.6635,0.5833,0.665 T0.6112,0.665 T0.6286,0.6635 Q0.6421,0.6631,0.6718,0.6501 Q0.626,0.6578,0.6089,0.6517 Q0.6063,0.6509,0.6002,0.6491 T0.5884,0.6454 T0.5757,0.6409 T0.561,0.6344 T0.5466,0.6256 Q0.536,0.6183,0.5108,0.5989 T0.4722,0.5712 T0.4527,0.564 L0.4418,0.5661 Q0.4308,0.5681,0.4366,0.5563 Q0.4401,0.5485,0.4476,0.5392 L0.455,0.5298 Q0.4631,0.5188,0.4714,0.51 T0.4866,0.4947 T0.5021,0.4831 T0.516,0.4749 T0.53,0.4694 T0.5428,0.4657 T0.5558,0.4637 T0.5674,0.4629 T0.5795,0.4625 Q0.5489,0.4543,0.534,0.4384 L0.5286,0.4327 Q0.5244,0.4282,0.5318,0.4174 T0.5486,0.398 L0.5579,0.3899 Q0.567,0.387,0.5749,0.3834 T0.5878,0.3772 T0.6012,0.3687 T0.6126,0.3603 T0.6268,0.3487 T0.6421,0.3361 Q0.6567,0.3242,0.6712,0.3159 T0.7009,0.3034 T0.7277,0.2973 T0.7556,0.2961 T0.7809,0.2977 T0.8074,0.301 Q0.8096,0.3014,0.8109,0.3016 T0.8143,0.302 T0.818,0.3026 Q0.8012,0.2936,0.7767,0.2781 Q0.6718,0.2117,0.5744,0.2476 Q0.5637,0.2516,0.5492,0.2604 T0.5218,0.2779 T0.4923,0.2965 T0.461,0.3124 T0.4282,0.3216 T0.394,0.3206 Q0.3637,0.3136,0.3474,0.2936 T0.3353,0.2459 Q0.3385,0.2247,0.355,0.2023 Q0.3646,0.1892,0.3763,0.179 Q0.3898,0.1672,0.4063,0.1595 Q0.3927,0.1562,0.3785,0.1485 Q0.3604,0.1387,0.3453,0.1354 T0.3161,0.1354 T0.2899,0.1458 T0.2627,0.166 Q0.262,0.1664,0.254,0.1733 T0.2414,0.1841 T0.2267,0.196 T0.2094,0.2086 T0.1915,0.2196 T0.1725,0.2288 T0.1546,0.2337 T0.1373,0.2343 T0.1223,0.228 T0.1097,0.2145 Q0.0962,0.1929,0.1258,0.1582 Q0.131,0.1517,0.1371,0.1464 Q0.1462,0.1387,0.156,0.1321 T0.1752,0.1205 T0.1878,0.1134 Q0.1891,0.1117,0.1947,0.103 T0.2035,0.0899 T0.2125,0.0781 T0.2243,0.064 Q0.1949,0.0616,0.1672,0.0828 Q0.153,0.0934,0.1412,0.1009 T0.1146,0.1146 T0.0873,0.1213 T0.0616,0.116 T0.0374,0.0962 Q0.0339,0.0909,0.0295,0.0846 T0.024,0.0767 T0.0202,0.0728 T0.0148,0.0704 T0.0068,0.0697 Q0.0003,0.0693,0.0058,0.0567 Q0.0077,0.0522,0.011,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.1934,0.6075 Q0.2012,0.612,0.2188,0.6218 T0.2459,0.6369 T0.2718,0.6508 T0.2983,0.6644 T0.322,0.6758 T0.3447,0.6854 T0.363,0.6911 T0.3783,0.6936 Q0.3796,0.6936,0.396,0.6952 T0.4265,0.6952 T0.4548,0.6879 Q0.4656,0.6834,0.4805,0.6756 T0.5073,0.6614 T0.5327,0.6485 T0.5564,0.6389 T0.5759,0.6363 T0.5909,0.6422 Q0.5953,0.6467,0.604,0.6554 T0.6155,0.6669 T0.6251,0.6746 T0.6387,0.683 T0.6556,0.6903 L0.6498,0.6956 Q0.6414,0.7042,0.6319,0.7177 T0.6162,0.7403 T0.5979,0.7583 T0.5703,0.7703 Q0.5313,0.7785,0.4799,0.7605 Q0.5097,0.7817,0.5508,0.7911 T0.6302,0.8001 Q0.6431,0.7997,0.659,0.7942 T0.6888,0.7813 T0.7194,0.766 T0.7506,0.753 T0.7819,0.7472 T0.8131,0.754 Q0.8239,0.7589,0.8346,0.7681 T0.8544,0.7895 T0.8689,0.818 T0.8737,0.8507 L0.8733,0.858 Q0.8591,0.849,0.8474,0.845 T0.8242,0.8421 T0.8021,0.8482 T0.7772,0.8625 Q0.7653,0.8707,0.7513,0.8752 T0.7184,0.8831 T0.6908,0.889 Q0.7017,0.8915,0.7152,0.8956 T0.7396,0.9037 T0.7633,0.9115 T0.7872,0.9174 T0.8107,0.9194 T0.8347,0.9155 Q0.8493,0.9115,0.8671,0.9196 T0.9015,0.9421 T0.9345,0.9708 T0.9682,0.9943 T0.9997,1 Q0.999,0.94,0.9983,0.6508 T0.9976,0.2322 Q0.9976,0.2109,0.9992,0.1379 T1,0.0249 Q0.9949,0.0249,0.9917,0.0257 T0.9863,0.0273 T0.9817,0.0308 T0.9776,0.0357 T0.9721,0.0437 T0.9651,0.0539 Q0.956,0.0657,0.9456,0.072 T0.9247,0.0789 T0.904,0.0775 T0.8823,0.0694 T0.862,0.0573 T0.8422,0.0432 Q0.7856,0,0.7328,0.0318 Q0.7392,0.0318,0.7452,0.0324 T0.756,0.0345 T0.7657,0.0386 T0.7741,0.0437 T0.7821,0.0506 T0.7894,0.0588 T0.7966,0.0683 T0.8039,0.0787 T0.8119,0.0904 T0.8202,0.1028 Q0.8249,0.1093,0.849,0.1234 T0.8811,0.1448 Q0.8998,0.1612,0.8893,0.1783 Q0.8835,0.1877,0.8759,0.1928 T0.8593,0.1979 T0.8417,0.1967 T0.8222,0.1895 T0.8034,0.1791 T0.7848,0.1663 T0.7687,0.1534 T0.7543,0.141 T0.744,0.1322 Q0.7186,0.1106,0.6984,0.101 T0.6559,0.0926 T0.6082,0.1077 Q0.5899,0.1175,0.5733,0.1206 T0.5411,0.1202 T0.5122,0.1098 T0.4822,0.0914 Q0.4927,0.1032,0.5036,0.1122 T0.5276,0.1285 T0.5503,0.1401 T0.5777,0.1518 T0.6055,0.1636 Q0.6258,0.1726,0.6375,0.1863 T0.6519,0.2162 Q0.6556,0.2403,0.6392,0.2605 T0.5926,0.2876 Q0.5804,0.2909,0.5672,0.2899 T0.5428,0.2856 T0.5193,0.2764 T0.4966,0.264 T0.4744,0.2497 T0.4523,0.2352 T0.4301,0.2222 T0.4077,0.2118 Q0.3021,0.173,0.1903,0.2436 Q0.1669,0.2583,0.1504,0.2674 T0.1129,0.2846 T0.075,0.294 T0.0384,0.2911 T0,0.2746 Q0.021,0.297,0.0459,0.3082 T0.0953,0.3207 T0.146,0.3193 T0.1988,0.3131 T0.2508,0.3095 T0.3029,0.3176 T0.3529,0.3448 Q0.3566,0.348,0.3652,0.355 T0.3771,0.3645 T0.3877,0.3731 T0.3993,0.3815 T0.4103,0.3884 T0.4226,0.3953 T0.4352,0.4009 T0.4497,0.4064 L0.4443,0.4121 Q0.4372,0.4198,0.4277,0.4251 T0.4072,0.4337 T0.3867,0.439 T0.3656,0.4431 T0.3481,0.4468 Q0.3241,0.4529,0.2943,0.4463 Q0.3227,0.4623,0.3458,0.471 T0.3959,0.4814 Q0.404,0.4818,0.4187,0.4825 T0.4409,0.4835 T0.4605,0.4857 T0.4809,0.4908 T0.4992,0.4996 T0.5186,0.5141 T0.5371,0.5353 L0.5449,0.5451 L0.5337,0.543 Q0.5242,0.541,0.5091,0.55 T0.4683,0.5796 T0.4314,0.6083 Q0.4203,0.6161,0.4086,0.6212 T0.382,0.6308 T0.3667,0.6353 Q0.3485,0.6418,0.2995,0.633 T0.1934,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.989,0.0469 Q0.9829,0.0379,0.9753,0.0316 T0.9635,0.0253 Q0.9574,0.0257,0.9539,0.0263 T0.9466,0.03 T0.9418,0.0345 T0.9355,0.0434 T0.9277,0.0546 Q0.9193,0.0653,0.9103,0.0712 T0.8919,0.0775 T0.8745,0.0765 T0.8564,0.0695 T0.8396,0.0593 T0.8228,0.0473 Q0.7612,0,0.7073,0.0326 L0.7386,0.0767 L0.7419,0.0767 Q0.7506,0.0767,0.7556,0.0773 T0.7641,0.0789 T0.7709,0.0842 T0.7767,0.0922 T0.7854,0.1054 T0.7974,0.1236 Q0.8057,0.1354,0.8157,0.1444 T0.8325,0.1574 T0.8474,0.1654 T0.8571,0.1701 Q0.8564,0.1717,0.8554,0.1733 Q0.8503,0.1815,0.844,0.186 T0.8301,0.1909 T0.8153,0.1903 T0.7993,0.1845 T0.7836,0.1758 T0.768,0.1648 T0.754,0.1535 T0.741,0.1425 T0.7312,0.1338 Q0.7031,0.1101,0.6805,0.0995 T0.6357,0.0901 T0.5886,0.1048 Q0.5721,0.1138,0.5574,0.1168 T0.5295,0.117 T0.5055,0.1089 T0.4814,0.0942 L0.4789,0.0926 L0.4711,0.0922 Q0.4589,0.095,0.4811,0.1203 Q0.495,0.1358,0.5087,0.1474 T0.5366,0.167 T0.5611,0.1796 T0.5879,0.1909 T0.6134,0.2011 Q0.6218,0.2047,0.6273,0.2092 Q0.6283,0.2117,0.6286,0.2141 Q0.6305,0.2263,0.6249,0.2388 T0.606,0.2616 T0.5747,0.2761 Q0.5602,0.2785,0.5487,0.2779 T0.5245,0.2724 T0.5015,0.2616 T0.4784,0.2476 T0.4547,0.2321 T0.4297,0.217 T0.4024,0.2043 Q0.2985,0.166,0.1901,0.2345 Q0.1694,0.248,0.1523,0.2569 T0.1165,0.2726 T0.0818,0.2812 T0.0494,0.2796 T0.0181,0.2663 L0.0116,0.2671 Q0,0.272,0.0232,0.2969 Q0.0468,0.3222,0.0742,0.3365 T0.1278,0.3542 T0.1807,0.3564 T0.233,0.352 T0.2815,0.3473 T0.3269,0.3509 T0.3656,0.3699 Q0.4037,0.4005,0.4153,0.4082 Q0.405,0.4139,0.3921,0.4174 T0.3633,0.4237 T0.3414,0.4278 Q0.3207,0.4331,0.2956,0.4278 Q0.2943,0.4278,0.293,0.4274 Q0.2933,0.429,0.2938,0.4313 T0.2964,0.4398 T0.3007,0.4513 T0.3069,0.4619 T0.3149,0.4698 Q0.3427,0.4857,0.3664,0.4947 T0.4169,0.5053 Q0.423,0.5057,0.4385,0.5065 T0.4606,0.508 T0.4779,0.51 T0.4958,0.5139 T0.5095,0.5204 Q0.4992,0.5232,0.485,0.5328 T0.451,0.5585 T0.4201,0.5824 Q0.4095,0.5897,0.3987,0.5946 T0.3738,0.6036 T0.3591,0.6081 Q0.3433,0.6134,0.2965,0.605 T0.1972,0.5816 L0.2026,0.5922 T0.2133,0.6134 L0.2188,0.624 Q0.2201,0.6248,0.2214,0.6252 Q0.2291,0.6297,0.2486,0.6405 T0.2786,0.657 T0.3066,0.6721 T0.3353,0.6866 T0.3598,0.6972 T0.383,0.7051 T0.4005,0.7076 Q0.4008,0.7076,0.4169,0.7092 T0.4471,0.7092 T0.475,0.7019 Q0.4895,0.6958,0.5176,0.6811 T0.5644,0.6586 T0.5921,0.6533 Q0.6128,0.6741,0.6173,0.6782 Q0.6131,0.6835,0.6068,0.6929 T0.5963,0.7078 T0.5858,0.7198 T0.5716,0.7304 T0.5534,0.7365 Q0.5179,0.7443,0.4708,0.728 L0.4708,0.7294 T0.471,0.7333 T0.4716,0.7392 T0.4734,0.7463 T0.4764,0.7539 T0.4814,0.7612 T0.4889,0.7679 Q0.4914,0.77,0.4944,0.7716 Q0.5534,0.8104,0.6409,0.8091 Q0.6521,0.8091,0.6654,0.8053 T0.6907,0.7959 T0.7157,0.7838 T0.741,0.772 T0.7652,0.7631 T0.7888,0.7594 T0.8106,0.7643 Q0.8196,0.7684,0.8267,0.7753 Q0.8409,0.7892,0.8399,0.8169 Q0.8216,0.8067,0.8062,0.8053 T0.7788,0.8087 T0.7493,0.825 Q0.7386,0.8324,0.7256,0.8365 T0.6947,0.844 T0.668,0.8499 L0.697,0.894 L0.7002,0.8948 Q0.7099,0.8968,0.7222,0.9009 T0.7446,0.9086 T0.767,0.9158 T0.7901,0.9213 T0.8132,0.9229 T0.8367,0.9192 Q0.8483,0.916,0.8633,0.9241 T0.8937,0.9462 T0.9248,0.9737 T0.959,0.9955 T0.9939,1 Q1,0.9984,0.9932,0.9845 Q0.991,0.9804,0.9877,0.9755 Q0.9813,0.9666,0.9739,0.9606 T0.9626,0.9555 Q0.9519,0.958,0.9372,0.949 T0.9071,0.9262 T0.8753,0.8987 T0.8403,0.8777 T0.8051,0.8752 Q0.787,0.8805,0.7657,0.8772 Q0.7751,0.8736,0.7828,0.8683 Q0.8086,0.8511,0.8262,0.8497 T0.8651,0.8617 Q0.8696,0.865,0.8727,0.8646 T0.8758,0.8605 L0.8761,0.8536 Q0.8774,0.8295,0.8646,0.8016 T0.8277,0.7496 Q0.8086,0.7308,0.7896,0.7219 Q0.7741,0.7145,0.7575,0.7141 T0.727,0.7182 T0.6978,0.7296 T0.6699,0.7439 T0.6433,0.7571 T0.6179,0.7643 Q0.627,0.7561,0.6402,0.7367 T0.6618,0.7088 L0.6673,0.7035 Q0.6718,0.699,0.6647,0.6884 T0.6483,0.6692 L0.6389,0.6607 Q0.6289,0.657,0.6249,0.6554 T0.6163,0.6507 T0.6102,0.6458 T0.6021,0.637 T0.5889,0.6236 Q0.5828,0.6175,0.5766,0.6138 Q0.5673,0.6081,0.5561,0.6077 T0.5345,0.6105 T0.5116,0.6201 T0.4887,0.633 T0.4653,0.6468 T0.4427,0.6578 Q0.4295,0.6635,0.4167,0.665 T0.3888,0.665 T0.3714,0.6635 Q0.3579,0.6631,0.3282,0.6501 Q0.374,0.6578,0.3911,0.6517 Q0.3937,0.6509,0.4024,0.6482 T0.4179,0.6434 T0.4348,0.6364 T0.4534,0.6256 Q0.464,0.6183,0.4892,0.5989 T0.5278,0.5712 T0.5476,0.564 L0.5582,0.5661 Q0.5692,0.5681,0.5634,0.5563 Q0.5599,0.5485,0.5524,0.5392 L0.545,0.5298 Q0.5369,0.5188,0.5286,0.51 T0.5134,0.4947 T0.4979,0.4831 T0.484,0.4749 T0.47,0.4694 T0.4572,0.4657 T0.4442,0.4637 T0.4326,0.4629 T0.4205,0.4625 Q0.4511,0.4543,0.466,0.4384 L0.4714,0.4327 Q0.4795,0.4237,0.4469,0.394 L0.4421,0.3899 Q0.433,0.387,0.4251,0.3834 T0.4122,0.3772 T0.3988,0.3687 T0.3874,0.3603 T0.3732,0.3487 T0.3579,0.3361 Q0.3433,0.3242,0.3288,0.3159 T0.2993,0.3034 T0.2725,0.2973 T0.2446,0.2961 T0.2193,0.2977 T0.1926,0.301 Q0.1855,0.3022,0.182,0.3026 Q0.1991,0.2936,0.2233,0.2781 Q0.3282,0.2117,0.4256,0.2476 Q0.4363,0.2516,0.451,0.2604 T0.4784,0.2779 T0.5077,0.2965 T0.539,0.3124 T0.5718,0.3216 T0.606,0.3206 Q0.6363,0.3136,0.6526,0.2936 T0.6647,0.2459 Q0.6615,0.2247,0.645,0.2023 Q0.6354,0.1892,0.6237,0.179 Q0.6102,0.1672,0.5937,0.1595 Q0.6073,0.1562,0.6215,0.1485 Q0.6396,0.1387,0.6547,0.1354 T0.6839,0.1354 T0.7101,0.1458 T0.7373,0.166 Q0.738,0.1664,0.746,0.1733 T0.7586,0.1841 T0.7733,0.196 T0.7907,0.2086 T0.8086,0.2196 T0.8275,0.2288 T0.8454,0.2337 T0.8627,0.2343 T0.8777,0.228 T0.8903,0.2145 Q0.9038,0.1929,0.8742,0.1582 Q0.869,0.1517,0.8629,0.1464 Q0.8538,0.1387,0.844,0.1321 T0.8248,0.1205 T0.8122,0.1134 Q0.8109,0.1117,0.8053,0.103 T0.7965,0.0899 T0.7875,0.0781 T0.7757,0.064 Q0.8051,0.0616,0.8328,0.0828 Q0.847,0.0934,0.8588,0.1009 T0.8854,0.1146 T0.9127,0.1213 T0.9384,0.116 T0.9626,0.0962 Q0.9664,0.0909,0.9706,0.0846 T0.976,0.0767 T0.9798,0.0728 T0.9852,0.0704 T0.9932,0.0697 Q0.9997,0.0693,0.9942,0.0567 Q0.9923,0.0522,0.989,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0.9099,0.1158 L0.9099,0.1888 L0.9951,0.1888 L0.9951,0.1158 L0.9099,0.1158 Z M0.9075,0.1116 L0.9976,0.1116 Q1,0.1116,1,0.1137 L1,0.1909 Q1,0.193,0.9976,0.193 L0.9075,0.193 Q0.905,0.193,0.905,0.1909 L0.905,0.1137 Q0.905,0.1116,0.9075,0.1116 Z M0.7852,0.6086 L0.7852,0.682 L0.8704,0.682 L0.8704,0.6086 L0.7852,0.6086 Z M0.7828,0.6044 L0.8729,0.6044 Q0.8753,0.6044,0.8753,0.6065 L0.8753,0.6841 Q0.8753,0.6862,0.8729,0.6862 L0.7828,0.6862 Q0.7803,0.6862,0.7803,0.6841 L0.7803,0.6065 Q0.7803,0.6044,0.7828,0.6044 Z M0.66,0.1137 L0.7501,0.1137 L0.7501,0.1909 L0.66,0.1909 L0.66,0.1137 Z M0.6624,0.4102 L0.6624,0.4836 L0.7477,0.4836 L0.7477,0.4102 L0.6624,0.4102 Z M0.66,0.406 L0.7501,0.406 Q0.7526,0.406,0.7526,0.4081 L0.7526,0.4857 Q0.7526,0.4878,0.7501,0.4878 L0.66,0.4878 Q0.6576,0.4878,0.6576,0.4857 L0.6576,0.4081 Q0.6576,0.406,0.66,0.406 Z M0.6624,0.7131 L0.6624,0.7865 L0.7477,0.7865 L0.7477,0.7131 L0.6624,0.7131 Z M0.66,0.7089 L0.7501,0.7089 Q0.7526,0.7089,0.7526,0.711 L0.7526,0.7886 Q0.7526,0.7907,0.7501,0.7907 L0.66,0.7907 Q0.6576,0.7907,0.6576,0.7886 L0.6576,0.711 Q0.6576,0.7089,0.66,0.7089 Z M0.5407,0.2227 L0.5407,0.2836 L0.6113,0.2836 L0.6113,0.2227 L0.5407,0.2227 Z M0.5309,0.206 L0.621,0.206 Q0.623,0.206,0.6249,0.2068 T0.6281,0.2089 T0.6301,0.2116 T0.6308,0.2143 L0.6308,0.2919 Q0.6308,0.2957,0.6276,0.298 T0.621,0.3003 L0.5309,0.3003 Q0.5265,0.3003,0.5239,0.2976 T0.5212,0.2919 L0.5212,0.2143 Q0.5212,0.211,0.5244,0.2085 T0.5309,0.206 Z M0.5407,0.3184 L0.5407,0.3792 L0.6113,0.3792 L0.6113,0.3184 L0.5407,0.3184 Z M0.5309,0.3016 L0.621,0.3016 Q0.6254,0.3016,0.6281,0.3043 T0.6308,0.31 L0.6308,0.3876 Q0.6308,0.3914,0.6276,0.3937 T0.621,0.396 L0.5309,0.396 Q0.5265,0.396,0.5239,0.3932 T0.5212,0.3876 L0.5212,0.31 Q0.5212,0.3062,0.5244,0.3039 T0.5309,0.3016 Z M0.5309,0.4081 L0.621,0.4081 L0.621,0.4857 L0.5309,0.4857 L0.5309,0.4081 Z M0.5309,0.5059 L0.621,0.5059 L0.621,0.5835 L0.5309,0.5835 L0.5309,0.5059 Z M0.5407,0.6149 L0.5407,0.6758 L0.6113,0.6758 L0.6113,0.6149 L0.5407,0.6149 Z M0.5309,0.5982 L0.621,0.5982 Q0.6254,0.5982,0.6281,0.6009 T0.6308,0.6065 L0.6308,0.6841 Q0.6308,0.6879,0.6276,0.6902 T0.621,0.6925 L0.5309,0.6925 Q0.5265,0.6925,0.5239,0.6898 T0.5212,0.6841 L0.5212,0.6065 Q0.5212,0.6028,0.5244,0.6005 T0.5309,0.5982 Z M0.4062,0.1137 L0.4963,0.1137 L0.4963,0.1909 L0.4062,0.1909 L0.4062,0.1137 Z M0.4062,0.2143 L0.4963,0.2143 L0.4963,0.2919 L0.4062,0.2919 L0.4062,0.2143 Z M0.4184,0.4186 L0.4184,0.4753 L0.4842,0.4753 L0.4842,0.4186 L0.4184,0.4186 Z M0.4062,0.3977 L0.4963,0.3977 Q0.5017,0.3977,0.5051,0.401 T0.5085,0.4081 L0.5085,0.4857 Q0.5085,0.4904,0.5046,0.4933 T0.4963,0.4962 L0.4062,0.4962 Q0.4009,0.4962,0.3975,0.4929 T0.3941,0.4857 L0.3941,0.4081 Q0.3941,0.4035,0.398,0.4006 T0.4062,0.3977 Z M0.4062,0.5059 L0.4963,0.5059 L0.4963,0.5835 L0.4062,0.5835 L0.4062,0.5059 Z M0.4087,0.7131 L0.4087,0.7865 L0.4939,0.7865 L0.4939,0.7131 L0.4087,0.7131 Z M0.4062,0.7089 L0.4963,0.7089 Q0.4988,0.7089,0.4988,0.711 L0.4988,0.7886 Q0.4988,0.7907,0.4963,0.7907 L0.4062,0.7907 Q0.4038,0.7907,0.4038,0.7886 L0.4038,0.711 Q0.4038,0.7089,0.4062,0.7089 Z M0.2689,0.1137 L0.359,0.1137 L0.359,0.1909 L0.2689,0.1909 L0.2689,0.1137 Z M0.2689,0.2143 L0.359,0.2143 L0.359,0.2919 L0.2689,0.2919 L0.2689,0.2143 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.31 L0.359,0.31 L0.359,0.3876 L0.2689,0.3876 L0.2689,0.31 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.4081 L0.359,0.4081 L0.359,0.4857 L0.2689,0.4857 L0.2689,0.4081 Z M0.2689,0.5059 L0.359,0.5059 L0.359,0.5835 L0.2689,0.5835 L0.2689,0.5059 Z M0.2689,0.711 L0.359,0.711 L0.359,0.7886 L0.2689,0.7886 L0.2689,0.711 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.1374,0.1137 L0.2275,0.1137 L0.2275,0.1909 L0.1374,0.1909 L0.1374,0.1137 Z M0.1374,0.2143 L0.2275,0.2143 L0.2275,0.2919 L0.1374,0.2919 L0.1374,0.2143 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.31 L0.2275,0.31 L0.2275,0.3876 L0.1374,0.3876 L0.1374,0.31 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.4081 L0.2275,0.4081 L0.2275,0.4857 L0.1374,0.4857 L0.1374,0.4081 Z M0.1374,0.5059 L0.2275,0.5059 L0.2275,0.5835 L0.1374,0.5835 L0.1374,0.5059 Z M0.1374,0.6065 L0.2275,0.6065 L0.2275,0.6841 L0.1374,0.6841 L0.1374,0.6065 Z M0.1374,0.711 L0.2275,0.711 L0.2275,0.7886 L0.1374,0.7886 L0.1374,0.711 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0,0.1137 L0.0901,0.1137 L0.0901,0.1909 L0,0.1909 L0,0.1137 Z M0,0.2143 L0.0901,0.2143 L0.0901,0.2919 L0,0.2919 L0,0.2143 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.31 L0.0901,0.31 L0.0901,0.3876 L0,0.3876 L0,0.31 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.4081 L0.0901,0.4081 L0.0901,0.4857 L0,0.4857 L0,0.4081 Z M0,0.5059 L0.0901,0.5059 L0.0901,0.5835 L0,0.5835 L0,0.5059 Z M0,0.6065 L0.0901,0.6065 L0.0901,0.6841 L0,0.6841 L0,0.6065 Z M0,0.711 L0.0901,0.711 L0.0901,0.7886 L0,0.7886 L0,0.711 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M0.0901,0.1158 L0.0901,0.1888 L0.0049,0.1888 L0.0049,0.1158 L0.0901,0.1158 Z M0.0925,0.1116 L0.0024,0.1116 Q0,0.1116,0,0.1137 L0,0.1909 Q0,0.193,0.0024,0.193 L0.0925,0.193 Q0.095,0.193,0.095,0.1909 L0.095,0.1137 Q0.095,0.1116,0.0925,0.1116 Z M0.2148,0.6086 L0.2148,0.682 L0.1296,0.682 L0.1296,0.6086 L0.2148,0.6086 Z M0.2172,0.6044 L0.1271,0.6044 Q0.1247,0.6044,0.1247,0.6065 L0.1247,0.6841 Q0.1247,0.6862,0.1271,0.6862 L0.2172,0.6862 Q0.2197,0.6862,0.2197,0.6841 L0.2197,0.6065 Q0.2197,0.6044,0.2172,0.6044 Z M0.34,0.1137 L0.2499,0.1137 L0.2499,0.1909 L0.34,0.1909 L0.34,0.1137 Z M0.3376,0.4102 L0.3376,0.4836 L0.2523,0.4836 L0.2523,0.4102 L0.3376,0.4102 Z M0.34,0.406 L0.2499,0.406 Q0.2474,0.406,0.2474,0.4081 L0.2474,0.4857 Q0.2474,0.4878,0.2499,0.4878 L0.34,0.4878 Q0.3424,0.4878,0.3424,0.4857 L0.3424,0.4081 Q0.3424,0.406,0.34,0.406 Z M0.3376,0.7131 L0.3376,0.7865 L0.2523,0.7865 L0.2523,0.7131 L0.3376,0.7131 Z M0.34,0.7089 L0.2499,0.7089 Q0.2474,0.7089,0.2474,0.711 L0.2474,0.7886 Q0.2474,0.7907,0.2499,0.7907 L0.34,0.7907 Q0.3424,0.7907,0.3424,0.7886 L0.3424,0.711 Q0.3424,0.7089,0.34,0.7089 Z M0.4593,0.2227 L0.4593,0.2836 L0.3887,0.2836 L0.3887,0.2227 L0.4593,0.2227 Z M0.4691,0.206 L0.379,0.206 Q0.3746,0.206,0.3719,0.2087 T0.3692,0.2143 L0.3692,0.2919 Q0.3692,0.2957,0.3724,0.298 T0.379,0.3003 L0.4691,0.3003 Q0.4735,0.3003,0.4761,0.2976 T0.4788,0.2919 L0.4788,0.2143 Q0.4788,0.211,0.4756,0.2085 T0.4691,0.206 Z M0.4593,0.3184 L0.4593,0.3792 L0.3887,0.3792 L0.3887,0.3184 L0.4593,0.3184 Z M0.4691,0.3016 L0.379,0.3016 Q0.3746,0.3016,0.3719,0.3043 T0.3692,0.31 L0.3692,0.3876 Q0.3692,0.3914,0.3724,0.3937 T0.379,0.396 L0.4691,0.396 Q0.4735,0.396,0.4761,0.3932 T0.4788,0.3876 L0.4788,0.31 Q0.4788,0.3062,0.4756,0.3039 T0.4691,0.3016 Z M0.4691,0.4081 L0.379,0.4081 L0.379,0.4857 L0.4691,0.4857 L0.4691,0.4081 Z M0.4691,0.5059 L0.379,0.5059 L0.379,0.5835 L0.4691,0.5835 L0.4691,0.5059 Z M0.4593,0.6149 L0.4593,0.6758 L0.3887,0.6758 L0.3887,0.6149 L0.4593,0.6149 Z M0.4691,0.5982 L0.379,0.5982 Q0.3746,0.5982,0.3719,0.6009 T0.3692,0.6065 L0.3692,0.6841 Q0.3692,0.6879,0.3724,0.6902 T0.379,0.6925 L0.4691,0.6925 Q0.4735,0.6925,0.4761,0.6898 T0.4788,0.6841 L0.4788,0.6065 Q0.4788,0.6028,0.4756,0.6005 T0.4691,0.5982 Z M0.5938,0.1137 L0.5037,0.1137 L0.5037,0.1909 L0.5938,0.1909 L0.5938,0.1137 Z M0.5938,0.2143 L0.5037,0.2143 L0.5037,0.2919 L0.5938,0.2919 L0.5938,0.2143 Z M0.5816,0.4186 L0.5816,0.4753 L0.5158,0.4753 L0.5158,0.4186 L0.5816,0.4186 Z M0.5938,0.3977 L0.5037,0.3977 Q0.4983,0.3977,0.4949,0.401 T0.4915,0.4081 L0.4915,0.4857 Q0.4915,0.4904,0.4954,0.4933 T0.5037,0.4962 L0.5938,0.4962 Q0.5991,0.4962,0.6025,0.4929 T0.6059,0.4857 L0.6059,0.4081 Q0.6059,0.4035,0.602,0.4006 T0.5938,0.3977 Z M0.5938,0.5059 L0.5037,0.5059 L0.5037,0.5835 L0.5938,0.5835 L0.5938,0.5059 Z M0.5913,0.7131 L0.5913,0.7865 L0.5061,0.7865 L0.5061,0.7131 L0.5913,0.7131 Z M0.5938,0.7089 L0.5037,0.7089 Q0.5012,0.7089,0.5012,0.711 L0.5012,0.7886 Q0.5012,0.7907,0.5037,0.7907 L0.5938,0.7907 Q0.5962,0.7907,0.5962,0.7886 L0.5962,0.711 Q0.5962,0.7089,0.5938,0.7089 Z M0.7311,0.1137 L0.641,0.1137 L0.641,0.1909 L0.7311,0.1909 L0.7311,0.1137 Z M0.7311,0.2143 L0.641,0.2143 L0.641,0.2919 L0.7311,0.2919 L0.7311,0.2143 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.31 L0.641,0.31 L0.641,0.3876 L0.7311,0.3876 L0.7311,0.31 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.4081 L0.641,0.4081 L0.641,0.4857 L0.7311,0.4857 L0.7311,0.4081 Z M0.7311,0.5059 L0.641,0.5059 L0.641,0.5835 L0.7311,0.5835 L0.7311,0.5059 Z M0.7311,0.711 L0.641,0.711 L0.641,0.7886 L0.7311,0.7886 L0.7311,0.711 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.8626,0.1137 L0.7725,0.1137 L0.7725,0.1909 L0.8626,0.1909 L0.8626,0.1137 Z M0.8626,0.2143 L0.7725,0.2143 L0.7725,0.2919 L0.8626,0.2919 L0.8626,0.2143 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.31 L0.7725,0.31 L0.7725,0.3876 L0.8626,0.3876 L0.8626,0.31 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.4081 L0.7725,0.4081 L0.7725,0.4857 L0.8626,0.4857 L0.8626,0.4081 Z M0.8626,0.5059 L0.7725,0.5059 L0.7725,0.5835 L0.8626,0.5835 L0.8626,0.5059 Z M0.8626,0.6065 L0.7725,0.6065 L0.7725,0.6841 L0.8626,0.6841 L0.8626,0.6065 Z M0.8626,0.711 L0.7725,0.711 L0.7725,0.7886 L0.8626,0.7886 L0.8626,0.711 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M1,0.1137 L0.9099,0.1137 L0.9099,0.1909 L1,0.1909 L1,0.1137 Z M1,0.2143 L0.9099,0.2143 L0.9099,0.2919 L1,0.2919 L1,0.2143 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.31 L0.9099,0.31 L0.9099,0.3876 L1,0.3876 L1,0.31 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.4081 L0.9099,0.4081 L0.9099,0.4857 L1,0.4857 L1,0.4081 Z M1,0.5059 L0.9099,0.5059 L0.9099,0.5835 L1,0.5835 L1,0.5059 Z M1,0.6065 L0.9099,0.6065 L0.9099,0.6841 L1,0.6841 L1,0.6065 Z M1,0.711 L0.9099,0.711 L0.9099,0.7886 L1,0.7886 L1,0.711 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0.8574,0.3217 L0.8574,0.4098 L0.9691,0.4098 L0.9691,0.3217 L0.8574,0.3217 Z M0.8419,0.2978 L0.9846,0.2978 Q0.9892,0.2978,0.9928,0.2997 T0.9982,0.3043 T1,0.31 L1,0.4216 Q1,0.427,0.9951,0.4304 T0.9846,0.4337 L0.8419,0.4337 Q0.8352,0.4337,0.8308,0.4299 T0.8265,0.4216 L0.8265,0.31 Q0.8265,0.3062,0.829,0.3035 T0.835,0.2993 T0.8419,0.2978 Z M0.8574,0.888 L0.8574,0.9757 L0.9691,0.9757 L0.9691,0.888 L0.8574,0.888 Z M0.8419,0.8637 L0.9846,0.8637 Q0.9892,0.8637,0.9928,0.8658 T0.9982,0.8706 T1,0.8758 L1,0.9878 Q1,0.9929,0.9951,0.9964 T0.9846,1 L0.8419,1 Q0.8352,1,0.8308,0.996 T0.8265,0.9878 L0.8265,0.8758 Q0.8265,0.8708,0.8314,0.8672 T0.8419,0.8637 Z M0.6509,0.169 L0.6509,0.2706 L0.7806,0.2706 L0.7806,0.169 L0.6509,0.169 Z M0.6442,0.159 L0.7868,0.159 Q0.7899,0.159,0.7917,0.1604 T0.7935,0.164 L0.7935,0.2756 Q0.7935,0.2781,0.7915,0.2796 T0.7868,0.281 L0.6442,0.281 Q0.6416,0.281,0.6395,0.2794 T0.6375,0.2756 L0.6375,0.164 Q0.6375,0.1619,0.6398,0.1604 T0.6442,0.159 Z M0.6632,0.6044 L0.6632,0.6858 L0.7678,0.6858 L0.7678,0.6044 L0.6632,0.6044 Z M0.6442,0.5742 L0.7868,0.5742 Q0.7951,0.5742,0.8007,0.5791 T0.8064,0.5893 L0.8064,0.7013 Q0.8064,0.7076,0.8002,0.712 T0.7868,0.7164 L0.6442,0.7164 Q0.6359,0.7164,0.6305,0.7114 T0.6251,0.7013 L0.6251,0.5893 Q0.6251,0.5847,0.6282,0.5812 T0.6357,0.5759 T0.6442,0.5742 Z M0.4264,0.164 L0.5695,0.164 L0.5695,0.2756 L0.4264,0.2756 L0.4264,0.164 Z M0.4325,0.315 L0.4325,0.4165 L0.5628,0.4165 L0.5628,0.315 L0.4325,0.315 Z M0.4264,0.3049 L0.5695,0.3049 Q0.5721,0.3049,0.5739,0.3064 T0.5757,0.31 L0.5757,0.4216 Q0.5757,0.4237,0.5736,0.4251 T0.5695,0.4266 L0.4264,0.4266 Q0.4238,0.4266,0.422,0.4251 T0.4202,0.4216 L0.4202,0.31 Q0.4202,0.3087,0.4207,0.3077 T0.4222,0.306 T0.4243,0.3052 T0.4264,0.3049 Z M0.4264,0.4476 L0.5695,0.4476 L0.5695,0.5596 L0.4264,0.5596 L0.4264,0.4476 Z M0.4392,0.7408 L0.4392,0.8326 L0.5561,0.8326 L0.5561,0.7408 L0.4392,0.7408 Z M0.4264,0.7206 L0.5695,0.7206 Q0.5747,0.7206,0.5783,0.724 T0.5819,0.7307 L0.5819,0.8427 Q0.5819,0.8469,0.5778,0.8498 T0.5695,0.8528 L0.4264,0.8528 Q0.4207,0.8528,0.4171,0.8496 T0.4135,0.8427 L0.4135,0.7307 Q0.4135,0.7265,0.4176,0.7236 T0.4264,0.7206 Z M0.2178,0.164 L0.3605,0.164 L0.3605,0.2756 L0.2178,0.2756 L0.2178,0.164 Z M0.2178,0.31 L0.3605,0.31 L0.3605,0.4216 L0.2178,0.4216 L0.2178,0.31 Z M0.2178,0.5893 L0.3605,0.5893 L0.3605,0.7013 L0.2178,0.7013 L0.2178,0.5893 Z M0.2178,0.7307 L0.3605,0.7307 L0.3605,0.8427 L0.2178,0.8427 L0.2178,0.7307 Z M0.2178,0.8758 L0.3605,0.8758 L0.3605,0.9878 L0.2178,0.9878 L0.2178,0.8758 Z M0,0.164 L0.1432,0.164 L0.1432,0.2756 L0,0.2756 L0,0.164 Z M0,0.31 L0.1432,0.31 L0.1432,0.4216 L0,0.4216 L0,0.31 Z M0,0.4476 L0.1432,0.4476 L0.1432,0.5596 L0,0.5596 L0,0.4476 Z M0,0.5893 L0.1432,0.5893 L0.1432,0.7013 L0,0.7013 L0,0.5893 Z M0,0.7307 L0.1432,0.7307 L0.1432,0.8427 L0,0.8427 L0,0.7307 Z M0,0.8758 L0.1432,0.8758 L0.1432,0.9878 L0,0.9878 L0,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M0.1432,0.3217 L0.1432,0.4098 L0.0309,0.4098 L0.0309,0.3217 L0.1432,0.3217 Z M0.1586,0.2978 L0.0154,0.2978 Q0.0088,0.2978,0.0044,0.3016 T0,0.31 L0,0.4216 Q0,0.427,0.0049,0.4304 T0.0154,0.4337 L0.1586,0.4337 Q0.1648,0.4337,0.1694,0.4299 T0.174,0.4216 L0.174,0.31 Q0.174,0.3062,0.1715,0.3035 T0.1653,0.2993 T0.1586,0.2978 Z M0.1432,0.888 L0.1432,0.9757 L0.0309,0.9757 L0.0309,0.888 L0.1432,0.888 Z M0.1586,0.8637 L0.0154,0.8637 Q0.0108,0.8637,0.0072,0.8658 T0.0018,0.8706 T0,0.8758 L0,0.9878 Q0,0.9929,0.0049,0.9964 T0.0154,1 L0.1586,1 Q0.1648,1,0.1694,0.996 T0.174,0.9878 L0.174,0.8758 Q0.174,0.8725,0.1715,0.8695 T0.1653,0.8651 T0.1586,0.8637 Z M0.3496,0.169 L0.3496,0.2706 L0.2194,0.2706 L0.2194,0.169 L0.3496,0.169 Z M0.3563,0.159 L0.2132,0.159 Q0.2106,0.159,0.2088,0.1604 T0.207,0.164 L0.207,0.2756 Q0.207,0.2781,0.2091,0.2796 T0.2132,0.281 L0.3563,0.281 Q0.3589,0.281,0.3607,0.2794 T0.3625,0.2756 L0.3625,0.164 Q0.3625,0.1619,0.3605,0.1604 T0.3563,0.159 Z M0.3368,0.6044 L0.3368,0.6858 L0.2327,0.6858 L0.2327,0.6044 L0.3368,0.6044 Z M0.3563,0.5742 L0.2132,0.5742 Q0.2049,0.5742,0.1993,0.5791 T0.1936,0.5893 L0.1936,0.7013 Q0.1936,0.7076,0.2001,0.712 T0.2132,0.7164 L0.3563,0.7164 Q0.3641,0.7164,0.3697,0.7114 T0.3754,0.7013 L0.3754,0.5893 Q0.3754,0.5847,0.3723,0.5812 T0.3648,0.5759 T0.3563,0.5742 Z M0.5736,0.164 L0.431,0.164 L0.431,0.2756 L0.5736,0.2756 L0.5736,0.164 Z M0.5675,0.315 L0.5675,0.4165 L0.4377,0.4165 L0.4377,0.315 L0.5675,0.315 Z M0.5736,0.3049 L0.431,0.3049 Q0.4284,0.3049,0.4264,0.3064 T0.4243,0.31 L0.4243,0.4216 Q0.4243,0.4237,0.4266,0.4251 T0.431,0.4266 L0.5736,0.4266 Q0.5767,0.4266,0.5785,0.4251 T0.5803,0.4216 L0.5803,0.31 Q0.5803,0.3087,0.5798,0.3077 T0.5783,0.306 T0.5762,0.3052 T0.5736,0.3049 Z M0.5736,0.4476 L0.431,0.4476 L0.431,0.5596 L0.5736,0.5596 L0.5736,0.4476 Z M0.5613,0.7408 L0.5613,0.8326 L0.4439,0.8326 L0.4439,0.7408 L0.5613,0.7408 Z M0.5736,0.7206 L0.431,0.7206 Q0.4253,0.7206,0.4217,0.724 T0.4181,0.7307 L0.4181,0.8427 Q0.4181,0.8469,0.4222,0.8498 T0.431,0.8528 L0.5736,0.8528 Q0.5778,0.8528,0.5808,0.8511 T0.5855,0.8471 T0.587,0.8427 L0.587,0.7307 Q0.587,0.7265,0.5826,0.7236 T0.5736,0.7206 Z M0.7827,0.164 L0.6395,0.164 L0.6395,0.2756 L0.7827,0.2756 L0.7827,0.164 Z M0.7827,0.31 L0.6395,0.31 L0.6395,0.4216 L0.7827,0.4216 L0.7827,0.31 Z M0.7827,0.5893 L0.6395,0.5893 L0.6395,0.7013 L0.7827,0.7013 L0.7827,0.5893 Z M0.7827,0.7307 L0.6395,0.7307 L0.6395,0.8427 L0.7827,0.8427 L0.7827,0.7307 Z M0.7827,0.8758 L0.6395,0.8758 L0.6395,0.9878 L0.7827,0.9878 L0.7827,0.8758 Z M1,0.164 L0.8574,0.164 L0.8574,0.2756 L1,0.2756 L1,0.164 Z M1,0.31 L0.8574,0.31 L0.8574,0.4216 L1,0.4216 L1,0.31 Z M1,0.4476 L0.8574,0.4476 L0.8574,0.5596 L1,0.5596 L1,0.4476 Z M1,0.5893 L0.8574,0.5893 L0.8574,0.7013 L1,0.7013 L1,0.5893 Z M1,0.7307 L0.8574,0.7307 L0.8574,0.8427 L1,0.8427 L1,0.7307 Z M1,0.8758 L0.8574,0.8758 L0.8574,0.9878 L1,0.9878 L1,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.0008,0.9979 Q0.0008,1,0.0032,1 Q0.0482,1,0.0602,0.9975 Q0.0667,0.9963,0.0847,0.9917 T0.1084,0.9859 Q0.1124,0.9851,0.1221,0.9851 L0.1253,0.9851 L0.1285,0.9851 Q0.1518,0.9851,0.1655,0.9809 Q0.1695,0.9801,0.1731,0.9788 T0.1791,0.9766 T0.1839,0.9747 T0.1888,0.9732 L0.1936,0.972 T0.1988,0.9712 T0.2048,0.971 L0.2096,0.971 Q0.2201,0.9714,0.2233,0.9714 T0.2345,0.9693 Q0.2386,0.9685,0.2502,0.9674 T0.2715,0.966 L0.2811,0.9656 Q0.2916,0.9647,0.2916,0.9573 Q0.2916,0.954,0.2892,0.9515 T0.2819,0.9486 Q0.2755,0.9482,0.2683,0.9482 Q0.2643,0.9482,0.2582,0.9484 T0.2498,0.9486 Q0.2426,0.9486,0.2402,0.9473 Q0.2386,0.9469,0.2317,0.943 T0.2161,0.937 Q0.2104,0.9357,0.1992,0.933 T0.1831,0.9291 Q0.1807,0.9287,0.1783,0.9285 T0.1739,0.928 T0.1703,0.9276 T0.1675,0.9272 T0.1655,0.9266 T0.1639,0.9253 Q0.1598,0.9216,0.1462,0.9216 L0.143,0.9216 L0.1398,0.9216 L0.1365,0.9216 Q0.1197,0.9195,0.1197,0.9154 L0.1197,0.9154 Q0.1205,0.9121,0.1373,0.9088 T0.1703,0.9054 Q0.1791,0.9054,0.1863,0.904 T0.1984,0.9011 T0.2064,0.8992 Q0.2402,0.8967,0.2506,0.8934 Q0.2538,0.8926,0.2602,0.8899 T0.2707,0.8872 Q0.2715,0.8872,0.2731,0.8876 Q0.2795,0.8884,0.2884,0.8884 Q0.3068,0.8884,0.3092,0.8826 L0.3092,0.8797 Q0.3092,0.8772,0.3076,0.8747 T0.3044,0.8706 T0.3028,0.8681 Q0.3076,0.8656,0.3237,0.8656 Q0.3317,0.8656,0.3622,0.8623 Q0.3655,0.8623,0.3867,0.8617 T0.4217,0.8602 Q0.4241,0.8598,0.4289,0.8598 L0.4345,0.8598 L0.441,0.8598 Q0.4578,0.8598,0.4667,0.8586 Q0.4739,0.8573,0.4851,0.8567 T0.5004,0.8552 Q0.5012,0.8552,0.5108,0.855 T0.5269,0.853 T0.5333,0.8461 Q0.5333,0.8445,0.5325,0.8432 T0.5309,0.8409 T0.5289,0.8391 T0.5261,0.8378 L0.5229,0.837 T0.5185,0.8362 T0.5133,0.8355 T0.5076,0.8349 T0.502,0.8341 Q0.4932,0.8324,0.4859,0.8324 Q0.4827,0.8324,0.4787,0.8328 T0.4731,0.8333 Q0.4707,0.8333,0.4683,0.8324 Q0.4482,0.825,0.4394,0.825 Q0.4369,0.825,0.4345,0.8254 Q0.4225,0.827,0.4153,0.827 Q0.4088,0.827,0.4064,0.8258 Q0.3976,0.8204,0.3751,0.8204 Q0.3703,0.8204,0.3667,0.8214 T0.3622,0.8225 Q0.3598,0.8225,0.3558,0.82 Q0.351,0.8175,0.3402,0.8167 T0.3177,0.8156 T0.3028,0.8146 L0.2956,0.8121 T0.2871,0.8094 T0.2771,0.8073 T0.2635,0.8063 Q0.261,0.8063,0.2586,0.8067 Q0.2378,0.8071,0.2273,0.8071 Q0.2072,0.8071,0.2024,0.8051 Q0.1976,0.8034,0.1932,0.8026 T0.1867,0.8013 T0.1847,0.8001 L0.1847,0.7993 Q0.1855,0.7984,0.1855,0.7978 T0.1859,0.7966 T0.1867,0.7959 T0.188,0.7955 T0.1896,0.7951 T0.1916,0.7949 T0.1944,0.7945 T0.1984,0.7941 T0.2032,0.7939 Q0.212,0.7926,0.2325,0.7918 T0.2562,0.7905 Q0.2691,0.7893,0.2787,0.7847 Q0.2835,0.7827,0.2968,0.7791 T0.3149,0.7756 Q0.3189,0.7756,0.3305,0.7733 T0.3446,0.771 L0.3454,0.771 Q0.3462,0.771,0.3498,0.7721 T0.3574,0.774 T0.3655,0.7748 Q0.3703,0.7748,0.3743,0.7731 T0.3823,0.7708 T0.3916,0.77 T0.3992,0.7694 Q0.4008,0.7694,0.4076,0.7688 T0.4205,0.7675 L0.4305,0.7665 Q0.4345,0.7657,0.4402,0.7617 T0.4458,0.7545 Q0.4458,0.7503,0.4353,0.7487 Q0.4297,0.7478,0.4209,0.7462 T0.4044,0.7433 T0.3904,0.7416 L0.3847,0.7416 Q0.3807,0.7416,0.3747,0.7422 T0.3663,0.7428 T0.3606,0.7424 Q0.3582,0.742,0.3562,0.7418 T0.3522,0.7412 T0.3482,0.7406 T0.3446,0.7401 T0.341,0.7397 T0.3373,0.7393 T0.3341,0.7391 L0.3293,0.7391 Q0.3277,0.7395,0.3253,0.7395 Q0.3205,0.7395,0.3052,0.7387 T0.2835,0.7379 Q0.2755,0.7379,0.253,0.7325 Q0.2506,0.7316,0.2462,0.731 T0.2382,0.7296 T0.2305,0.7271 T0.2181,0.7234 T0.2048,0.7196 T0.2,0.7155 Q0.2,0.7142,0.2016,0.713 Q0.208,0.7059,0.2285,0.6978 T0.2618,0.6898 Q0.2739,0.6898,0.2831,0.6848 T0.2956,0.6794 Q0.3028,0.6786,0.3289,0.6765 T0.3566,0.674 Q0.3582,0.674,0.3855,0.6723 T0.4406,0.6688 T0.4755,0.6653 Q0.5004,0.6607,0.5124,0.6599 L0.5165,0.6599 Q0.5213,0.6599,0.5277,0.6605 T0.5373,0.6611 Q0.543,0.6611,0.5494,0.6599 Q0.592,0.6524,0.6,0.6524 Q0.6064,0.6524,0.61,0.6499 T0.6161,0.6474 Q0.6177,0.6474,0.6261,0.6495 T0.6418,0.6516 L0.6482,0.6516 Q0.653,0.6512,0.6651,0.6506 T0.6876,0.6493 T0.7084,0.6477 T0.7229,0.645 Q0.7269,0.6437,0.7333,0.6437 Q0.7373,0.6437,0.7458,0.6443 T0.7574,0.645 Q0.7614,0.645,0.7631,0.6445 T0.7655,0.6441 Q0.7671,0.6441,0.7723,0.6448 T0.7823,0.6454 Q0.788,0.6454,0.7952,0.6445 Q0.8233,0.6408,0.8723,0.6367 Q0.9012,0.6342,0.9092,0.6342 L0.9165,0.6342 L0.9205,0.6342 Q0.9277,0.6342,0.9382,0.6323 T0.9526,0.6304 Q0.9534,0.6304,0.9594,0.6307 T0.9695,0.6309 Q0.9976,0.6309,0.9992,0.6234 Q1,0.6201,1,0.618 Q1,0.6089,0.9775,0.608 Q0.9703,0.6076,0.9582,0.6076 Q0.9518,0.6076,0.9394,0.6078 T0.9213,0.608 Q0.9036,0.608,0.8972,0.6072 Q0.894,0.6068,0.8871,0.6058 T0.8751,0.6039 T0.8627,0.6024 T0.8506,0.6018 L0.8474,0.6018 Q0.841,0.6022,0.8281,0.6041 T0.8112,0.606 Q0.8096,0.606,0.808,0.6056 Q0.7783,0.6002,0.7647,0.6002 Q0.7598,0.6002,0.7454,0.6006 T0.7261,0.601 Q0.7221,0.601,0.7145,0.6022 T0.7036,0.6035 Q0.6996,0.6035,0.6964,0.6022 Q0.6723,0.5902,0.6442,0.5902 Q0.6426,0.5902,0.6386,0.5904 T0.6337,0.5906 Q0.6273,0.5906,0.6249,0.5894 L0.6177,0.5856 T0.6116,0.5838 T0.6016,0.5825 T0.5855,0.5823 Q0.5719,0.5823,0.5574,0.5798 T0.5373,0.5774 L0.5341,0.5774 Q0.5293,0.5778,0.5213,0.5786 T0.51,0.5794 Q0.5036,0.5794,0.4988,0.5769 Q0.4932,0.574,0.4771,0.574 Q0.4723,0.574,0.4631,0.5745 T0.4498,0.5749 Q0.4418,0.5749,0.4394,0.5736 Q0.4353,0.5724,0.4305,0.5715 T0.4225,0.5707 T0.4145,0.5705 T0.4056,0.5699 Q0.4008,0.5691,0.398,0.5689 T0.394,0.5689 T0.3908,0.5686 T0.3855,0.5674 Q0.3831,0.567,0.3811,0.5662 T0.3779,0.5645 T0.3743,0.563 T0.3695,0.5624 Q0.3655,0.5624,0.3594,0.5635 T0.3494,0.5645 T0.3406,0.5635 T0.3325,0.5624 Q0.3309,0.5624,0.3301,0.5628 L0.3141,0.5628 Q0.2635,0.5628,0.2506,0.5612 Q0.2474,0.5608,0.2434,0.5608 Q0.2402,0.5608,0.2349,0.561 T0.2273,0.5612 Q0.2209,0.5612,0.2177,0.5595 Q0.2145,0.557,0.2145,0.5558 Q0.2169,0.5537,0.2345,0.5521 Q0.2876,0.5475,0.3028,0.5425 Q0.3068,0.5413,0.3149,0.5396 T0.3309,0.5367 T0.3478,0.5338 T0.3606,0.5309 Q0.3631,0.5305,0.3755,0.5259 T0.3968,0.5214 Q0.3992,0.5214,0.4024,0.5218 T0.4096,0.5222 Q0.4161,0.5222,0.4213,0.5209 T0.4313,0.5185 T0.4402,0.5172 Q0.4418,0.5168,0.447,0.5168 L0.4558,0.5168 T0.4639,0.5162 T0.4707,0.5141 T0.4731,0.5102 Q0.4731,0.5089,0.4723,0.5081 Q0.4699,0.5019,0.4627,0.4994 T0.4369,0.4965 Q0.4273,0.4961,0.4193,0.4946 T0.4048,0.4915 T0.3952,0.4894 Q0.392,0.4886,0.388,0.4886 Q0.3847,0.4886,0.3775,0.4892 T0.3647,0.4898 Q0.3598,0.4898,0.355,0.4894 Q0.3165,0.4861,0.3141,0.4853 Q0.2779,0.4774,0.2715,0.4774 Q0.2699,0.4774,0.2683,0.4776 T0.2627,0.4778 Q0.2498,0.4778,0.2281,0.4762 Q0.2241,0.4749,0.2233,0.4737 Q0.2249,0.472,0.2321,0.4708 Q0.2402,0.4695,0.2554,0.4695 L0.2675,0.4695 L0.2795,0.4695 Q0.294,0.4695,0.2996,0.4679 Q0.3141,0.4629,0.3373,0.4616 Q0.3414,0.4612,0.3466,0.4606 T0.3582,0.4589 T0.3687,0.4562 T0.3727,0.4525 Q0.3727,0.4509,0.3703,0.4492 Q0.3639,0.4455,0.3474,0.4419 T0.3213,0.4384 L0.3157,0.4384 L0.31,0.4384 Q0.294,0.4384,0.2859,0.4363 Q0.2795,0.4347,0.2691,0.4347 L0.2618,0.4347 L0.2546,0.4347 Q0.2458,0.4347,0.2394,0.4338 Q0.2257,0.4318,0.2193,0.4303 T0.2092,0.4266 T0.2052,0.4224 T0.2024,0.4154 T0.196,0.4052 Q0.1912,0.3994,0.1867,0.3949 T0.1807,0.3884 T0.1791,0.3849 Q0.1791,0.3824,0.1839,0.3745 Q0.1871,0.3687,0.1944,0.3637 T0.2072,0.3561 T0.2221,0.3501 T0.2321,0.3463 Q0.2353,0.3455,0.241,0.3445 T0.2522,0.3428 T0.2639,0.3411 T0.2731,0.3384 T0.2956,0.3351 T0.3197,0.3326 Q0.3213,0.3322,0.3237,0.3322 T0.3325,0.3331 T0.3414,0.3339 Q0.3478,0.3339,0.3518,0.3314 Q0.3606,0.3264,0.3703,0.3264 Q0.3775,0.3264,0.3847,0.3293 Q0.3888,0.331,0.396,0.331 Q0.4024,0.331,0.4116,0.3295 T0.4265,0.3266 T0.4414,0.3227 T0.4514,0.3202 Q0.4578,0.3181,0.4839,0.3152 T0.5112,0.3123 T0.5177,0.3119 T0.5285,0.3111 T0.5398,0.3096 T0.549,0.3065 T0.5526,0.3015 Q0.5526,0.2999,0.5518,0.2982 Q0.547,0.2903,0.5386,0.2883 T0.5165,0.2862 Q0.5116,0.2862,0.5056,0.2866 T0.4972,0.287 Q0.4884,0.287,0.4787,0.2833 Q0.4594,0.2758,0.4265,0.2758 Q0.4169,0.2758,0.4129,0.2766 Q0.4112,0.2771,0.4104,0.2771 Q0.4088,0.2771,0.4056,0.2748 T0.3968,0.2721 Q0.3695,0.2696,0.359,0.2659 Q0.355,0.2646,0.351,0.2646 T0.3426,0.2661 T0.3365,0.2675 T0.3333,0.2659 Q0.3277,0.2605,0.3116,0.2553 T0.2859,0.2501 L0.2843,0.2501 L0.2819,0.2501 Q0.2779,0.2501,0.2723,0.2493 T0.2586,0.2472 T0.2474,0.2455 Q0.1799,0.236,0.1719,0.236 Q0.1663,0.236,0.1606,0.235 T0.1502,0.2316 T0.1454,0.2265 Q0.1454,0.2223,0.1558,0.2173 Q0.1679,0.2115,0.1859,0.2092 T0.2185,0.207 T0.2353,0.2057 Q0.2369,0.2049,0.2502,0.2041 T0.2799,0.2022 T0.302,0.2007 Q0.3084,0.1999,0.3245,0.1954 T0.347,0.1908 Q0.3502,0.1908,0.3534,0.1916 Q0.3663,0.197,0.388,0.197 Q0.404,0.197,0.4088,0.1929 Q0.4096,0.192,0.4108,0.192 T0.4177,0.1945 T0.4277,0.197 T0.4369,0.1949 Q0.4442,0.1908,0.4843,0.1875 Q0.4892,0.1871,0.4968,0.1839 T0.5084,0.1808 L0.5108,0.1808 Q0.5213,0.1825,0.5454,0.1825 Q0.5823,0.1825,0.5992,0.1779 Q0.6096,0.1746,0.6217,0.1746 Q0.6249,0.1746,0.6309,0.1748 T0.6394,0.175 Q0.6434,0.175,0.6466,0.1746 Q0.6474,0.1746,0.6594,0.1738 T0.6787,0.1713 T0.6892,0.1659 Q0.6924,0.1634,0.6924,0.1593 Q0.6924,0.1547,0.6888,0.151 T0.6795,0.1472 Q0.6779,0.1472,0.6763,0.1477 Q0.6699,0.1493,0.6667,0.1493 Q0.661,0.1493,0.6506,0.1456 Q0.6329,0.1394,0.6129,0.1394 Q0.6048,0.1394,0.5992,0.1377 T0.5888,0.136 T0.5803,0.1377 T0.5751,0.1394 Q0.5711,0.1394,0.5639,0.1369 Q0.5365,0.1273,0.5116,0.1273 Q0.4876,0.1273,0.4667,0.1253 Q0.4627,0.1248,0.4542,0.1211 T0.4337,0.1174 L0.4305,0.1174 Q0.4217,0.1178,0.4153,0.119 T0.408,0.1203 Q0.4056,0.1203,0.4,0.117 Q0.3976,0.1153,0.396,0.1136 T0.3932,0.1112 T0.39,0.1099 T0.3847,0.1095 Q0.3799,0.1095,0.3687,0.1103 Q0.355,0.1112,0.3434,0.1141 T0.3301,0.117 Q0.3261,0.117,0.3229,0.1145 Q0.3181,0.1116,0.3052,0.1087 T0.2787,0.1043 T0.2546,0.1029 Q0.2506,0.1029,0.2474,0.1033 Q0.2418,0.1037,0.2349,0.1054 T0.2249,0.107 Q0.2225,0.107,0.2193,0.1066 Q0.2024,0.1024,0.1863,0.1024 Q0.1823,0.1024,0.1562,0.0971 T0.1269,0.0917 T0.1161,0.0896 T0.106,0.0854 Q0.1076,0.0846,0.11,0.0842 Q0.1173,0.0821,0.1261,0.0821 Q0.1301,0.0821,0.1369,0.0825 T0.1462,0.083 Q0.1494,0.083,0.151,0.0825 T0.1831,0.0813 T0.2201,0.0784 T0.243,0.074 T0.2667,0.0718 Q0.2707,0.0718,0.2755,0.0724 T0.2843,0.073 T0.2936,0.0724 T0.3024,0.0705 T0.3072,0.068 T0.3092,0.0657 T0.3108,0.0647 Q0.3116,0.0643,0.3133,0.0643 L0.3157,0.0643 L0.3181,0.0643 Q0.3446,0.0643,0.3446,0.0556 T0.3205,0.0469 L0.3189,0.0469 Q0.3157,0.0469,0.3157,0.0465 Q0.3084,0.0427,0.2835,0.0386 Q0.2739,0.0369,0.2675,0.0342 T0.2538,0.0299 Q0.249,0.029,0.245,0.029 T0.2378,0.0303 T0.2321,0.0315 Q0.2305,0.0315,0.2273,0.0307 Q0.2201,0.0286,0.2004,0.0272 T0.1775,0.0245 Q0.1735,0.0228,0.1663,0.0226 T0.1522,0.0197 T0.1345,0.0145 T0.1124,0.0079 Q0.0996,0.0033,0.0671,0.0044 T0.0297,0.0041 Q0.0169,0,0.0024,0.0017 Q0,0.0021,0,0.0037 Z M0.0064,0.9954 L0.0056,0.0058 Q0.0177,0.005,0.0281,0.0083 Q0.0345,0.01,0.0675,0.0089 T0.1108,0.012 Q0.1221,0.0162,0.1329,0.0187 T0.1498,0.0236 T0.1606,0.0268 T0.1691,0.0274 T0.1759,0.0286 Q0.1807,0.0303,0.2,0.0315 T0.2257,0.0348 Q0.2305,0.0361,0.2337,0.0361 Q0.2361,0.0361,0.2398,0.0348 T0.2466,0.0336 Q0.249,0.0336,0.2522,0.034 Q0.2586,0.0353,0.2655,0.0382 T0.2827,0.0427 Q0.3068,0.0469,0.3124,0.0502 Q0.3157,0.0523,0.3237,0.0523 L0.3277,0.0523 L0.3309,0.0523 Q0.3398,0.0523,0.3398,0.0556 Q0.3398,0.0581,0.3341,0.0587 T0.3205,0.0593 T0.3092,0.0603 T0.3036,0.0643 T0.2932,0.068 Q0.2884,0.0684,0.2843,0.0684 Q0.2811,0.0684,0.2763,0.0678 T0.2667,0.0672 Q0.2586,0.0672,0.2422,0.0697 T0.2177,0.0747 Q0.2137,0.0759,0.1831,0.0767 T0.1494,0.0784 L0.1474,0.0784 T0.1398,0.078 T0.1285,0.0776 Q0.1173,0.0776,0.1084,0.08 Q0.1004,0.0817,0.1004,0.0846 Q0.1004,0.0867,0.104,0.0888 T0.1124,0.0923 T0.1213,0.0948 T0.1269,0.0958 T0.155,0.1014 T0.1863,0.107 Q0.2016,0.107,0.2185,0.1107 Q0.2217,0.1116,0.2257,0.1116 Q0.2289,0.1116,0.2365,0.1099 T0.2482,0.1074 L0.2538,0.1074 Q0.2683,0.1074,0.2912,0.1107 T0.3197,0.1182 Q0.3245,0.1215,0.3301,0.1215 Q0.3333,0.1215,0.3454,0.1184 T0.3687,0.1145 Q0.3735,0.1141,0.3779,0.1139 T0.3843,0.1134 T0.3871,0.1132 Q0.3896,0.1132,0.39,0.1136 T0.392,0.1161 T0.3968,0.1203 Q0.404,0.1248,0.408,0.1248 Q0.4096,0.1248,0.4165,0.1234 T0.4305,0.1215 L0.4337,0.1215 Q0.4442,0.1215,0.4518,0.1251 T0.4659,0.1294 Q0.4876,0.1319,0.5116,0.1319 T0.5622,0.141 Q0.5703,0.1439,0.5751,0.1439 Q0.5791,0.1439,0.5831,0.1423 T0.5888,0.1406 Q0.592,0.1406,0.5944,0.1412 T0.6012,0.1427 T0.6129,0.1435 Q0.6313,0.1435,0.649,0.1497 Q0.6602,0.1535,0.6667,0.1535 Q0.6715,0.1535,0.6779,0.1522 Q0.6787,0.1518,0.6795,0.1518 Q0.6827,0.1518,0.6847,0.1541 T0.6867,0.1593 Q0.6867,0.1618,0.6851,0.1634 Q0.6835,0.1655,0.6775,0.1667 T0.6598,0.169 T0.6458,0.1705 T0.6402,0.1709 Q0.6378,0.1709,0.6321,0.1705 T0.6225,0.1701 Q0.6096,0.1701,0.5968,0.1738 Q0.5823,0.1779,0.5446,0.1779 Q0.5205,0.1779,0.5116,0.1767 Q0.51,0.1763,0.5084,0.1763 Q0.5028,0.1763,0.4944,0.1796 T0.4835,0.1829 L0.4771,0.1833 T0.4659,0.1844 L0.4546,0.1858 T0.443,0.1881 T0.4337,0.1912 Q0.4321,0.1916,0.4305,0.1916 Q0.4273,0.1916,0.4209,0.1895 T0.412,0.1875 Q0.408,0.1875,0.4056,0.19 Q0.4024,0.1925,0.388,0.1925 Q0.3663,0.1925,0.3558,0.1879 Q0.3518,0.1862,0.3462,0.1862 Q0.3373,0.1862,0.3213,0.191 T0.3012,0.1962 Q0.2964,0.1966,0.2799,0.1976 T0.2494,0.1997 T0.2313,0.2024 L0.2313,0.2024 L0.2293,0.2024 L0.2261,0.2024 L0.2217,0.2024 Q0.204,0.2024,0.1851,0.2047 T0.1526,0.2136 Q0.1406,0.2198,0.1406,0.226 Q0.1406,0.2319,0.1518,0.2362 T0.1719,0.2406 Q0.1791,0.2406,0.2466,0.2497 Q0.2498,0.2501,0.2574,0.2516 T0.2711,0.2538 T0.2819,0.2547 L0.2851,0.2547 Q0.2851,0.2543,0.2859,0.2543 Q0.294,0.2543,0.3096,0.2592 T0.3285,0.2684 Q0.3317,0.2721,0.3373,0.2721 Q0.3406,0.2721,0.3462,0.2708 T0.3542,0.2696 Q0.3558,0.2696,0.3574,0.27 Q0.3679,0.2737,0.3968,0.2762 Q0.3984,0.2766,0.4004,0.2779 L0.4044,0.2804 T0.4096,0.2816 Q0.412,0.2816,0.4145,0.2808 Q0.4177,0.2804,0.4249,0.2804 Q0.4586,0.2804,0.4763,0.287 Q0.4867,0.2912,0.4972,0.2912 Q0.5004,0.2912,0.5064,0.2908 T0.5165,0.2903 Q0.5293,0.2903,0.5361,0.2922 T0.547,0.3003 L0.547,0.3011 Q0.547,0.3036,0.5414,0.3049 T0.5249,0.3067 T0.51,0.3082 Q0.5092,0.3082,0.4831,0.3111 T0.4498,0.3161 Q0.408,0.3264,0.3952,0.3264 Q0.3896,0.3264,0.3863,0.3252 Q0.3783,0.3223,0.3703,0.3223 Q0.359,0.3223,0.3494,0.3277 Q0.347,0.3293,0.343,0.3293 Q0.3414,0.3293,0.3353,0.3285 T0.3253,0.3277 T0.3181,0.3285 Q0.3149,0.3289,0.2952,0.3306 T0.2699,0.3347 Q0.2675,0.3364,0.2598,0.3372 T0.2434,0.3395 T0.2297,0.3426 L0.2205,0.3461 T0.2048,0.3523 T0.1908,0.3606 T0.1791,0.3725 Q0.1743,0.3803,0.1743,0.3845 Q0.1743,0.3874,0.1763,0.3901 T0.1827,0.3976 T0.1912,0.4073 T0.198,0.4189 T0.2032,0.4276 T0.2137,0.4326 T0.2386,0.438 Q0.2458,0.4392,0.2562,0.4392 L0.2639,0.4392 L0.2699,0.4392 Q0.2795,0.4392,0.2843,0.4405 Q0.2932,0.443,0.3092,0.443 L0.3157,0.443 L0.3213,0.443 Q0.3293,0.443,0.3458,0.4463 T0.3671,0.4525 Q0.3598,0.4558,0.3373,0.4571 Q0.3124,0.4587,0.298,0.4637 Q0.294,0.465,0.2811,0.465 L0.2703,0.465 L0.2586,0.465 Q0.241,0.465,0.2305,0.4666 Q0.2185,0.4687,0.2185,0.4728 Q0.2185,0.4745,0.2205,0.4764 T0.2241,0.4791 L0.2265,0.4803 L0.2273,0.4803 Q0.2506,0.4824,0.261,0.4824 Q0.2675,0.4824,0.2699,0.482 Q0.2699,0.4815,0.2707,0.4815 Q0.2771,0.4815,0.3124,0.4898 Q0.3173,0.4907,0.3229,0.4913 T0.3386,0.4925 T0.3542,0.4936 T0.3663,0.494 Q0.3711,0.494,0.3787,0.4936 T0.3888,0.4932 Q0.392,0.4932,0.3944,0.4936 T0.4032,0.4959 T0.4181,0.499 T0.4369,0.5006 Q0.449,0.501,0.455,0.5021 T0.4639,0.5048 T0.4675,0.5093 L0.4675,0.5097 Q0.4675,0.5106,0.4663,0.511 T0.4622,0.5116 L0.4566,0.512 T0.4486,0.5124 T0.4402,0.5127 Q0.4345,0.5131,0.4257,0.5153 T0.4096,0.5176 Q0.4064,0.5176,0.4024,0.5172 L0.3968,0.5172 Q0.3863,0.5172,0.3731,0.522 T0.359,0.5268 Q0.355,0.528,0.3317,0.5321 T0.3004,0.5388 Q0.2867,0.5433,0.2337,0.5479 Q0.2096,0.55,0.2096,0.5562 Q0.2096,0.5591,0.2145,0.5628 Q0.2193,0.5662,0.2289,0.5662 Q0.2313,0.5662,0.2373,0.5657 T0.2458,0.5653 L0.2498,0.5653 Q0.2635,0.5674,0.3124,0.5674 Q0.3261,0.5674,0.3301,0.567 L0.3317,0.567 Q0.3341,0.567,0.3394,0.568 T0.3486,0.5691 Q0.3542,0.5691,0.3606,0.5678 T0.3699,0.5666 T0.3759,0.5686 T0.3839,0.5718 T0.3908,0.573 T0.3964,0.5734 T0.4048,0.5742 T0.4165,0.5751 T0.4277,0.5757 T0.4369,0.5778 Q0.4426,0.5794,0.4538,0.5794 Q0.4586,0.5794,0.4679,0.5792 T0.4811,0.579 Q0.4924,0.579,0.4956,0.5807 Q0.502,0.584,0.5108,0.584 L0.5137,0.584 T0.5173,0.5838 L0.5205,0.5834 L0.5237,0.583 L0.5269,0.5825 T0.5305,0.5821 T0.5341,0.5819 L0.5365,0.5819 Q0.5414,0.5819,0.5562,0.5842 T0.5855,0.5865 Q0.5912,0.5865,0.5948,0.5867 T0.6012,0.5869 T0.6064,0.5873 T0.61,0.5877 T0.6133,0.5886 T0.6157,0.5896 L0.6185,0.591 T0.6217,0.5931 Q0.6265,0.5952,0.6369,0.5952 L0.6418,0.5952 L0.6466,0.5952 Q0.6723,0.5952,0.6932,0.606 Q0.698,0.608,0.7044,0.608 Q0.7084,0.608,0.7161,0.6068 T0.7261,0.6056 Q0.7309,0.6056,0.7454,0.6051 T0.7647,0.6047 Q0.7775,0.6047,0.8072,0.6101 Q0.8096,0.6105,0.812,0.6105 Q0.8177,0.6105,0.8297,0.6085 T0.8474,0.606 L0.8506,0.606 Q0.8562,0.606,0.8647,0.607 T0.8831,0.6095 T0.8964,0.6118 Q0.9036,0.6122,0.9221,0.6122 L0.9406,0.6122 L0.959,0.6122 L0.9767,0.6122 Q0.9952,0.613,0.9952,0.6172 Q0.9952,0.6176,0.9944,0.6221 Q0.9936,0.6259,0.9574,0.6259 L0.955,0.6259 L0.9526,0.6259 Q0.9478,0.6259,0.9373,0.628 T0.9205,0.63 L0.9173,0.63 Q0.9133,0.6296,0.9084,0.6296 Q0.9004,0.6296,0.8715,0.6321 Q0.8233,0.6367,0.7944,0.64 Q0.7888,0.6408,0.7839,0.6408 T0.7739,0.6402 T0.7671,0.6396 Q0.7639,0.6396,0.7622,0.64 Q0.7614,0.6404,0.759,0.6404 Q0.7574,0.6404,0.749,0.6398 T0.7357,0.6392 Q0.7261,0.6392,0.7213,0.6412 Q0.7173,0.6425,0.7032,0.6437 T0.6711,0.6458 T0.6474,0.647 Q0.645,0.6474,0.6418,0.6474 Q0.6353,0.6474,0.6277,0.6454 T0.6161,0.6433 Q0.6104,0.6433,0.6072,0.6456 T0.6,0.6479 Q0.5912,0.6479,0.5486,0.6557 Q0.5422,0.657,0.5373,0.657 Q0.5349,0.657,0.5289,0.6564 T0.5173,0.6557 L0.5124,0.6557 Q0.4996,0.6562,0.4739,0.6611 Q0.4675,0.6624,0.4398,0.6645 T0.3851,0.668 T0.3558,0.6698 Q0.355,0.6698,0.3285,0.6721 T0.2948,0.6752 Q0.2892,0.6761,0.2843,0.6786 T0.2747,0.6831 T0.2618,0.6852 Q0.253,0.6852,0.2394,0.6893 T0.2141,0.6993 T0.1968,0.7105 Q0.1944,0.713,0.1944,0.7155 Q0.1944,0.7196,0.2012,0.7229 T0.2177,0.7285 T0.2281,0.7308 Q0.2321,0.7329,0.2365,0.7339 T0.245,0.7356 T0.2514,0.7366 Q0.2522,0.737,0.2554,0.7379 T0.2614,0.7393 T0.2679,0.7406 T0.2755,0.7416 T0.2835,0.742 Q0.29,0.742,0.3048,0.7428 T0.3253,0.7437 L0.3301,0.7437 L0.3333,0.7437 Q0.3341,0.7437,0.3598,0.7466 Q0.3639,0.7474,0.3671,0.7474 T0.3759,0.7466 T0.3855,0.7457 Q0.388,0.7457,0.3904,0.7462 Q0.3984,0.7466,0.4137,0.7495 T0.4349,0.7532 T0.441,0.7557 Q0.441,0.7599,0.4289,0.7619 Q0.4257,0.7628,0.4197,0.7634 T0.4072,0.7644 T0.3984,0.7652 Q0.3968,0.7652,0.3912,0.7655 T0.3811,0.7665 T0.3719,0.7694 Q0.3695,0.7702,0.3655,0.7702 T0.3546,0.7686 T0.3462,0.7665 L0.3446,0.7665 Q0.3406,0.7665,0.3293,0.769 T0.3149,0.7715 Q0.3084,0.7715,0.2952,0.7748 T0.2763,0.781 Q0.2675,0.7847,0.2554,0.7864 Q0.253,0.7864,0.2325,0.7874 T0.2024,0.7893 Q0.2008,0.7893,0.1984,0.7897 T0.1948,0.7901 T0.1916,0.7903 T0.1888,0.7908 T0.1867,0.7914 T0.1847,0.7922 L0.1831,0.793 T0.1819,0.7943 L0.1811,0.7959 T0.1799,0.7976 Q0.1791,0.7993,0.1791,0.8001 Q0.1791,0.8034,0.1831,0.8051 T0.1932,0.8078 T0.2,0.8092 Q0.2072,0.8117,0.2257,0.8117 Q0.2337,0.8117,0.2586,0.8109 L0.2635,0.8109 Q0.2707,0.8109,0.2767,0.8117 T0.2859,0.8136 T0.2936,0.8163 T0.3012,0.8187 Q0.3068,0.82,0.3277,0.8206 T0.3526,0.8237 Q0.3582,0.8266,0.3622,0.8266 Q0.3647,0.8266,0.3687,0.8258 T0.3751,0.825 Q0.396,0.825,0.404,0.8291 Q0.408,0.8316,0.4161,0.8316 Q0.4217,0.8316,0.4353,0.8295 L0.4394,0.8295 Q0.4466,0.8295,0.4659,0.8366 Q0.4699,0.8378,0.4747,0.8378 Q0.4763,0.8378,0.4807,0.8374 T0.4884,0.837 Q0.494,0.837,0.5004,0.8382 Q0.5052,0.8391,0.5124,0.8399 Q0.5221,0.8411,0.5249,0.8422 T0.5277,0.8461 Q0.5277,0.8482,0.5233,0.849 T0.5112,0.8501 T0.4996,0.8511 Q0.4964,0.8519,0.4851,0.8523 T0.4659,0.8544 Q0.4578,0.8557,0.4402,0.8557 L0.4349,0.8557 L0.4297,0.8557 L0.4209,0.8557 Q0.408,0.8565,0.3867,0.8571 T0.3614,0.8582 Q0.3317,0.8611,0.3237,0.8611 L0.3225,0.8611 L0.3213,0.8611 Q0.2972,0.8611,0.2972,0.8681 Q0.2972,0.8714,0.3008,0.8747 T0.3044,0.8805 Q0.3044,0.881,0.3036,0.8814 Q0.3012,0.8839,0.2851,0.8839 Q0.2779,0.8839,0.2739,0.883 Q0.2723,0.8826,0.2699,0.8826 T0.2643,0.8835 T0.259,0.8853 T0.2542,0.8876 T0.249,0.8893 Q0.2386,0.8922,0.2056,0.8946 Q0.2016,0.8951,0.1916,0.898 T0.1703,0.9009 Q0.1518,0.9009,0.1337,0.9044 T0.1149,0.9146 L0.1149,0.9158 Q0.1149,0.9175,0.1157,0.9187 T0.1173,0.921 T0.1201,0.9226 T0.1233,0.9237 T0.1269,0.9245 L0.1305,0.9251 T0.1337,0.9255 L0.1357,0.9258 Q0.139,0.9262,0.1446,0.9262 T0.1542,0.9266 T0.1598,0.9282 Q0.1614,0.9303,0.1647,0.9314 T0.1739,0.9328 T0.1815,0.9336 Q0.1863,0.9345,0.1976,0.9372 T0.2145,0.9415 Q0.2225,0.9432,0.2285,0.9467 T0.2378,0.9515 Q0.2426,0.9531,0.2514,0.9531 Q0.2538,0.9531,0.2602,0.9529 T0.2707,0.9527 Q0.2763,0.9527,0.2815,0.9531 T0.2867,0.9569 T0.2811,0.9614 Q0.2434,0.9627,0.2329,0.9652 Q0.2249,0.9672,0.2241,0.9672 L0.2096,0.9668 L0.2056,0.9668 Q0.2,0.9668,0.196,0.9672 T0.1884,0.9687 T0.1815,0.9708 T0.1731,0.9737 T0.1639,0.9772 Q0.1518,0.9805,0.1285,0.9805 L0.1253,0.9805 Q0.1124,0.9805,0.1076,0.9818 Q0.1012,0.983,0.0831,0.9876 T0.0594,0.9934 Q0.049,0.9954,0.0064,0.9954 Z M0.0032,0.9979 L0.0024,0.0037 Q0.0161,0.0021,0.0289,0.0062 Q0.0337,0.0075,0.0671,0.0066 T0.1116,0.01 T0.1337,0.0166 T0.151,0.022 Q0.1582,0.0249,0.1659,0.0251 T0.1767,0.0265 Q0.1807,0.0278,0.2,0.0292 T0.2265,0.0328 Q0.2305,0.034,0.2329,0.034 T0.239,0.0326 T0.2458,0.0311 T0.253,0.0319 Q0.2578,0.0332,0.2614,0.0342 T0.2667,0.0363 T0.2723,0.0384 T0.2827,0.0406 Q0.3084,0.0448,0.3141,0.0485 Q0.3149,0.0494,0.3225,0.0494 T0.3361,0.0504 T0.3422,0.0554 T0.3357,0.0606 T0.3205,0.0618 T0.31,0.0626 Q0.3092,0.0626,0.3056,0.0659 T0.2932,0.0701 Q0.2884,0.0709,0.2843,0.0709 Q0.2811,0.0709,0.2759,0.0701 T0.2667,0.0693 Q0.2586,0.0693,0.2422,0.0718 T0.2193,0.0763 Q0.2137,0.0784,0.1831,0.079 T0.1502,0.0805 Q0.1494,0.0809,0.147,0.0809 Q0.1454,0.0809,0.1386,0.0803 T0.1277,0.0796 Q0.1173,0.0796,0.1092,0.0821 Q0.1036,0.0834,0.1036,0.0854 Q0.1036,0.0879,0.1129,0.0908 T0.1269,0.0937 Q0.1293,0.0937,0.1558,0.0991 T0.1863,0.1045 Q0.2016,0.1045,0.2193,0.1087 Q0.2225,0.1091,0.2249,0.1091 Q0.2289,0.1091,0.2357,0.1076 T0.2482,0.1054 Q0.2506,0.1049,0.2546,0.1049 Q0.2699,0.1049,0.2924,0.1085 T0.3213,0.1165 Q0.3253,0.119,0.3301,0.119 Q0.3325,0.119,0.3446,0.1161 T0.3687,0.1124 Q0.3831,0.1112,0.3863,0.1112 T0.3908,0.1118 T0.3936,0.1145 T0.3984,0.1186 Q0.4048,0.1228,0.408,0.1228 Q0.4096,0.1228,0.4161,0.1213 T0.4305,0.1195 L0.4337,0.1195 Q0.4426,0.1195,0.4474,0.1211 T0.4566,0.1246 T0.4667,0.1273 Q0.4876,0.1298,0.5116,0.1298 Q0.5365,0.1298,0.5631,0.1389 Q0.5703,0.1414,0.5751,0.1414 Q0.5783,0.1414,0.5819,0.1398 T0.5888,0.1381 Q0.5928,0.1381,0.5984,0.1398 T0.6129,0.1414 Q0.6321,0.1414,0.6498,0.1477 Q0.661,0.1514,0.6667,0.1514 Q0.6707,0.1514,0.6771,0.1501 Q0.6787,0.1497,0.6795,0.1497 Q0.6835,0.1497,0.6863,0.1526 T0.6892,0.1593 Q0.6892,0.1626,0.6876,0.1647 Q0.6851,0.1672,0.6799,0.1686 T0.6695,0.1705 T0.6566,0.1715 T0.6458,0.1725 Q0.6434,0.173,0.6402,0.173 Q0.6378,0.173,0.6317,0.1727 T0.6225,0.1725 Q0.6096,0.1725,0.5984,0.1759 Q0.5823,0.1804,0.5454,0.1804 Q0.5205,0.1804,0.5116,0.1788 L0.5084,0.1788 Q0.5036,0.1788,0.4956,0.1819 T0.4843,0.185 Q0.4434,0.1887,0.4353,0.1929 Q0.4321,0.1945,0.4289,0.1945 T0.4197,0.1922 T0.4112,0.19 T0.4072,0.1912 Q0.4032,0.1949,0.388,0.1949 Q0.3663,0.1949,0.3542,0.19 Q0.351,0.1883,0.3462,0.1883 Q0.3406,0.1883,0.3321,0.1906 T0.3153,0.1954 T0.302,0.1983 Q0.2972,0.1991,0.2859,0.1997 T0.2643,0.201 T0.245,0.2024 T0.2337,0.2041 Q0.2321,0.2045,0.2181,0.2045 T0.1851,0.207 T0.1542,0.2157 Q0.143,0.2211,0.143,0.226 Q0.143,0.2314,0.153,0.2348 T0.1719,0.2381 Q0.1791,0.2381,0.2474,0.2476 Q0.2506,0.248,0.2558,0.2489 T0.2651,0.2505 T0.2739,0.252 T0.2819,0.2526 Q0.2835,0.2526,0.2851,0.2522 L0.2859,0.2522 Q0.2948,0.2522,0.3104,0.2574 T0.3309,0.2671 Q0.3333,0.2696,0.3365,0.2696 Q0.339,0.2696,0.3442,0.2684 T0.3526,0.2671 T0.3582,0.2679 Q0.3687,0.2717,0.3968,0.2742 Q0.4,0.2746,0.4036,0.2771 T0.4096,0.2796 Q0.4112,0.2796,0.4137,0.2787 Q0.4169,0.2779,0.4257,0.2779 Q0.4594,0.2779,0.4779,0.2849 Q0.4876,0.2891,0.4972,0.2891 Q0.4996,0.2891,0.506,0.2887 T0.5165,0.2883 Q0.5253,0.2883,0.5305,0.2889 T0.541,0.292 T0.5494,0.299 L0.5494,0.3015 Q0.5494,0.3036,0.5466,0.3051 T0.5386,0.3073 T0.5285,0.3086 T0.5181,0.3094 T0.5108,0.3102 Q0.5068,0.3107,0.494,0.3121 L0.4683,0.315 T0.4506,0.3181 Q0.449,0.3181,0.4426,0.32 T0.4313,0.3229 T0.4197,0.3256 T0.4064,0.3281 T0.3952,0.3289 Q0.3888,0.3289,0.3855,0.3273 Q0.3783,0.3243,0.3703,0.3243 Q0.3598,0.3243,0.351,0.3297 Q0.3478,0.3314,0.3422,0.3314 Q0.3398,0.3314,0.3337,0.3306 T0.3245,0.3297 T0.3189,0.3306 Q0.3149,0.3314,0.2956,0.3328 T0.2715,0.3368 Q0.2691,0.338,0.2635,0.3391 T0.2522,0.3407 T0.2406,0.3424 T0.2313,0.3447 Q0.2305,0.3447,0.2213,0.348 T0.206,0.354 T0.1924,0.3621 T0.1815,0.3733 Q0.1767,0.3816,0.1767,0.3849 Q0.1767,0.387,0.1783,0.3893 T0.1847,0.3961 T0.1936,0.4063 T0.2004,0.4181 T0.2052,0.4264 T0.2145,0.4309 T0.2386,0.4359 Q0.2458,0.4372,0.2554,0.4372 Q0.2578,0.4372,0.2627,0.437 T0.2699,0.4367 Q0.2795,0.4367,0.2851,0.4384 Q0.294,0.4409,0.3092,0.4409 Q0.3116,0.4409,0.3157,0.4407 T0.3213,0.4405 Q0.3301,0.4405,0.3466,0.444 T0.3687,0.4509 Q0.3703,0.4517,0.3703,0.4525 Q0.3703,0.4575,0.3373,0.4596 Q0.3133,0.4608,0.2988,0.4658 Q0.294,0.4674,0.2803,0.4674 Q0.2763,0.4674,0.2687,0.4672 T0.257,0.467 Q0.2402,0.467,0.2313,0.4687 Q0.2209,0.4708,0.2209,0.4737 Q0.2209,0.4741,0.2213,0.4749 T0.2229,0.4764 L0.2249,0.4774 T0.2265,0.4778 L0.2273,0.4782 Q0.2498,0.4803,0.2618,0.4803 Q0.2667,0.4803,0.2691,0.4799 Q0.2699,0.4795,0.2715,0.4795 Q0.2779,0.4795,0.3133,0.4873 Q0.3181,0.4886,0.3233,0.4892 T0.3386,0.4905 T0.3542,0.4915 T0.3655,0.4919 Q0.3703,0.4919,0.3779,0.4915 T0.3888,0.4911 T0.3952,0.4915 Q0.3976,0.4919,0.4008,0.4927 L0.4072,0.4944 T0.4149,0.4961 T0.4249,0.4975 T0.4369,0.4985 Q0.449,0.499,0.4554,0.5 T0.4651,0.5031 T0.4699,0.5085 Q0.4707,0.5093,0.4707,0.5097 Q0.4707,0.5114,0.4687,0.5124 T0.4631,0.5139 T0.4562,0.5145 T0.4478,0.5147 L0.4402,0.5147 Q0.4353,0.5151,0.4265,0.5174 T0.4096,0.5197 L0.4024,0.5197 Q0.4,0.5193,0.3968,0.5193 Q0.3896,0.5193,0.3831,0.5209 T0.3703,0.5253 T0.3598,0.5288 Q0.3558,0.5301,0.3321,0.5342 T0.302,0.5409 Q0.2876,0.5454,0.2337,0.55 Q0.2112,0.5521,0.2112,0.5558 Q0.2112,0.5574,0.2161,0.5612 Q0.2201,0.5637,0.2281,0.5637 Q0.2305,0.5637,0.2361,0.5633 T0.2442,0.5628 Q0.2474,0.5628,0.2498,0.5633 Q0.2635,0.5653,0.3133,0.5653 Q0.3261,0.5653,0.3301,0.5649 L0.3325,0.5649 Q0.3349,0.5649,0.3402,0.5657 T0.3494,0.5666 Q0.3542,0.5666,0.3598,0.5657 Q0.3663,0.5645,0.3695,0.5645 Q0.3735,0.5645,0.3775,0.5666 T0.3847,0.5695 Q0.3888,0.5707,0.3912,0.5709 T0.3968,0.5711 T0.4048,0.572 Q0.4096,0.5724,0.4165,0.5726 T0.4281,0.5734 T0.4378,0.5757 Q0.4418,0.5774,0.4522,0.5774 Q0.4562,0.5774,0.4655,0.5769 T0.4795,0.5765 Q0.4932,0.5765,0.4972,0.579 Q0.5028,0.5819,0.51,0.5819 Q0.5141,0.5819,0.5221,0.5809 T0.5341,0.5798 L0.5373,0.5798 Q0.5422,0.5798,0.557,0.5821 T0.5855,0.5844 L0.5936,0.5844 L0.5992,0.5844 T0.604,0.5848 T0.6076,0.5852 T0.6108,0.5859 T0.6137,0.5865 T0.6161,0.5873 T0.6181,0.5881 T0.6209,0.5896 L0.6233,0.591 Q0.6265,0.5927,0.6353,0.5927 L0.6402,0.5927 L0.645,0.5927 Q0.6723,0.5927,0.6948,0.6039 Q0.6988,0.606,0.7036,0.606 Q0.7076,0.606,0.7153,0.6045 T0.7261,0.6031 Q0.7309,0.6031,0.7454,0.6027 T0.7647,0.6022 Q0.7783,0.6022,0.808,0.608 L0.812,0.608 Q0.8169,0.608,0.8293,0.6062 T0.8474,0.6039 L0.8506,0.6039 Q0.857,0.6039,0.8655,0.6049 T0.8835,0.6074 T0.8972,0.6093 Q0.9036,0.6101,0.9213,0.6101 L0.9398,0.6101 L0.959,0.6101 L0.9767,0.6101 Q0.9976,0.6109,0.9976,0.6176 Q0.9976,0.6188,0.9968,0.6226 Q0.9952,0.6284,0.9671,0.6284 L0.959,0.6284 L0.9526,0.6284 Q0.9486,0.6284,0.9382,0.6302 T0.9205,0.6321 L0.9173,0.6321 Q0.9133,0.6317,0.9092,0.6317 Q0.9012,0.6317,0.8715,0.6346 Q0.8233,0.6387,0.7944,0.6421 Q0.7888,0.6429,0.7831,0.6429 Q0.7783,0.6429,0.7731,0.6425 T0.7663,0.6421 L0.7622,0.6421 Q0.7614,0.6425,0.7586,0.6425 T0.7474,0.6421 T0.7349,0.6416 Q0.7261,0.6416,0.7221,0.6429 Q0.7173,0.645,0.7032,0.646 T0.6711,0.6479 T0.6482,0.6491 Q0.645,0.6495,0.6418,0.6495 Q0.6353,0.6495,0.6273,0.6474 T0.6161,0.6454 Q0.612,0.6454,0.6088,0.6477 T0.6,0.6499 Q0.5912,0.6499,0.5494,0.6578 Q0.5422,0.6591,0.5373,0.6591 Q0.5341,0.6591,0.5281,0.6584 T0.5173,0.6578 L0.5124,0.6578 Q0.5004,0.6582,0.4747,0.6632 Q0.4675,0.6649,0.4398,0.6667 T0.3851,0.6703 T0.3566,0.6719 Q0.3534,0.6723,0.3406,0.6734 T0.3141,0.6757 T0.2948,0.6773 Q0.2908,0.6781,0.2819,0.6829 T0.2618,0.6877 Q0.2482,0.6877,0.2273,0.6958 T0.1992,0.7117 Q0.1976,0.7138,0.1976,0.7155 Q0.1976,0.7188,0.2032,0.7213 T0.2181,0.7258 T0.2289,0.7292 Q0.2337,0.7308,0.2378,0.7316 L0.2458,0.7333 T0.2522,0.7345 Q0.2747,0.7399,0.2835,0.7399 Q0.29,0.7399,0.3048,0.7408 T0.3253,0.7416 Q0.3277,0.7416,0.3297,0.7414 T0.3341,0.7412 L0.3598,0.7445 Q0.3639,0.7449,0.3663,0.7449 Q0.3695,0.7449,0.3751,0.7443 T0.3855,0.7437 L0.3904,0.7437 Q0.3984,0.7441,0.4141,0.7472 T0.4353,0.7507 Q0.4434,0.752,0.4434,0.7553 Q0.4434,0.7578,0.439,0.7605 T0.4297,0.764 Q0.4257,0.7648,0.4201,0.7655 T0.4076,0.7665 T0.3984,0.7673 Q0.3968,0.7673,0.3912,0.7677 T0.3815,0.7688 T0.3735,0.771 T0.3655,0.7727 Q0.3606,0.7727,0.3534,0.7708 T0.3458,0.769 L0.3446,0.769 Q0.3414,0.769,0.3297,0.7713 T0.3149,0.7735 Q0.3092,0.7735,0.296,0.7771 T0.2771,0.7831 Q0.2683,0.7872,0.2554,0.7885 Q0.253,0.7889,0.2325,0.7897 T0.2032,0.7914 Q0.2008,0.7918,0.198,0.792 T0.194,0.7924 T0.1912,0.7928 T0.1888,0.793 T0.1867,0.7934 T0.1851,0.7943 T0.1843,0.7953 T0.1835,0.7968 T0.1823,0.7984 T0.1815,0.8001 Q0.1815,0.8022,0.1847,0.8032 T0.1932,0.8053 T0.2008,0.8071 Q0.2072,0.8092,0.2265,0.8092 Q0.2361,0.8092,0.2586,0.8088 L0.2635,0.8088 Q0.2731,0.8088,0.2795,0.8098 T0.2928,0.8134 T0.302,0.8167 Q0.306,0.8179,0.3277,0.8183 T0.3542,0.8221 Q0.359,0.8246,0.3622,0.8246 Q0.3639,0.8246,0.3675,0.8237 T0.3751,0.8229 Q0.3968,0.8229,0.4048,0.8275 Q0.4088,0.8291,0.4161,0.8291 Q0.4225,0.8291,0.4353,0.8275 Q0.4369,0.827,0.4394,0.827 Q0.4474,0.827,0.4675,0.8345 Q0.4707,0.8358,0.4739,0.8358 Q0.4755,0.8358,0.4799,0.8353 T0.4876,0.8349 Q0.494,0.8349,0.5012,0.8362 L0.5076,0.837 T0.5133,0.8376 T0.5181,0.8382 T0.5221,0.8391 L0.5253,0.8399 T0.5277,0.8409 L0.5293,0.8422 T0.5301,0.8438 L0.5301,0.8461 Q0.5301,0.8499,0.5249,0.8511 T0.5112,0.8526 T0.5004,0.8532 Q0.4964,0.854,0.4851,0.8546 T0.4667,0.8565 Q0.4578,0.8577,0.441,0.8577 L0.4345,0.8577 L0.4289,0.8577 L0.4209,0.8577 Q0.4153,0.8582,0.4088,0.8586 T0.3968,0.8592 T0.3855,0.8594 T0.3755,0.8596 T0.3675,0.86 T0.3622,0.8602 Q0.3317,0.8635,0.3237,0.8635 Q0.2996,0.8635,0.2996,0.8681 Q0.2996,0.8698,0.3032,0.8733 T0.3068,0.8797 L0.3068,0.8822 Q0.3052,0.8859,0.2859,0.8859 Q0.2787,0.8859,0.2739,0.8851 L0.2699,0.8851 Q0.2659,0.8851,0.259,0.888 T0.2498,0.8913 Q0.2394,0.8946,0.2056,0.8971 Q0.2032,0.8971,0.1928,0.9002 T0.1703,0.9034 Q0.1526,0.9034,0.1353,0.9067 T0.1173,0.915 L0.1173,0.9158 Q0.1173,0.9166,0.1177,0.9175 T0.1189,0.9191 T0.1205,0.9204 T0.1229,0.9212 T0.1257,0.922 T0.1285,0.9226 T0.1309,0.9231 T0.1337,0.9235 T0.1365,0.9237 L0.1446,0.9237 T0.1554,0.9243 T0.1614,0.9268 T0.1659,0.9293 T0.1743,0.9303 T0.1823,0.9316 Q0.1863,0.932,0.1912,0.9332 T0.2028,0.9361 T0.2153,0.939 Q0.2233,0.9411,0.2297,0.9448 T0.2386,0.9494 Q0.2426,0.9506,0.2506,0.9506 Q0.253,0.9506,0.2594,0.9504 T0.2699,0.9502 Q0.2763,0.9502,0.2819,0.9511 Q0.2851,0.9511,0.2871,0.9529 T0.2892,0.9573 Q0.2892,0.9627,0.2811,0.9635 Q0.2434,0.9652,0.2337,0.9672 Q0.2257,0.9693,0.2241,0.9693 Q0.2217,0.9693,0.2096,0.9689 L0.2056,0.9689 Q0.1976,0.9689,0.1924,0.9699 T0.1787,0.9741 L0.1647,0.9793 Q0.1518,0.983,0.1285,0.983 L0.1261,0.983 L0.1237,0.983 Q0.1124,0.983,0.1076,0.9838 Q0.102,0.9851,0.0839,0.9896 T0.0602,0.9954 T0.0406,0.9971 T0.0153,0.9979 L0.0032,0.9979 Z",type:0}},"":{type:5,data:{d:"M0.9992,0.998 Q0.9992,1,0.9968,1 Q0.9521,1,0.9392,0.998 Q0.9335,0.997,0.9152,0.9921 T0.8913,0.9862 Q0.8873,0.9852,0.8783,0.9852 L0.8747,0.9852 L0.871,0.9852 Q0.8483,0.9852,0.8345,0.9812 Q0.8289,0.9792,0.8228,0.9773 T0.8135,0.9743 T0.8054,0.9723 T0.7948,0.9713 L0.7908,0.9713 L0.777,0.9713 T0.7656,0.9693 Q0.7616,0.9683,0.7498,0.9674 T0.7283,0.9664 L0.7186,0.9654 Q0.708,0.9644,0.708,0.9575 Q0.708,0.9545,0.7109,0.952 T0.7178,0.9486 L0.7315,0.9486 L0.7417,0.9486 L0.7502,0.9486 Q0.7575,0.9486,0.7599,0.9476 Q0.7616,0.9466,0.7685,0.9426 T0.7843,0.9377 Q0.7899,0.9357,0.8009,0.9332 T0.8175,0.9298 Q0.82,0.9288,0.8228,0.9283 T0.8277,0.9278 T0.8313,0.9273 T0.8341,0.9263 T0.8362,0.9258 Q0.8402,0.9219,0.854,0.9219 L0.8569,0.9219 L0.8597,0.9219 L0.8637,0.9219 Q0.88,0.9199,0.88,0.9159 L0.88,0.9149 Q0.88,0.913,0.8719,0.911 T0.852,0.9075 T0.8297,0.906 Q0.8224,0.906,0.8167,0.905 T0.8074,0.9031 T0.8001,0.9006 T0.794,0.8991 Q0.7599,0.8971,0.7494,0.8942 Q0.7461,0.8932,0.7397,0.8902 T0.7299,0.8872 L0.7267,0.8872 Q0.721,0.8882,0.7121,0.8882 Q0.6926,0.8882,0.691,0.8833 L0.691,0.8803 Q0.691,0.8773,0.6922,0.8749 T0.6955,0.8704 L0.6975,0.8684 Q0.6926,0.8655,0.6764,0.8655 Q0.6683,0.8655,0.6375,0.8625 Q0.6358,0.8625,0.629,0.862 T0.6148,0.8615 T0.5973,0.861 T0.5791,0.8605 L0.5718,0.8605 L0.5661,0.8605 L0.5596,0.8605 Q0.5426,0.8605,0.5337,0.8586 Q0.5264,0.8576,0.515,0.8571 T0.4996,0.8556 Q0.498,0.8556,0.4947,0.8551 T0.4882,0.8546 T0.4809,0.8541 T0.474,0.8531 T0.4692,0.8506 T0.4672,0.8467 Q0.4672,0.8437,0.468,0.8422 T0.4704,0.8398 T0.4736,0.8383 T0.4781,0.8368 T0.4838,0.8358 T0.4911,0.8353 T0.4988,0.8338 Q0.5069,0.8328,0.5142,0.8328 Q0.5174,0.8328,0.5215,0.8333 T0.5264,0.8338 Q0.5296,0.8338,0.532,0.8328 Q0.5523,0.8249,0.5604,0.8249 L0.5653,0.8249 Q0.5775,0.8269,0.5848,0.8269 Q0.5912,0.8269,0.5937,0.8259 Q0.6026,0.821,0.6253,0.821 Q0.6294,0.821,0.6334,0.822 T0.6383,0.8229 Q0.6399,0.8229,0.644,0.82 Q0.6504,0.817,0.6723,0.816 T0.6975,0.814 Q0.6999,0.814,0.7048,0.8121 T0.7129,0.8091 T0.7226,0.8071 T0.7372,0.8061 L0.7421,0.8061 Q0.7624,0.8071,0.7721,0.8071 Q0.7924,0.8071,0.7981,0.8051 Q0.8021,0.8032,0.807,0.8027 T0.8135,0.8017 T0.8151,0.8002 L0.8151,0.7992 Q0.8143,0.7962,0.8127,0.7957 T0.8045,0.7943 Q0.7997,0.7943,0.7964,0.7933 Q0.7883,0.7933,0.7676,0.7923 T0.7437,0.7903 Q0.7307,0.7893,0.7218,0.7844 Q0.7194,0.7834,0.7141,0.7819 L0.7036,0.7789 T0.693,0.7765 T0.6853,0.7755 Q0.6805,0.7755,0.6691,0.773 T0.6553,0.7705 Q0.6545,0.7705,0.6545,0.7715 Q0.6537,0.7715,0.65,0.7725 T0.6423,0.774 T0.635,0.7745 Q0.6294,0.7745,0.6257,0.773 T0.618,0.771 T0.6083,0.77 T0.601,0.7695 Q0.5985,0.7695,0.5921,0.769 T0.5795,0.7676 T0.5702,0.7666 Q0.5653,0.7656,0.5596,0.7616 T0.5539,0.7547 Q0.5539,0.7498,0.5645,0.7488 Q0.5693,0.7478,0.5852,0.7448 T0.6099,0.7418 L0.6148,0.7418 Q0.6196,0.7418,0.6253,0.7423 T0.6338,0.7428 L0.6399,0.7428 Q0.6431,0.7418,0.6488,0.7409 T0.6586,0.7394 T0.6659,0.7389 L0.6707,0.7389 Q0.6723,0.7399,0.6748,0.7399 Q0.6796,0.7399,0.6946,0.7389 T0.7161,0.7379 Q0.7251,0.7379,0.7478,0.7319 Q0.7494,0.7319,0.7539,0.7315 T0.762,0.73 T0.7697,0.727 T0.7818,0.723 T0.7948,0.7196 T0.7997,0.7151 Q0.7997,0.7141,0.7989,0.7132 Q0.7924,0.7062,0.7717,0.6978 T0.738,0.6894 Q0.7267,0.6894,0.717,0.6845 T0.7048,0.6795 Q0.6975,0.6785,0.6711,0.6766 T0.6431,0.6746 Q0.6415,0.6736,0.6144,0.6721 T0.5596,0.6686 T0.5247,0.6657 Q0.4996,0.6607,0.4874,0.6597 L0.4834,0.6597 Q0.4785,0.6597,0.4724,0.6607 T0.4631,0.6617 Q0.4574,0.6617,0.4501,0.6597 Q0.4088,0.6518,0.4006,0.6518 Q0.3933,0.6518,0.3901,0.6499 T0.3844,0.6479 T0.3739,0.6499 T0.3585,0.6518 L0.352,0.6518 Q0.3471,0.6508,0.335,0.6503 T0.3122,0.6494 T0.2912,0.6479 T0.2766,0.6449 Q0.2733,0.6439,0.2668,0.6439 Q0.2628,0.6439,0.2543,0.6444 T0.2425,0.6449 T0.2368,0.6439 L0.2344,0.6439 Q0.2336,0.6439,0.2283,0.6444 T0.2182,0.6449 Q0.2117,0.6449,0.2052,0.6439 Q0.1768,0.6409,0.1281,0.637 Q0.0989,0.634,0.0916,0.634 L0.0835,0.634 L0.0795,0.634 Q0.0722,0.634,0.0616,0.632 T0.0479,0.6301 Q0.0462,0.6301,0.0406,0.6306 T0.0308,0.6311 Q0.0032,0.6311,0.0008,0.6231 Q0,0.6202,0,0.6182 Q0,0.6083,0.0227,0.6083 Q0.03,0.6073,0.0422,0.6073 Q0.0487,0.6073,0.0608,0.6078 T0.0787,0.6083 Q0.0973,0.6083,0.103,0.6073 Q0.1062,0.6063,0.1131,0.6053 T0.1253,0.6039 T0.1375,0.6024 T0.1492,0.6014 L0.1533,0.6014 Q0.1598,0.6024,0.1723,0.6044 T0.189,0.6063 Q0.1906,0.6063,0.1922,0.6053 Q0.2214,0.6004,0.236,0.6004 Q0.2409,0.6004,0.2551,0.6009 T0.2741,0.6014 Q0.2782,0.6014,0.2859,0.6024 T0.2968,0.6034 Q0.3009,0.6034,0.3041,0.6024 Q0.3277,0.5905,0.356,0.5905 L0.3613,0.5905 L0.3666,0.5905 Q0.3731,0.5905,0.3755,0.5895 Q0.382,0.5856,0.3844,0.5846 T0.3938,0.5831 T0.4144,0.5826 T0.4428,0.5801 T0.4631,0.5776 L0.4663,0.5776 Q0.4704,0.5776,0.4785,0.5786 T0.4899,0.5796 Q0.4964,0.5796,0.5012,0.5767 Q0.5069,0.5737,0.5223,0.5737 Q0.5272,0.5737,0.5369,0.5742 T0.5499,0.5747 Q0.558,0.5747,0.5612,0.5737 Q0.5661,0.5717,0.5714,0.5712 T0.5835,0.5702 T0.5945,0.5692 T0.6018,0.5687 L0.6062,0.5687 T0.6095,0.5682 T0.6148,0.5678 Q0.6172,0.5668,0.6196,0.5653 T0.6245,0.5633 T0.631,0.5628 T0.6407,0.5638 T0.6504,0.5648 T0.6594,0.5638 T0.6675,0.5628 L0.6707,0.5628 L0.6861,0.5628 Q0.7364,0.5628,0.7494,0.5608 L0.7567,0.5608 L0.7652,0.5608 L0.7729,0.5608 Q0.7794,0.5608,0.7818,0.5589 Q0.7851,0.5569,0.7859,0.5559 Q0.7835,0.5539,0.7656,0.5519 Q0.7121,0.547,0.6975,0.543 Q0.6934,0.541,0.6853,0.5396 L0.6691,0.5366 T0.6525,0.5336 T0.6391,0.5312 Q0.6367,0.5302,0.6245,0.5257 T0.6034,0.5213 L0.5977,0.5213 Q0.5945,0.5223,0.5904,0.5223 Q0.5839,0.5223,0.5787,0.5208 T0.5685,0.5183 T0.5596,0.5173 Q0.558,0.5173,0.5552,0.5168 T0.5495,0.5163 L0.5434,0.5163 T0.5373,0.5158 T0.532,0.5148 T0.5284,0.5129 T0.5272,0.5104 L0.5272,0.5074 Q0.5304,0.5015,0.5377,0.499 T0.5629,0.4965 Q0.5734,0.4955,0.5811,0.4946 T0.5953,0.4916 T0.605,0.4896 Q0.6083,0.4886,0.6123,0.4886 Q0.6148,0.4886,0.6225,0.4891 T0.635,0.4896 L0.6456,0.4896 Q0.6837,0.4857,0.6861,0.4857 Q0.7226,0.4768,0.7283,0.4768 Q0.7307,0.4768,0.732,0.4773 T0.7372,0.4777 Q0.7502,0.4777,0.7721,0.4758 Q0.7753,0.4748,0.777,0.4738 Q0.7745,0.4718,0.768,0.4708 Q0.7599,0.4688,0.7445,0.4688 Q0.7405,0.4688,0.7324,0.4693 T0.721,0.4698 Q0.7056,0.4698,0.6999,0.4679 Q0.6861,0.4629,0.6626,0.4619 Q0.6586,0.4609,0.6533,0.4604 T0.6419,0.459 T0.6314,0.456 T0.6269,0.452 Q0.6269,0.451,0.6302,0.4491 Q0.6358,0.4451,0.6525,0.4416 T0.6788,0.4382 L0.6845,0.4382 L0.6902,0.4382 Q0.7064,0.4382,0.7145,0.4362 Q0.7202,0.4342,0.7307,0.4342 L0.7356,0.4342 L0.7409,0.4342 L0.7453,0.4342 Q0.7543,0.4342,0.7607,0.4332 Q0.7778,0.4313,0.7847,0.4288 T0.7932,0.4248 T0.7972,0.4169 T0.8045,0.4045 L0.8094,0.3986 T0.8135,0.3942 L0.8167,0.3912 T0.8191,0.3887 T0.8204,0.3867 T0.8208,0.3848 Q0.8208,0.3818,0.8167,0.3739 Q0.8127,0.3689,0.8054,0.3635 T0.7924,0.3556 T0.7778,0.3497 T0.768,0.3462 Q0.764,0.3442,0.7478,0.3427 T0.7267,0.3383 Q0.7234,0.3363,0.7044,0.3348 T0.6805,0.3323 L0.6764,0.3323 T0.6679,0.3328 T0.6586,0.3333 Q0.6521,0.3333,0.648,0.3314 Q0.6391,0.3264,0.6294,0.3264 Q0.6229,0.3264,0.6156,0.3294 Q0.6115,0.3304,0.6042,0.3304 Q0.5977,0.3304,0.5888,0.3289 T0.5738,0.3264 T0.5584,0.3225 L0.5491,0.3195 Q0.5418,0.3185,0.5162,0.3155 T0.4895,0.3121 T0.4826,0.3116 T0.4716,0.3111 T0.4607,0.3096 T0.4513,0.3066 T0.4477,0.3017 Q0.4477,0.2997,0.4485,0.2977 Q0.4526,0.2898,0.4611,0.2878 T0.4842,0.2859 Q0.4882,0.2859,0.4943,0.2864 T0.5028,0.2868 Q0.5118,0.2868,0.5215,0.2829 Q0.5401,0.276,0.5734,0.276 Q0.5831,0.276,0.5872,0.277 L0.5904,0.277 T0.5949,0.2745 T0.6034,0.272 Q0.631,0.269,0.6407,0.2661 Q0.6448,0.2641,0.6488,0.2641 T0.6573,0.2656 T0.6634,0.2671 Q0.6659,0.2671,0.6675,0.2651 Q0.6723,0.2601,0.6886,0.2552 T0.7137,0.2502 L0.7153,0.2502 L0.7178,0.2502 Q0.7218,0.2502,0.7279,0.2493 T0.7417,0.2468 T0.7526,0.2453 Q0.8208,0.2354,0.8281,0.2354 Q0.8337,0.2354,0.8394,0.2344 T0.8496,0.2315 T0.854,0.2265 Q0.854,0.2216,0.8443,0.2176 Q0.8354,0.2127,0.8216,0.2102 T0.7972,0.2072 T0.777,0.2067 T0.7648,0.2057 Q0.7632,0.2047,0.7498,0.2038 T0.7202,0.2018 T0.6983,0.1998 Q0.6918,0.1998,0.676,0.1949 T0.6537,0.1899 Q0.6496,0.1899,0.6472,0.1919 Q0.6342,0.1968,0.6123,0.1968 Q0.5961,0.1968,0.5912,0.1929 Q0.5904,0.1919,0.5892,0.1919 T0.5823,0.1944 T0.5722,0.1968 T0.5637,0.1949 Q0.5556,0.1909,0.5158,0.1869 Q0.5109,0.1869,0.5032,0.1835 T0.4915,0.18 Q0.4907,0.18,0.4891,0.181 Q0.4793,0.182,0.455,0.182 Q0.4177,0.182,0.4015,0.1771 Q0.3901,0.1741,0.3779,0.1741 Q0.3747,0.1741,0.369,0.1746 T0.3609,0.1751 Q0.3569,0.1751,0.3536,0.1741 Q0.3512,0.1741,0.3459,0.1736 T0.3358,0.1726 T0.3256,0.1716 T0.3167,0.1696 T0.3106,0.1662 Q0.3082,0.1632,0.3082,0.1592 Q0.3082,0.1543,0.3114,0.1508 T0.3204,0.1474 L0.3236,0.1474 Q0.3301,0.1494,0.3333,0.1494 Q0.339,0.1494,0.3496,0.1454 Q0.3674,0.1385,0.3869,0.1385 Q0.395,0.1385,0.4011,0.137 T0.4116,0.1355 T0.4197,0.1375 T0.425,0.1395 Q0.429,0.1395,0.4355,0.1365 Q0.4631,0.1266,0.4882,0.1266 Q0.5126,0.1266,0.5337,0.1246 Q0.5369,0.1246,0.5458,0.1207 T0.5661,0.1167 L0.5693,0.1167 Q0.5791,0.1177,0.5852,0.1187 T0.5921,0.1197 Q0.5945,0.1197,0.6002,0.1167 Q0.6026,0.1147,0.605,0.1123 T0.6091,0.1093 T0.6156,0.1088 T0.6318,0.1098 Q0.6456,0.1108,0.6569,0.1137 T0.6707,0.1167 Q0.674,0.1167,0.6772,0.1147 Q0.6821,0.1108,0.6946,0.1083 T0.721,0.1044 T0.7453,0.1029 L0.7526,0.1029 Q0.7583,0.1039,0.7652,0.1053 T0.7753,0.1068 Q0.7778,0.1068,0.7802,0.1058 Q0.7981,0.1019,0.8143,0.1019 Q0.8175,0.1019,0.8439,0.0964 T0.8735,0.091 Q0.8759,0.091,0.8836,0.089 T0.8938,0.0851 Q0.8929,0.0841,0.8905,0.0841 Q0.8832,0.0821,0.8735,0.0821 Q0.8694,0.0821,0.8629,0.0826 T0.854,0.0831 Q0.8508,0.0831,0.8491,0.0821 Q0.8475,0.0821,0.8171,0.0811 T0.7802,0.0781 T0.7571,0.0737 T0.7332,0.0712 Q0.7299,0.0712,0.7247,0.0717 T0.7161,0.0722 L0.7064,0.0722 Q0.7032,0.0712,0.7003,0.0707 T0.6959,0.0692 T0.693,0.0678 T0.6914,0.0663 T0.6902,0.0648 T0.689,0.0643 L0.6869,0.0643 L0.6845,0.0643 L0.6821,0.0643 Q0.6553,0.0643,0.6553,0.0554 T0.6796,0.0465 L0.6813,0.0465 L0.6845,0.0465 Q0.6918,0.0425,0.717,0.0376 Q0.7226,0.0366,0.7267,0.0356 T0.7324,0.0336 T0.7376,0.0317 T0.747,0.0297 Q0.7518,0.0287,0.7551,0.0287 T0.762,0.0297 T0.768,0.0307 L0.7729,0.0307 Q0.7802,0.0277,0.7997,0.0267 T0.8224,0.0237 Q0.8273,0.0227,0.8341,0.0223 T0.8475,0.0193 T0.8654,0.0143 T0.8873,0.0079 Q0.9002,0.003,0.9331,0.004 T0.97,0.004 Q0.9838,0,0.9976,0.001 Q1,0.001,1,0.003 Z M0.9935,0.996 L0.9951,0.0049 Q0.9822,0.0049,0.9716,0.0079 Q0.9659,0.0099,0.9327,0.0089 T0.8897,0.0119 Q0.8775,0.0158,0.8666,0.0183 T0.85,0.0237 Q0.8418,0.0267,0.8341,0.0272 T0.8248,0.0277 Q0.82,0.0297,0.8001,0.0312 T0.7745,0.0346 Q0.7697,0.0356,0.7664,0.0356 Q0.764,0.0356,0.7603,0.0341 T0.7534,0.0326 Q0.751,0.0326,0.7478,0.0336 Q0.7429,0.0346,0.7397,0.0356 T0.7344,0.0376 T0.7283,0.0401 T0.7178,0.0425 Q0.6926,0.0465,0.6878,0.0495 Q0.6845,0.0514,0.6764,0.0514 L0.6723,0.0514 L0.6691,0.0514 Q0.6602,0.0514,0.6602,0.0554 Q0.6602,0.0574,0.6659,0.0579 T0.6796,0.0589 T0.691,0.0603 T0.6967,0.0643 T0.7072,0.0673 Q0.7121,0.0682,0.7153,0.0682 T0.7238,0.0678 T0.7332,0.0673 Q0.7413,0.0673,0.7579,0.0692 T0.7818,0.0742 Q0.7867,0.0752,0.8171,0.0762 T0.8508,0.0781 L0.852,0.0781 T0.8601,0.0776 T0.8719,0.0772 Q0.8832,0.0772,0.8921,0.0791 Q0.8994,0.0811,0.8994,0.0841 T0.8933,0.0895 T0.8812,0.094 T0.8735,0.0959 T0.8451,0.1014 T0.8143,0.1068 Q0.7989,0.1068,0.7818,0.1098 Q0.7778,0.1108,0.7745,0.1108 T0.768,0.1103 T0.7603,0.1088 T0.7518,0.1068 L0.7461,0.1068 Q0.7315,0.1068,0.7088,0.1103 T0.6805,0.1177 Q0.6756,0.1207,0.6699,0.1207 Q0.6667,0.1207,0.6545,0.1177 T0.631,0.1147 Q0.6269,0.1137,0.6225,0.1133 T0.616,0.1128 L0.6123,0.1128 T0.6103,0.1133 L0.6083,0.1157 T0.6034,0.1197 Q0.5961,0.1246,0.5921,0.1246 Q0.5904,0.1246,0.5835,0.1231 T0.5693,0.1217 L0.5661,0.1217 Q0.558,0.1217,0.5535,0.1231 T0.5446,0.1266 T0.5345,0.1296 Q0.5126,0.1316,0.4882,0.1316 T0.438,0.1405 Q0.4298,0.1434,0.425,0.1434 Q0.4209,0.1434,0.4173,0.1419 T0.4112,0.1405 T0.4027,0.1419 T0.3869,0.1434 Q0.3682,0.1434,0.3512,0.1494 Q0.3398,0.1533,0.3333,0.1533 Q0.3285,0.1533,0.3228,0.1513 L0.3212,0.1513 Q0.3179,0.1513,0.3155,0.1538 T0.3131,0.1592 Q0.3131,0.1612,0.3147,0.1632 Q0.3171,0.1652,0.3232,0.1667 T0.3406,0.1691 T0.3544,0.1701 L0.3601,0.1701 L0.3678,0.1701 L0.3771,0.1701 Q0.3909,0.1701,0.4031,0.1731 Q0.4177,0.178,0.4558,0.178 Q0.4793,0.178,0.4882,0.1761 L0.4915,0.1761 Q0.498,0.1761,0.5061,0.179 T0.5158,0.183 Q0.5166,0.183,0.5231,0.1835 T0.5341,0.1845 T0.545,0.1855 T0.5572,0.1874 T0.5661,0.1909 Q0.5677,0.1919,0.5702,0.1919 T0.5791,0.1894 T0.588,0.1869 Q0.5921,0.1869,0.5945,0.1899 Q0.5977,0.1919,0.6123,0.1919 Q0.6334,0.1919,0.6448,0.1879 Q0.6488,0.186,0.6537,0.186 Q0.6626,0.186,0.6784,0.1909 T0.6983,0.1958 Q0.704,0.1968,0.7206,0.1973 T0.7506,0.1993 T0.7689,0.2018 L0.7705,0.2018 L0.7741,0.2018 L0.7786,0.2018 Q0.8248,0.2018,0.8475,0.2136 Q0.8597,0.2196,0.8597,0.2255 T0.8483,0.2359 T0.8281,0.2404 Q0.8208,0.2404,0.7534,0.2493 Q0.7502,0.2502,0.7425,0.2512 T0.7287,0.2532 T0.7178,0.2542 L0.7153,0.2542 L0.7137,0.2542 Q0.7064,0.2542,0.6906,0.2591 T0.6715,0.2681 T0.6626,0.272 Q0.6594,0.272,0.6541,0.2705 T0.6464,0.269 T0.6431,0.27 Q0.6318,0.273,0.6034,0.276 Q0.6018,0.276,0.5998,0.2774 T0.5957,0.2799 T0.5904,0.2809 L0.5856,0.2809 Q0.5823,0.2799,0.575,0.2799 Q0.541,0.2799,0.5239,0.2868 Q0.5134,0.2908,0.5028,0.2908 Q0.4996,0.2908,0.4935,0.2903 T0.4842,0.2898 Q0.4704,0.2898,0.4635,0.2918 T0.4534,0.2997 L0.4534,0.3007 Q0.4534,0.3037,0.459,0.3046 T0.4753,0.3066 T0.4899,0.3076 Q0.4907,0.3076,0.5166,0.3106 T0.5507,0.3155 Q0.5921,0.3264,0.605,0.3264 Q0.6107,0.3264,0.6131,0.3254 Q0.6212,0.3225,0.6294,0.3225 Q0.6407,0.3225,0.6513,0.3274 Q0.6529,0.3294,0.6569,0.3294 Q0.6594,0.3294,0.665,0.3284 T0.6748,0.3274 T0.6821,0.3284 Q0.6861,0.3294,0.6967,0.3299 T0.7165,0.3314 T0.7299,0.3343 Q0.7324,0.3363,0.7486,0.3383 T0.7697,0.3422 Q0.7713,0.3432,0.7774,0.3452 T0.7887,0.3492 T0.8005,0.3546 T0.8127,0.3625 T0.8208,0.3719 Q0.8256,0.3798,0.8256,0.3848 Q0.8256,0.3877,0.8236,0.3902 T0.8171,0.3976 T0.809,0.4075 T0.8021,0.4189 T0.7968,0.4273 T0.7867,0.4322 T0.7616,0.4382 Q0.7543,0.4392,0.7437,0.4392 L0.7364,0.4392 L0.7299,0.4392 Q0.7202,0.4392,0.7161,0.4402 Q0.7072,0.4431,0.691,0.4431 L0.6845,0.4431 L0.6788,0.4431 Q0.6707,0.4431,0.6545,0.4461 T0.6334,0.452 Q0.6399,0.456,0.6626,0.457 Q0.6878,0.458,0.7024,0.4639 Q0.7064,0.4649,0.7186,0.4649 L0.7295,0.4649 L0.7413,0.4649 Q0.7591,0.4649,0.7697,0.4669 Q0.7818,0.4688,0.7818,0.4728 Q0.7818,0.4748,0.7798,0.4763 T0.7753,0.4787 L0.7737,0.4797 L0.7729,0.4797 Q0.7494,0.4827,0.7388,0.4827 Q0.7332,0.4827,0.7307,0.4817 L0.7291,0.4817 Q0.7226,0.4817,0.6878,0.4896 Q0.6829,0.4906,0.6772,0.4911 T0.6618,0.4921 T0.646,0.4931 T0.6342,0.4936 T0.6212,0.4931 T0.6115,0.4926 Q0.6083,0.4926,0.6058,0.4931 T0.5969,0.4955 T0.5819,0.499 T0.5629,0.5005 Q0.5572,0.5005,0.5527,0.501 T0.545,0.502 T0.5397,0.503 T0.5361,0.5045 T0.5337,0.5064 T0.5328,0.5094 L0.532,0.5094 Q0.532,0.5104,0.5337,0.5109 T0.5381,0.5114 T0.5438,0.5119 T0.5515,0.5124 L0.5604,0.5124 Q0.5661,0.5134,0.5746,0.5153 T0.5912,0.5173 L0.5977,0.5173 L0.6026,0.5173 Q0.6139,0.5173,0.6273,0.5218 T0.6407,0.5272 Q0.6448,0.5282,0.6683,0.5321 T0.6991,0.5381 Q0.7137,0.543,0.7664,0.548 Q0.7908,0.55,0.7908,0.5559 Q0.7908,0.5589,0.7859,0.5628 Q0.781,0.5658,0.7713,0.5658 Q0.7689,0.5658,0.7628,0.5653 T0.7543,0.5648 T0.7502,0.5658 Q0.7364,0.5678,0.6878,0.5678 Q0.674,0.5678,0.6699,0.5668 L0.6683,0.5668 Q0.6659,0.5668,0.6606,0.5678 T0.6513,0.5687 Q0.6464,0.5687,0.6391,0.5678 Q0.6334,0.5668,0.6302,0.5668 T0.6241,0.5687 T0.6164,0.5717 Q0.6123,0.5727,0.6099,0.5727 L0.6062,0.5727 T0.6022,0.5732 T0.5953,0.5737 Q0.5904,0.5747,0.5839,0.5747 T0.5726,0.5752 T0.5629,0.5776 T0.5458,0.5796 Q0.5418,0.5796,0.5324,0.5791 T0.5191,0.5786 Q0.5077,0.5786,0.5045,0.5806 Q0.498,0.5836,0.4899,0.5836 Q0.4858,0.5836,0.4777,0.5826 T0.4655,0.5816 L0.4631,0.5816 Q0.4582,0.5816,0.4436,0.5841 T0.4144,0.5865 L0.4051,0.5865 L0.3986,0.5865 T0.3933,0.587 T0.3897,0.5875 T0.3869,0.5885 T0.3844,0.5895 T0.3812,0.591 T0.3779,0.5925 Q0.3739,0.5955,0.3633,0.5955 L0.3585,0.5955 L0.3536,0.5955 Q0.3277,0.5955,0.3066,0.6053 Q0.3017,0.6083,0.296,0.6083 Q0.2912,0.6083,0.2839,0.6068 T0.2741,0.6053 Q0.2693,0.6053,0.2547,0.6048 T0.236,0.6044 Q0.2222,0.6044,0.193,0.6103 L0.1882,0.6103 Q0.1825,0.6103,0.1703,0.6083 T0.1525,0.6063 L0.15,0.6063 Q0.1436,0.6063,0.135,0.6073 T0.1168,0.6098 T0.1038,0.6113 Q0.0973,0.6123,0.0787,0.6123 L0.06,0.6123 L0.0414,0.6123 L0.0235,0.6123 Q0.0049,0.6133,0.0049,0.6172 L0.0057,0.6222 Q0.0073,0.6261,0.043,0.6261 L0.0454,0.6261 L0.0479,0.6261 Q0.0527,0.6261,0.0633,0.6281 T0.0803,0.6301 L0.0835,0.6301 L0.0916,0.6301 Q0.0998,0.6301,0.129,0.632 Q0.1768,0.636,0.206,0.64 Q0.2117,0.6409,0.2165,0.6409 Q0.2206,0.6409,0.2263,0.6405 T0.2336,0.64 L0.2384,0.64 L0.2409,0.64 T0.251,0.6395 T0.2644,0.639 Q0.2741,0.639,0.279,0.6409 Q0.283,0.6429,0.2972,0.6439 T0.3293,0.6459 T0.3528,0.6469 L0.3577,0.6469 Q0.3642,0.6469,0.3723,0.6449 T0.3844,0.6429 Q0.3901,0.6429,0.3933,0.6454 T0.4006,0.6479 Q0.4088,0.6479,0.4517,0.6558 Q0.4582,0.6568,0.4623,0.6568 Q0.4655,0.6568,0.4716,0.6563 T0.4826,0.6558 L0.4882,0.6558 Q0.5004,0.6558,0.5255,0.6607 Q0.5328,0.6627,0.5604,0.6647 T0.6152,0.6682 T0.644,0.6696 T0.6719,0.6721 T0.7056,0.6756 Q0.7113,0.6756,0.7198,0.6805 T0.738,0.6855 Q0.7453,0.6855,0.7551,0.6879 T0.7741,0.6939 T0.7912,0.7018 T0.8029,0.7102 Q0.8054,0.7132,0.8054,0.7151 Q0.8054,0.7191,0.8009,0.7216 T0.7908,0.7255 T0.779,0.7285 T0.7721,0.731 Q0.7672,0.7329,0.7632,0.7339 T0.7551,0.7354 T0.7486,0.7369 Q0.7478,0.7369,0.7445,0.7379 T0.7388,0.7394 T0.7324,0.7404 L0.7242,0.7413 T0.7161,0.7418 Q0.7105,0.7418,0.6955,0.7428 T0.6748,0.7438 L0.6699,0.7438 L0.6667,0.7438 Q0.6659,0.7438,0.6407,0.7468 Q0.6367,0.7478,0.6334,0.7478 T0.6245,0.7468 T0.6148,0.7458 L0.6099,0.7458 Q0.6018,0.7468,0.5864,0.7498 T0.5653,0.7532 T0.5596,0.7557 Q0.5596,0.7596,0.571,0.7616 Q0.5742,0.7626,0.5803,0.7631 T0.5929,0.7641 T0.6018,0.7656 L0.6087,0.7656 T0.6192,0.7666 T0.6277,0.7695 Q0.631,0.7705,0.6342,0.7705 Q0.6383,0.7705,0.6452,0.7685 T0.6537,0.7666 L0.6553,0.7666 Q0.6602,0.7666,0.6711,0.769 T0.6853,0.7715 Q0.691,0.7715,0.7048,0.775 T0.7242,0.7814 Q0.7324,0.7854,0.7445,0.7864 Q0.7478,0.7864,0.768,0.7873 T0.7972,0.7893 Q0.7989,0.7893,0.8017,0.7898 T0.8054,0.7903 L0.8082,0.7903 T0.811,0.7908 T0.8131,0.7913 T0.8151,0.7918 L0.8167,0.7928 T0.8179,0.7943 T0.8191,0.7957 T0.8204,0.7977 T0.8208,0.8002 Q0.8208,0.8032,0.8171,0.8051 T0.8074,0.8081 T0.7997,0.8091 Q0.7932,0.8121,0.7737,0.8121 Q0.7664,0.8121,0.7413,0.8111 L0.7364,0.8111 Q0.7275,0.8111,0.7214,0.8121 T0.7084,0.8155 T0.6991,0.819 Q0.6934,0.82,0.6723,0.8205 T0.6472,0.8239 Q0.6423,0.8269,0.6375,0.8269 Q0.6358,0.8269,0.6338,0.8264 T0.6294,0.8254 T0.6253,0.8249 Q0.6042,0.8249,0.5961,0.8289 Q0.5921,0.8318,0.5839,0.8318 Q0.5783,0.8318,0.5645,0.8299 L0.5604,0.8299 Q0.5531,0.8299,0.5337,0.8368 Q0.5296,0.8378,0.5255,0.8378 Q0.5239,0.8378,0.5195,0.8373 T0.5118,0.8368 Q0.5061,0.8368,0.4996,0.8388 Q0.4947,0.8388,0.4882,0.8398 Q0.4777,0.8417,0.4753,0.8427 T0.4728,0.8467 Q0.4728,0.8487,0.4769,0.8492 T0.4886,0.8501 T0.5004,0.8516 Q0.5036,0.8516,0.515,0.8521 T0.5345,0.8546 Q0.5426,0.8556,0.5596,0.8556 L0.5649,0.8556 L0.5702,0.8556 L0.5791,0.8556 Q0.5921,0.8566,0.6135,0.8571 T0.6383,0.8586 Q0.6683,0.8615,0.6764,0.8615 L0.6776,0.8615 L0.6788,0.8615 Q0.7032,0.8615,0.7032,0.8684 Q0.7032,0.8714,0.6995,0.8749 T0.6959,0.8803 L0.6959,0.8813 Q0.6991,0.8843,0.7153,0.8843 Q0.7218,0.8843,0.7259,0.8833 L0.7299,0.8833 Q0.7324,0.8833,0.7348,0.8838 T0.7393,0.8848 T0.7433,0.8863 L0.7474,0.8882 T0.751,0.8892 Q0.7616,0.8922,0.7948,0.8952 Q0.7981,0.8952,0.8082,0.8981 T0.8297,0.9011 T0.8528,0.9026 T0.8747,0.907 T0.8856,0.9149 L0.8856,0.9159 Q0.8856,0.9179,0.8848,0.9189 T0.8828,0.9209 T0.88,0.9228 T0.8767,0.9243 T0.8731,0.9248 T0.8698,0.9253 T0.8666,0.9258 L0.8646,0.9258 Q0.8621,0.9258,0.8577,0.9263 T0.85,0.9268 T0.8439,0.9273 T0.8402,0.9278 Q0.8386,0.9298,0.8358,0.9308 T0.8305,0.9322 T0.8244,0.9332 T0.8183,0.9337 Q0.8135,0.9347,0.8025,0.9372 T0.7859,0.9416 Q0.7778,0.9436,0.7717,0.9471 T0.7624,0.9515 Q0.7575,0.9535,0.7486,0.9535 Q0.7461,0.9535,0.7397,0.953 T0.7291,0.9525 Q0.7234,0.9525,0.7186,0.953 T0.7137,0.957 T0.7194,0.9614 Q0.7567,0.9634,0.7664,0.9654 Q0.7745,0.9674,0.7762,0.9674 L0.7908,0.9674 Q0.7924,0.9664,0.7948,0.9664 Q0.8029,0.9664,0.8082,0.9679 T0.8224,0.9723 T0.8362,0.9773 Q0.8483,0.9812,0.871,0.9812 L0.8751,0.9812 Q0.8873,0.9812,0.8929,0.9822 T0.9165,0.9876 T0.9408,0.9931 Q0.9513,0.996,0.9935,0.996 Z M0.9968,0.998 L0.9976,0.003 Q0.9838,0.002,0.9708,0.0059 Q0.9659,0.0069,0.9327,0.0059 T0.8881,0.0099 Q0.8767,0.0138,0.8658,0.0163 T0.8491,0.0218 Q0.8418,0.0247,0.8341,0.0247 T0.8232,0.0257 Q0.8191,0.0277,0.7997,0.0292 T0.7737,0.0326 Q0.7697,0.0336,0.7672,0.0336 T0.7612,0.0321 T0.7543,0.0307 T0.747,0.0317 Q0.7397,0.0326,0.7336,0.0356 T0.717,0.0406 Q0.6918,0.0445,0.6861,0.0475 Q0.6845,0.0485,0.6772,0.0485 T0.6638,0.05 T0.6577,0.0549 T0.6642,0.0598 T0.6796,0.0613 T0.6902,0.0623 Q0.691,0.0623,0.6946,0.0658 T0.7064,0.0702 L0.7161,0.0702 Q0.7194,0.0702,0.7242,0.0697 T0.7332,0.0692 Q0.7413,0.0692,0.7579,0.0717 T0.781,0.0762 Q0.7867,0.0781,0.8171,0.0786 T0.85,0.0801 L0.8532,0.0801 Q0.8548,0.0801,0.8613,0.0796 T0.8727,0.0791 Q0.8832,0.0791,0.8913,0.0811 Q0.897,0.0831,0.897,0.0851 Q0.897,0.088,0.8873,0.0905 T0.8735,0.093 Q0.871,0.093,0.8447,0.0984 T0.8143,0.1039 Q0.7981,0.1039,0.781,0.1078 Q0.7778,0.1088,0.7745,0.1088 T0.7644,0.1073 T0.7518,0.1048 L0.7453,0.1048 Q0.7299,0.1048,0.7076,0.1083 T0.6788,0.1157 Q0.6748,0.1187,0.6699,0.1187 Q0.6675,0.1187,0.6557,0.1157 T0.6318,0.1118 Q0.6164,0.1108,0.6135,0.1108 T0.6095,0.1113 T0.6067,0.1142 T0.6018,0.1187 Q0.5953,0.1227,0.5921,0.1227 Q0.5904,0.1227,0.5839,0.1212 T0.5693,0.1187 L0.5661,0.1187 Q0.558,0.1187,0.5527,0.1207 T0.5434,0.1246 T0.5337,0.1266 Q0.5126,0.1296,0.4882,0.1296 T0.4371,0.1385 Q0.4298,0.1414,0.425,0.1414 Q0.4225,0.1414,0.4189,0.1395 T0.4112,0.1375 Q0.4079,0.1375,0.4019,0.1395 T0.3869,0.1414 Q0.3682,0.1414,0.3504,0.1474 Q0.3398,0.1513,0.3333,0.1513 Q0.3293,0.1513,0.3228,0.1494 L0.3204,0.1494 Q0.3179,0.1494,0.3155,0.1508 T0.3118,0.1543 T0.3106,0.1592 T0.3127,0.1642 T0.32,0.1677 T0.3305,0.1701 T0.3435,0.1716 T0.3544,0.1721 Q0.3569,0.1731,0.3601,0.1731 Q0.3625,0.1731,0.3686,0.1726 T0.3779,0.1721 Q0.3901,0.1721,0.4023,0.1751 Q0.4177,0.18,0.455,0.18 Q0.4793,0.18,0.4882,0.178 L0.4915,0.178 Q0.4964,0.178,0.5045,0.1815 T0.5158,0.185 Q0.5564,0.1889,0.5645,0.1929 Q0.5677,0.1939,0.571,0.1939 Q0.575,0.1939,0.5807,0.1919 T0.5888,0.1899 T0.5929,0.1909 Q0.5969,0.1949,0.6123,0.1949 Q0.6334,0.1949,0.6456,0.1899 Q0.6488,0.1879,0.6537,0.1879 Q0.661,0.1879,0.6772,0.1929 T0.6983,0.1978 Q0.704,0.1988,0.7206,0.1998 T0.7502,0.2018 T0.7664,0.2038 Q0.7672,0.2038,0.7818,0.2043 T0.8151,0.2067 T0.8459,0.2156 Q0.8573,0.2206,0.8573,0.2255 Q0.8573,0.2295,0.8516,0.2324 T0.8394,0.2369 T0.8281,0.2384 Q0.8208,0.2384,0.7526,0.2473 Q0.7494,0.2483,0.7421,0.2493 T0.7287,0.2512 T0.7178,0.2522 L0.7153,0.2522 L0.7137,0.2522 Q0.7056,0.2522,0.6898,0.2572 T0.6691,0.2671 Q0.6667,0.269,0.6634,0.269 Q0.661,0.269,0.6557,0.2681 T0.6472,0.2671 Q0.6448,0.2671,0.6415,0.2681 Q0.631,0.271,0.6034,0.274 Q0.5994,0.274,0.5961,0.2765 T0.5904,0.2789 L0.5864,0.2789 Q0.5831,0.2779,0.5742,0.2779 Q0.541,0.2779,0.5223,0.2849 Q0.5126,0.2888,0.5028,0.2888 Q0.5004,0.2888,0.4943,0.2883 T0.4842,0.2878 Q0.4704,0.2878,0.4627,0.2898 T0.4509,0.2987 Q0.4501,0.2997,0.4501,0.3007 Q0.4501,0.3046,0.4574,0.3066 T0.4765,0.3091 T0.4899,0.3096 Q0.4931,0.3106,0.5061,0.3121 T0.5316,0.315 T0.5499,0.3175 Q0.5507,0.3175,0.5572,0.3195 T0.5685,0.3225 T0.5803,0.3254 T0.5933,0.3279 T0.605,0.3284 T0.6148,0.3274 Q0.6221,0.3244,0.6294,0.3244 Q0.6399,0.3244,0.6496,0.3294 Q0.6529,0.3314,0.6577,0.3314 Q0.6602,0.3314,0.6663,0.3304 T0.6756,0.3294 T0.6813,0.3304 Q0.6837,0.3304,0.6873,0.3309 L0.6946,0.3318 T0.7024,0.3323 T0.7105,0.3328 T0.7178,0.3338 T0.7238,0.3348 T0.7283,0.3363 Q0.7324,0.3383,0.7482,0.3403 T0.7689,0.3442 Q0.7697,0.3442,0.779,0.3477 T0.794,0.3536 T0.8074,0.3615 T0.8183,0.3729 Q0.8232,0.3808,0.8232,0.3848 Q0.8232,0.3867,0.8216,0.3892 T0.8151,0.3961 T0.8062,0.406 T0.7997,0.4179 T0.7952,0.4263 T0.7855,0.4308 T0.7616,0.4362 Q0.7543,0.4372,0.7445,0.4372 L0.7372,0.4372 L0.7307,0.4372 Q0.7202,0.4372,0.7153,0.4382 Q0.7064,0.4402,0.691,0.4402 L0.6845,0.4402 L0.6788,0.4402 Q0.6699,0.4402,0.6533,0.4436 T0.6318,0.451 Q0.6302,0.451,0.6302,0.452 Q0.6302,0.457,0.6626,0.459 Q0.6869,0.4609,0.7015,0.4659 Q0.7064,0.4669,0.7202,0.4669 L0.7311,0.4669 L0.7429,0.4669 Q0.7599,0.4669,0.7689,0.4688 Q0.7794,0.4708,0.7794,0.4738 L0.7786,0.4748 T0.777,0.4763 T0.7749,0.4773 L0.7737,0.4777 L0.7729,0.4777 Q0.7502,0.4797,0.738,0.4797 L0.7315,0.4797 L0.7291,0.4797 Q0.7226,0.4797,0.6869,0.4876 Q0.6821,0.4886,0.6768,0.4891 T0.6614,0.4901 T0.6456,0.4916 L0.6342,0.4916 Q0.6294,0.4916,0.6217,0.4911 T0.6115,0.4906 Q0.6083,0.4906,0.6054,0.4911 T0.5961,0.4936 T0.5815,0.4965 T0.5629,0.4985 Q0.5507,0.4985,0.5442,0.4995 T0.5345,0.5025 T0.5304,0.5084 L0.5296,0.5094 Q0.5296,0.5114,0.5316,0.5124 T0.5373,0.5138 T0.5442,0.5143 L0.5523,0.5143 L0.5604,0.5143 Q0.5637,0.5153,0.5677,0.5158 T0.5742,0.5173 T0.5811,0.5188 T0.5904,0.5193 L0.5977,0.5193 L0.6026,0.5193 Q0.6107,0.5193,0.6168,0.5208 T0.6294,0.5252 T0.6399,0.5292 Q0.6448,0.5302,0.6679,0.5341 T0.6983,0.541 Q0.7129,0.545,0.7664,0.55 Q0.7883,0.5519,0.7883,0.5559 Q0.7883,0.5579,0.7843,0.5608 T0.7721,0.5638 Q0.7697,0.5638,0.764,0.5633 T0.7559,0.5628 L0.7502,0.5628 Q0.7364,0.5648,0.6869,0.5648 L0.6699,0.5648 L0.6675,0.5648 Q0.665,0.5648,0.6602,0.5658 T0.6513,0.5668 Q0.6464,0.5668,0.6399,0.5658 Q0.6342,0.5648,0.6302,0.5648 T0.6225,0.5668 T0.6156,0.5697 Q0.6107,0.5707,0.6087,0.5707 T0.6034,0.5712 T0.5953,0.5717 Q0.5904,0.5727,0.5835,0.5727 T0.5718,0.5732 T0.562,0.5757 Q0.558,0.5767,0.5483,0.5767 L0.5349,0.5767 L0.5207,0.5767 Q0.5069,0.5767,0.5028,0.5786 Q0.4972,0.5816,0.4899,0.5816 Q0.4858,0.5816,0.4781,0.5806 T0.4663,0.5796 L0.4631,0.5796 Q0.4582,0.5796,0.4432,0.5821 T0.4144,0.5846 L0.4071,0.5846 L0.4011,0.5846 T0.3958,0.5851 T0.3921,0.5856 T0.3889,0.5861 T0.3865,0.5865 T0.384,0.5875 L0.382,0.5885 L0.3796,0.5895 L0.3771,0.5905 Q0.3731,0.5925,0.365,0.5925 L0.3601,0.5925 L0.3552,0.5925 Q0.3277,0.5925,0.3049,0.6044 Q0.3017,0.6053,0.296,0.6053 Q0.292,0.6053,0.2847,0.6044 T0.2741,0.6034 Q0.2693,0.6034,0.2547,0.6029 T0.236,0.6024 Q0.2222,0.6024,0.1922,0.6073 Q0.1906,0.6083,0.1882,0.6083 Q0.1833,0.6083,0.1711,0.6063 T0.1533,0.6044 Q0.1517,0.6034,0.1492,0.6034 Q0.1436,0.6034,0.135,0.6048 T0.1168,0.6078 T0.103,0.6093 Q0.0973,0.6103,0.0787,0.6103 L0.0604,0.6103 L0.0414,0.6103 L0.0235,0.6103 Q0.0024,0.6113,0.0024,0.6172 Q0.0024,0.6192,0.0032,0.6231 Q0.0049,0.6281,0.0333,0.6281 L0.0414,0.6281 L0.0479,0.6281 Q0.0519,0.6281,0.0624,0.6301 T0.0803,0.632 L0.0835,0.632 L0.0916,0.632 Q0.0989,0.632,0.1281,0.634 L0.206,0.6419 Q0.2117,0.6429,0.2174,0.6429 Q0.2214,0.6429,0.2271,0.6424 T0.2344,0.6419 L0.2376,0.6419 Q0.2393,0.6429,0.2417,0.6429 T0.2526,0.6419 T0.2652,0.6409 Q0.2741,0.6409,0.2782,0.6429 T0.2968,0.6459 T0.3293,0.6479 T0.352,0.6489 Q0.3552,0.6499,0.3585,0.6499 Q0.365,0.6499,0.3731,0.6474 T0.3844,0.6449 Q0.3885,0.6449,0.3917,0.6474 T0.4006,0.6499 Q0.4088,0.6499,0.4509,0.6578 Q0.4574,0.6588,0.4631,0.6588 Q0.4655,0.6588,0.4716,0.6583 T0.4834,0.6578 L0.4874,0.6578 Q0.5004,0.6588,0.5255,0.6637 Q0.532,0.6647,0.56,0.6667 T0.6148,0.6701 T0.644,0.6716 Q0.6464,0.6726,0.6594,0.6736 T0.6861,0.6756 T0.7048,0.6775 Q0.7097,0.6775,0.7186,0.6825 T0.738,0.6874 Q0.7518,0.6874,0.7729,0.6958 T0.8005,0.7122 Q0.8029,0.7141,0.8029,0.7151 Q0.8029,0.7191,0.7968,0.7216 T0.7818,0.726 T0.7705,0.729 L0.764,0.731 T0.7583,0.7324 T0.753,0.7334 T0.7486,0.7349 Q0.7251,0.7399,0.7161,0.7399 Q0.7097,0.7399,0.6951,0.7409 T0.6748,0.7418 L0.6703,0.7418 L0.6667,0.7418 Q0.6659,0.7418,0.6399,0.7448 L0.6334,0.7448 Q0.631,0.7448,0.6249,0.7443 T0.6148,0.7438 L0.6099,0.7438 Q0.601,0.7448,0.5856,0.7473 T0.5645,0.7507 Q0.5572,0.7517,0.5572,0.7547 T0.5616,0.7606 T0.5702,0.7646 Q0.5734,0.7646,0.5779,0.7651 T0.5864,0.7661 T0.5949,0.7671 T0.6018,0.7676 L0.6071,0.7676 T0.6135,0.7681 T0.62,0.769 T0.6269,0.771 T0.6342,0.7725 Q0.6391,0.7725,0.6464,0.7705 T0.6541,0.7685 L0.6553,0.7685 Q0.6586,0.7685,0.6699,0.771 T0.6853,0.7735 Q0.6902,0.7735,0.704,0.777 T0.7226,0.7834 Q0.7315,0.7873,0.7445,0.7883 Q0.747,0.7883,0.7676,0.7893 T0.7972,0.7913 Q0.7989,0.7913,0.8017,0.7918 T0.8058,0.7923 T0.809,0.7928 T0.8114,0.7933 T0.8131,0.7938 T0.8147,0.7943 T0.8159,0.7953 L0.8171,0.7967 T0.8175,0.7982 Q0.8183,0.7992,0.8183,0.8002 Q0.8183,0.8022,0.8155,0.8032 T0.807,0.8051 T0.7989,0.8071 Q0.7932,0.8091,0.7737,0.8091 L0.7413,0.8091 L0.7372,0.8091 Q0.7275,0.8091,0.721,0.8101 T0.7072,0.8136 T0.6983,0.817 Q0.6942,0.818,0.6825,0.818 T0.6602,0.819 T0.6456,0.822 Q0.6415,0.8249,0.6375,0.8249 Q0.6367,0.8249,0.6326,0.8239 T0.6253,0.8229 Q0.6034,0.8229,0.5953,0.8279 Q0.5912,0.8289,0.5848,0.8289 Q0.5775,0.8289,0.5645,0.8279 Q0.5629,0.8269,0.5604,0.8269 Q0.5523,0.8269,0.5328,0.8348 Q0.5296,0.8358,0.5264,0.8358 Q0.5247,0.8358,0.5203,0.8353 T0.5126,0.8348 Q0.5061,0.8348,0.4988,0.8358 Q0.4947,0.8368,0.4882,0.8378 Q0.4826,0.8388,0.4797,0.8388 T0.474,0.8398 T0.4704,0.8422 T0.4696,0.8462 T0.472,0.8501 T0.4781,0.8521 T0.4854,0.8526 L0.4935,0.8526 T0.4996,0.8536 Q0.5036,0.8536,0.515,0.8546 T0.5337,0.8566 Q0.5426,0.8576,0.5596,0.8576 L0.5657,0.8576 L0.571,0.8576 L0.5791,0.8576 Q0.5921,0.8586,0.6135,0.8591 T0.6383,0.8605 Q0.6683,0.8635,0.6764,0.8635 Q0.6999,0.8635,0.6999,0.8684 Q0.6999,0.8694,0.6967,0.8734 T0.6934,0.8803 L0.6934,0.8823 Q0.6951,0.8863,0.7137,0.8863 Q0.7218,0.8863,0.7267,0.8853 L0.7299,0.8853 Q0.734,0.8853,0.7409,0.8882 T0.7502,0.8912 Q0.7607,0.8942,0.794,0.8971 Q0.7972,0.8971,0.8078,0.9001 T0.8297,0.9031 Q0.8475,0.9031,0.8646,0.9065 T0.8824,0.9149 L0.8824,0.9159 Q0.8824,0.9169,0.882,0.9179 T0.8808,0.9194 L0.8792,0.9204 T0.8767,0.9214 T0.8743,0.9224 T0.8715,0.9228 L0.8686,0.9228 T0.8662,0.9233 T0.8637,0.9238 L0.8556,0.9238 T0.8447,0.9243 T0.8386,0.9268 Q0.837,0.9278,0.835,0.9288 T0.8305,0.9303 T0.8244,0.9308 T0.8175,0.9318 Q0.8127,0.9327,0.8017,0.9352 T0.7851,0.9397 Q0.777,0.9416,0.7705,0.9451 T0.7616,0.9496 Q0.7575,0.9505,0.7494,0.9505 L0.7405,0.9505 L0.7307,0.9505 Q0.7234,0.9505,0.7178,0.9515 Q0.7145,0.9515,0.7125,0.9535 T0.7105,0.9575 Q0.7105,0.9624,0.7194,0.9634 Q0.7567,0.9654,0.7664,0.9674 Q0.7745,0.9693,0.7762,0.9693 L0.7908,0.9693 L0.7948,0.9693 Q0.8005,0.9693,0.8058,0.9698 T0.8143,0.9718 T0.824,0.9753 T0.8354,0.9792 Q0.8483,0.9832,0.871,0.9832 L0.8739,0.9832 L0.8767,0.9832 Q0.8873,0.9832,0.8921,0.9842 Q0.8978,0.9852,0.9157,0.9896 T0.94,0.996 Q0.9424,0.996,0.9469,0.9965 T0.9566,0.997 T0.9676,0.9975 T0.9781,0.998 L0.9874,0.998 L0.9943,0.998 L0.9968,0.998 Z",type:0}},"":{type:5,data:{d:"M1,0.5071 L0.8623,0.7102 L0.5865,0.7102 L0.4489,0.5071 L0.5865,0.3045 L0.8623,0.3045 Z M0.4135,0.7974 L0.2758,1 L0,1 L0,0.5944 L0.2758,0.5944 Z M0.4135,0.2026 L0.2758,0.4056 L0,0.4056 L0,0 L0.2758,0 Z",type:0}},"":{type:5,data:{d:"M0.9726,0.5071 L0.8451,0.3184 L0.59,0.3184 L0.4624,0.5071 L0.59,0.6955 L0.8451,0.6955 Z M1,0.5071 L0.8588,0.716 L0.5768,0.716 L0.4355,0.5071 L0.5768,0.2982 L0.8588,0.2982 Z M0.401,0.7911 L0.2735,0.6028 L0.0236,0.6028 L0.0236,0.9799 L0.2735,0.9799 Z M0.4284,0.7911 L0.2872,1 L0,1 L0,0.5826 L0.2872,0.5826 Z M0.401,0.2089 L0.2735,0.0201 L0.0236,0.0201 L0.0236,0.3972 L0.2735,0.3972 Z M0.4284,0.2089 L0.2872,0.4174 L0,0.4174 L0,0 L0.2872,0 Z",type:0}},"":{type:5,data:{d:"M1,0.4996 Q1,0.5365,0.9924,0.568 T0.9718,0.6177 T0.9433,0.6363 L0.9425,0.6367 Q0.9355,0.6367,0.9285,0.6326 L0.9308,0.6326 Q0.9129,0.6195,0.9013,0.587 T0.8871,0.5164 T0.8943,0.4373 T0.9254,0.3687 L0.9254,0.3691 Q0.9339,0.3628,0.9425,0.3628 Q0.9584,0.3628,0.9716,0.3811 T0.9924,0.431 T1,0.4996 Z M0.9234,0.6321 L0.8776,0.7169 Q0.8791,0.732,0.8791,0.7441 Q0.8791,0.781,0.8716,0.8123 T0.8508,0.862 T0.8224,0.8809 L0.8216,0.8809 Q0.8154,0.8809,0.8092,0.8775 L0.8088,0.8775 Q0.7905,0.8633,0.7792,0.8297 T0.766,0.7571 T0.775,0.6768 T0.8088,0.6086 L0.7431,0.6065 Q0.719,0.6313,0.7072,0.6705 T0.6965,0.7487 T0.7097,0.823 T0.7423,0.8763 L0.7913,0.8775 L0.7314,0.9895 L0.4648,0.5038 L0.7299,0.0176 L0.7882,0.1229 L0.7404,0.1212 Q0.7244,0.138,0.7136,0.1617 T0.6982,0.2108 T0.6943,0.2632 T0.7003,0.3144 T0.7159,0.3586 T0.7396,0.3909 L0.8072,0.3926 Q0.7894,0.3796,0.7777,0.3479 T0.7635,0.2789 T0.7697,0.2011 T0.7987,0.1326 Q0.8092,0.1225,0.8204,0.1225 Q0.8321,0.1225,0.8426,0.1332 T0.8609,0.1623 T0.8731,0.2062 T0.8776,0.2592 Q0.8776,0.268,0.8768,0.2815 L0.9219,0.3628 L0.8644,0.3612 Q0.8403,0.3855,0.8284,0.4247 T0.8177,0.5029 T0.8309,0.5772 T0.8632,0.6305 Z M0.6759,0.6384 Q0.6576,0.625,0.6457,0.5914 T0.6319,0.5185 T0.6407,0.4375 T0.6751,0.3691 L0.609,0.367 Q0.5849,0.3918,0.5731,0.431 T0.5626,0.509 T0.5758,0.5833 T0.6082,0.6367 Z M0.6459,0.0025 L0.391,0.4551 L0.0035,0.4551 L0.246,0 Z M0.6553,1 L0.2421,1 L0,0.5449 L0.3875,0.5449 Z",type:0}},"":{type:5,data:{d:"M0.4996,0 Q0.5562,0,0.5961,0.0166 T0.6364,0.0566 L0.6368,0.0575 Q0.6368,0.0646,0.6324,0.0713 L0.6324,0.0692 Q0.6196,0.0868,0.5872,0.0986 T0.5166,0.1128 T0.4373,0.1057 T0.3689,0.0747 L0.3694,0.0747 Q0.3627,0.0663,0.3627,0.0575 Q0.3627,0.0336,0.4028,0.0168 T0.4996,0 Z M0.6324,0.0768 L0.717,0.1225 Q0.7321,0.1212,0.744,0.1212 Q0.8007,0.1212,0.8406,0.1378 T0.8809,0.1779 L0.8813,0.1783 Q0.8813,0.1846,0.8778,0.1909 Q0.8636,0.2093,0.8299,0.2208 T0.7573,0.2343 T0.6769,0.2253 T0.6089,0.1913 L0.6067,0.2567 Q0.6315,0.281,0.6707,0.2928 T0.7489,0.3035 T0.8231,0.2903 T0.8764,0.258 L0.8778,0.2085 L0.9898,0.2685 L0.504,0.5352 L0.0177,0.2701 L0.1227,0.2118 L0.1213,0.2596 Q0.1457,0.2836,0.1851,0.2955 T0.2635,0.3062 T0.3377,0.293 T0.3911,0.2605 L0.3928,0.193 Q0.38,0.2106,0.3483,0.2221 T0.2792,0.2364 T0.2013,0.2303 T0.1324,0.2013 Q0.1222,0.1909,0.1222,0.1795 Q0.1222,0.156,0.1623,0.1393 T0.2591,0.1225 Q0.2679,0.1225,0.2817,0.1233 L0.3627,0.078 L0.3609,0.1359 Q0.3857,0.1598,0.4249,0.1718 T0.5031,0.1825 T0.5773,0.1693 T0.6306,0.1367 Z M0.6386,0.3242 Q0.6253,0.3427,0.5917,0.3544 T0.5186,0.3683 T0.4376,0.3595 T0.3689,0.3251 L0.3671,0.3909 Q0.3915,0.4148,0.4309,0.4268 T0.5093,0.4375 T0.5835,0.4243 T0.6368,0.3918 Z M0.0027,0.354 L0.4553,0.6091 L0.4553,0.9962 L0,0.7542 Z M1,0.3448 L1,0.7576 L0.5447,1 L0.5447,0.6128 Z",type:0}},"":{type:5,data:{d:"M0,0.7699 Q0,0.7172,0.0277,0.6731 T0.103,0.6032 T0.2066,0.5774 Q0.2487,0.5774,0.2865,0.5922 T0.3526,0.6339 L0.3935,0.6715 Q0.4218,0.6984,0.438,0.7336 T0.4541,0.8075 Q0.4541,0.8602,0.4264,0.9046 T0.3511,0.9745 T0.2475,1 Q0.2054,1,0.1676,0.9852 T0.1016,0.9435 L0.0606,0.9059 Q0.0317,0.879,0.0159,0.8438 T0,0.7699 Z M0.5459,0.7699 Q0.5459,0.7172,0.5736,0.6731 T0.6489,0.6032 T0.7525,0.5774 Q0.7946,0.5774,0.8324,0.5922 T0.8984,0.6339 L0.9394,0.6715 Q0.9579,0.6892,0.9714,0.7105 T0.9925,0.7567 T1,0.8075 Q1,0.8602,0.9723,0.9046 T0.897,0.9745 T0.7934,1 Q0.7513,1,0.7135,0.9852 T0.6474,0.9435 L0.6065,0.9059 Q0.5776,0.879,0.5617,0.8438 T0.5459,0.7699 Z M0,0.1925 Q0,0.1398,0.0277,0.0957 T0.103,0.0258 T0.2066,0 Q0.2487,0,0.2865,0.0148 T0.3526,0.0565 L0.3935,0.0941 Q0.4218,0.121,0.438,0.1562 T0.4541,0.2301 Q0.4541,0.2828,0.4264,0.3269 T0.3511,0.3968 T0.2475,0.4226 Q0.2054,0.4226,0.1676,0.4078 T0.1016,0.3661 L0.0606,0.3285 Q0.0317,0.3016,0.0159,0.2664 T0,0.1925 Z M0.5459,0.1925 Q0.5459,0.1398,0.5736,0.0957 T0.6489,0.0258 T0.7525,0 Q0.7946,0,0.8324,0.0148 T0.8984,0.0565 L0.9394,0.0941 Q0.9677,0.121,0.9838,0.1562 T1,0.2301 Q1,0.2828,0.9723,0.3269 T0.897,0.3968 T0.7934,0.4226 Q0.7513,0.4226,0.7135,0.4078 T0.6474,0.3661 L0.6065,0.3285 Q0.5874,0.3108,0.5741,0.2895 T0.5534,0.2433 T0.5459,0.1925 Z M0.3301,0.6403 Q0.3306,0.6403,0.3312,0.6409 L0.3272,0.6371 L0.3272,0.6371 Z M0.8759,0.6403 Q0.8765,0.6403,0.8771,0.6409 Q0.8748,0.6387,0.8725,0.6371 L0.8731,0.6371 Z M0.3301,0.0629 Q0.3306,0.0629,0.3312,0.0634 L0.3272,0.0597 L0.3272,0.0602 Z M0.8759,0.0629 Q0.8765,0.0629,0.8771,0.0634 Q0.8748,0.0613,0.8725,0.0597 Q0.8731,0.0597,0.8731,0.0602 Z M0.779,0.4113 L0.7501,0.3849 Q0.7046,0.3844,0.6636,0.3661 Q0.7132,0.407,0.779,0.4113 Z M0.7957,0.4118 Q0.8477,0.4113,0.8918,0.3874 T0.9619,0.3223 T0.9885,0.2323 L0.9585,0.2048 Q0.9538,0.2769,0.8987,0.3282 T0.7657,0.3844 Z M0.9879,0.2167 Q0.9827,0.1554,0.9388,0.1097 Q0.9585,0.1478,0.959,0.1898 Z M0.2331,0.4113 L0.2043,0.3849 Q0.1587,0.3844,0.1177,0.3661 Q0.1673,0.407,0.2331,0.4113 Z M0.2499,0.4118 Q0.2885,0.4118,0.324,0.3973 T0.3852,0.3589 T0.4264,0.3019 T0.4426,0.2323 L0.4126,0.2048 Q0.408,0.2769,0.3529,0.3282 T0.2198,0.3844 Z M0.442,0.2167 Q0.4368,0.1554,0.393,0.1097 Q0.4126,0.1478,0.4132,0.1903 Z M0.779,0.9887 L0.7501,0.9624 Q0.7046,0.9618,0.6636,0.9435 Q0.7132,0.9844,0.779,0.9887 Z M0.7957,0.9892 Q0.8477,0.9887,0.8918,0.9648 T0.9619,0.8997 T0.9885,0.8097 L0.9585,0.7823 Q0.9538,0.8543,0.8987,0.9056 T0.7657,0.9618 Z M0.9879,0.7941 Q0.9827,0.7328,0.9388,0.6871 Q0.9585,0.7253,0.959,0.7672 Z M0.2331,0.9887 L0.2043,0.9624 Q0.1587,0.9618,0.1177,0.9435 Q0.1673,0.9844,0.2331,0.9887 Z M0.2499,0.9892 Q0.3018,0.9887,0.3459,0.9648 T0.416,0.8997 T0.4426,0.8102 L0.4126,0.7823 Q0.408,0.8543,0.3529,0.9056 T0.2198,0.9618 Z M0.442,0.7941 Q0.4368,0.7328,0.393,0.6871 Q0.4126,0.7253,0.4132,0.7672 Z M0.3191,0.6452 Q0.2943,0.622,0.2643,0.6091 T0.2066,0.5962 Q0.1795,0.5962,0.1497,0.6091 T0.0949,0.6441 T0.0537,0.6995 T0.0375,0.7699 Q0.0375,0.8419,0.0941,0.8941 Q0.1189,0.9172,0.1489,0.9304 T0.2066,0.9435 Q0.2337,0.9435,0.2634,0.9304 T0.3182,0.8952 T0.3595,0.8398 T0.3756,0.7699 Q0.3756,0.6978,0.3191,0.6452 Z M0.026,0.7731 L0.026,0.7699 Q0.026,0.6935,0.0854,0.6376 Q0.0658,0.6538,0.0514,0.6739 T0.0289,0.718 T0.0202,0.7677 Z M0.0271,0.7892 L0.0208,0.7833 Q0.0254,0.8419,0.0681,0.886 Q0.0664,0.8828,0.0646,0.879 Q0.0329,0.8382,0.0271,0.7892 Z M0.3866,0.7823 Q0.382,0.8511,0.3278,0.9016 Q0.3843,0.8565,0.3918,0.7871 Z M0.3872,0.7677 L0.393,0.7726 L0.393,0.7699 Q0.393,0.7349,0.3791,0.7038 Q0.3739,0.6973,0.3681,0.6914 Q0.3866,0.7274,0.3872,0.7677 Z M0.865,0.6452 Q0.8402,0.622,0.8102,0.6091 T0.7525,0.5962 Q0.7305,0.5962,0.7072,0.6043 T0.6619,0.628 T0.6223,0.6642 T0.5941,0.7124 T0.5834,0.7699 Q0.5834,0.8419,0.6399,0.8941 Q0.6561,0.9097,0.6757,0.9207 T0.7149,0.9376 T0.7525,0.9435 Q0.7796,0.9435,0.8093,0.9304 T0.8641,0.8952 T0.9054,0.8398 T0.9215,0.7699 Q0.9215,0.6978,0.865,0.6452 Z M0.5718,0.7731 L0.5718,0.7699 Q0.5718,0.6935,0.6313,0.6376 Q0.6013,0.6618,0.584,0.6954 T0.5661,0.7677 Z M0.573,0.7892 L0.5666,0.7833 Q0.5713,0.8419,0.614,0.886 Q0.6122,0.8828,0.6105,0.879 Q0.5788,0.8382,0.573,0.7892 Z M0.9325,0.7823 Q0.9279,0.8511,0.8736,0.9016 Q0.9308,0.8565,0.9377,0.7871 Z M0.9331,0.7672 L0.9388,0.7726 L0.9388,0.7699 Q0.9388,0.7349,0.925,0.7038 Q0.9198,0.6973,0.914,0.6914 Q0.9325,0.7274,0.9331,0.7672 Z M0.3191,0.0677 Q0.2943,0.0446,0.2643,0.0317 T0.2066,0.0188 Q0.1795,0.0188,0.1497,0.0317 T0.0949,0.0667 T0.0537,0.122 T0.0375,0.1925 Q0.0375,0.2645,0.0941,0.3167 Q0.1189,0.3398,0.1489,0.353 T0.2066,0.3661 Q0.2337,0.3661,0.2634,0.353 T0.3182,0.3177 T0.3595,0.2624 T0.3756,0.1925 Q0.3756,0.1204,0.3191,0.0677 Z M0.026,0.1957 L0.026,0.1925 Q0.026,0.1161,0.0854,0.0602 Q0.0554,0.0844,0.0381,0.118 T0.0202,0.1903 Z M0.0271,0.2118 L0.0208,0.2059 Q0.0254,0.2645,0.0681,0.3086 Q0.0664,0.3054,0.0646,0.3016 Q0.0329,0.2608,0.0271,0.2118 Z M0.3866,0.2048 Q0.382,0.2737,0.3278,0.3242 Q0.3849,0.279,0.3918,0.2097 Z M0.3872,0.1903 L0.393,0.1952 L0.393,0.1925 Q0.393,0.1581,0.3791,0.1263 Q0.3739,0.1199,0.3681,0.114 Q0.3866,0.15,0.3872,0.1903 Z M0.865,0.0677 Q0.8402,0.0446,0.8102,0.0317 T0.7525,0.0188 Q0.7253,0.0188,0.6956,0.0317 T0.6408,0.0667 T0.5995,0.122 T0.5834,0.1925 Q0.5834,0.2645,0.6399,0.3167 Q0.6647,0.3398,0.6947,0.353 T0.7525,0.3661 Q0.7796,0.3661,0.8093,0.353 T0.8641,0.3177 T0.9054,0.2624 T0.9215,0.1925 Q0.9215,0.1204,0.865,0.0677 Z M0.5718,0.1957 L0.5718,0.1925 Q0.5718,0.1161,0.6313,0.0602 Q0.6013,0.0844,0.584,0.118 T0.5661,0.1903 Z M0.573,0.2118 L0.5666,0.2059 Q0.5713,0.2645,0.614,0.3086 Q0.6122,0.3054,0.6105,0.3016 Q0.5788,0.2608,0.573,0.2118 Z M0.9325,0.2048 Q0.9279,0.2737,0.8736,0.3242 Q0.9308,0.279,0.9377,0.2097 Z M0.9331,0.1898 L0.9388,0.1952 L0.9388,0.1925 Q0.9388,0.1575,0.925,0.1263 Q0.9198,0.1199,0.914,0.114 Q0.9325,0.15,0.9331,0.1898 Z",type:0}},"":{type:5,data:{d:"M0.7639,0.1974 L0.9593,0.1974 L0.9593,0.1443 L0.7639,0.1443 L0.7639,0.1974 Z M0.7586,0.109 L0.9727,0.109 Q0.9839,0.109,0.992,0.1153 T1,0.1291 L1,0.382 Q1,0.3906,0.9912,0.3964 T0.9727,0.4021 L0.7586,0.4021 Q0.7468,0.4021,0.739,0.3956 T0.7313,0.382 L0.7313,0.1291 Q0.7313,0.1246,0.7339,0.1207 T0.7406,0.1143 T0.7495,0.1104 T0.7586,0.109 Z M0.7639,0.6575 L0.9593,0.6575 L0.9593,0.6044 L0.7639,0.6044 L0.7639,0.6575 Z M0.7313,0.8347 L0.7313,0.5843 Q0.7313,0.5757,0.7401,0.5699 T0.7586,0.5641 L0.9706,0.5641 Q0.9818,0.5641,0.9898,0.5705 T0.9979,0.5843 L0.9979,0.8347 Q0.9979,0.8433,0.989,0.8491 T0.9706,0.8549 L0.7586,0.8549 Q0.7468,0.8549,0.739,0.8483 T0.7313,0.8347 Z M0,1 L0,0 L0.7607,0 L0.7607,1 L0,1 Z",type:0}},"":{type:5,data:{d:"M0.9764,0.0029 L0.3179,0.4552 L0,0.4552 L0,0 Z M1,1 L0,1 L0,0.5448 L0.3091,0.5448 Z",type:0}},"":{type:5,data:{d:"M0.0236,0.0029 L0.6824,0.4552 L1,0.4552 L1,0 Z M0,1 L1,1 L1,0.5448 L0.6912,0.5448 Z",type:0}},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z M.25,.15 L1,.15 L1,.85 L.25,.85 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z M0,.15 L1,.15 L1,.85 L0,.85 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z M0,.15 L.75,.15 L.75,.85 L0,.85 Z"},"":{type:5,data:{d:"M0.6574,0.303 Q0.623,0.291,0.5852,0.285 T0.5082,0.279 T0.4311,0.285 T0.359,0.303 L0.3082,0.248 Q0.3525,0.232,0.4041,0.2235 T0.5082,0.215 T0.6123,0.2235 T0.7082,0.248 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.8557,0.582 L0.7656,0.551 Q0.7852,0.53,0.7951,0.507 T0.8049,0.46 Q0.8049,0.424,0.782,0.3905 T0.718,0.332 T0.6221,0.293 T0.5082,0.279 L0.5082,0.215 Q0.5607,0.215,0.6123,0.2235 T0.709,0.248 T0.7918,0.287 T0.8557,0.3375 T0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.582 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.482,0.705,0.4557,0.703 T0.4049,0.697 L0.4311,0.635 Q0.4508,0.638,0.4697,0.6395 T0.5082,0.641 Q0.5672,0.641,0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 Q0.8049,0.436,0.7951,0.413 T0.7656,0.369 L0.8557,0.338 Q0.882,0.365,0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 L0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 T0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 L0.9098,0.46 Q0.9098,0.492,0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.338 L0.2508,0.369 Q0.2311,0.39,0.2213,0.413 T0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 Q0.5279,0.641,0.5467,0.6395 T0.5852,0.635 L0.6115,0.697 Q0.5869,0.701,0.5607,0.703 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.1607,0.582 Q0.1344,0.555,0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.3375 T0.2246,0.287 T0.3074,0.248 T0.4041,0.2235 T0.5082,0.215 L0.5082,0.279 Q0.4492,0.279,0.3943,0.293 T0.2984,0.332 T0.2344,0.3905 T0.2115,0.46 Q0.2115,0.484,0.2213,0.507 T0.2508,0.551 Z",type:0},scaleType:1},"":s.LINE_H,"":s.LINE_V,"":s.FADE_RIGHT,"":s.FADE_LEFT,"":s.FADE_DOWN,"":s.FADE_UP,"":s.CURVE_DOWN_RIGHT,"":s.CURVE_DOWN_LEFT,"":s.CURVE_UP_RIGHT,"":s.CURVE_UP_LEFT,"":[s.LINE_V,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_RIGHT],"":[s.CURVE_DOWN_RIGHT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_UP_LEFT],"":[s.LINE_V,s.CURVE_DOWN_LEFT],"":[s.CURVE_DOWN_LEFT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_LEFT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT],"":[s.CURVE_DOWN_LEFT,s.CURVE_DOWN_RIGHT],"":[s.LINE_H,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_UP_RIGHT],"":[s.CURVE_UP_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_UP_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_LEFT,s.CURVE_DOWN_RIGHT],"":[s.LINE_H,s.CURVE_DOWN_LEFT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_RIGHT,s.CURVE_UP_LEFT],"":[s.LINE_V,s.CURVE_DOWN_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_LEFT,s.CURVE_UP_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE],"":s.NODE_STROKE,"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN],"":[s.NODE_STROKE,s.NODE_LINE_DOWN],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP],"":[s.NODE_STROKE,s.NODE_LINE_UP],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_UP],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_UP],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"🬀":r(1),"🬁":r(2),"🬂":r(3),"🬃":r(4),"🬄":r(5),"🬅":r(6),"🬆":r(7),"🬇":r(8),"🬈":r(9),"🬉":r(10),"🬊":r(11),"🬋":r(12),"🬌":r(13),"🬍":r(14),"🬎":r(15),"🬏":r(16),"🬐":r(17),"🬑":r(18),"🬒":r(19),"🬓":r(20),"🬔":r(22),"🬕":r(23),"🬖":r(24),"🬗":r(25),"🬘":r(26),"🬙":r(27),"🬚":r(28),"🬛":r(29),"🬜":r(30),"🬝":r(31),"🬞":r(32),"🬟":r(33),"🬠":r(34),"🬡":r(35),"🬢":r(36),"🬣":r(37),"🬤":r(38),"🬥":r(39),"🬦":r(40),"🬧":r(41),"🬨":r(43),"🬩":r(44),"🬪":r(45),"🬫":r(46),"🬬":r(47),"🬭":r(48),"🬮":r(49),"🬯":r(50),"🬰":r(51),"🬱":r(52),"🬲":r(53),"🬳":r(54),"🬴":r(55),"🬵":r(56),"🬶":r(57),"🬷":r(58),"🬸":r(59),"🬹":r(60),"🬺":r(61),"🬻":r(62),"🬼":{type:3,data:"M0,0.6667 L0,1 L0.5,1 Z"},"🬽":{type:3,data:"M0,0.6667 L0,1 L1,1 Z"},"🬾":{type:3,data:"M0,0.3333 L0,1 L0.5,1 Z"},"🬿":{type:3,data:"M0,0.3333 L0,1 L1,1 Z"},"🭀":{type:3,data:"M0,0 L0,1 L0.5,1 Z"},"🭁":{type:3,data:"M0,0.3333 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭂":{type:3,data:"M0,0.3333 L1,0 L1,1 L0,1 Z"},"🭃":{type:3,data:"M0,0.6667 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭄":{type:3,data:"M0,0.6667 L1,0 L1,1 L0,1 Z"},"🭅":{type:3,data:"M0,1 L0.5,0 L1,0 L1,1 Z"},"🭆":{type:3,data:"M0,0.6667 L1,0.3333 L1,1 L0,1 Z"},"🭇":{type:3,data:"M0.5,1 L1,0.6667 L1,1 Z"},"🭈":{type:3,data:"M0,1 L1,0.6667 L1,1 Z"},"🭉":{type:3,data:"M0.5,1 L1,0.3333 L1,1 Z"},"🭊":{type:3,data:"M0,1 L1,0.3333 L1,1 Z"},"🭋":{type:3,data:"M0.5,1 L1,0 L1,1 Z"},"🭌":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.3333 Z"},"🭍":{type:3,data:"M0,0 L0,1 L1,1 L1,0.3333 Z"},"🭎":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.6667 Z"},"🭏":{type:3,data:"M0,0 L0,1 L1,1 L1,0.6667 Z"},"🭐":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 Z"},"🭑":{type:3,data:"M0,0.3333 L0,1 L1,1 L1,0.6667 Z"},"🭒":{type:3,data:"M0,0.6667 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭓":{type:3,data:"M0,0.6667 L1,1 L1,0 L0,0 Z"},"🭔":{type:3,data:"M0,0.3333 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭕":{type:3,data:"M0,0.3333 L1,1 L1,0 L0,0 Z"},"🭖":{type:3,data:"M0,0 L0.5,1 L1,1 L1,0 Z"},"🭗":{type:3,data:"M0,0.3333 L0,0 L0.5,0 Z"},"🭘":{type:3,data:"M0,0.3333 L0,0 L1,0 Z"},"🭙":{type:3,data:"M0,0.6667 L0,0 L0.5,0 Z"},"🭚":{type:3,data:"M0,0.6667 L0,0 L1,0 Z"},"🭛":{type:3,data:"M0,1 L0,0 L0.5,0 Z"},"🭜":{type:3,data:"M0,0.6667 L0,0 L1,0 L1,0.3333 Z"},"🭝":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.6667 Z"},"🭞":{type:3,data:"M0,1 L0,0 L1,0 L1,0.6667 Z"},"🭟":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.3333 Z"},"🭠":{type:3,data:"M0,1 L0,0 L1,0 L1,0.3333 Z"},"🭡":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 Z"},"🭢":{type:3,data:"M0.5,0 L1,0 L1,0.3333 Z"},"🭣":{type:3,data:"M0,0 L1,0 L1,0.3333 Z"},"🭤":{type:3,data:"M0.5,0 L1,0 L1,0.6667 Z"},"🭥":{type:3,data:"M0,0 L1,0 L1,0.6667 Z"},"🭦":{type:3,data:"M0.5,0 L1,0 L1,1 Z"},"🭧":{type:3,data:"M0,0.3333 L1,0.6667 L1,0 L0,0 Z"},"🭨":{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0.5,0.5 Z"},"🭩":{type:3,data:"M0,0 L0.5,0.5 L1,0 L1,1 L0,1 Z"},"🭪":{type:3,data:"M0,0 L1,0 L0.5,0.5 L1,1 L0,1 Z"},"🭫":{type:3,data:"M0,0 L1,0 L1,1 L0.5,0.5 L0,1 Z"},"🭬":{type:3,data:"M0,0 L0.5,0.5 L0,1 Z"},"🭭":{type:3,data:"M0,0 L1,0 L0.5,0.5 Z"},"🭮":{type:3,data:"M1,0 L1,1 L0.5,0.5 Z"},"🭯":{type:3,data:"M0,1 L1,1 L0.5,0.5 Z"},"🭰":{type:0,data:[{x:1,y:0,w:1,h:8}]},"🭱":{type:0,data:[{x:2,y:0,w:1,h:8}]},"🭲":{type:0,data:[{x:3,y:0,w:1,h:8}]},"🭳":{type:0,data:[{x:4,y:0,w:1,h:8}]},"🭴":{type:0,data:[{x:5,y:0,w:1,h:8}]},"🭵":{type:0,data:[{x:6,y:0,w:1,h:8}]},"🭶":{type:0,data:[{x:0,y:1,w:8,h:1}]},"🭷":{type:0,data:[{x:0,y:2,w:8,h:1}]},"🭸":{type:0,data:[{x:0,y:3,w:8,h:1}]},"🭹":{type:0,data:[{x:0,y:4,w:8,h:1}]},"🭺":{type:0,data:[{x:0,y:5,w:8,h:1}]},"🭻":{type:0,data:[{x:0,y:6,w:8,h:1}]},"🭼":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🭽":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭾":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭿":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🮀":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮁":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮂":{type:0,data:[{x:0,y:0,w:8,h:2}]},"🮃":{type:0,data:[{x:0,y:0,w:8,h:3}]},"🮄":{type:0,data:[{x:0,y:0,w:8,h:5}]},"🮅":{type:0,data:[{x:0,y:0,w:8,h:6}]},"🮆":{type:0,data:[{x:0,y:0,w:8,h:7}]},"🮇":{type:0,data:[{x:6,y:0,w:2,h:8}]},"🮈":{type:0,data:[{x:5,y:0,w:3,h:8}]},"🮉":{type:0,data:[{x:3,y:0,w:5,h:8}]},"🮊":{type:0,data:[{x:2,y:0,w:6,h:8}]},"🮋":{type:0,data:[{x:1,y:0,w:7,h:8}]},"🮌":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},"🮍":{type:1,data:[[1,0],[0,1]],clipPath:"M0.5,0 L1,0 L1,1 L0.5,1 Z"},"🮎":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},"🮏":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},"🮐":{type:1,data:[[0,1],[1,0]]},"🮑":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},{type:0,data:[{x:0,y:0,w:8,h:4}]}],"🮒":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},{type:0,data:[{x:0,y:4,w:8,h:4}]}],"🮔":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},{type:0,data:[{x:4,y:0,w:4,h:8}]}],"🮕":{type:0,data:[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}]},"🮖":{type:0,data:[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}]},"🮗":{type:0,data:[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},"🮘":{type:2,data:"M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25",strokeWidth:1},"🮙":{type:2,data:"M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75",strokeWidth:1},"🮚":{type:5,data:{d:"M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0",type:0}},"🮛":{type:5,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1",type:0}},"🮜":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L0,1 Z"},"🮝":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,1 Z"},"🮞":{type:1,data:[[1,0],[0,1]],clipPath:"M1,0 L1,1 L0,1 Z"},"🮟":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,1 L0,1 Z"},"🮠":{type:2,data:"M.5,0 L0,.5",strokeWidth:1},"🮡":{type:2,data:"M.5,0 L1,.5",strokeWidth:1},"🮢":{type:2,data:"M0,.5 L.5,1",strokeWidth:1},"🮣":{type:2,data:"M1,.5 L.5,1",strokeWidth:1},"🮤":{type:2,data:"M.5,0 L0,.5 L.5,1",strokeWidth:1},"🮥":{type:2,data:"M.5,0 L1,.5 L.5,1",strokeWidth:1},"🮦":{type:2,data:"M0,.5 L.5,1 L1,.5",strokeWidth:1},"🮧":{type:2,data:"M0,.5 L.5,0 L1,.5",strokeWidth:1},"🮨":{type:2,data:"M.5,0 L0,.5 M1,.5 L.5,1",strokeWidth:1},"🮩":{type:2,data:"M.5,0 L1,.5 M0,.5 L.5,1",strokeWidth:1},"🮪":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5",strokeWidth:1},"🮫":{type:2,data:"M.5,0 L0,.5 L.5,1 L1,.5",strokeWidth:1},"🮬":{type:2,data:"M0,.5 L.5,0 L1,.5 L.5,1",strokeWidth:1},"🮭":{type:2,data:"M1,.5 L.5,0 L0,.5 L.5,1",strokeWidth:1},"🮮":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5 Z",strokeWidth:1},"🮯":{type:2,data:"M0,.5 L1,.5 M.5,.35 L.5,.65",strokeWidth:1},"🮰":{type:3,data:"M0.1,0.2 L0.1,.8 L.4,.6 L.9,0.6 Z",scaleType:1},"🮱":{type:4,data:{d:"M.1,.525 L.35,.675 L.9,.35",type:1},scaleType:1},"🮲":{type:3,data:"M.29,.27 L0.13,.56 L.22,.59 L.35,0.35 L.67,.35 L.57,.57 L.71,.76 L.22,.76 L.42,1 L.53,.98 L.43,.86 L.9,.86 L.71,.6 L1,.6 L1,.52 L.83,.52 L.92,.36 L1,.36 L1,.27Z M.99,.13 A.12,.12,0,1,1,.75,.13 A.12,.12,0,1,1,.99,.13"},"🮳":{type:3,data:"M0,.27 L.3,.27 L.55,.12 L.63,.18 L.33,.36 L0,.36 M0,.52 L.33,.52 L.59,.89 L.73,.89 L.73,.98 L.53,.98 L.28,.6 L0,.6"},"🮴":{type:4,data:{d:"M.15,.55 L.5,.4 L.5,.45 L.65,.45 L.65,.35 L.85,.35 L.85,.625 L.5,.625 L.5,.7 Z",type:0},scaleType:1},"🮵":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.15,.5 L.5,.35 L.5,.425 L.85,.425 L.85,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮶":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.85,.5 L.5,.35 L.5,.425 L.15,.425 L.15,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮷":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.675 L.2,.5 L.35,.5 L.35,.325 L.65,.325 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮸":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.325 L.2,.5 L.35,.5 L.35,.675 L.65,.675 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮹":{type:5,data:{d:"M1,.89 L.11,.89 L.11,.37 L.36,.12 L.74,.12 L.96,.34 L1,.34 L1,.405 L.92,.405 L.69,.185 L.41,.185 L.21,.42 L.21,.825 L1,.825 Z",type:0}},"🮺":{type:5,data:{d:"M0,.89 L0,.825 L.78,.825 L.78,.53 L.7,.415 L0,.415 L0,.35 L.75,.35 L.88,.48 L.88,.89 Z",type:0}},"🮻":{type:5,data:{d:"M.31,.275 L.44,.275 L.44,.47 L.05,.47 L.05,.405 L.31,.405 Z M.56,.275 L.69,.275 L.69,.405 L.95,.405 L.95,.47 L.56,.47 Z M.05,.53 L.44,.53 L.44,.725 L.31,.725 L.31,.595 L.05,.595 Z M.56,.53 L.95,.53 L.95,.595 L.69,.595 L.69,.725 L.56,.725 Z",type:0},scaleType:1},"🮼":[{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0,.935 L.885,.935 L.885,.065 L0,.065 Z"},{type:3,data:"M.67,.5 A.17,.085,0,1,1,.33,.5 A.17,.085,0,1,1,.67,.5",scaleType:1}],"🮽":{type:4,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1 Z",type:1}},"🮾":{type:4,data:{d:"M1,.5 L.5,1",type:1}},"🮿":{type:4,data:{d:"M.5,0 L1,.5 L.5,1 L0,.5 Z",type:1}},"🯀":{type:3,data:"M.16,.445 A.02,.01,0,0,1,.39,.33 L.5,.375 L.61,.33 A.02,.01,0,0,1,.84,.445 L.75,.5 L.84,.555 A.02,.01,0,0,1,.61,.67 L.5,.625 L.39,.67 A.02,.01,0,0,1,.16,.555 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",scaleType:1},"🯁":{type:3,data:"M0.142308,0.924 Q0.130770,0.924,0.120193,0.922 T0.101924,0.916 T0.089424,0.9065 T0.084616,0.895 V0.025 Q0.084616,0.019,0.089424,0.0135 T0.101924,0.004 T0.120193,-0.002 T0.142308,-0.004 Q0.150000,-0.004,0.157693,-0.003 T0.173078,0.000 L0.430770,0.085 Q0.451924,0.059,0.494232,0.041 T0.587501,0.013 T0.692309,-0.0005 T0.800001,-0.004 H1 V0.055 H0.800001 Q0.771155,0.055,0.742309,0.056 T0.685578,0.060 T0.630770,0.0685 T0.580770,0.0825 T0.543270,0.1045 T0.528847,0.133 V0.647 H0.530770 Q0.596155,0.645,0.659616,0.638 T0.782693,0.6165 T0.893270,0.5805 T1,0.531 V0.623 Q0.934616,0.644,0.880770,0.6595 T0.769232,0.685 T0.650963,0.700 T0.528847,0.707 V0.787 Q0.528847,0.802,0.543270,0.8155 T0.580770,0.8375 T0.630770,0.8515 T0.685578,0.860 T0.742309,0.864 T0.800001,0.865 H1 V0.924 H0.800001 Q0.746155,0.924,0.692309,0.9205 T0.587501,0.907 T0.494232,0.879 T0.430770,0.835 L0.173078,0.920 Q0.165386,0.922,0.157693,0.923 T0.142308,0.924 Z M0.2,0.841 L0.415385,0.770 V0.150 L0.2,0.079 V0.841 Z M1,0.698 Q0.973077,0.694,0.969231,0.6885 T0.965385,0.677 Q0.965385,0.672,0.969231,0.6665 T1,0.657 V0.698 Z"},"🯂":{type:3,data:"M0,0.623 V0.531 Q0.044231,0.512,0.0625,0.4905 T0.092308,0.4465 T0.108654,0.4005 T0.113462,0.354 V0.351 Q0.113462,0.345,0.117308,0.3395 T0.129808,0.3305 T0.148846,0.3245 T0.171154,0.323 H0.501923 Q0.532692,0.323,0.563462,0.3185 T0.617308,0.303 T0.651923,0.2765 T0.663462,0.2435 V0.241 Q0.663462,0.219,0.657692,0.197 T0.639423,0.1545 T0.604808,0.115 T0.549615,0.082 T0.475577,0.0615 T0.392308,0.055 H0 V-0.004 H1 V0.055 H0.667308 Q0.694231,0.071,0.713462,0.09 T0.746154,0.129 T0.766346,0.1705 T0.775,0.213 H1 V0.316 Q0.971154,0.305,0.955769,0.2965 T0.920192,0.2825 T0.876923,0.2745 T0.830769,0.272 H0.773077 Q0.767308,0.297,0.743269,0.319 T0.680769,0.355 T0.595192,0.375 T0.501923,0.381 H0.227308 Q0.223462,0.415,0.211,0.4485 T0.172692,0.5135 T0.108269,0.573 T0,0.623 Z M0.611538,0.924 H0 V0.865 H0.611538 Q0.642308,0.865,0.673077,0.8605 T0.727885,0.845 T0.762308,0.8185 T0.773077,0.787 V0.785 Q0.773077,0.769,0.762308,0.7535 T0.727885,0.727 T0.673077,0.7115 T0.611538,0.707 H0.059615 Q0.048077,0.707,0.037115,0.7045 T0,0.694 V0.653 Q0.026923,0.648,0.037115,0.646 T0.059615,0.644 H0.722692 Q0.753462,0.644,0.784231,0.6395 T0.839038,0.624 T0.873846,0.5975 T0.884615,0.566 V0.564 Q0.884615,0.548,0.873846,0.532 T0.839038,0.5055 T0.784231,0.49 T0.722692,0.4855 H0.392308 Q0.380769,0.4855,0.370192,0.483 T0.351923,0.4765 T0.339423,0.467 T0.334615,0.4555 T0.339423,0.444 T0.351923,0.4345 T0.370192,0.428 T0.392308,0.4255 H0.832692 Q0.855769,0.4255,0.878846,0.4235 T0.922115,0.416 T0.957692,0.4015 T1,0.3815 V0.4685 Q0.975,0.4705,0.966346,0.4725 T0.95,0.4765 Q0.961538,0.4835,0.969231,0.4915 T1,0.5075 V0.6205 Q0.965385,0.6455,0.926923,0.665 T0.84,0.6935 Q0.866923,0.7115,0.877596,0.7345 T0.894231,0.7855 V0.787 Q0.894231,0.815,0.876923,0.8425 T0.820192,0.8895 T0.726923,0.916 T0.611538,0.924 Z"},"🯃":{type:3,data:"M0,0.473 V0.386 Q0.025490,0.378,0.028431,0.3695 T0.031373,0.352 V0.35 Q0.031373,0.342,0.028431,0.333 T0,0.316 V0.213 H0.652941 Q0.684314,0.213,0.715686,0.2085 T0.770588,0.193 T0.804902,0.1665 T0.815686,0.135 V0.133 Q0.815686,0.117,0.804902,0.101 T0.770588,0.0745 T0.715686,0.0595 T0.652941,0.055 H0 V-0.004 H0.652941 Q0.707843,-0.004,0.761765,0.004 T0.856863,0.031 T0.915686,0.0775 T0.933333,0.133 V0.135 Q0.933333,0.163,0.915686,0.1905 T0.856863,0.237 T0.761765,0.264 T0.652941,0.272 H0.109804 Q0.131373,0.29,0.140196,0.31 T0.149020,0.35 V0.352 Q0.149020,0.37,0.142157,0.388 T0.118627,0.4225 T0.076471,0.452 T0,0.473 Z M0,0.625 V0.513 Q0.029412,0.526,0.032353,0.54 T0.035294,0.568 V0.57 Q0.035294,0.584,0.032353,0.598 T0,0.625 Z"},"🯄":{type:3,data:"M0.019231,1.085 V-0.165 H0.980769 V1.085 H0.019231 Z M0.446154,0.527 H0.553846 Q0.553846,0.511,0.5625,0.495 T0.591346,0.466 T0.631731,0.4405 T0.666346,0.4135 T0.688462,0.3825 T0.696154,0.35 Q0.696154,0.33,0.682692,0.311 T0.641346,0.2785 T0.575,0.259 T0.498077,0.253 T0.421154,0.259 T0.356731,0.279 T0.315385,0.3125 T0.301923,0.352 V0.357 H0.409615 V0.354 Q0.409615,0.345,0.414423,0.335 T0.431731,0.318 T0.4625,0.3075 T0.498077,0.304 Q0.517308,0.304,0.534615,0.307 T0.564423,0.3165 T0.582692,0.332 T0.588462,0.35 Q0.588462,0.366,0.572115,0.3805 T0.535577,0.4075 T0.496154,0.4335 T0.465385,0.4625 T0.45,0.4945 T0.446154,0.527 Z M0.5,0.667 Q0.519231,0.667,0.5375,0.664 T0.569231,0.654 T0.588462,0.637 T0.594231,0.617 T0.588462,0.5975 T0.569231,0.581 T0.5375,0.571 T0.5,0.568 T0.4625,0.571 T0.430769,0.581 T0.411538,0.5975 T0.405769,0.617 T0.411538,0.637 T0.430769,0.654 T0.4625,0.664 T0.5,0.667 Z",scaleType:1},"🯅":{type:5,data:{d:"M.44,.425 L.44,.51 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.51 L.56,.425 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯆":{type:5,data:{d:"M.44,.425 L.44,.46 L.23,.385 L.19,.43 L.44,.51 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.61,.735 L.7,.71 L.56,.56 L.56,.51 L.81,.43 L.77,.385 L.56,.46 L.56,.425 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯇":{type:5,data:{d:"M.44,.425 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.425 Z M.18,.53 L.23,.575 L.81,.43 L.77,.385 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯈":{type:5,data:{d:"M.44,.425 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.62,.735 L.71,.71 L.56,.56 L.56,.425 Z M.23,.385 L.18,.43 L.77,.575 L.81,.53 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯉":{type:5,data:{d:"M.44,.425 L.45,.495 L.15,.645 L.34,.645 L.34,.7 L.44,.7 L.44,.645 L.56,.645 L.56,.7 L.66,.7 L.66,.645 L.84,.645 L.54,.495 L.56,.425 Z M.39,.6 L.5,.55 L.60,.6 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯊":{type:5,data:{d:"M.26,.375 L.5,.255 L.74,.375 L.74,.665 L.5,.55 L.26,.665 Z M.37,.395 L.37,.54 L.5,.475 L.63,.54 L.63,.395 L.5,.33 Z",type:0},scaleType:1},"🯋":{type:5,data:{d:"M.09,.41 L.32,.295 L.5,.375 L.68,.295 L.91,.41 L.75,.5 L.91,.59 L.68,.705 L.5,.625 L.32,.705 L.09,.59 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",type:0},scaleType:1},"🯌":{type:5,data:{d:"M.55,.305 L.88,.305 L.88,.355 L.65,.355 L.65,.47 L.88,.47 L.88,.52 L.55,.52 Z",type:0},scaleType:1},"🯍":{type:5,data:{d:"M.38,.39 L.5,.33 L.62,.39 L.62,.53 L.5,.47 L.38,.53 Z",type:0},scaleType:1},"🯎":{type:3,data:"M0,0 L0.6667,0 L0.6667,1 L0,1 Z"},"🯏":{type:3,data:"M0,0 L0.3333,0 L0.3333,1 L0,1 Z"},"🯐":{type:2,data:"M1,.5 L0,1",strokeWidth:1},"🯑":{type:2,data:"M1,0 L0,.5",strokeWidth:1},"🯒":{type:2,data:"M0,0 L1,.5",strokeWidth:1},"🯓":{type:2,data:"M0,.5 L1,1",strokeWidth:1},"🯔":{type:2,data:"M0,0 L.5,1",strokeWidth:1},"🯕":{type:2,data:"M.5,0 L1,1",strokeWidth:1},"🯖":{type:2,data:"M1,0 L.5,1",strokeWidth:1},"🯗":{type:2,data:"M.5,0 L0,1",strokeWidth:1},"🯘":{type:2,data:"M0,0 L.5,.5 L1,0",strokeWidth:1},"🯙":{type:2,data:"M1,0 L.5,.5 L1,1",strokeWidth:1},"🯚":{type:2,data:"M0,1 L.5,.5 L1,1",strokeWidth:1},"🯛":{type:2,data:"M0,0 L.5,.5 L0,1",strokeWidth:1},"🯜":{type:2,data:"M0,0 L.5,1 L1,0",strokeWidth:1},"🯝":{type:2,data:"M1,0 L0,.5 L1,1",strokeWidth:1},"🯞":{type:2,data:"M0,1 L.5,0 L1,1",strokeWidth:1},"🯟":{type:2,data:"M0,0 L1,.5 L0,1",strokeWidth:1},"🯠":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0",type:1}},"🯡":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1",type:1}},"🯢":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1",type:1}},"🯣":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1",type:1}},"🯤":{type:0,data:[{x:2,y:0,w:4,h:4}]},"🯥":{type:0,data:[{x:2,y:4,w:4,h:4}]},"🯦":{type:0,data:[{x:0,y:2,w:4,h:4}]},"🯧":{type:0,data:[{x:4,y:2,w:4,h:4}]},"🯨":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0 Z",type:0}},"🯩":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1 Z",type:0}},"🯪":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1 Z",type:0}},"🯫":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1 Z",type:0}},"🯬":{type:5,data:{d:"M1,0 L.5,0 C.5,.276,.724,.5,1,.5 Z",type:0}},"🯭":{type:5,data:{d:"M0,1 L.5,1 C.5,.724,.276,.5,0,.5 Z",type:0}},"🯮":{type:5,data:{d:"M1,1 L1,.5 C.724,.5,.5,.724,.5,1 Z",type:0}},"🯯":{type:5,data:{d:"M0,0 L0,.5 C.276,.5,.5,.276,.5,0 Z",type:0}},"🯰":{type:5,data:{d:a(126),type:0},scaleType:1},"🯱":{type:5,data:{d:a(48),type:0},scaleType:1},"🯲":{type:5,data:{d:a(109),type:0},scaleType:1},"🯳":{type:5,data:{d:a(121),type:0},scaleType:1},"🯴":{type:5,data:{d:a(51),type:0},scaleType:1},"🯵":{type:5,data:{d:a(91),type:0},scaleType:1},"🯶":{type:5,data:{d:a(95),type:0},scaleType:1},"🯷":{type:5,data:{d:a(114),type:0},scaleType:1},"🯸":{type:5,data:{d:a(127),type:0},scaleType:1},"🯹":{type:5,data:{d:a(123),type:0},scaleType:1},"🯺":{type:5,data:{d:"M.5,.175 C.2,.175,.15,.305,.15,.435 L.05,.63 L.35,.63 C.35,.682,.42,.76,.5,.76 C.58,.76,.65,.682,.65,.63 L.95,.63 L.85,.435 C.85,.305,.8,.175,.5,.175 Z",type:0},scaleType:1},...Object.fromEntries(Array.from({length:256},(t,e)=>[String.fromCodePoint(10240+e),{type:6,data:e}]))},e.blockPatternCodepoints=new Set([9617,9618,9619,129932,129933,129934,129935,129936,129937,129938,129940,129948,129949,129950,129951])},917(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.ExtendedAttrs=e.AttributeData=void 0;class i{static toColorRGB(t){return[t>>>16&255,t>>>8&255,255&t]}static fromColorRGB(t){return(255&t[0])<<16|(255&t[1])<<8|255&t[2]}clone(){const t=new i;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}fg=0;bg=0;extended=new s;isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}e.AttributeData=i;class s{_ext=0;get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(t){this._ext=t}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(t){this._ext&=-469762049,this._ext|=t<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(t){this._ext&=-67108864,this._ext|=67108863&t}_urlId=0;get urlId(){return this._urlId}set urlId(t){this._urlId=t}get underlineVariantOffset(){const t=(3758096384&this._ext)>>29;return t<0?4294967288^t:t}set underlineVariantOffset(t){this._ext&=536870911,this._ext|=t<<29&3758096384}constructor(t=0,e=0){this._ext=t,this._urlId=e}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}e.ExtendedAttrs=s},930(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.DebouncedIdleTask=e.IdleTaskQueue=e.PriorityTaskQueue=void 0;class i{_tasks=[];_idleCallback;_i=0;_logService;constructor(t){this._logService=t}enqueue(t){this._tasks.push(t),this._start()}flush(){for(;this._ir)return s-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-e))}ms`),void this._start();s=r}this.clear()}}class s extends i{_requestCallback(t){return setTimeout(()=>t(this._createDeadline(16)))}_cancelCallback(t){clearTimeout(t)}_createDeadline(t){const e=performance.now()+t;return{timeRemaining:()=>Math.max(0,e-performance.now())}}}e.PriorityTaskQueue=s,e.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(t){return requestIdleCallback(t)}_cancelCallback(t){cancelIdleCallback(t)}}:s,e.DebouncedIdleTask=class{_queue;constructor(t){this._queue=new e.IdleTaskQueue(t)}set(t){this._queue.clear(),this._queue.enqueue(t)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},948(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.createSelectionRenderModel=function(){return new i};class i{hasSelection;columnSelectMode;viewportStartRow;viewportEndRow;viewportCappedStartRow;viewportCappedEndRow;startCol;endCol;selectionStart;selectionEnd;constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(t,e,i,s=!1){if(this.selectionStart=e,this.selectionEnd=i,!e||!i||e[0]===i[0]&&e[1]===i[1])return void this.clear();const r=t.buffers.active.ydisp,a=e[1]-r,o=i[1]-r,n=Math.max(a,0),L=Math.min(o,t.rows-1);n>=t.rows||L<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=a,this.viewportEndRow=o,this.viewportCappedStartRow=n,this.viewportCappedEndRow=L,this.startCol=e[0],this.endCol=i[0])}isCellSelected(t,e,i){return!!this.hasSelection&&(i-=t.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&i>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&e=this.startCol)}}},959(t,e){function i(t){return 57508<=t&&t<=57558}function s(t){return t>=128512&&t<=128591||t>=127744&&t<=128511||t>=128640&&t<=128767||t>=9728&&t<=9983||t>=9984&&t<=10175||t>=65024&&t<=65039||t>=129280&&t<=129535||t>=127462&&t<=127487}Object.defineProperty(e,"__esModule",{value:!0}),e.throwIfFalsy=function(t){if(!t)throw new Error("value must not be falsy");return t},e.isPowerlineGlyph=i,e.isRestrictedPowerlineGlyph=function(t){return 57520<=t&&t<=57527},e.isEmoji=s,e.allowRescaling=function(t,e,r,a){return 1===e&&r>Math.ceil(1.5*a)&&void 0!==t&&t>255&&!s(t)&&!i(t)&&!function(t){return 57344<=t&&t<=63743}(t)},e.treatGlyphAsBackgroundColor=function(t){return i(t)||function(t){return 9472<=t&&t<=9631}(t)},e.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},e.computeNextVariantOffset=function(t,e,i=0){return(t-(2*Math.round(e)-i))%(2*Math.round(e))}},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=l,e.contrastRatio=d;let i=0,s=0,r=0,a=0;var o,n,L,T,h;function l(t){const e=t.toString(16);return e.length<2?"0"+e:e}function d(t,e){return t>>0},t.toColor=function(e,i,s,r){return{css:t.toCss(e,i,s,r),rgba:t.toRgba(e,i,s,r)}}}(o||(e.channels=o={})),function(t){function e(t,e){return a=Math.round(255*e),[i,s,r]=h.toChannels(t.rgba),{css:o.toCss(i,s,r,a),rgba:o.toRgba(i,s,r,a)}}t.blend=function(t,e){if(a=(255&e.rgba)/255,1===a)return{css:e.css,rgba:e.rgba};const n=e.rgba>>24&255,L=e.rgba>>16&255,T=e.rgba>>8&255,h=t.rgba>>24&255,l=t.rgba>>16&255,d=t.rgba>>8&255;return i=h+Math.round((n-h)*a),s=l+Math.round((L-l)*a),r=d+Math.round((T-d)*a),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,i){const s=h.ensureContrastRatio(t.rgba,e.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[i,s,r]=h.toChannels(e),{css:o.toCss(i,s,r),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,i){return a=255&t.rgba,e(t,a*i/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(n||(e.color=n={})),function(t){let e,n;try{const t=document.createElement("canvas");t.width=1,t.height=1;const i=t.getContext("2d",{willReadFrequently:!0});i&&(e=i,e.globalCompositeOperation="copy",n=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),a=parseInt(t.slice(4,5).repeat(2),16),o.toColor(i,s,r,a);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const L=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(L)return i=parseInt(L[1]),s=parseInt(L[2]),r=parseInt(L[3]),a=Math.round(255*(void 0===L[5]?1:parseFloat(L[5]))),o.toColor(i,s,r,a);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!n)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=n,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[i,s,r,a]=e.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,a),css:t}}}(L||(e.css=L={})),function(t){function e(t,e,i){const s=t/255,r=e/255,a=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(T||(e.rgb=T={})),function(t){function e(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));for(;h0||n>0||L>0);)o-=Math.max(0,Math.ceil(.1*o)),n-=Math.max(0,Math.ceil(.1*n)),L-=Math.max(0,Math.ceil(.1*L)),h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));return(o<<24|n<<16|L<<8|255)>>>0}function n(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));for(;h>>0}t.blend=function(t,e){if(a=(255&e)/255,1===a)return e;const n=e>>24&255,L=e>>16&255,T=e>>8&255,h=t>>24&255,l=t>>16&255,d=t>>8&255;return i=h+Math.round((n-h)*a),s=l+Math.round((L-l)*a),r=d+Math.round((T-d)*a),o.toRgba(i,s,r)},t.ensureContrastRatio=function(t,i,s){const r=T.relativeLuminance(t>>8),a=T.relativeLuminance(i>>8);if(d(r,a)>8));if(od(r,T.relativeLuminance(e>>8))?a:e}return a}const o=n(t,i,s),L=d(r,T.relativeLuminance(o>>8));if(Ld(r,T.relativeLuminance(a>>8))?o:a}return o}},t.reduceLuminance=e,t.increaseLuminance=n,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function i(s){var r=e[s];if(void 0!==r)return r.exports;var a=e[s]={exports:{}};return t[s](a,a.exports,i),a.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.WebglAddon=void 0;const e=i(426),r=i(95),a=i(399),o=i(414);class n extends e.Disposable{_terminal;_renderer;_onChangeTextureAtlas=this._register(new o.Emitter);onChangeTextureAtlas=this._onChangeTextureAtlas.event;_onAddTextureAtlasCanvas=this._register(new o.Emitter);onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=this._register(new o.Emitter);onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;_onContextLoss=this._register(new o.Emitter);onContextLoss=this._onContextLoss.event;_customGlyphs;_preserveDrawingBuffer;constructor(t){if(r.isSafari&&(0,r.getSafariVersion)()<16){const t={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",t))throw new Error("Webgl2 is only supported on Safari 16 and above")}super(),this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){const i=t._core;if(!t.element)return void this._register(i.onWillOpen(()=>this.activate(t)));this._terminal=t;const s=i.coreService,r=i.optionsService,n=i,L=n._renderService,T=n._characterJoinerService,h=n._charSizeService,l=n._coreBrowserService,d=n._decorationService,c=n._themeService;this._renderer=this._register(new a.WebglRenderer(t,T,h,l,s,d,r,c,this._customGlyphs,this._preserveDrawingBuffer)),this._register(o.EventUtils.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(o.EventUtils.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(o.EventUtils.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(o.EventUtils.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),L.setRenderer(this._renderer),this._register((0,e.toDisposable)(()=>{if(this._terminal._core._store._isDisposed)return;const e=this._terminal._core._renderService;e.setRenderer(this._terminal._core._createRenderer()),e.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}}t.WebglAddon=n})(),s})()); - //# sourceMappingURL=addon-webgl.js.map -\ No newline at end of file -diff --git a/lib/addon-webgl.mjs b/lib/addon-webgl.mjs -index 85c58455d58b8251404bfcc3d11130365085e8ee..5e1ad535a5888b0316e2d4e1d8152a1289a8ceb4 100644 ---- a/lib/addon-webgl.mjs -+++ b/lib/addon-webgl.mjs -@@ -48,7 +48,7 @@ void main() { - if (v_texpage == 0) { - outColor = texture(u_texture[0], v_texcoord); - } ${e} --}`}var N=0,S,Oe=0,H0=0,ie=class extends W{constructor(t,r,n,a){super();this._terminal=t;this._gl=r;this._dimensions=n;this._optionsService=a;this._activeBuffer=0;this._vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};let s=this._gl;j.maxAtlasPages===void 0&&(j.maxAtlasPages=Math.min(32,y(s.getParameter(s.MAX_TEXTURE_IMAGE_UNITS))),j.maxTextureSize=y(s.getParameter(s.MAX_TEXTURE_SIZE))),this._program=y(te(s,zt,Xt(j.maxAtlasPages))),this._register(g(()=>s.deleteProgram(this._program))),this._projectionLocation=y(s.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=y(s.getUniformLocation(this._program,"u_resolution")),this._textureLocation=y(s.getUniformLocation(this._program,"u_texture")),this._vertexArrayObject=s.createVertexArray(),s.bindVertexArray(this._vertexArrayObject);let o=new Float32Array([0,0,1,0,0,1,1,1]),l=s.createBuffer();this._register(g(()=>s.deleteBuffer(l))),s.bindBuffer(s.ARRAY_BUFFER,l),s.bufferData(s.ARRAY_BUFFER,o,s.STATIC_DRAW),s.enableVertexAttribArray(0),s.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);let T=new Uint8Array([0,1,2,3]),h=s.createBuffer();this._register(g(()=>s.deleteBuffer(h))),s.bindBuffer(s.ELEMENT_ARRAY_BUFFER,h),s.bufferData(s.ELEMENT_ARRAY_BUFFER,T,s.STATIC_DRAW),this._attributesBuffer=y(s.createBuffer()),this._register(g(()=>s.deleteBuffer(this._attributesBuffer))),s.bindBuffer(s.ARRAY_BUFFER,this._attributesBuffer),s.enableVertexAttribArray(2),s.vertexAttribPointer(2,2,s.FLOAT,!1,44,0),s.vertexAttribDivisor(2,1),s.enableVertexAttribArray(3),s.vertexAttribPointer(3,2,s.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(3,1),s.enableVertexAttribArray(4),s.vertexAttribPointer(4,1,s.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(4,1),s.enableVertexAttribArray(5),s.vertexAttribPointer(5,2,s.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(5,1),s.enableVertexAttribArray(6),s.vertexAttribPointer(6,2,s.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(6,1),s.enableVertexAttribArray(1),s.vertexAttribPointer(1,2,s.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(1,1),s.useProgram(this._program);let d=new Int32Array(j.maxAtlasPages);for(let _=0;_s.deleteTexture(c.texture))),s.activeTexture(s.TEXTURE0+_),s.bindTexture(s.TEXTURE_2D,c.texture),s.texParameteri(s.TEXTURE_2D,s.TEXTURE_WRAP_S,s.CLAMP_TO_EDGE),s.texParameteri(s.TEXTURE_2D,s.TEXTURE_WRAP_T,s.CLAMP_TO_EDGE),s.texImage2D(s.TEXTURE_2D,0,s.RGBA,1,1,0,s.RGBA,s.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[_]=c}s.enable(s.BLEND),s.blendFunc(s.SRC_ALPHA,s.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return this._atlas?this._atlas.beginFrame():!0}updateCell(t,r,n,a,s,o,l,T,h){this._updateCell(this._vertices.attributes,t,r,n,a,s,o,l,T,h)}_updateCell(t,r,n,a,s,o,l,T,h,d){if(N=(n*this._terminal.cols+r)*11,a===0||a===void 0){t.fill(0,N,N+11-1-2);return}this._atlas&&(T&&T.length>1?S=this._atlas.getRasterizedGlyphCombinedChar(T,s,o,l,!1,this._terminal.element):S=this._atlas.getRasterizedGlyph(a,s,o,l,!1,this._terminal.element),Oe=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),s!==d&&S.offset.x>Oe?(H0=S.offset.x-Oe,t[N]=-(S.offset.x-H0)+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=(S.size.x-H0)/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x+H0/this._atlas.pages[S.texturePage].canvas.width,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x-H0/this._atlas.pages[S.texturePage].canvas.width,t[N+8]=S.sizeClipSpace.y):(t[N]=-S.offset.x+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=S.size.x/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x,t[N+8]=S.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&ke(a,h,S.size.x,this._dimensions.device.cell.width)&&(t[N+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width))}clear(){let t=this._terminal,r=t.cols*t.rows*11;this._vertices.count!==r?this._vertices.attributes=new Float32Array(r):this._vertices.attributes.fill(0);let n=0;for(;n=e.rows||T<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=n,this.viewportStartRow=s,this.viewportEndRow=o,this.viewportCappedStartRow=l,this.viewportCappedEndRow=T,this.startCol=t[0],this.endCol=r[0]}isCellSelected(e,t,r){return this.hasSelection?(r-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&r>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&r<=this.viewportCappedEndRow:r>this.viewportStartRow&&r=this.startCol&&t=this.startCol):!1}};function ot(){return new Ie}var Tt=2147483648,re=class{constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=ot()}resize(e,t){let r=e*t*4;r!==this.cells.length&&(this.cells=new Uint32Array(r),this.lineLengths=new Uint32Array(t))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}};var qt=`#version 300 es -+}`}var N=0,S,Oe=0,H0=0,ie=class extends W{constructor(t,r,n,a){super();this._terminal=t;this._gl=r;this._dimensions=n;this._optionsService=a;this._activeBuffer=0;this._vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};let s=this._gl;j.maxAtlasPages===void 0&&(j.maxAtlasPages=Math.min(32,y(s.getParameter(s.MAX_TEXTURE_IMAGE_UNITS))),j.maxTextureSize=y(s.getParameter(s.MAX_TEXTURE_SIZE))),this._program=y(te(s,zt,Xt(j.maxAtlasPages))),this._register(g(()=>s.deleteProgram(this._program))),this._projectionLocation=y(s.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=y(s.getUniformLocation(this._program,"u_resolution")),this._textureLocation=y(s.getUniformLocation(this._program,"u_texture")),this._vertexArrayObject=s.createVertexArray(),s.bindVertexArray(this._vertexArrayObject);let o=new Float32Array([0,0,1,0,0,1,1,1]),l=s.createBuffer();this._register(g(()=>s.deleteBuffer(l))),s.bindBuffer(s.ARRAY_BUFFER,l),s.bufferData(s.ARRAY_BUFFER,o,s.STATIC_DRAW),s.enableVertexAttribArray(0),s.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);let T=new Uint8Array([0,1,2,3]),h=s.createBuffer();this._register(g(()=>s.deleteBuffer(h))),s.bindBuffer(s.ELEMENT_ARRAY_BUFFER,h),s.bufferData(s.ELEMENT_ARRAY_BUFFER,T,s.STATIC_DRAW),this._attributesBuffer=y(s.createBuffer()),this._register(g(()=>s.deleteBuffer(this._attributesBuffer))),s.bindBuffer(s.ARRAY_BUFFER,this._attributesBuffer),s.enableVertexAttribArray(2),s.vertexAttribPointer(2,2,s.FLOAT,!1,44,0),s.vertexAttribDivisor(2,1),s.enableVertexAttribArray(3),s.vertexAttribPointer(3,2,s.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(3,1),s.enableVertexAttribArray(4),s.vertexAttribPointer(4,1,s.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(4,1),s.enableVertexAttribArray(5),s.vertexAttribPointer(5,2,s.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(5,1),s.enableVertexAttribArray(6),s.vertexAttribPointer(6,2,s.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(6,1),s.enableVertexAttribArray(1),s.vertexAttribPointer(1,2,s.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(1,1),s.useProgram(this._program);let d=new Int32Array(j.maxAtlasPages);for(let _=0;_s.deleteTexture(c.texture))),s.activeTexture(s.TEXTURE0+_),s.bindTexture(s.TEXTURE_2D,c.texture),s.texParameteri(s.TEXTURE_2D,s.TEXTURE_WRAP_S,s.CLAMP_TO_EDGE),s.texParameteri(s.TEXTURE_2D,s.TEXTURE_WRAP_T,s.CLAMP_TO_EDGE),s.texImage2D(s.TEXTURE_2D,0,s.RGBA,1,1,0,s.RGBA,s.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[_]=c}s.enable(s.BLEND),s.blendFunc(s.SRC_ALPHA,s.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return this._atlas?this._atlas.beginFrame():!0}updateCell(t,r,n,a,s,o,l,T,h){this._updateCell(this._vertices.attributes,t,r,n,a,s,o,l,T,h)}_updateCell(t,r,n,a,s,o,l,T,h,d){if(N=(n*this._terminal.cols+r)*11,a===0||a===void 0){t.fill(0,N,N+11-1-2);return}this._atlas&&(T&&T.length>1?S=this._atlas.getRasterizedGlyphCombinedChar(T,s,o,l,!1,this._terminal.element):S=this._atlas.getRasterizedGlyph(a,s,o,l,!1,this._terminal.element),Oe=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),s!==d&&S.offset.x>Oe?(H0=S.offset.x-Oe,t[N]=-(S.offset.x-H0)+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=(S.size.x-H0)/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x+H0/this._atlas.pages[S.texturePage].canvas.width,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x-H0/this._atlas.pages[S.texturePage].canvas.width,t[N+8]=S.sizeClipSpace.y):(t[N]=-S.offset.x+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=S.size.x/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x,t[N+8]=S.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&ke(a,h,S.size.x,this._dimensions.device.cell.width)&&(t[N+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width))}clear(){let t=this._terminal,r=t.cols*t.rows*11;this._vertices.count!==r?this._vertices.attributes=new Float32Array(r):this._vertices.attributes.fill(0);let n=0;for(;n=e.rows||T<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=n,this.viewportStartRow=s,this.viewportEndRow=o,this.viewportCappedStartRow=l,this.viewportCappedEndRow=T,this.startCol=t[0],this.endCol=r[0]}isCellSelected(e,t,r){return this.hasSelection?(r-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&r>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&r<=this.viewportCappedEndRow:r>this.viewportStartRow&&r=this.startCol&&t=this.startCol):!1}};function ot(){return new Ie}var Tt=2147483648,re=class{constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=ot()}resize(e,t){let r=e*t*4;r!==this.cells.length&&(this.cells=new Uint32Array(r),this.lineLengths=new Uint32Array(t))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}};var qt=`#version 300 es - layout (location = 0) in vec2 a_position; - layout (location = 1) in vec2 a_size; - layout (location = 2) in vec4 a_color; -diff --git a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts -index 568d3a4f968a98ad64cad81eaaf543b0b06401ed..aa382eed1b178a21a1e8cb7f40a2d166a2d4fb71 100644 ---- a/src/GlyphRenderer.ts -+++ b/src/GlyphRenderer.ts -@@ -381,8 +381,12 @@ export class GlyphRenderer extends Disposable { - gl.bindTexture(gl.TEXTURE_2D, this._atlasTextures[i].texture); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); -+ // Why: glyph atlas pages are sampled 1:1. ANGLE/OpenGL drivers on Linux -+ // Wayland can fail mipmap allocation for large atlas uploads, corrupting -+ // terminal glyph textures instead of cleanly losing the WebGL context. -+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); -+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.pages[i].canvas); -- gl.generateMipmap(gl.TEXTURE_2D); - this._atlasTextures[i].version = atlas.pages[i].version; - } - diff --git a/config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch b/config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch new file mode 100644 index 00000000000..283c5c9e72d --- /dev/null +++ b/config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch @@ -0,0 +1,40 @@ +diff --git a/lib/addon-webgl.js b/lib/addon-webgl.js +index 462567b..9223708 100644 +--- a/lib/addon-webgl.js ++++ b/lib/addon-webgl.js +@@ -1,2 +1,2 @@ +-!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.WebglAddon=e():t.WebglAddon=e()}(globalThis,()=>(()=>{"use strict";var t={0(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TEXT_BASELINE=e.DIM_OPACITY=void 0;const s=i(95);e.DIM_OPACITY=.5,e.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},28(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GlyphRenderer=void 0;const s=i(115),r=i(564),a=i(426);i(816);const o=i(719),n=i(959);let L,T=0,h=0,l=0;class d extends a.Disposable{_terminal;_gl;_dimensions;_optionsService;_program;_vertexArrayObject;_projectionLocation;_resolutionLocation;_textureLocation;_atlasTextures;_attributesBuffer;_atlas;_activeBuffer=0;_vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};constructor(t,e,i,r){super(),this._terminal=t,this._gl=e,this._dimensions=i,this._optionsService=r;const L=this._gl;void 0===s.TextureAtlas.maxAtlasPages&&(s.TextureAtlas.maxAtlasPages=Math.min(32,(0,n.throwIfFalsy)(L.getParameter(L.MAX_TEXTURE_IMAGE_UNITS))),s.TextureAtlas.maxTextureSize=(0,n.throwIfFalsy)(L.getParameter(L.MAX_TEXTURE_SIZE))),this._program=(0,n.throwIfFalsy)((0,o.createProgram)(L,"#version 300 es\nlayout (location = 0) in vec2 a_unitquad;\nlayout (location = 1) in vec2 a_cellpos;\nlayout (location = 2) in vec2 a_offset;\nlayout (location = 3) in vec2 a_size;\nlayout (location = 4) in float a_texpage;\nlayout (location = 5) in vec2 a_texcoord;\nlayout (location = 6) in vec2 a_texsize;\n\nuniform mat4 u_projection;\nuniform vec2 u_resolution;\n\nout vec2 v_texcoord;\nflat out int v_texpage;\n\nvoid main() {\n vec2 zeroToOne = (a_offset / u_resolution) + a_cellpos + (a_unitquad * a_size);\n gl_Position = u_projection * vec4(zeroToOne, 0.0, 1.0);\n v_texpage = int(a_texpage);\n v_texcoord = a_texcoord + a_unitquad * a_texsize;\n}",function(t){let e="";for(let i=1;iL.deleteProgram(this._program))),this._projectionLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_resolution")),this._textureLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_texture")),this._vertexArrayObject=L.createVertexArray(),L.bindVertexArray(this._vertexArrayObject);const T=new Float32Array([0,0,1,0,0,1,1,1]),h=L.createBuffer();this._register((0,a.toDisposable)(()=>L.deleteBuffer(h))),L.bindBuffer(L.ARRAY_BUFFER,h),L.bufferData(L.ARRAY_BUFFER,T,L.STATIC_DRAW),L.enableVertexAttribArray(0),L.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);const l=new Uint8Array([0,1,2,3]),d=L.createBuffer();this._register((0,a.toDisposable)(()=>L.deleteBuffer(d))),L.bindBuffer(L.ELEMENT_ARRAY_BUFFER,d),L.bufferData(L.ELEMENT_ARRAY_BUFFER,l,L.STATIC_DRAW),this._attributesBuffer=(0,n.throwIfFalsy)(L.createBuffer()),this._register((0,a.toDisposable)(()=>L.deleteBuffer(this._attributesBuffer))),L.bindBuffer(L.ARRAY_BUFFER,this._attributesBuffer),L.enableVertexAttribArray(2),L.vertexAttribPointer(2,2,L.FLOAT,!1,44,0),L.vertexAttribDivisor(2,1),L.enableVertexAttribArray(3),L.vertexAttribPointer(3,2,L.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(3,1),L.enableVertexAttribArray(4),L.vertexAttribPointer(4,1,L.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(4,1),L.enableVertexAttribArray(5),L.vertexAttribPointer(5,2,L.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(5,1),L.enableVertexAttribArray(6),L.vertexAttribPointer(6,2,L.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(6,1),L.enableVertexAttribArray(1),L.vertexAttribPointer(1,2,L.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(1,1),L.useProgram(this._program);const c=new Int32Array(s.TextureAtlas.maxAtlasPages);for(let t=0;tL.deleteTexture(e.texture))),L.activeTexture(L.TEXTURE0+t),L.bindTexture(L.TEXTURE_2D,e.texture),L.texParameteri(L.TEXTURE_2D,L.TEXTURE_WRAP_S,L.CLAMP_TO_EDGE),L.texParameteri(L.TEXTURE_2D,L.TEXTURE_WRAP_T,L.CLAMP_TO_EDGE),L.texImage2D(L.TEXTURE_2D,0,L.RGBA,1,1,0,L.RGBA,L.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[t]=e}L.enable(L.BLEND),L.blendFunc(L.SRC_ALPHA,L.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return!this._atlas||this._atlas.beginFrame()}updateCell(t,e,i,s,r,a,o,n,L){this._updateCell(this._vertices.attributes,t,e,i,s,r,a,o,n,L)}_updateCell(t,e,i,s,a,o,d,c,_,Q){T=11*(i*this._terminal.cols+e),s!==r.NULL_CELL_CODE&&void 0!==s?this._atlas&&(L=c&&c.length>1?this._atlas.getRasterizedGlyphCombinedChar(c,a,o,d,!1,this._terminal.element):this._atlas.getRasterizedGlyph(s,a,o,d,!1,this._terminal.element),h=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),a!==Q&&L.offset.x>h?(l=L.offset.x-h,t[T]=-(L.offset.x-l)+this._dimensions.device.char.left,t[T+1]=-L.offset.y+this._dimensions.device.char.top,t[T+2]=(L.size.x-l)/this._dimensions.device.canvas.width,t[T+3]=L.size.y/this._dimensions.device.canvas.height,t[T+4]=L.texturePage,t[T+5]=L.texturePositionClipSpace.x+l/this._atlas.pages[L.texturePage].canvas.width,t[T+6]=L.texturePositionClipSpace.y,t[T+7]=L.sizeClipSpace.x-l/this._atlas.pages[L.texturePage].canvas.width,t[T+8]=L.sizeClipSpace.y):(t[T]=-L.offset.x+this._dimensions.device.char.left,t[T+1]=-L.offset.y+this._dimensions.device.char.top,t[T+2]=L.size.x/this._dimensions.device.canvas.width,t[T+3]=L.size.y/this._dimensions.device.canvas.height,t[T+4]=L.texturePage,t[T+5]=L.texturePositionClipSpace.x,t[T+6]=L.texturePositionClipSpace.y,t[T+7]=L.sizeClipSpace.x,t[T+8]=L.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&(0,n.allowRescaling)(s,_,L.size.x,this._dimensions.device.cell.width)&&(t[T+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width)):t.fill(0,T,T+11-1-2)}clear(){const t=this._terminal,e=t.cols*t.rows*11;this._vertices.count!==e?this._vertices.attributes=new Float32Array(e):this._vertices.attributes.fill(0);let i=0;for(;i=0,e.isChromeOS=/\bCrOS\b/.test(i)},105(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.WindowIntervalTimer=e.eventType=void 0,e.getWindow=r,e.addDisposableListener=o,e.addStandardDisposableListener=function(t,e,i,s){return o(t,e,i,s)},e.getDomNodePagePosition=function(t){const e=t.getBoundingClientRect(),i=r(t);return{left:e.left+i.scrollX,top:e.top+i.scrollY,width:e.width,height:e.height}},e.scheduleAtNextAnimationFrame=function(t,e,i=0){const s=T(t),r=new n(e,i);return s.next.push(r),s.animFrameRequested||(s.animFrameRequested=!0,t.requestAnimationFrame(()=>function(t){const e=T(t);for(e.animFrameRequested=!1,e.current=e.next,e.next=[],e.inAnimationFrameRunner=!0;e.current.length>0;)e.current.sort(n.sort),e.current.shift().execute();e.inAnimationFrameRunner=!1}(t))),r};const s=i(578);function r(t){const e=t;if(e?.ownerDocument?.defaultView)return e.ownerDocument.defaultView;const i=t;return i?.view?i.view:window}class a{_handler;_node;_type;_options;constructor(t,e,i,s){this._node=t,this._type=e,this._handler=i,this._options=s,t.addEventListener(e,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function o(t,e,i,s){return new a(t,e,i,s)}e.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class n{_runner;priority;_canceled=!1;constructor(t,e){this._runner=t,this.priority=e}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(t){console.error(t)}}static sort(t,e){return e.priority-t.priority}}const L=new Map;function T(t){let e=L.get(t);return e||(e={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},L.set(t,e)),e}class h extends s.IntervalTimer{_defaultTarget;constructor(t){super(),this._defaultTarget=t?r(t):void 0}cancelAndSet(t,e,i){super.cancelAndSet(t,e,i??this._defaultTarget??window)}}e.WindowIntervalTimer=h},115(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextureAtlas=void 0;const s=i(0),r=i(183),a=i(959),o=i(993),n=i(836),L=i(930),T=i(917),h=i(564),l=i(414),d={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}};let c;class _{_document;_config;_unicodeService;_logService;_didWarmUp=!1;_cacheMap=new n.FourKeyMap;_cacheMapCombined=new n.FourKeyMap;_pages=[];get pages(){return this._pages}_activePages=[];_overflowSizePage;_tmpCanvas;_tmpCtx;_workBoundingBox={top:0,left:0,bottom:0,right:0};_workAttributeData=new T.AttributeData;_textureSize=512;static maxAtlasPages;static maxTextureSize;_onAddTextureAtlasCanvas=new l.Emitter;onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=new l.Emitter;onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;constructor(t,e,i,s){this._document=t,this._config=e,this._unicodeService=i,this._logService=s,this._createNewPage(),this._tmpCanvas=p(t,4*this._config.deviceCellWidth+4,this._config.deviceCellHeight+4),this._tmpCtx=(0,a.throwIfFalsy)(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:!0}))}dispose(){this._tmpCanvas.remove();for(const t of this.pages)t.canvas.remove();this._onAddTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)}_doWarmUp(){const t=new L.IdleTaskQueue(this._logService);for(let e=33;e<126;e++)t.enqueue(()=>{if(!this._cacheMap.get(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT)){const t=this._drawToCache(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT,!1,void 0);this._cacheMap.set(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT,t)}})}_requestClearModel=!1;beginFrame(){const t=this._requestClearModel;return this._requestClearModel=!1,t}clearTexture(){if(0!==this._pages[0].currentRow.x||0!==this._pages[0].currentRow.y){for(const t of this._pages)t.clear();this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1}}_createNewPage(){if(_.maxAtlasPages&&this._pages.length>=Math.max(4,_.maxAtlasPages)){const t=this._pages.filter(t=>2*t.canvas.width<=(_.maxTextureSize||4096)).sort((t,e)=>e.canvas.width!==t.canvas.width?e.canvas.width-t.canvas.width:e.percentageUsed-t.percentageUsed);let e=-1,i=0;for(let s=0;st.canvas.width!==s[0].canvas.width)){const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}const r=s.map(t=>t.glyphs[0].texturePage).sort((t,e)=>t>e?1:-1),a=this.pages.length-s.length,o=this._mergePages(s,a);o.version=++Q.nextVersion;for(let t=r.length-1;t>=0;t--)this._deletePage(r[t]);this.pages.push(o),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(o.canvas)}const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}_mergePages(t,e){const i=2*t[0].canvas.width,s=new Q(this._document,i,t);for(const[r,a]of t.entries()){const t=r*a.canvas.width%i,o=Math.floor(r/2)*a.canvas.height;s.ctx.drawImage(a.canvas,t,o);for(const s of a.glyphs)s.texturePage=e,s.sizeClipSpace.x=s.size.x/i,s.sizeClipSpace.y=s.size.y/i,s.texturePosition.x+=t,s.texturePosition.y+=o,s.texturePositionClipSpace.x=s.texturePosition.x/i,s.texturePositionClipSpace.y=s.texturePosition.y/i;this._onRemoveTextureAtlasCanvas.fire(a.canvas);const n=this._activePages.indexOf(a);-1!==n&&this._activePages.splice(n,1)}return s}_deletePage(t){this._pages.splice(t,1);for(let e=t;e=this._config.colors.ansi.length)throw new Error("No color found for idx "+t);return this._config.colors.ansi[t]}_getBackgroundColor(t,e,i,s){if(this._config.allowTransparency)return o.NULL_COLOR;let r;switch(t){case 16777216:case 33554432:r=this._getColorFromAnsiIndex(e);break;case 50331648:const t=T.AttributeData.toColorRGB(e);r=o.channels.toColor(t[0],t[1],t[2]);break;default:r=i?o.color.opaque(this._config.colors.foreground):this._config.colors.background}return this._config.allowTransparency||(r=o.color.opaque(r)),r}_getForegroundColor(t,e,i,r,a,n,L,h,l,d){const c=this._getMinimumContrastColor(t,e,i,r,a,n,L,l,h,d);if(c)return c;let _;switch(a){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&l&&n<8&&(n+=8),_=this._getColorFromAnsiIndex(n);break;case 50331648:const t=T.AttributeData.toColorRGB(n);_=o.channels.toColor(t[0],t[1],t[2]);break;default:_=L?this._config.colors.background:this._config.colors.foreground}return this._config.allowTransparency&&(_=o.color.opaque(_)),h&&(_=o.color.multiplyOpacity(_,s.DIM_OPACITY)),_}_resolveBackgroundRgba(t,e,i){switch(t){case 16777216:case 33554432:return this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(t,e,i,s){switch(t){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&e<8&&(e+=8),this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(t,e,i,s,r,a,n,L,T,h){if(1===this._config.minimumContrastRatio||h)return;const l=this._getContrastCache(T),d=l.getColor(t,s);if(void 0!==d)return d??void 0;const c=this._resolveBackgroundRgba(e,i,n),_=this._resolveForegroundRgba(r,a,n,L),Q=o.rgba.ensureContrastRatio(c,_,this._config.minimumContrastRatio/(T?2:1));if(!Q)return void l.setColor(t,s,null);const u=o.channels.toColor(Q>>24&255,Q>>16&255,Q>>8&255);return l.setColor(t,s,u),u}_getContrastCache(t){return t?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(t,e,i,o,n,L){const h="number"==typeof t?String.fromCharCode(t):t;L&&this._tmpCanvas.parentElement!==L&&(this._tmpCanvas.style.display="none",L.append(this._tmpCanvas));const l=Math.min(this._config.deviceCellWidth*Math.max(h.length,2)+4,this._config.deviceMaxTextureSize);this._tmpCanvas.width=t?2*t-o:t-o;!1==!(o>=t)||0===_?(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(T+_,s),this._tmpCtx.lineTo(h,s)):(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(T+_,s),this._tmpCtx.moveTo(T+_+t,s),this._tmpCtx.lineTo(h,s)),o=(0,a.computeNextVariantOffset)(h-T,t,o);break;case 5:const Q=.6,u=.3,p=h-T,g=Math.floor(Q*p),M=Math.floor(u*p),y=p-g-M;this._tmpCtx.setLineDash([g,M,y]),this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(h,s);break;default:this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(h,s)}n||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!O&&this._config.fontSize>=12&&!this._config.allowTransparency&&" "!==h){this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";const e=this._tmpCtx.measureText(h);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in e&&e.actualBoundingBoxDescent>0){this._tmpCtx.save();const e=new Path2D;e.rect(i,L-Math.ceil(t/2),this._config.deviceCellWidth*I,l-L+Math.ceil(t/2)),this._tmpCtx.clip(e),this._tmpCtx.lineWidth=3*this._config.devicePixelRatio,this._tmpCtx.strokeStyle=E.css,this._tmpCtx.strokeText(h,D,D+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(C){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),e=t%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(D,D+e),this._tmpCtx.lineTo(D+this._config.deviceCharWidth*I,D+e),this._tmpCtx.stroke()}if(O||this._tmpCtx.fillText(h,D,D+this._config.deviceCharHeight),"_"===h&&!this._config.allowTransparency){let t=u(this._tmpCtx.getImageData(D,D,this._config.deviceCellWidth,this._config.deviceCellHeight),E,S,W);if(t)for(let e=1;e<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=E.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(h,D,D+this._config.deviceCharHeight-e),t=u(this._tmpCtx.getImageData(D,D,this._config.deviceCellWidth,this._config.deviceCellHeight),E,S,W),t);e++);}if(f){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),e=this._tmpCtx.lineWidth%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(D,D+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.lineTo(D+this._config.deviceCharWidth*I,D+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.stroke()}this._tmpCtx.restore();const N=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height);let P;if(P=this._config.allowTransparency?function(t){for(let e=0;e0)return!1;return!0}(N):u(N,E,S,W),P)return d;const B=this._findGlyphBoundingBox(N,this._workBoundingBox,l,Z,O,D);let $,F;for(;;){if(0===this._activePages.length){const t=this._createNewPage();$=t,F=t.currentRow,F.height=B.size.y;break}$=this._activePages[this._activePages.length-1],F=$.currentRow;for(const t of this._activePages)B.size.y<=t.currentRow.height&&($=t,F=t.currentRow);for(let t=this._activePages.length-1;t>=0;t--)for(const e of this._activePages[t].fixedRows)e.height<=F.height&&B.size.y<=e.height&&($=this._activePages[t],F=e);if(B.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new Q(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),$=this._overflowSizePage,F=this._overflowSizePage.currentRow,F.x+B.size.x>=$.canvas.width&&(F.x=0,F.y+=F.height,F.height=0);break}if(F.y+B.size.y>=$.canvas.height||F.height>B.size.y+2){let t=!1;if($.currentRow.y+$.currentRow.height+B.size.y>=$.canvas.height){let e;for(const t of this._activePages)if(t.currentRow.y+t.currentRow.height+B.size.y=_.maxAtlasPages&&F.y+B.size.y<=$.canvas.height&&F.height>=B.size.y&&F.x+B.size.x<=$.canvas.width)t=!0;else{const e=this._createNewPage();$=e,F=e.currentRow,F.height=B.size.y,t=!0}}t||($.currentRow.height>0&&$.fixedRows.push($.currentRow),F={x:0,y:$.currentRow.y+$.currentRow.height,height:B.size.y},$.fixedRows.push(F),$.currentRow={x:0,y:F.y+F.height,height:0})}if(F.x+B.size.x<=$.canvas.width)break;F===$.currentRow?(F.x=0,F.y+=F.height,F.height=0):$.fixedRows.splice($.fixedRows.indexOf(F),1)}return B.texturePage=this._pages.indexOf($),B.texturePosition.x=F.x,B.texturePosition.y=F.y,B.texturePositionClipSpace.x=F.x/$.canvas.width,B.texturePositionClipSpace.y=F.y/$.canvas.height,B.sizeClipSpace.x/=$.canvas.width,B.sizeClipSpace.y/=$.canvas.height,F.height=Math.max(F.height,B.size.y),F.x+=B.size.x,$.ctx.putImageData(N,B.texturePosition.x-this._workBoundingBox.left,B.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,B.size.x,B.size.y),$.addGlyph(B),$.version=++Q.nextVersion,B}_findGlyphBoundingBox(t,e,i,s,r,a){e.top=0;const o=s?this._config.deviceCellHeight:this._tmpCanvas.height,n=s?this._config.deviceCellWidth:i;let L=!1;for(let i=0;i=a;i--){for(let s=0;s=0;i--){for(let s=0;s>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=i.rgba>>>24,L=i.rgba>>>16&255,T=i.rgba>>>8&255,h=Math.floor((Math.abs(r-n)+Math.abs(a-L)+Math.abs(o-T))/12);let l=!0;for(let e=0;e{this._refreshCharAtlas(t,e),this.reset(t)})),this._register((0,r.toDisposable)(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=(0,a.throwIfFalsy)(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,e,i){}handleSelectionChanged(t,e,i,s=!1){}_setTransparency(t,e){if(e===this._alpha)return;const i=this._canvas;this._alpha=e,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,i),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,e){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=(0,s.acquireTextureAtlas)(t,this._optionsService.rawOptions,e,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,e){this._deviceCellWidth=e.device.cell.width,this._deviceCellHeight=e.device.cell.height,this._deviceCharWidth=e.device.char.width,this._deviceCharHeight=e.device.char.height,this._deviceCharLeft=e.device.char.left,this._deviceCharTop=e.device.char.top,this._canvas.width=e.device.canvas.width,this._canvas.height=e.device.canvas.height,this._canvas.style.width=`${e.css.canvas.width}px`,this._canvas.style.height=`${e.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,e,i=1){this._ctx.fillRect(t*this._deviceCellWidth,(e+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,i*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,e,i,s){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight))}_fillCharTrueColor(t,e,i,s){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=o.TEXT_BASELINE,this._clipCell(i,s,e.getWidth()),this._ctx.fillText(e.getChars(),i*this._deviceCellWidth+this._deviceCharLeft,s*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,e,i){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,e,i){return`${i?"italic":""} ${e?t.options.fontWeightBold:t.options.fontWeight} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}}e.BaseRenderLayer=n},183(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.tryDrawCustomGlyph=function(t,e,i,s,o,n,L,T,h,l,d,c=0){const _=r.customGlyphDefinitions[e];if(_){const e=Array.isArray(_)?_:[_];for(const r of e)a(t,r,i,s,o,n,L,T,h,l,d,c);return!0}return!1};const s=i(959),r=i(915);function a(t,e,i,r,a,T,d,c,_,Q,u,p=0){let g=a,M=T,y=i,v=r;switch(1===e.scaleType&&(g=d,M=c,y=i+(a-d)/2,v=r+(T-c)/2),e.clipPath&&(t.save(),function(t,e,i,s,r,a){t.beginPath();for(const o of e.split(" ")){const e=o[0];if("Z"===e){t.closePath();continue}const n=o.substring(1).split(",");if(!n[0]||!n[1])continue;const L=i+parseFloat(n[0])*r,T=s+parseFloat(n[1])*a;"M"===e?t.moveTo(L,T):"L"===e&&t.lineTo(L,T)}t.clip()}(t,e.clipPath,y,v,g,M)),e.type){case 0:!function(t,e,i,s,r,a){for(let o=0;o7&&parseInt(h.slice(7,9),16)||1;else{if(!h.startsWith("rgba"))throw new Error(`Unexpected fillStyle color format "${h}" when drawing pattern glyph`);[L,d,c,_]=h.substring(5,h.length-1).split(",").map(t=>parseFloat(t))}for(let t=0;t>1&1,c=1&n;0!==d||0!==c?l.setTransform((new DOMMatrix).translateSelf(-d,-c)):l.setTransform(new DOMMatrix),t.fillStyle=l,t.fillRect(i,r,a,o)}(t,e.data,y,v,g,M,p);break;case 2:!function(t,e,i,s,r,a,o,n){let L;t.save(),t.beginPath(),t.rect(i,s,r,a),t.clip(),t.beginPath(),L="function"==typeof e?e(.15,.15/a*r):e;const T={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const e of L.split(" ")){const n=e[0];if("Z"===n){t.closePath(),T.lastCommand=n;continue}const L=h[n];if(!L){console.error(`Could not find drawing instructions for "${n}"`);continue}const d=e.substring(1).split(",");d[0]&&d[1]&&(L(t,l(d,r,a,i,s,!0,o,0,0,!1),T),T.lastCommand=n)}void 0!==n?(t.strokeStyle=t.fillStyle,t.lineWidth=o*n,t.stroke()):t.fill(),t.closePath(),t.restore()}(t,e.data,y,v,g,M,Q,e.strokeWidth);break;case 3:!function(t,e,i,s,r,a,o,L){const T="string"==typeof e?e:e(0,0);t.beginPath();let h=0,l=0,d=0,c=0,_="";for(const e of T.split(" ")){const o=e[0],L=e.substring(1).split(",");if("Z"===o){t.closePath(),_=o;continue}if("V"===o){const e=s+parseFloat(L[0])*a;t.lineTo(h,e),l=e,d=h,c=l,_=o;continue}if("H"===o){const e=i+parseFloat(L[0])*r;t.lineTo(e,l),h=e,d=h,c=l,_=o;continue}if(!L[0]||!L[1])continue;if("A"===o){const e=parseFloat(L[0])*r,o=parseFloat(L[1])*a,T=parseFloat(L[2])*Math.PI/180,d=parseInt(L[3]),c=parseInt(L[4]),_=i+parseFloat(L[5])*r,Q=s+parseFloat(L[6])*a;n(t,h,l,e,o,T,d,c,_,Q),h=_,l=Q;continue}const T=L.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});if("M"===o)t.moveTo(T[0],T[1]),h=T[0],l=T[1],d=h,c=l;else if("L"===o)t.lineTo(T[0],T[1]),h=T[0],l=T[1],d=h,c=l;else if("Q"===o)t.quadraticCurveTo(T[0],T[1],T[2],T[3]),d=T[0],c=T[1],h=T[2],l=T[3];else if("T"===o){let e,i;"Q"===_||"T"===_?(e=2*h-d,i=2*l-c):(e=h,i=l),t.quadraticCurveTo(e,i,T[0],T[1]),d=e,c=i,h=T[0],l=T[1]}else"C"===o&&(t.bezierCurveTo(T[0],T[1],T[2],T[3],T[4],T[5]),d=T[2],c=T[3],h=T[4],l=T[5]);_=o}void 0!==L?(t.strokeStyle=t.fillStyle,t.lineWidth=o*L,t.stroke()):t.fill()}(t,e.data,y,v,g,M,Q,e.strokeWidth);break;case 4:!function(t,e,i,s,r,a,o,n){t.save(),t.fillRect(i,s,r,a),n&&(t.fillStyle=n,t.strokeStyle=n),t.lineWidth=o,t.lineCap="square",t.beginPath();for(const o of e.d.split(" ")){const e=o[0],n=o.substring(1).split(",");if(!n[0]||!n[1]){"Z"===e&&t.closePath();continue}const L=n.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});"M"===e?t.moveTo(L[0],L[1]):"L"===e&&t.lineTo(L[0],L[1])}1===e.type?t.stroke():t.fill(),t.restore()}(t,e.data,y,v,g,M,Q,u);break;case 5:!function(t,e,i,s,r,a,o,n){const L=new Path2D;L.rect(i,s,r,a),t.clip(L),t.beginPath();const T=o/12;t.lineWidth=n*T;const d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const o of e.d.split(" ")){const L=o[0];if("Z"===L){t.closePath(),d.lastCommand=L;continue}const c=h[L];if(!c){console.error(`Could not find drawing instructions for "${L}"`);continue}const _=o.substring(1).split(",");_[0]&&_[1]&&(c(t,l(_,r,a,i,s,!1,n,(e.leftPadding??0)*(T/2),(e.rightPadding??0)*(T/2)),d),d.lastCommand=L)}1===e.type?(t.strokeStyle=t.fillStyle,t.stroke()):t.fill(),t.closePath()}(t,e.data,y,v,g,M,_,Q);break;case 6:!function(t,e,i,s,r,a){const n=r/8,L=.1*a,T=.8*a/8,h=Math.min(n,T);for(let r=0;r<8;r++)if(e&1<1){const t=Math.sqrt(y);u=(s*=t)*s,p=(r*=t)*r}let v=(u*p-u*M-p*g)/(u*M+p*g);v<0&&(v=0);const f=(o===n?-1:1)*Math.sqrt(v),C=f*(s*Q/r),m=f*(-r*_/s),w=h*C-l*m+(e+L)/2,x=l*C+h*m+(i+T)/2,b=(_-C)/s,E=(Q-m)/r,R=(-_-C)/s,k=(-Q-m)/r,A=Math.atan2(E,b);let Z=Math.atan2(k,R)-A;0===n&&Z>0?Z-=2*Math.PI:1===n&&Z<0&&(Z+=2*Math.PI);const S=A+Z;t.ellipse(w,x,s,r,a,A,S,0===n)}const L=new Map;function T(t,e,i=0){return Math.max(Math.min(t,e),i)}const h={C:(t,e,i)=>{t.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),i.lastControlX=e[2],i.lastControlY=e[3],i.currentX=e[4],i.currentY=e[5]},L:(t,e,i)=>{t.lineTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},M:(t,e,i)=>{t.moveTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},Q:(t,e,i)=>{t.quadraticCurveTo(e[0],e[1],e[2],e[3]),i.lastControlX=e[0],i.lastControlY=e[1],i.currentX=e[2],i.currentY=e[3]},T:(t,e,i)=>{let s,r;"Q"===i.lastCommand||"T"===i.lastCommand?(s=2*i.currentX-i.lastControlX,r=2*i.currentY-i.lastControlY):(s=i.currentX,r=i.currentY),t.quadraticCurveTo(s,r,e[0],e[1]),i.lastControlX=s,i.lastControlY=r,i.currentX=e[0],i.currentY=e[1]}};function l(t,e,i,s,r,a,o,n=0,L=0,h=!0){const l=t.map(t=>parseFloat(t)||parseInt(t));if(l.length<2)throw new Error("Too few arguments for instruction");for(let t=0;tL.deleteProgram(this._program))),this._projectionLocation=(0,a.throwIfFalsy)(L.getUniformLocation(this._program,"u_projection")),this._vertexArrayObject=L.createVertexArray(),L.bindVertexArray(this._vertexArrayObject);const T=new Float32Array([0,0,1,0,0,1,1,1]),h=L.createBuffer();this._register((0,s.toDisposable)(()=>L.deleteBuffer(h))),L.bindBuffer(L.ARRAY_BUFFER,h),L.bufferData(L.ARRAY_BUFFER,T,L.STATIC_DRAW),L.enableVertexAttribArray(3),L.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);const l=new Uint8Array([0,1,2,3]),d=L.createBuffer();this._register((0,s.toDisposable)(()=>L.deleteBuffer(d))),L.bindBuffer(L.ELEMENT_ARRAY_BUFFER,d),L.bufferData(L.ELEMENT_ARRAY_BUFFER,l,L.STATIC_DRAW),this._attributesBuffer=(0,a.throwIfFalsy)(L.createBuffer()),this._register((0,s.toDisposable)(()=>L.deleteBuffer(this._attributesBuffer))),L.bindBuffer(L.ARRAY_BUFFER,this._attributesBuffer),L.enableVertexAttribArray(0),L.vertexAttribPointer(0,2,L.FLOAT,!1,o,0),L.vertexAttribDivisor(0,1),L.enableVertexAttribArray(1),L.vertexAttribPointer(1,2,L.FLOAT,!1,o,2*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(1,1),L.enableVertexAttribArray(2),L.vertexAttribPointer(2,4,L.FLOAT,!1,o,4*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(2,1),this._updateCachedColors(n.colors),this._register(this._themeService.onChangeColors(t=>{this._updateCachedColors(t),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){const e=this._gl;e.useProgram(this._program),e.bindVertexArray(this._vertexArrayObject),e.uniformMatrix4fv(this._projectionLocation,!1,r.PROJECTION_MATRIX),e.bindBuffer(e.ARRAY_BUFFER,this._attributesBuffer),e.bufferData(e.ARRAY_BUFFER,t.attributes,e.DYNAMIC_DRAW),e.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,e.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){const e=this._terminal,i=this._vertices;let s,r,a,o,n,L,T,h,l,d,c,_=1;for(s=0;s>24&255)/255,d=(L>>16&255)/255,c=(L>>8&255)/255,_=1,this._addRectangle(t.attributes,e,T,h,(o-a)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,l,d,c,_)}_addRectangle(t,e,i,s,r,a,o,n,L,T){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o,t[e+5]=n,t[e+6]=L,t[e+7]=T}_addRectangleFloat(t,e,i,s,r,a,o){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o[0],t[e+5]=o[1],t[e+6]=o[2],t[e+7]=o[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(255&t.rgba)/255])}}e.RectangleRenderer=Q},208(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.RenderModel=e.COMBINED_CHAR_BIT_MASK=void 0;const s=i(948);e.COMBINED_CHAR_BIT_MASK=2147483648,e.RenderModel=class{cells;lineLengths;selection;cursor;constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=(0,s.createSelectionRenderModel)()}resize(t,e){const i=t*e*4;i!==this.cells.length&&(this.cells=new Uint32Array(i),this.lineLengths=new Uint32Array(e))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}}},215(t,e){Object.defineProperty(e,"__esModule",{value:!0})},267(t,e){Object.defineProperty(e,"__esModule",{value:!0})},306(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.LinkRenderLayer=void 0;const s=i(657),r=i(814),a=i(133);class o extends a.BaseRenderLayer{_state;constructor(t,e,i,s,r,a,o){super(i,t,"link",e,!0,r,a,o),this._register(s.onShowLinkUnderline(t=>this._handleShowLinkUnderline(t))),this._register(s.onHideLinkUnderline(t=>this._handleHideLinkUnderline(t)))}resize(t,e){super.resize(t,e),this._state=void 0}reset(t){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);const t=this._state.y2-this._state.y1-1;t>0&&this._clearCells(0,this._state.y1+1,this._state.cols,t),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(t){if(t.fg===r.INVERTED_DEFAULT_COLOR?this._ctx.fillStyle=this._themeService.colors.background.css:void 0!==t.fg&&(0,s.is256Color)(t.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[t.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,t.y1===t.y2)this._fillBottomLineAtCells(t.x1,t.y1,t.x2-t.x1);else{this._fillBottomLineAtCells(t.x1,t.y1,t.cols-t.x1);for(let e=t.y1+1;e65535?(t-=65536,String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):String.fromCharCode(t)},e.utf32ToString=function(t,e=0,i=t.length){let s="";for(let r=e;r65535?(e-=65536,s+=String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):s+=String.fromCharCode(e)}return s},e.StringToUtf32=class{_interim=0;clear(){this._interim=0}decode(t,e){const i=t.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=t.charCodeAt(r++);56320<=i&&i<=57343?e[s++]=1024*(this._interim-55296)+i-56320+65536:(e[s++]=this._interim,e[s++]=i),this._interim=0}for(let a=r;a=i)return this._interim=r,s;const o=t.charCodeAt(a);56320<=o&&o<=57343?e[s++]=1024*(r-55296)+o-56320+65536:(e[s++]=r,e[s++]=o);continue}65279!==r&&(e[s++]=r)}return s}},e.Utf8ToUtf32=class{interim=new Uint8Array(3);clear(){this.interim.fill(0)}decode(t,e){const i=t.length;if(!i)return 0;let s,r,a,o,n=0,L=0,T=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let a,o=0;for(;(a=63&this.interim[++o])&&o<4;)r<<=6,r|=a;const L=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,h=L-o;for(;T=i)return 0;if(a=t[T++],128!=(192&a)){T--,s=!0;break}this.interim[o++]=a,r<<=6,r|=63&a}s||(2===L?r<128?T--:e[n++]=r:3===L?r<2048||r>=55296&&r<=57343||65279===r||(e[n++]=r):r<65536||r>1114111||(e[n++]=r)),this.interim.fill(0)}const h=i-4;let l=T;for(;l=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(L=(31&s)<<6|63&r,L<128){l--;continue}e[n++]=L}else if(224==(240&s)){if(l>=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,n;if(a=t[l++],128!=(192&a)){l--;continue}if(L=(15&s)<<12|(63&r)<<6|63&a,L<2048||L>=55296&&L<=57343||65279===L)continue;e[n++]=L}else if(240==(248&s)){if(l>=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,n;if(a=t[l++],128!=(192&a)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=a,n;if(o=t[l++],128!=(192&o)){l--;continue}if(L=(7&s)<<18|(63&r)<<12|(63&a)<<6|63&o,L<65536||L>1114111)continue;e[n++]=L}}return n}}},399(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.JoinedCellData=e.WebglRenderer=void 0;const s=i(864),r=i(670),a=i(773),o=i(697),n=i(917),L=i(721),T=i(564),h=i(536),l=i(28),d=i(203),c=i(208);i(816);const _=i(306),Q=i(414),u=i(105),p=i(426),g=i(959);class M extends p.Disposable{_terminal;_characterJoinerService;_charSizeService;_coreBrowserService;_coreService;_decorationService;_optionsService;_themeService;_customGlyphs;_renderLayers;_cursorBlinkStateManager=this._register(new p.MutableDisposable);_textBlinkStateManager;_charAtlasDisposable=this._register(new p.MutableDisposable);_charAtlas;_devicePixelRatio;_deviceMaxTextureSize;_observerDisposable=this._register(new p.MutableDisposable);_model=new c.RenderModel;_rowHasBlinkingCells=[];_rowHasBlinkingCellsCount=0;_workCell=new L.CellData;_cellColorResolver;_canvas;_gl;_rectangleRenderer=this._register(new p.MutableDisposable);_glyphRenderer=this._register(new p.MutableDisposable);dimensions;_core;_isAttached;_contextRestorationTimeout;_onChangeTextureAtlas=this._register(new Q.Emitter);onChangeTextureAtlas=this._onChangeTextureAtlas.event;_onAddTextureAtlasCanvas=this._register(new Q.Emitter);onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=this._register(new Q.Emitter);onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;_onRequestRedraw=this._register(new Q.Emitter);onRequestRedraw=this._onRequestRedraw.event;_onContextLoss=this._register(new Q.Emitter);onContextLoss=this._onContextLoss.event;constructor(t,e,i,a,n,L,T,l,d=!0,c){super(),this._terminal=t,this._characterJoinerService=e,this._charSizeService=i,this._coreBrowserService=a,this._coreService=n,this._decorationService=L,this._optionsService=T,this._themeService=l,this._customGlyphs=d,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas");const Q={antialias:!1,depth:!1,preserveDrawingBuffer:c};if(this._gl=this._canvas.getContext("webgl2",Q),!this._gl)throw new Error("WebGL2 not supported "+this._gl);this._register(this._themeService.onChangeColors(()=>this._handleColorChange())),this._cellColorResolver=new s.CellColorResolver(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new _.LinkRenderLayer(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,T,this._themeService)],this.dimensions=(0,g.createRenderDimensions)(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(T.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new h.TextBlinkStateManager(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextlost",t=>{console.log("webglcontextlost event received"),t.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,console.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(t)},3e3)})),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextrestored",t=>{console.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,(0,r.removeTerminalFromCache)(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,this._coreBrowserService.window,(t,e)=>this._setCanvasDevicePixelDimensions(t,e)),this._register(this._coreBrowserService.onWindowChange(t=>{this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,t,(t,e)=>this._setCanvasDevicePixelDimensions(t,e))})),this._register((0,u.addDisposableListener)(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register((0,p.toDisposable)(()=>{for(const t of this._renderLayers)t.dispose();this._canvas.parentElement?.removeChild(this._canvas),(0,r.removeTerminalFromCache)(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,e){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(const t of this._renderLayers)t.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(const t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(const t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,e,i){for(const s of this._renderLayers)s.handleSelectionChanged(this._terminal,t,e,i);this._model.selection.update(this._core,t,e,i),this._requestRedrawViewport()}handleCursorMove(){for(const t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new d.RectangleRenderer(this._terminal,this._gl,this.dimensions,this._themeService),this._glyphRenderer.value=new l.GlyphRenderer(this._terminal,this._gl,this.dimensions,this._optionsService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0)return void(this._isAttached=!1);const t=(0,r.acquireTextureAtlas)(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=(0,p.combinedDisposable)(Q.EventUtils.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),Q.EventUtils.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(const t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,e){if(!this._isAttached){if(!(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height))return;this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0}for(const i of this._renderLayers)i.handleGridChanged(this._terminal,t,e);if(!this._glyphRenderer.value||!this._rectangleRenderer.value)return;this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,e);let i=!1,s=0;for(;this._charAtlas&&this._glyphRenderer.value.beginFrame()&&s++<32;)i=!0,this._clearModel(!0),this._updateModel(0,this._terminal.rows-1);i&&this._glyphRenderer.value.invalidateAtlasTextures(),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),this._cursorBlinkStateManager.value&&!this._cursorBlinkStateManager.value.isCursorVisible||this._rectangleRenderer.value.renderCursor()}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new a.CursorBlinkStateManager(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,e){const i=this._core;let s,r,a,o,n,L,h,l,d,_,Q,u,p,g,M,f=this._workCell,C=0,m=!0;t=v(t,i.rows-1,0),e=v(e,i.rows-1,0);const w=this._coreService.decPrivateModes.cursorStyle??i.options.cursorStyle??"block",x=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,b=x-i.buffer.ydisp,E=Math.min(this._terminal.buffer.active.cursorX,i.cols-1);let R=-1;const k=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let A=!1;for(r=t;r<=e;r++){a=r+i.buffer.ydisp,o=i.buffer.lines.get(a);let t=!1;for(this._model.lineLengths[r]=0,d=x===a,C=0,n=this._characterJoinerService.getJoinedCharacters(a),g=0;g=C,h=g,n.length>0&&g===n[0][0]&&m){l=n.shift();const t=this._model.selection.isCellSelected(this._terminal,l[0],a);for(p=l[0]+1;p=l[1],m?(L=!0,f=new y(f,o.translateToString(!0,l[0],l[1]),l[1]-l[0]),h=l[1]-1):C=l[1]}if(_=f.getChars(),Q=f.getCode(),p=4*(r*i.cols+g),!t&&f.isBlink()&&(t=!0),this._cellColorResolver.resolve(f,g,a,this.dimensions.device.cell.width,this.dimensions.device.cell.height),k&&a===x&&(g===E&&(this._model.cursor={x:E,y:b,width:f.getWidth(),style:this._coreBrowserService.isFocused?w:i.options.cursorInactiveStyle,cursorWidth:i.options.cursorWidth,dpr:this._devicePixelRatio},R=E+f.getWidth()-1),g>=E&&g<=R&&(this._coreBrowserService.isFocused&&"block"===w||!1===this._coreBrowserService.isFocused&&"block"===i.options.cursorInactiveStyle)&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&f.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),Q!==T.NULL_CELL_CODE&&(this._model.lineLengths[r]=g+1),(this._model.cells[p]!==Q||this._model.cells[p+1]!==this._cellColorResolver.result.bg||this._model.cells[p+2]!==this._cellColorResolver.result.fg||this._model.cells[p+3]!==this._cellColorResolver.result.ext)&&(A=!0,_.length>1&&(Q|=c.COMBINED_CHAR_BIT_MASK),this._model.cells[p]=Q,this._model.cells[p+1]=this._cellColorResolver.result.bg,this._model.cells[p+2]=this._cellColorResolver.result.fg,this._model.cells[p+3]=this._cellColorResolver.result.ext,u=f.getWidth(),this._glyphRenderer.value.updateCell(g,r,Q,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,_,u,s),L)){for(f=this._workCell,g++;g<=h;g++)M=4*(r*i.cols+g),this._glyphRenderer.value.updateCell(g,r,T.NULL_CELL_CODE,0,0,0,T.NULL_CELL_CHAR,0,0),this._model.cells[M]=T.NULL_CELL_CODE,this._model.cells[M+1]=this._cellColorResolver.result.bg,this._model.cells[M+2]=this._cellColorResolver.result.fg,this._model.cells[M+3]=this._cellColorResolver.result.ext;g--}}this._setRowBlinkState(r,t)}A&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,e){this._rowHasBlinkingCells[t]!==e&&(this._rowHasBlinkingCells[t]=e,this._rowHasBlinkingCellsCount+=e?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){this._charSizeService.width&&this._charSizeService.height&&(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=1===this._optionsService.rawOptions.lineHeight?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,e){this._canvas.width===t&&this._canvas.height===e||(this._canvas.width=t,this._canvas.height=e,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){const t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}}e.WebglRenderer=M;class y extends n.AttributeData{_width;content=0;fg;bg;combinedData="";constructor(t,e,i){super(),this.fg=t.fg,this.bg=t.bg,this.combinedData=e,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}function v(t,e,i=0){return Math.max(Math.min(t,e),i)}e.JoinedCellData=y},414(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.EventUtils=e.Emitter=void 0;const s=i(426);var r;e.Emitter=class{_listeners=[];_disposed=!1;_event;get event(){return this._event||(this._event=(t,e,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:t,thisArgs:e};this._listeners.push(r);const a=(0,s.toDisposable)(()=>{const t=this._listeners.indexOf(r);-1!==t&&this._listeners.splice(t,1)});return i&&(Array.isArray(i)?i.push(a):i.add(a)),a}),this._event}fire(t){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:e,thisArgs:i}=this._listeners[0];return void e.call(i,t)}default:{const e=this._listeners.slice();for(const{fn:i,thisArgs:s}of e)i.call(s,t)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(t){t.forward=function(t,e){return t(t=>e.fire(t))},t.map=function(t,e){return(i,s,r)=>t(t=>i.call(s,e(t)),void 0,r)},t.any=function(...t){return(e,i,r)=>{const a=new s.DisposableStore;for(const s of t)a.add(s(t=>e.call(i,t)));return r&&(Array.isArray(r)?r.push(a):r.add(a)),a}},t.runAndSubscribe=function(t,e,i){return e(i),t(t=>e(t))}}(r||(e.EventUtils=r={}))},426(t,e){function i(t){return{dispose:t}}function s(t){if(!t)return t;if(Array.isArray(t)){for(const e of t)e.dispose();return[]}return t.dispose(),t}Object.defineProperty(e,"__esModule",{value:!0}),e.MutableDisposable=e.Disposable=e.DisposableStore=void 0,e.toDisposable=i,e.dispose=s,e.combinedDisposable=function(...t){return i(()=>s(t))};class r{_disposables=new Set;_isDisposed=!1;get isDisposed(){return this._isDisposed}add(t){return this._isDisposed?t.dispose():this._disposables.add(t),t}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const t of this._disposables)t.dispose();this._disposables.clear()}}clear(){for(const t of this._disposables)t.dispose();this._disposables.clear()}}e.DisposableStore=r;class a{static None=Object.freeze({dispose(){}});_store=new r;dispose(){this._store.dispose()}_register(t){return this._store.add(t)}}e.Disposable=a,e.MutableDisposable=class{_value;_isDisposed=!1;get value(){return this._isDisposed?void 0:this._value}set value(t){this._isDisposed||t===this._value||(this._value?.dispose(),this._value=t)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},536(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextBlinkStateManager=void 0;const s=i(426);class r extends s.Disposable{_renderCallback;_coreBrowserService;_optionsService;_intervalDuration=0;_interval;_blinkOn=!0;_needsBlinkInViewport=!1;_isViewportVisible=!0;constructor(t,e,i){super(),this._renderCallback=t,this._coreBrowserService=e,this._optionsService=i,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",t=>{this.setIntervalDuration(t)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const t=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(t||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}e.TextBlinkStateManager=r},564(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.WHITESPACE_CELL_CODE=e.WHITESPACE_CELL_WIDTH=e.WHITESPACE_CELL_CHAR=e.NULL_CELL_CODE=e.NULL_CELL_WIDTH=e.NULL_CELL_CHAR=e.CHAR_DATA_CODE_INDEX=e.CHAR_DATA_WIDTH_INDEX=e.CHAR_DATA_CHAR_INDEX=e.CHAR_DATA_ATTR_INDEX=e.DEFAULT_EXT=e.DEFAULT_ATTR=e.DEFAULT_COLOR=void 0,e.DEFAULT_COLOR=0,e.DEFAULT_ATTR=e.DEFAULT_COLOR<<9|256,e.DEFAULT_EXT=0,e.CHAR_DATA_ATTR_INDEX=0,e.CHAR_DATA_CHAR_INDEX=1,e.CHAR_DATA_WIDTH_INDEX=2,e.CHAR_DATA_CODE_INDEX=3,e.NULL_CELL_CHAR="",e.NULL_CELL_WIDTH=1,e.NULL_CELL_CODE=0,e.WHITESPACE_CELL_CHAR=" ",e.WHITESPACE_CELL_WIDTH=1,e.WHITESPACE_CELL_CODE=32},578(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.IntervalTimer=e.TimeoutTimer=void 0,e.timeout=function(t){return new Promise(e=>setTimeout(e,t))},e.disposableTimeout=function(t,e=0,i){const r=setTimeout(()=>{t(),i&&a.dispose()},e),a=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(a),a};const s=i(426);e.TimeoutTimer=class{_token=-1;_isDisposed=!1;dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(t,e){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,t()},e)}setIfNotSet(t,e){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,t()},e))}},e.IntervalTimer=class{_disposable;_isDisposed=!1;cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(t,e,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{t()},e);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},657(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.generateConfig=function(t,e,i,r,a,o,n,L,T=!0){const h={foreground:o.foreground,background:o.background,cursor:s.NULL_COLOR,cursorAccent:s.NULL_COLOR,selectionForeground:s.NULL_COLOR,selectionBackgroundTransparent:s.NULL_COLOR,selectionBackgroundOpaque:s.NULL_COLOR,selectionInactiveBackgroundTransparent:s.NULL_COLOR,selectionInactiveBackgroundOpaque:s.NULL_COLOR,overviewRulerBorder:s.NULL_COLOR,scrollbarSliderBackground:s.NULL_COLOR,scrollbarSliderHoverBackground:s.NULL_COLOR,scrollbarSliderActiveBackground:s.NULL_COLOR,ansi:o.ansi.slice(),contrastCache:o.contrastCache,halfContrastCache:o.halfContrastCache};return{customGlyphs:T,devicePixelRatio:n,deviceMaxTextureSize:L,letterSpacing:a.letterSpacing,lineHeight:a.lineHeight,deviceCellWidth:t,deviceCellHeight:e,deviceCharWidth:i,deviceCharHeight:r,fontFamily:a.fontFamily,fontSize:a.fontSize,fontWeight:a.fontWeight,fontWeightBold:a.fontWeightBold,allowTransparency:a.allowTransparency,drawBoldTextInBrightColors:a.drawBoldTextInBrightColors,minimumContrastRatio:a.minimumContrastRatio,colors:h}},e.configEquals=function(t,e){for(let i=0;i=0){if((0,r.configEquals)(i.config,c))return i.atlas;1===i.ownedBy.length?(i.atlas.dispose(),a.splice(e,1)):i.ownedBy.splice(s,1);break}}for(let e=0;e{const s=e.find(e=>e.target===t);if(!s)return;if(!("devicePixelContentBoxSize"in s))return r?.disconnect(),void(r=void 0);const a=s.devicePixelContentBoxSize[0].inlineSize,o=s.devicePixelContentBoxSize[0].blockSize;a>0&&o>0&&i(a,o)});try{r.observe(t,{box:["device-pixel-content-box"]})}catch{r.disconnect(),r=void 0}return(0,s.toDisposable)(()=>r?.disconnect())};const s=i(426)},719(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GLTexture=e.PROJECTION_MATRIX=void 0,e.createProgram=function(t,e,i){const a=(0,s.throwIfFalsy)(t.createProgram());if(t.attachShader(a,(0,s.throwIfFalsy)(r(t,t.VERTEX_SHADER,e))),t.attachShader(a,(0,s.throwIfFalsy)(r(t,t.FRAGMENT_SHADER,i))),t.linkProgram(a),t.getProgramParameter(a,t.LINK_STATUS))return a;console.error(t.getProgramInfoLog(a)),t.deleteProgram(a)},e.createShader=r,e.expandFloat32Array=function(t,e){const i=Math.min(2*t.length,e),s=new Float32Array(i);for(let e=0;e>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(t){this.fg=t[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let e=!1;if(t[r.CHAR_DATA_CHAR_INDEX].length>2)e=!0;else if(2===t[r.CHAR_DATA_CHAR_INDEX].length){const i=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|t[r.CHAR_DATA_WIDTH_INDEX]<<22:e=!0}else e=!0}else this.content=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[r.CHAR_DATA_WIDTH_INDEX]<<22;e&&(this.combinedData=t[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|t[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor())return!1;if(this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor())return!1;if(this.isInverse()!==t.isInverse())return!1;if(this.isBold()!==t.isBold())return!1;if(this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;const e=this.isUnderlineColorDefault(),i=t.isUnderlineColorDefault();if(!e||!i){if(e!==i)return!1;if(this.getUnderlineColor()!==t.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==t.getUnderlineColorMode())return!1}}return this.isOverline()===t.isOverline()&&this.isBlink()===t.isBlink()&&this.isInvisible()===t.isInvisible()&&this.isItalic()===t.isItalic()&&this.isDim()===t.isDim()&&this.isStrikethrough()===t.isStrikethrough()}}e.CellData=o},773(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.CursorBlinkStateManager=void 0,e.CursorBlinkStateManager=class{_renderCallback;_coreBrowserService;isCursorVisible;_animationFrame;_blinkStartTimeout;_blinkInterval;_idleTimeout;_isIdlePaused=!1;_animationTimeRestarted;constructor(t,e){this._renderCallback=t,this._coreBrowserService=e,this.isCursorVisible=!0,this._coreBrowserService.isFocused&&(this._restartInterval(),this._resetIdleTimer())}get isPaused(){return!(this._blinkStartTimeout||this._blinkInterval)}dispose(){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}restartBlinkAnimation(){this._isIdlePaused&&this._resetIdleTimer(),this.isPaused||(this._animationTimeRestarted=Date.now(),this.isCursorVisible=!0,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(t=600){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0)return void this._restartInterval(t)}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);return this._animationTimeRestarted=void 0,void this._restartInterval(t)}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},600)},t)}pause(){this.isCursorVisible=!0,this._isIdlePaused=!1,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){this._isIdlePaused=!1,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){this.isCursorVisible=!0,this._isIdlePaused=!0,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}}},814(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.INVERTED_DEFAULT_COLOR=void 0,e.INVERTED_DEFAULT_COLOR=257},816(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),i(267)},836(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.FourKeyMap=e.TwoKeyMap=void 0;class i{_data={};set(t,e,i){this._data[t]||(this._data[t]={}),this._data[t][e]=i}get(t,e){return this._data[t]?this._data[t][e]:void 0}clear(){this._data={}}}e.TwoKeyMap=i,e.FourKeyMap=class{_data=new i;set(t,e,s,r,a){this._data.get(t,e)||this._data.set(t,e,new i),this._data.get(t,e).set(s,r,a)}get(t,e,i,s){return this._data.get(t,e)?.get(i,s)}clear(){this._data.clear()}}},864(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.CellColorResolver=void 0;const s=i(564),r=i(993),a=i(959),o=i(915);let n,L=0,T=0,h=!1,l=!1,d=!1,c=0;e.CellColorResolver=class{_terminal;_optionService;_selectionRenderModel;_decorationService;_coreBrowserService;_themeService;result={fg:0,bg:0,ext:0};constructor(t,e,i,s,r,a){this._terminal=t,this._optionService=e,this._selectionRenderModel=i,this._decorationService=s,this._coreBrowserService=r,this._themeService=a}resolve(t,e,i,_,Q){this.result.bg=t.bg,this.result.fg=t.fg,this.result.ext=268435456&t.bg?t.extended.ext:0,T=0,L=0,l=!1,h=!1,d=!1,n=this._themeService.colors,c=0;const u=t.getCode();if(u!==s.NULL_CELL_CODE&&4===t.extended.underlineStyle){const t=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));c=e*_%(2*Math.round(t))}if(0===c&&o.blockPatternCodepoints.has(u)&&(c=e*_%2*2+i*Q%2),this._decorationService.forEachDecorationAtCell(e,i,"bottom",t=>{t.backgroundColorRGB&&(T=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,h=!0)}),d=this._selectionRenderModel.isCellSelected(this._terminal,e,i),d){if(67108864&this.result.fg||50331648&this.result.bg){if(67108864&this.result.fg)switch(50331648&this.result.fg){case 16777216:case 33554432:T=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:T=(16777215&this.result.fg)<<8|255;break;default:T=this._themeService.colors.foreground.rgba}else switch(50331648&this.result.bg){case 16777216:case 33554432:T=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:T=(16777215&this.result.bg)<<8|255}T=r.rgba.blend(T,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}else T=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(l=!0,n.selectionForeground&&(L=n.selectionForeground.rgba>>8&16777215,h=!0),(0,a.treatGlyphAsBackgroundColor)(t.getCode())){if(67108864&this.result.fg&&!(50331648&this.result.bg))L=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(67108864&this.result.fg)switch(50331648&this.result.bg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:L=(16777215&this.result.bg)<<8|255}else switch(50331648&this.result.fg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:L=(16777215&this.result.fg)<<8|255;break;default:L=this._themeService.colors.foreground.rgba}L=r.rgba.blend(L,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}h=!0}}this._decorationService.forEachDecorationAtCell(e,i,"top",t=>{t.backgroundColorRGB&&(T=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,h=!0)}),l&&(T=d?-16777216&t.bg&-134217729|T|50331648:-16777216&t.bg|T|50331648),h&&(L=-16777216&t.fg&-67108865|L|50331648),67108864&this.result.fg&&(l&&!h&&(L=50331648&this.result.bg?-134217728&this.result.fg|67108863&this.result.bg:-134217728&this.result.fg|16777215&n.background.rgba>>8|50331648,h=!0),!l&&h&&(T=50331648&this.result.fg?-67108864&this.result.bg|67108863&this.result.fg:-67108864&this.result.bg|16777215&n.foreground.rgba>>8|50331648,l=!0)),n=void 0,this.result.bg=l?T:this.result.bg,this.result.fg=h?L:this.result.fg,this.result.ext&=536870911,this.result.ext|=c<<29&3758096384}}},915(t,e,i){var s;function r(t){return{type:2,data:()=>{const e=[],i=[3/8,2/8,3/8],s=[0,3/8,5/8];for(let r=0;r<3;r++){const a=t>>2*r&1,o=t>>2*r+1&1;a&&o?e.push(`M0,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):a?e.push(`M0,${s[r]} L0.5,${s[r]} L0.5,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):o&&e.push(`M0.5,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0.5,${s[r]+i[r]} Z`)}return e.join(" ")}}}function a(t){const e=[],i=.15,s=.015,r=.075,a=.0375,o=.95;if(64&t){const t=.175,a=.2125,n=.25,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}if(32&t){const t=o-i,s=.875,r=o,n=.265,L=.485;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(16&t){const t=o-i,s=.875,r=o,n=.515,L=.735;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(8&t){const t=.75,a=.7875,n=.825,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}if(4&t){const t=.05,i=.125,s=.2,r=.515,o=.735;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(2&t){const t=.05,i=.125,s=.2,r=.265,o=.485;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(1&t){const t=.4625,a=.5,n=.5375,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}return e.join(" ")}Object.defineProperty(e,"__esModule",{value:!0}),e.blockPatternCodepoints=e.customGlyphDefinitions=void 0,i(215),function(t){t.LINE_H=Object.freeze({type:2,data:"M0,.5 L1,.5",strokeWidth:1}),t.LINE_V=Object.freeze({type:2,data:"M.5,0 L.5,1",strokeWidth:1}),t.FADE_RIGHT=Object.freeze({type:2,data:"M0,.5 L.28,.5 M.32,.5 L.52,.5 M.60,.5 L.72,.5 M.84,.5 L.90,.5",strokeWidth:1}),t.FADE_LEFT=Object.freeze({type:2,data:"M.10,.5 L.16,.5 M.28,.5 L.40,.5 M.48,.5 L.68,.5 M.72,.5 L1,.5",strokeWidth:1}),t.FADE_DOWN=Object.freeze({type:2,data:"M.5,0 L.5,.28 M.5,.32 L.5,.52 M.5,.60 L.5,.72 M.5,.84 L.5,.90",strokeWidth:1}),t.FADE_UP=Object.freeze({type:2,data:"M.5,.10 L.5,.16 M.5,.28 L.5,.40 M.5,.48 L.5,.68 M.5,.72 L.5,1",strokeWidth:1}),t.CURVE_DOWN_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_DOWN_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.CURVE_UP_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_UP_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.NODE_FILL=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5"}),t.NODE_STROKE=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5",strokeWidth:1}),t.NODE_LINE_RIGHT=Object.freeze({type:3,data:"M1,.5 L.85,.5",strokeWidth:1}),t.NODE_LINE_LEFT=Object.freeze({type:3,data:"M0,.5 L.15,.5",strokeWidth:1}),t.NODE_LINE_DOWN=Object.freeze({type:3,data:"M.5,1 L.5,.7",strokeWidth:1}),t.NODE_LINE_UP=Object.freeze({type:3,data:"M.5,0 L.5,.3",strokeWidth:1})}(s||(s={})),e.customGlyphDefinitions={"─":{type:2,data:"M0,.5 L1,.5",strokeWidth:1},"━":{type:2,data:"M0,.5 L1,.5",strokeWidth:3},"│":{type:2,data:"M.5,0 L.5,1",strokeWidth:1},"┃":{type:2,data:"M.5,0 L.5,1",strokeWidth:3},"┄":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:1},"┅":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:3},"┆":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:1},"┇":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:3},"┈":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:1},"┉":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:3},"┊":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:1},"┋":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:3},"┌":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},"┍":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┎":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┏":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3},"┐":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},"┑":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┒":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┓":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3},"└":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},"┕":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┖":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┗":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3},"┘":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},"┙":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┚":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┛":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3},"├":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},"┝":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┞":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┟":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┠":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┡":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┢":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┣":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3},"┤":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},"┥":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┦":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┧":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┨":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┩":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┪":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┫":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3},"┬":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},"┭":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┮":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┯":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┰":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┱":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┲":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┳":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3},"┴":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:1},"┵":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┶":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┷":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┸":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┹":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┺":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┻":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:3},"┼":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:1},"┽":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┾":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┿":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"╀":[{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"╁":[{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╂":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"╃":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"╄":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"╅":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"╆":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"╇":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:3}],"╈":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3}],"╉":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3}],"╊":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3}],"╋":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:3},"╌":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:1},"╍":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:3},"╎":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:1},"╏":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:3},"═":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"║":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╒":{type:2,data:(t,e)=>`M.5,1 L.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╓":{type:2,data:(t,e)=>`M${.5-t},1 L${.5-t},.5 L1,.5 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╔":{type:2,data:(t,e)=>`M1,${.5-e} L${.5-t},${.5-e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╕":{type:2,data:(t,e)=>`M0,${.5-e} L.5,${.5-e} L.5,1 M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╖":{type:2,data:(t,e)=>`M${.5+t},1 L${.5+t},.5 L0,.5 M${.5-t},.5 L${.5-t},1`,strokeWidth:1},"╗":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5+t},${.5-e} L${.5+t},1`,strokeWidth:1},"╘":{type:2,data:(t,e)=>`M.5,0 L.5,${.5+e} L1,${.5+e} M.5,${.5-e} L1,${.5-e}`,strokeWidth:1},"╙":{type:2,data:(t,e)=>`M1,.5 L${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╚":{type:2,data:(t,e)=>`M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0 M1,${.5+e} L${.5-t},${.5+e} L${.5-t},0`,strokeWidth:1},"╛":{type:2,data:(t,e)=>`M0,${.5+e} L.5,${.5+e} L.5,0 M0,${.5-e} L.5,${.5-e}`,strokeWidth:1},"╜":{type:2,data:(t,e)=>`M0,.5 L${.5+t},.5 L${.5+t},0 M${.5-t},.5 L${.5-t},0`,strokeWidth:1},"╝":{type:2,data:(t,e)=>`M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M0,${.5+e} L${.5+t},${.5+e} L${.5+t},0`,strokeWidth:1},"╞":{type:2,data:(t,e)=>`M.5,0 L.5,1 M.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╟":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1 M${.5+t},.5 L1,.5`,strokeWidth:1},"╠":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╡":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L.5,${.5-e} M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╢":{type:2,data:(t,e)=>`M0,.5 L${.5-t},.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╣":{type:2,data:(t,e)=>`M${.5+t},0 L${.5+t},1 M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0`,strokeWidth:1},"╤":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e} M.5,${.5+e} L.5,1`,strokeWidth:1},"╥":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},1 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╦":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╧":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e} M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╨":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╩":{type:2,data:(t,e)=>`M0,${.5+e} L1,${.5+e} M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╪":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╫":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╬":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╭":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╮":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╯":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╰":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╱":{type:2,data:"M1,0 L0,1",strokeWidth:1},"╲":{type:2,data:"M0,0 L1,1",strokeWidth:1},"╳":{type:2,data:"M1,0 L0,1 M0,0 L1,1",strokeWidth:1},"╴":{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},"╵":{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},"╶":{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},"╷":{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},"╸":{type:2,data:"M.5,.5 L0,.5",strokeWidth:3},"╹":{type:2,data:"M.5,.5 L.5,0",strokeWidth:3},"╺":{type:2,data:"M.5,.5 L1,.5",strokeWidth:3},"╻":{type:2,data:"M.5,.5 L.5,1",strokeWidth:3},"╼":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"╽":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╾":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"╿":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"▀":{type:0,data:[{x:0,y:0,w:8,h:4}]},"▁":{type:0,data:[{x:0,y:7,w:8,h:1}]},"▂":{type:0,data:[{x:0,y:6,w:8,h:2}]},"▃":{type:0,data:[{x:0,y:5,w:8,h:3}]},"▄":{type:0,data:[{x:0,y:4,w:8,h:4}]},"▅":{type:0,data:[{x:0,y:3,w:8,h:5}]},"▆":{type:0,data:[{x:0,y:2,w:8,h:6}]},"▇":{type:0,data:[{x:0,y:1,w:8,h:7}]},"█":{type:0,data:[{x:0,y:0,w:8,h:8}]},"▉":{type:0,data:[{x:0,y:0,w:7,h:8}]},"▊":{type:0,data:[{x:0,y:0,w:6,h:8}]},"▋":{type:0,data:[{x:0,y:0,w:5,h:8}]},"▌":{type:0,data:[{x:0,y:0,w:4,h:8}]},"▍":{type:0,data:[{x:0,y:0,w:3,h:8}]},"▎":{type:0,data:[{x:0,y:0,w:2,h:8}]},"▏":{type:0,data:[{x:0,y:0,w:1,h:8}]},"▐":{type:0,data:[{x:4,y:0,w:4,h:8}]},"░":{type:1,data:[[1,0],[0,0]]},"▒":{type:1,data:[[1,0],[0,1]]},"▓":{type:1,data:[[1,1],[1,0]]},"▔":{type:0,data:[{x:0,y:0,w:8,h:1}]},"▕":{type:0,data:[{x:7,y:0,w:1,h:8}]},"▖":{type:0,data:[{x:0,y:4,w:4,h:4}]},"▗":{type:0,data:[{x:4,y:4,w:4,h:4}]},"▘":{type:0,data:[{x:0,y:0,w:4,h:4}]},"▙":{type:0,data:[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"▚":{type:0,data:[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}]},"▛":{type:0,data:[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}]},"▜":{type:0,data:[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}]},"▝":{type:0,data:[{x:4,y:0,w:4,h:4}]},"▞":{type:0,data:[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}]},"▟":{type:0,data:[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"":{type:5,data:{d:"M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.7,.03 L.7,.1 L.355,.1 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M0,0 L1,.5 L0,1",type:0,rightPadding:2}},"":{type:5,data:{d:"M-1,-.5 L1,.5 L-1,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L0,.5 L1,1",type:0,leftPadding:2}},"":{type:5,data:{d:"M2,-.5 L0,.5 L2,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0",type:0,rightPadding:1}},"":{type:5,data:{d:"M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0",type:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0",type:0,leftPadding:1}},"":{type:5,data:{d:"M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0",type:1,leftPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L-.5,1.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L1.5,1.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L-.5,-.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L1.5,-.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0.8069,0.6075 Q0.7991,0.612,0.7815,0.6218 T0.7542,0.6369 T0.7282,0.6508 T0.7016,0.6644 T0.6778,0.6758 T0.6551,0.6854 T0.637,0.6911 T0.622,0.6936 Q0.6186,0.6936,0.6128,0.6942 T0.6018,0.6952 T0.5894,0.6956 T0.5757,0.6952 T0.561,0.693 T0.5451,0.6879 Q0.5342,0.6834,0.5193,0.6756 T0.4925,0.6614 T0.4673,0.6485 T0.4436,0.6389 T0.4239,0.6363 T0.4089,0.6422 Q0.4045,0.6467,0.3958,0.6554 T0.3843,0.6669 T0.3748,0.6746 T0.3613,0.683 T0.3442,0.6903 L0.3499,0.6956 Q0.3584,0.7042,0.3679,0.7177 T0.3836,0.7403 T0.4019,0.7583 T0.4295,0.7703 Q0.4688,0.7785,0.5203,0.7605 Q0.4902,0.7817,0.449,0.7911 T0.3696,0.8001 Q0.3567,0.7997,0.341,0.7942 T0.3111,0.7813 T0.2805,0.766 T0.2493,0.753 T0.218,0.7472 T0.187,0.754 Q0.1762,0.7589,0.1653,0.7681 T0.1453,0.7895 T0.1308,0.818 T0.1264,0.8507 L0.1264,0.858 Q0.1453,0.8462,0.1607,0.8433 T0.1899,0.8452 T0.2226,0.8625 Q0.2344,0.8707,0.2485,0.8752 T0.2813,0.8831 T0.3089,0.889 Q0.2981,0.8915,0.2846,0.8956 T0.2603,0.9037 T0.2368,0.9115 T0.2129,0.9174 T0.1894,0.9194 T0.1653,0.9155 Q0.1504,0.9115,0.1328,0.9196 T0.0986,0.9421 T0.0655,0.9708 T0.0317,0.9943 T0,1 Q0.001,0.94,0.0017,0.6508 T0.0024,0.2322 Q0.002,0.2109,0.0007,0.1379 T0,0.0249 Q0.0058,0.0253,0.009,0.0257 T0.0156,0.0288 T0.0203,0.0333 T0.0264,0.0422 T0.0349,0.0539 Q0.044,0.0657,0.0542,0.072 T0.0752,0.0789 T0.096,0.0775 T0.1175,0.0694 T0.1379,0.0573 T0.1579,0.0432 Q0.2141,0,0.2673,0.0318 Q0.2588,0.0318,0.2517,0.033 T0.239,0.0361 T0.2276,0.0424 T0.218,0.0504 T0.2085,0.0612 T0.1997,0.0732 T0.1899,0.0875 T0.1795,0.1028 Q0.1748,0.1093,0.1507,0.1234 T0.1186,0.1448 Q0.0999,0.1612,0.1108,0.1783 Q0.1162,0.1877,0.1238,0.1928 T0.1404,0.1979 T0.158,0.1967 T0.1775,0.1895 T0.1963,0.1791 T0.2151,0.1663 T0.2314,0.1534 T0.2458,0.141 T0.2561,0.1322 Q0.2815,0.1106,0.3017,0.101 T0.344,0.0926 T0.3919,0.1077 Q0.4099,0.1175,0.4265,0.1206 T0.4588,0.1202 T0.488,0.1098 T0.5176,0.0914 Q0.5071,0.1032,0.4963,0.1122 T0.4722,0.1285 T0.4497,0.1401 T0.4223,0.1518 T0.3943,0.1636 Q0.374,0.1726,0.3623,0.1863 T0.3482,0.2162 Q0.3445,0.2403,0.3608,0.2605 T0.4072,0.2876 Q0.4194,0.2909,0.4326,0.2899 T0.457,0.2856 T0.4805,0.2764 T0.5032,0.264 T0.5256,0.2497 T0.5478,0.2352 T0.5698,0.2222 T0.5921,0.2118 Q0.6982,0.173,0.81,0.2436 Q0.833,0.2583,0.8496,0.2674 T0.887,0.2846 T0.925,0.294 T0.9617,0.2911 T1,0.2746 Q0.9817,0.2946,0.96,0.3056 T0.9175,0.3191 T0.8731,0.3209 T0.8277,0.3164 T0.7818,0.3109 T0.7359,0.3101 T0.6907,0.3197 T0.647,0.3448 Q0.6443,0.3472,0.6333,0.3562 T0.6194,0.3674 T0.6076,0.3766 T0.594,0.386 T0.5815,0.3931 T0.5661,0.4007 T0.5501,0.4064 L0.5556,0.4121 Q0.5627,0.4198,0.5722,0.4251 T0.5926,0.4337 T0.6131,0.439 T0.6343,0.4431 T0.6521,0.4468 Q0.6762,0.4529,0.7056,0.4463 Q0.6772,0.4623,0.6543,0.471 T0.604,0.4814 Q0.5962,0.4818,0.5815,0.4825 T0.5591,0.4835 T0.5393,0.4857 T0.519,0.4908 T0.5007,0.4996 T0.4812,0.5141 T0.4627,0.5353 L0.4549,0.5451 L0.4661,0.543 Q0.4759,0.541,0.4909,0.55 T0.5315,0.5796 T0.5684,0.6083 Q0.5799,0.6161,0.5915,0.6212 T0.6181,0.6308 L0.6331,0.6353 Q0.6514,0.6418,0.7004,0.633 T0.8069,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.011,0.0469 Q0.0152,0.0412,0.02,0.0361 T0.0292,0.0281 T0.0365,0.0253 Q0.0426,0.0257,0.0461,0.0263 T0.0534,0.03 T0.0582,0.0345 T0.0645,0.0434 T0.0723,0.0546 Q0.0807,0.0653,0.0897,0.0712 T0.1081,0.0775 T0.1255,0.0765 T0.1438,0.0695 T0.1605,0.0593 T0.1772,0.0473 Q0.2388,0,0.2927,0.0326 L0.2614,0.0767 L0.2581,0.0767 Q0.2494,0.0767,0.2444,0.0773 T0.2359,0.0789 T0.2291,0.0842 T0.2233,0.0922 T0.2146,0.1054 T0.2026,0.1236 Q0.1943,0.1354,0.1843,0.1444 T0.1675,0.1574 T0.1526,0.1654 T0.1429,0.1701 Q0.1436,0.1717,0.1446,0.1733 Q0.1504,0.1823,0.1581,0.1866 T0.1733,0.1915 T0.1909,0.1884 T0.2085,0.1809 T0.2265,0.169 T0.2427,0.1564 T0.2575,0.1436 T0.2688,0.1338 Q0.2969,0.1101,0.3195,0.0995 T0.3643,0.0901 T0.4114,0.1048 Q0.4279,0.1138,0.4426,0.1168 T0.4705,0.117 T0.4945,0.1089 T0.5186,0.0942 L0.5211,0.0926 L0.5289,0.0922 Q0.5411,0.095,0.5189,0.1203 Q0.505,0.1358,0.4913,0.1474 T0.4634,0.167 T0.4389,0.1796 T0.4121,0.1909 T0.3866,0.2011 Q0.3782,0.2047,0.3727,0.2092 Q0.3717,0.2117,0.3714,0.2141 Q0.3695,0.2263,0.3751,0.2388 T0.394,0.2616 T0.4253,0.2761 Q0.4398,0.2785,0.4513,0.2779 T0.4756,0.2724 T0.4987,0.2616 T0.5216,0.2476 T0.5453,0.2321 T0.5703,0.217 T0.5976,0.2043 Q0.7015,0.166,0.8099,0.2345 Q0.8361,0.2512,0.8572,0.2616 T0.9006,0.2779 T0.9432,0.281 T0.9819,0.2663 L0.9887,0.2671 Q1,0.272,0.9768,0.2969 Q0.9558,0.3193,0.9319,0.333 T0.8858,0.3513 T0.8391,0.3569 T0.793,0.3546 T0.7485,0.3497 T0.7065,0.3473 T0.6681,0.3522 T0.6344,0.3699 Q0.5963,0.4005,0.5847,0.4082 Q0.595,0.4139,0.6079,0.4174 T0.6367,0.4237 T0.6586,0.4278 Q0.6793,0.4331,0.7044,0.4278 Q0.7057,0.4278,0.707,0.4274 Q0.7067,0.429,0.7062,0.4313 T0.7036,0.4398 T0.6993,0.4513 T0.6931,0.4619 T0.6851,0.4698 Q0.6573,0.4857,0.6336,0.4947 T0.5831,0.5053 Q0.577,0.5057,0.5615,0.5065 T0.5394,0.508 T0.5221,0.51 T0.5042,0.5139 T0.4905,0.5204 Q0.5008,0.5232,0.515,0.5328 T0.549,0.5585 T0.5799,0.5824 Q0.5905,0.5897,0.6013,0.5946 T0.6262,0.6036 T0.6409,0.6081 Q0.6567,0.6134,0.7035,0.605 T0.8028,0.5816 L0.7974,0.5922 T0.7867,0.6134 L0.7812,0.624 Q0.7799,0.6248,0.7786,0.6252 Q0.7709,0.6297,0.7573,0.6372 T0.7346,0.6499 T0.7123,0.6621 T0.6897,0.6741 T0.6686,0.6847 T0.648,0.6941 T0.6297,0.7013 T0.6129,0.7062 T0.5995,0.7076 Q0.5992,0.7076,0.5831,0.7092 T0.5529,0.7092 T0.525,0.7019 Q0.5105,0.6958,0.4824,0.6811 T0.4356,0.6586 T0.4079,0.6533 Q0.3872,0.6741,0.3827,0.6782 Q0.3872,0.6835,0.3934,0.6929 T0.4037,0.7078 T0.4142,0.7198 T0.4284,0.7304 T0.4466,0.7365 Q0.4824,0.7443,0.5292,0.728 L0.5292,0.7294 T0.529,0.7333 T0.5284,0.7392 T0.5266,0.7463 T0.5236,0.7539 T0.5186,0.7612 T0.5111,0.7679 Q0.5086,0.77,0.5056,0.7716 Q0.4466,0.8104,0.3591,0.8091 Q0.344,0.8091,0.3246,0.802 T0.2893,0.7863 T0.2543,0.77 T0.2206,0.76 T0.1894,0.7643 Q0.1804,0.7684,0.1733,0.7753 Q0.1591,0.7892,0.1601,0.8169 Q0.1784,0.8067,0.1938,0.8053 T0.2212,0.8087 T0.2507,0.825 Q0.2614,0.8324,0.2744,0.8365 T0.3053,0.844 T0.332,0.8499 L0.303,0.894 L0.2998,0.8948 Q0.2904,0.8968,0.278,0.9009 T0.2554,0.9086 T0.233,0.9158 T0.2099,0.9213 T0.1868,0.9229 T0.1633,0.9192 Q0.1517,0.916,0.1367,0.9241 T0.1065,0.9462 T0.0753,0.9737 T0.041,0.9955 T0.0061,1 Q0,0.9984,0.0068,0.9845 Q0.009,0.9804,0.0123,0.9755 Q0.0187,0.9666,0.0261,0.9606 T0.0374,0.9555 Q0.0481,0.958,0.0629,0.949 T0.0931,0.9262 T0.1247,0.8987 T0.1597,0.8777 T0.1949,0.8752 Q0.213,0.8805,0.2343,0.8772 Q0.2249,0.8736,0.2172,0.8683 Q0.1914,0.8511,0.1738,0.8497 T0.1349,0.8617 Q0.1304,0.865,0.1273,0.8646 T0.1242,0.8605 L0.1239,0.8536 Q0.1226,0.8295,0.1354,0.8016 T0.1723,0.7496 Q0.1914,0.7308,0.2104,0.7219 Q0.2259,0.7145,0.2425,0.7141 T0.273,0.7182 T0.3022,0.7296 T0.3301,0.7439 T0.3569,0.7571 T0.3821,0.7643 Q0.373,0.7561,0.3598,0.7367 T0.3382,0.7088 L0.3327,0.7035 Q0.3282,0.699,0.3353,0.6884 T0.3517,0.6692 L0.3611,0.6607 Q0.3711,0.657,0.3751,0.6554 T0.3837,0.6507 T0.3898,0.6458 T0.3979,0.637 T0.4111,0.6236 Q0.4172,0.6175,0.4234,0.6138 Q0.4327,0.6081,0.4439,0.6077 T0.4655,0.6105 T0.4884,0.6201 T0.5113,0.633 T0.5347,0.6468 T0.5573,0.6578 Q0.5705,0.6635,0.5833,0.665 T0.6112,0.665 T0.6286,0.6635 Q0.6421,0.6631,0.6718,0.6501 Q0.626,0.6578,0.6089,0.6517 Q0.6063,0.6509,0.6002,0.6491 T0.5884,0.6454 T0.5757,0.6409 T0.561,0.6344 T0.5466,0.6256 Q0.536,0.6183,0.5108,0.5989 T0.4722,0.5712 T0.4527,0.564 L0.4418,0.5661 Q0.4308,0.5681,0.4366,0.5563 Q0.4401,0.5485,0.4476,0.5392 L0.455,0.5298 Q0.4631,0.5188,0.4714,0.51 T0.4866,0.4947 T0.5021,0.4831 T0.516,0.4749 T0.53,0.4694 T0.5428,0.4657 T0.5558,0.4637 T0.5674,0.4629 T0.5795,0.4625 Q0.5489,0.4543,0.534,0.4384 L0.5286,0.4327 Q0.5244,0.4282,0.5318,0.4174 T0.5486,0.398 L0.5579,0.3899 Q0.567,0.387,0.5749,0.3834 T0.5878,0.3772 T0.6012,0.3687 T0.6126,0.3603 T0.6268,0.3487 T0.6421,0.3361 Q0.6567,0.3242,0.6712,0.3159 T0.7009,0.3034 T0.7277,0.2973 T0.7556,0.2961 T0.7809,0.2977 T0.8074,0.301 Q0.8096,0.3014,0.8109,0.3016 T0.8143,0.302 T0.818,0.3026 Q0.8012,0.2936,0.7767,0.2781 Q0.6718,0.2117,0.5744,0.2476 Q0.5637,0.2516,0.5492,0.2604 T0.5218,0.2779 T0.4923,0.2965 T0.461,0.3124 T0.4282,0.3216 T0.394,0.3206 Q0.3637,0.3136,0.3474,0.2936 T0.3353,0.2459 Q0.3385,0.2247,0.355,0.2023 Q0.3646,0.1892,0.3763,0.179 Q0.3898,0.1672,0.4063,0.1595 Q0.3927,0.1562,0.3785,0.1485 Q0.3604,0.1387,0.3453,0.1354 T0.3161,0.1354 T0.2899,0.1458 T0.2627,0.166 Q0.262,0.1664,0.254,0.1733 T0.2414,0.1841 T0.2267,0.196 T0.2094,0.2086 T0.1915,0.2196 T0.1725,0.2288 T0.1546,0.2337 T0.1373,0.2343 T0.1223,0.228 T0.1097,0.2145 Q0.0962,0.1929,0.1258,0.1582 Q0.131,0.1517,0.1371,0.1464 Q0.1462,0.1387,0.156,0.1321 T0.1752,0.1205 T0.1878,0.1134 Q0.1891,0.1117,0.1947,0.103 T0.2035,0.0899 T0.2125,0.0781 T0.2243,0.064 Q0.1949,0.0616,0.1672,0.0828 Q0.153,0.0934,0.1412,0.1009 T0.1146,0.1146 T0.0873,0.1213 T0.0616,0.116 T0.0374,0.0962 Q0.0339,0.0909,0.0295,0.0846 T0.024,0.0767 T0.0202,0.0728 T0.0148,0.0704 T0.0068,0.0697 Q0.0003,0.0693,0.0058,0.0567 Q0.0077,0.0522,0.011,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.1934,0.6075 Q0.2012,0.612,0.2188,0.6218 T0.2459,0.6369 T0.2718,0.6508 T0.2983,0.6644 T0.322,0.6758 T0.3447,0.6854 T0.363,0.6911 T0.3783,0.6936 Q0.3796,0.6936,0.396,0.6952 T0.4265,0.6952 T0.4548,0.6879 Q0.4656,0.6834,0.4805,0.6756 T0.5073,0.6614 T0.5327,0.6485 T0.5564,0.6389 T0.5759,0.6363 T0.5909,0.6422 Q0.5953,0.6467,0.604,0.6554 T0.6155,0.6669 T0.6251,0.6746 T0.6387,0.683 T0.6556,0.6903 L0.6498,0.6956 Q0.6414,0.7042,0.6319,0.7177 T0.6162,0.7403 T0.5979,0.7583 T0.5703,0.7703 Q0.5313,0.7785,0.4799,0.7605 Q0.5097,0.7817,0.5508,0.7911 T0.6302,0.8001 Q0.6431,0.7997,0.659,0.7942 T0.6888,0.7813 T0.7194,0.766 T0.7506,0.753 T0.7819,0.7472 T0.8131,0.754 Q0.8239,0.7589,0.8346,0.7681 T0.8544,0.7895 T0.8689,0.818 T0.8737,0.8507 L0.8733,0.858 Q0.8591,0.849,0.8474,0.845 T0.8242,0.8421 T0.8021,0.8482 T0.7772,0.8625 Q0.7653,0.8707,0.7513,0.8752 T0.7184,0.8831 T0.6908,0.889 Q0.7017,0.8915,0.7152,0.8956 T0.7396,0.9037 T0.7633,0.9115 T0.7872,0.9174 T0.8107,0.9194 T0.8347,0.9155 Q0.8493,0.9115,0.8671,0.9196 T0.9015,0.9421 T0.9345,0.9708 T0.9682,0.9943 T0.9997,1 Q0.999,0.94,0.9983,0.6508 T0.9976,0.2322 Q0.9976,0.2109,0.9992,0.1379 T1,0.0249 Q0.9949,0.0249,0.9917,0.0257 T0.9863,0.0273 T0.9817,0.0308 T0.9776,0.0357 T0.9721,0.0437 T0.9651,0.0539 Q0.956,0.0657,0.9456,0.072 T0.9247,0.0789 T0.904,0.0775 T0.8823,0.0694 T0.862,0.0573 T0.8422,0.0432 Q0.7856,0,0.7328,0.0318 Q0.7392,0.0318,0.7452,0.0324 T0.756,0.0345 T0.7657,0.0386 T0.7741,0.0437 T0.7821,0.0506 T0.7894,0.0588 T0.7966,0.0683 T0.8039,0.0787 T0.8119,0.0904 T0.8202,0.1028 Q0.8249,0.1093,0.849,0.1234 T0.8811,0.1448 Q0.8998,0.1612,0.8893,0.1783 Q0.8835,0.1877,0.8759,0.1928 T0.8593,0.1979 T0.8417,0.1967 T0.8222,0.1895 T0.8034,0.1791 T0.7848,0.1663 T0.7687,0.1534 T0.7543,0.141 T0.744,0.1322 Q0.7186,0.1106,0.6984,0.101 T0.6559,0.0926 T0.6082,0.1077 Q0.5899,0.1175,0.5733,0.1206 T0.5411,0.1202 T0.5122,0.1098 T0.4822,0.0914 Q0.4927,0.1032,0.5036,0.1122 T0.5276,0.1285 T0.5503,0.1401 T0.5777,0.1518 T0.6055,0.1636 Q0.6258,0.1726,0.6375,0.1863 T0.6519,0.2162 Q0.6556,0.2403,0.6392,0.2605 T0.5926,0.2876 Q0.5804,0.2909,0.5672,0.2899 T0.5428,0.2856 T0.5193,0.2764 T0.4966,0.264 T0.4744,0.2497 T0.4523,0.2352 T0.4301,0.2222 T0.4077,0.2118 Q0.3021,0.173,0.1903,0.2436 Q0.1669,0.2583,0.1504,0.2674 T0.1129,0.2846 T0.075,0.294 T0.0384,0.2911 T0,0.2746 Q0.021,0.297,0.0459,0.3082 T0.0953,0.3207 T0.146,0.3193 T0.1988,0.3131 T0.2508,0.3095 T0.3029,0.3176 T0.3529,0.3448 Q0.3566,0.348,0.3652,0.355 T0.3771,0.3645 T0.3877,0.3731 T0.3993,0.3815 T0.4103,0.3884 T0.4226,0.3953 T0.4352,0.4009 T0.4497,0.4064 L0.4443,0.4121 Q0.4372,0.4198,0.4277,0.4251 T0.4072,0.4337 T0.3867,0.439 T0.3656,0.4431 T0.3481,0.4468 Q0.3241,0.4529,0.2943,0.4463 Q0.3227,0.4623,0.3458,0.471 T0.3959,0.4814 Q0.404,0.4818,0.4187,0.4825 T0.4409,0.4835 T0.4605,0.4857 T0.4809,0.4908 T0.4992,0.4996 T0.5186,0.5141 T0.5371,0.5353 L0.5449,0.5451 L0.5337,0.543 Q0.5242,0.541,0.5091,0.55 T0.4683,0.5796 T0.4314,0.6083 Q0.4203,0.6161,0.4086,0.6212 T0.382,0.6308 T0.3667,0.6353 Q0.3485,0.6418,0.2995,0.633 T0.1934,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.989,0.0469 Q0.9829,0.0379,0.9753,0.0316 T0.9635,0.0253 Q0.9574,0.0257,0.9539,0.0263 T0.9466,0.03 T0.9418,0.0345 T0.9355,0.0434 T0.9277,0.0546 Q0.9193,0.0653,0.9103,0.0712 T0.8919,0.0775 T0.8745,0.0765 T0.8564,0.0695 T0.8396,0.0593 T0.8228,0.0473 Q0.7612,0,0.7073,0.0326 L0.7386,0.0767 L0.7419,0.0767 Q0.7506,0.0767,0.7556,0.0773 T0.7641,0.0789 T0.7709,0.0842 T0.7767,0.0922 T0.7854,0.1054 T0.7974,0.1236 Q0.8057,0.1354,0.8157,0.1444 T0.8325,0.1574 T0.8474,0.1654 T0.8571,0.1701 Q0.8564,0.1717,0.8554,0.1733 Q0.8503,0.1815,0.844,0.186 T0.8301,0.1909 T0.8153,0.1903 T0.7993,0.1845 T0.7836,0.1758 T0.768,0.1648 T0.754,0.1535 T0.741,0.1425 T0.7312,0.1338 Q0.7031,0.1101,0.6805,0.0995 T0.6357,0.0901 T0.5886,0.1048 Q0.5721,0.1138,0.5574,0.1168 T0.5295,0.117 T0.5055,0.1089 T0.4814,0.0942 L0.4789,0.0926 L0.4711,0.0922 Q0.4589,0.095,0.4811,0.1203 Q0.495,0.1358,0.5087,0.1474 T0.5366,0.167 T0.5611,0.1796 T0.5879,0.1909 T0.6134,0.2011 Q0.6218,0.2047,0.6273,0.2092 Q0.6283,0.2117,0.6286,0.2141 Q0.6305,0.2263,0.6249,0.2388 T0.606,0.2616 T0.5747,0.2761 Q0.5602,0.2785,0.5487,0.2779 T0.5245,0.2724 T0.5015,0.2616 T0.4784,0.2476 T0.4547,0.2321 T0.4297,0.217 T0.4024,0.2043 Q0.2985,0.166,0.1901,0.2345 Q0.1694,0.248,0.1523,0.2569 T0.1165,0.2726 T0.0818,0.2812 T0.0494,0.2796 T0.0181,0.2663 L0.0116,0.2671 Q0,0.272,0.0232,0.2969 Q0.0468,0.3222,0.0742,0.3365 T0.1278,0.3542 T0.1807,0.3564 T0.233,0.352 T0.2815,0.3473 T0.3269,0.3509 T0.3656,0.3699 Q0.4037,0.4005,0.4153,0.4082 Q0.405,0.4139,0.3921,0.4174 T0.3633,0.4237 T0.3414,0.4278 Q0.3207,0.4331,0.2956,0.4278 Q0.2943,0.4278,0.293,0.4274 Q0.2933,0.429,0.2938,0.4313 T0.2964,0.4398 T0.3007,0.4513 T0.3069,0.4619 T0.3149,0.4698 Q0.3427,0.4857,0.3664,0.4947 T0.4169,0.5053 Q0.423,0.5057,0.4385,0.5065 T0.4606,0.508 T0.4779,0.51 T0.4958,0.5139 T0.5095,0.5204 Q0.4992,0.5232,0.485,0.5328 T0.451,0.5585 T0.4201,0.5824 Q0.4095,0.5897,0.3987,0.5946 T0.3738,0.6036 T0.3591,0.6081 Q0.3433,0.6134,0.2965,0.605 T0.1972,0.5816 L0.2026,0.5922 T0.2133,0.6134 L0.2188,0.624 Q0.2201,0.6248,0.2214,0.6252 Q0.2291,0.6297,0.2486,0.6405 T0.2786,0.657 T0.3066,0.6721 T0.3353,0.6866 T0.3598,0.6972 T0.383,0.7051 T0.4005,0.7076 Q0.4008,0.7076,0.4169,0.7092 T0.4471,0.7092 T0.475,0.7019 Q0.4895,0.6958,0.5176,0.6811 T0.5644,0.6586 T0.5921,0.6533 Q0.6128,0.6741,0.6173,0.6782 Q0.6131,0.6835,0.6068,0.6929 T0.5963,0.7078 T0.5858,0.7198 T0.5716,0.7304 T0.5534,0.7365 Q0.5179,0.7443,0.4708,0.728 L0.4708,0.7294 T0.471,0.7333 T0.4716,0.7392 T0.4734,0.7463 T0.4764,0.7539 T0.4814,0.7612 T0.4889,0.7679 Q0.4914,0.77,0.4944,0.7716 Q0.5534,0.8104,0.6409,0.8091 Q0.6521,0.8091,0.6654,0.8053 T0.6907,0.7959 T0.7157,0.7838 T0.741,0.772 T0.7652,0.7631 T0.7888,0.7594 T0.8106,0.7643 Q0.8196,0.7684,0.8267,0.7753 Q0.8409,0.7892,0.8399,0.8169 Q0.8216,0.8067,0.8062,0.8053 T0.7788,0.8087 T0.7493,0.825 Q0.7386,0.8324,0.7256,0.8365 T0.6947,0.844 T0.668,0.8499 L0.697,0.894 L0.7002,0.8948 Q0.7099,0.8968,0.7222,0.9009 T0.7446,0.9086 T0.767,0.9158 T0.7901,0.9213 T0.8132,0.9229 T0.8367,0.9192 Q0.8483,0.916,0.8633,0.9241 T0.8937,0.9462 T0.9248,0.9737 T0.959,0.9955 T0.9939,1 Q1,0.9984,0.9932,0.9845 Q0.991,0.9804,0.9877,0.9755 Q0.9813,0.9666,0.9739,0.9606 T0.9626,0.9555 Q0.9519,0.958,0.9372,0.949 T0.9071,0.9262 T0.8753,0.8987 T0.8403,0.8777 T0.8051,0.8752 Q0.787,0.8805,0.7657,0.8772 Q0.7751,0.8736,0.7828,0.8683 Q0.8086,0.8511,0.8262,0.8497 T0.8651,0.8617 Q0.8696,0.865,0.8727,0.8646 T0.8758,0.8605 L0.8761,0.8536 Q0.8774,0.8295,0.8646,0.8016 T0.8277,0.7496 Q0.8086,0.7308,0.7896,0.7219 Q0.7741,0.7145,0.7575,0.7141 T0.727,0.7182 T0.6978,0.7296 T0.6699,0.7439 T0.6433,0.7571 T0.6179,0.7643 Q0.627,0.7561,0.6402,0.7367 T0.6618,0.7088 L0.6673,0.7035 Q0.6718,0.699,0.6647,0.6884 T0.6483,0.6692 L0.6389,0.6607 Q0.6289,0.657,0.6249,0.6554 T0.6163,0.6507 T0.6102,0.6458 T0.6021,0.637 T0.5889,0.6236 Q0.5828,0.6175,0.5766,0.6138 Q0.5673,0.6081,0.5561,0.6077 T0.5345,0.6105 T0.5116,0.6201 T0.4887,0.633 T0.4653,0.6468 T0.4427,0.6578 Q0.4295,0.6635,0.4167,0.665 T0.3888,0.665 T0.3714,0.6635 Q0.3579,0.6631,0.3282,0.6501 Q0.374,0.6578,0.3911,0.6517 Q0.3937,0.6509,0.4024,0.6482 T0.4179,0.6434 T0.4348,0.6364 T0.4534,0.6256 Q0.464,0.6183,0.4892,0.5989 T0.5278,0.5712 T0.5476,0.564 L0.5582,0.5661 Q0.5692,0.5681,0.5634,0.5563 Q0.5599,0.5485,0.5524,0.5392 L0.545,0.5298 Q0.5369,0.5188,0.5286,0.51 T0.5134,0.4947 T0.4979,0.4831 T0.484,0.4749 T0.47,0.4694 T0.4572,0.4657 T0.4442,0.4637 T0.4326,0.4629 T0.4205,0.4625 Q0.4511,0.4543,0.466,0.4384 L0.4714,0.4327 Q0.4795,0.4237,0.4469,0.394 L0.4421,0.3899 Q0.433,0.387,0.4251,0.3834 T0.4122,0.3772 T0.3988,0.3687 T0.3874,0.3603 T0.3732,0.3487 T0.3579,0.3361 Q0.3433,0.3242,0.3288,0.3159 T0.2993,0.3034 T0.2725,0.2973 T0.2446,0.2961 T0.2193,0.2977 T0.1926,0.301 Q0.1855,0.3022,0.182,0.3026 Q0.1991,0.2936,0.2233,0.2781 Q0.3282,0.2117,0.4256,0.2476 Q0.4363,0.2516,0.451,0.2604 T0.4784,0.2779 T0.5077,0.2965 T0.539,0.3124 T0.5718,0.3216 T0.606,0.3206 Q0.6363,0.3136,0.6526,0.2936 T0.6647,0.2459 Q0.6615,0.2247,0.645,0.2023 Q0.6354,0.1892,0.6237,0.179 Q0.6102,0.1672,0.5937,0.1595 Q0.6073,0.1562,0.6215,0.1485 Q0.6396,0.1387,0.6547,0.1354 T0.6839,0.1354 T0.7101,0.1458 T0.7373,0.166 Q0.738,0.1664,0.746,0.1733 T0.7586,0.1841 T0.7733,0.196 T0.7907,0.2086 T0.8086,0.2196 T0.8275,0.2288 T0.8454,0.2337 T0.8627,0.2343 T0.8777,0.228 T0.8903,0.2145 Q0.9038,0.1929,0.8742,0.1582 Q0.869,0.1517,0.8629,0.1464 Q0.8538,0.1387,0.844,0.1321 T0.8248,0.1205 T0.8122,0.1134 Q0.8109,0.1117,0.8053,0.103 T0.7965,0.0899 T0.7875,0.0781 T0.7757,0.064 Q0.8051,0.0616,0.8328,0.0828 Q0.847,0.0934,0.8588,0.1009 T0.8854,0.1146 T0.9127,0.1213 T0.9384,0.116 T0.9626,0.0962 Q0.9664,0.0909,0.9706,0.0846 T0.976,0.0767 T0.9798,0.0728 T0.9852,0.0704 T0.9932,0.0697 Q0.9997,0.0693,0.9942,0.0567 Q0.9923,0.0522,0.989,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0.9099,0.1158 L0.9099,0.1888 L0.9951,0.1888 L0.9951,0.1158 L0.9099,0.1158 Z M0.9075,0.1116 L0.9976,0.1116 Q1,0.1116,1,0.1137 L1,0.1909 Q1,0.193,0.9976,0.193 L0.9075,0.193 Q0.905,0.193,0.905,0.1909 L0.905,0.1137 Q0.905,0.1116,0.9075,0.1116 Z M0.7852,0.6086 L0.7852,0.682 L0.8704,0.682 L0.8704,0.6086 L0.7852,0.6086 Z M0.7828,0.6044 L0.8729,0.6044 Q0.8753,0.6044,0.8753,0.6065 L0.8753,0.6841 Q0.8753,0.6862,0.8729,0.6862 L0.7828,0.6862 Q0.7803,0.6862,0.7803,0.6841 L0.7803,0.6065 Q0.7803,0.6044,0.7828,0.6044 Z M0.66,0.1137 L0.7501,0.1137 L0.7501,0.1909 L0.66,0.1909 L0.66,0.1137 Z M0.6624,0.4102 L0.6624,0.4836 L0.7477,0.4836 L0.7477,0.4102 L0.6624,0.4102 Z M0.66,0.406 L0.7501,0.406 Q0.7526,0.406,0.7526,0.4081 L0.7526,0.4857 Q0.7526,0.4878,0.7501,0.4878 L0.66,0.4878 Q0.6576,0.4878,0.6576,0.4857 L0.6576,0.4081 Q0.6576,0.406,0.66,0.406 Z M0.6624,0.7131 L0.6624,0.7865 L0.7477,0.7865 L0.7477,0.7131 L0.6624,0.7131 Z M0.66,0.7089 L0.7501,0.7089 Q0.7526,0.7089,0.7526,0.711 L0.7526,0.7886 Q0.7526,0.7907,0.7501,0.7907 L0.66,0.7907 Q0.6576,0.7907,0.6576,0.7886 L0.6576,0.711 Q0.6576,0.7089,0.66,0.7089 Z M0.5407,0.2227 L0.5407,0.2836 L0.6113,0.2836 L0.6113,0.2227 L0.5407,0.2227 Z M0.5309,0.206 L0.621,0.206 Q0.623,0.206,0.6249,0.2068 T0.6281,0.2089 T0.6301,0.2116 T0.6308,0.2143 L0.6308,0.2919 Q0.6308,0.2957,0.6276,0.298 T0.621,0.3003 L0.5309,0.3003 Q0.5265,0.3003,0.5239,0.2976 T0.5212,0.2919 L0.5212,0.2143 Q0.5212,0.211,0.5244,0.2085 T0.5309,0.206 Z M0.5407,0.3184 L0.5407,0.3792 L0.6113,0.3792 L0.6113,0.3184 L0.5407,0.3184 Z M0.5309,0.3016 L0.621,0.3016 Q0.6254,0.3016,0.6281,0.3043 T0.6308,0.31 L0.6308,0.3876 Q0.6308,0.3914,0.6276,0.3937 T0.621,0.396 L0.5309,0.396 Q0.5265,0.396,0.5239,0.3932 T0.5212,0.3876 L0.5212,0.31 Q0.5212,0.3062,0.5244,0.3039 T0.5309,0.3016 Z M0.5309,0.4081 L0.621,0.4081 L0.621,0.4857 L0.5309,0.4857 L0.5309,0.4081 Z M0.5309,0.5059 L0.621,0.5059 L0.621,0.5835 L0.5309,0.5835 L0.5309,0.5059 Z M0.5407,0.6149 L0.5407,0.6758 L0.6113,0.6758 L0.6113,0.6149 L0.5407,0.6149 Z M0.5309,0.5982 L0.621,0.5982 Q0.6254,0.5982,0.6281,0.6009 T0.6308,0.6065 L0.6308,0.6841 Q0.6308,0.6879,0.6276,0.6902 T0.621,0.6925 L0.5309,0.6925 Q0.5265,0.6925,0.5239,0.6898 T0.5212,0.6841 L0.5212,0.6065 Q0.5212,0.6028,0.5244,0.6005 T0.5309,0.5982 Z M0.4062,0.1137 L0.4963,0.1137 L0.4963,0.1909 L0.4062,0.1909 L0.4062,0.1137 Z M0.4062,0.2143 L0.4963,0.2143 L0.4963,0.2919 L0.4062,0.2919 L0.4062,0.2143 Z M0.4184,0.4186 L0.4184,0.4753 L0.4842,0.4753 L0.4842,0.4186 L0.4184,0.4186 Z M0.4062,0.3977 L0.4963,0.3977 Q0.5017,0.3977,0.5051,0.401 T0.5085,0.4081 L0.5085,0.4857 Q0.5085,0.4904,0.5046,0.4933 T0.4963,0.4962 L0.4062,0.4962 Q0.4009,0.4962,0.3975,0.4929 T0.3941,0.4857 L0.3941,0.4081 Q0.3941,0.4035,0.398,0.4006 T0.4062,0.3977 Z M0.4062,0.5059 L0.4963,0.5059 L0.4963,0.5835 L0.4062,0.5835 L0.4062,0.5059 Z M0.4087,0.7131 L0.4087,0.7865 L0.4939,0.7865 L0.4939,0.7131 L0.4087,0.7131 Z M0.4062,0.7089 L0.4963,0.7089 Q0.4988,0.7089,0.4988,0.711 L0.4988,0.7886 Q0.4988,0.7907,0.4963,0.7907 L0.4062,0.7907 Q0.4038,0.7907,0.4038,0.7886 L0.4038,0.711 Q0.4038,0.7089,0.4062,0.7089 Z M0.2689,0.1137 L0.359,0.1137 L0.359,0.1909 L0.2689,0.1909 L0.2689,0.1137 Z M0.2689,0.2143 L0.359,0.2143 L0.359,0.2919 L0.2689,0.2919 L0.2689,0.2143 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.31 L0.359,0.31 L0.359,0.3876 L0.2689,0.3876 L0.2689,0.31 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.4081 L0.359,0.4081 L0.359,0.4857 L0.2689,0.4857 L0.2689,0.4081 Z M0.2689,0.5059 L0.359,0.5059 L0.359,0.5835 L0.2689,0.5835 L0.2689,0.5059 Z M0.2689,0.711 L0.359,0.711 L0.359,0.7886 L0.2689,0.7886 L0.2689,0.711 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.1374,0.1137 L0.2275,0.1137 L0.2275,0.1909 L0.1374,0.1909 L0.1374,0.1137 Z M0.1374,0.2143 L0.2275,0.2143 L0.2275,0.2919 L0.1374,0.2919 L0.1374,0.2143 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.31 L0.2275,0.31 L0.2275,0.3876 L0.1374,0.3876 L0.1374,0.31 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.4081 L0.2275,0.4081 L0.2275,0.4857 L0.1374,0.4857 L0.1374,0.4081 Z M0.1374,0.5059 L0.2275,0.5059 L0.2275,0.5835 L0.1374,0.5835 L0.1374,0.5059 Z M0.1374,0.6065 L0.2275,0.6065 L0.2275,0.6841 L0.1374,0.6841 L0.1374,0.6065 Z M0.1374,0.711 L0.2275,0.711 L0.2275,0.7886 L0.1374,0.7886 L0.1374,0.711 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0,0.1137 L0.0901,0.1137 L0.0901,0.1909 L0,0.1909 L0,0.1137 Z M0,0.2143 L0.0901,0.2143 L0.0901,0.2919 L0,0.2919 L0,0.2143 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.31 L0.0901,0.31 L0.0901,0.3876 L0,0.3876 L0,0.31 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.4081 L0.0901,0.4081 L0.0901,0.4857 L0,0.4857 L0,0.4081 Z M0,0.5059 L0.0901,0.5059 L0.0901,0.5835 L0,0.5835 L0,0.5059 Z M0,0.6065 L0.0901,0.6065 L0.0901,0.6841 L0,0.6841 L0,0.6065 Z M0,0.711 L0.0901,0.711 L0.0901,0.7886 L0,0.7886 L0,0.711 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M0.0901,0.1158 L0.0901,0.1888 L0.0049,0.1888 L0.0049,0.1158 L0.0901,0.1158 Z M0.0925,0.1116 L0.0024,0.1116 Q0,0.1116,0,0.1137 L0,0.1909 Q0,0.193,0.0024,0.193 L0.0925,0.193 Q0.095,0.193,0.095,0.1909 L0.095,0.1137 Q0.095,0.1116,0.0925,0.1116 Z M0.2148,0.6086 L0.2148,0.682 L0.1296,0.682 L0.1296,0.6086 L0.2148,0.6086 Z M0.2172,0.6044 L0.1271,0.6044 Q0.1247,0.6044,0.1247,0.6065 L0.1247,0.6841 Q0.1247,0.6862,0.1271,0.6862 L0.2172,0.6862 Q0.2197,0.6862,0.2197,0.6841 L0.2197,0.6065 Q0.2197,0.6044,0.2172,0.6044 Z M0.34,0.1137 L0.2499,0.1137 L0.2499,0.1909 L0.34,0.1909 L0.34,0.1137 Z M0.3376,0.4102 L0.3376,0.4836 L0.2523,0.4836 L0.2523,0.4102 L0.3376,0.4102 Z M0.34,0.406 L0.2499,0.406 Q0.2474,0.406,0.2474,0.4081 L0.2474,0.4857 Q0.2474,0.4878,0.2499,0.4878 L0.34,0.4878 Q0.3424,0.4878,0.3424,0.4857 L0.3424,0.4081 Q0.3424,0.406,0.34,0.406 Z M0.3376,0.7131 L0.3376,0.7865 L0.2523,0.7865 L0.2523,0.7131 L0.3376,0.7131 Z M0.34,0.7089 L0.2499,0.7089 Q0.2474,0.7089,0.2474,0.711 L0.2474,0.7886 Q0.2474,0.7907,0.2499,0.7907 L0.34,0.7907 Q0.3424,0.7907,0.3424,0.7886 L0.3424,0.711 Q0.3424,0.7089,0.34,0.7089 Z M0.4593,0.2227 L0.4593,0.2836 L0.3887,0.2836 L0.3887,0.2227 L0.4593,0.2227 Z M0.4691,0.206 L0.379,0.206 Q0.3746,0.206,0.3719,0.2087 T0.3692,0.2143 L0.3692,0.2919 Q0.3692,0.2957,0.3724,0.298 T0.379,0.3003 L0.4691,0.3003 Q0.4735,0.3003,0.4761,0.2976 T0.4788,0.2919 L0.4788,0.2143 Q0.4788,0.211,0.4756,0.2085 T0.4691,0.206 Z M0.4593,0.3184 L0.4593,0.3792 L0.3887,0.3792 L0.3887,0.3184 L0.4593,0.3184 Z M0.4691,0.3016 L0.379,0.3016 Q0.3746,0.3016,0.3719,0.3043 T0.3692,0.31 L0.3692,0.3876 Q0.3692,0.3914,0.3724,0.3937 T0.379,0.396 L0.4691,0.396 Q0.4735,0.396,0.4761,0.3932 T0.4788,0.3876 L0.4788,0.31 Q0.4788,0.3062,0.4756,0.3039 T0.4691,0.3016 Z M0.4691,0.4081 L0.379,0.4081 L0.379,0.4857 L0.4691,0.4857 L0.4691,0.4081 Z M0.4691,0.5059 L0.379,0.5059 L0.379,0.5835 L0.4691,0.5835 L0.4691,0.5059 Z M0.4593,0.6149 L0.4593,0.6758 L0.3887,0.6758 L0.3887,0.6149 L0.4593,0.6149 Z M0.4691,0.5982 L0.379,0.5982 Q0.3746,0.5982,0.3719,0.6009 T0.3692,0.6065 L0.3692,0.6841 Q0.3692,0.6879,0.3724,0.6902 T0.379,0.6925 L0.4691,0.6925 Q0.4735,0.6925,0.4761,0.6898 T0.4788,0.6841 L0.4788,0.6065 Q0.4788,0.6028,0.4756,0.6005 T0.4691,0.5982 Z M0.5938,0.1137 L0.5037,0.1137 L0.5037,0.1909 L0.5938,0.1909 L0.5938,0.1137 Z M0.5938,0.2143 L0.5037,0.2143 L0.5037,0.2919 L0.5938,0.2919 L0.5938,0.2143 Z M0.5816,0.4186 L0.5816,0.4753 L0.5158,0.4753 L0.5158,0.4186 L0.5816,0.4186 Z M0.5938,0.3977 L0.5037,0.3977 Q0.4983,0.3977,0.4949,0.401 T0.4915,0.4081 L0.4915,0.4857 Q0.4915,0.4904,0.4954,0.4933 T0.5037,0.4962 L0.5938,0.4962 Q0.5991,0.4962,0.6025,0.4929 T0.6059,0.4857 L0.6059,0.4081 Q0.6059,0.4035,0.602,0.4006 T0.5938,0.3977 Z M0.5938,0.5059 L0.5037,0.5059 L0.5037,0.5835 L0.5938,0.5835 L0.5938,0.5059 Z M0.5913,0.7131 L0.5913,0.7865 L0.5061,0.7865 L0.5061,0.7131 L0.5913,0.7131 Z M0.5938,0.7089 L0.5037,0.7089 Q0.5012,0.7089,0.5012,0.711 L0.5012,0.7886 Q0.5012,0.7907,0.5037,0.7907 L0.5938,0.7907 Q0.5962,0.7907,0.5962,0.7886 L0.5962,0.711 Q0.5962,0.7089,0.5938,0.7089 Z M0.7311,0.1137 L0.641,0.1137 L0.641,0.1909 L0.7311,0.1909 L0.7311,0.1137 Z M0.7311,0.2143 L0.641,0.2143 L0.641,0.2919 L0.7311,0.2919 L0.7311,0.2143 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.31 L0.641,0.31 L0.641,0.3876 L0.7311,0.3876 L0.7311,0.31 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.4081 L0.641,0.4081 L0.641,0.4857 L0.7311,0.4857 L0.7311,0.4081 Z M0.7311,0.5059 L0.641,0.5059 L0.641,0.5835 L0.7311,0.5835 L0.7311,0.5059 Z M0.7311,0.711 L0.641,0.711 L0.641,0.7886 L0.7311,0.7886 L0.7311,0.711 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.8626,0.1137 L0.7725,0.1137 L0.7725,0.1909 L0.8626,0.1909 L0.8626,0.1137 Z M0.8626,0.2143 L0.7725,0.2143 L0.7725,0.2919 L0.8626,0.2919 L0.8626,0.2143 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.31 L0.7725,0.31 L0.7725,0.3876 L0.8626,0.3876 L0.8626,0.31 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.4081 L0.7725,0.4081 L0.7725,0.4857 L0.8626,0.4857 L0.8626,0.4081 Z M0.8626,0.5059 L0.7725,0.5059 L0.7725,0.5835 L0.8626,0.5835 L0.8626,0.5059 Z M0.8626,0.6065 L0.7725,0.6065 L0.7725,0.6841 L0.8626,0.6841 L0.8626,0.6065 Z M0.8626,0.711 L0.7725,0.711 L0.7725,0.7886 L0.8626,0.7886 L0.8626,0.711 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M1,0.1137 L0.9099,0.1137 L0.9099,0.1909 L1,0.1909 L1,0.1137 Z M1,0.2143 L0.9099,0.2143 L0.9099,0.2919 L1,0.2919 L1,0.2143 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.31 L0.9099,0.31 L0.9099,0.3876 L1,0.3876 L1,0.31 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.4081 L0.9099,0.4081 L0.9099,0.4857 L1,0.4857 L1,0.4081 Z M1,0.5059 L0.9099,0.5059 L0.9099,0.5835 L1,0.5835 L1,0.5059 Z M1,0.6065 L0.9099,0.6065 L0.9099,0.6841 L1,0.6841 L1,0.6065 Z M1,0.711 L0.9099,0.711 L0.9099,0.7886 L1,0.7886 L1,0.711 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0.8574,0.3217 L0.8574,0.4098 L0.9691,0.4098 L0.9691,0.3217 L0.8574,0.3217 Z M0.8419,0.2978 L0.9846,0.2978 Q0.9892,0.2978,0.9928,0.2997 T0.9982,0.3043 T1,0.31 L1,0.4216 Q1,0.427,0.9951,0.4304 T0.9846,0.4337 L0.8419,0.4337 Q0.8352,0.4337,0.8308,0.4299 T0.8265,0.4216 L0.8265,0.31 Q0.8265,0.3062,0.829,0.3035 T0.835,0.2993 T0.8419,0.2978 Z M0.8574,0.888 L0.8574,0.9757 L0.9691,0.9757 L0.9691,0.888 L0.8574,0.888 Z M0.8419,0.8637 L0.9846,0.8637 Q0.9892,0.8637,0.9928,0.8658 T0.9982,0.8706 T1,0.8758 L1,0.9878 Q1,0.9929,0.9951,0.9964 T0.9846,1 L0.8419,1 Q0.8352,1,0.8308,0.996 T0.8265,0.9878 L0.8265,0.8758 Q0.8265,0.8708,0.8314,0.8672 T0.8419,0.8637 Z M0.6509,0.169 L0.6509,0.2706 L0.7806,0.2706 L0.7806,0.169 L0.6509,0.169 Z M0.6442,0.159 L0.7868,0.159 Q0.7899,0.159,0.7917,0.1604 T0.7935,0.164 L0.7935,0.2756 Q0.7935,0.2781,0.7915,0.2796 T0.7868,0.281 L0.6442,0.281 Q0.6416,0.281,0.6395,0.2794 T0.6375,0.2756 L0.6375,0.164 Q0.6375,0.1619,0.6398,0.1604 T0.6442,0.159 Z M0.6632,0.6044 L0.6632,0.6858 L0.7678,0.6858 L0.7678,0.6044 L0.6632,0.6044 Z M0.6442,0.5742 L0.7868,0.5742 Q0.7951,0.5742,0.8007,0.5791 T0.8064,0.5893 L0.8064,0.7013 Q0.8064,0.7076,0.8002,0.712 T0.7868,0.7164 L0.6442,0.7164 Q0.6359,0.7164,0.6305,0.7114 T0.6251,0.7013 L0.6251,0.5893 Q0.6251,0.5847,0.6282,0.5812 T0.6357,0.5759 T0.6442,0.5742 Z M0.4264,0.164 L0.5695,0.164 L0.5695,0.2756 L0.4264,0.2756 L0.4264,0.164 Z M0.4325,0.315 L0.4325,0.4165 L0.5628,0.4165 L0.5628,0.315 L0.4325,0.315 Z M0.4264,0.3049 L0.5695,0.3049 Q0.5721,0.3049,0.5739,0.3064 T0.5757,0.31 L0.5757,0.4216 Q0.5757,0.4237,0.5736,0.4251 T0.5695,0.4266 L0.4264,0.4266 Q0.4238,0.4266,0.422,0.4251 T0.4202,0.4216 L0.4202,0.31 Q0.4202,0.3087,0.4207,0.3077 T0.4222,0.306 T0.4243,0.3052 T0.4264,0.3049 Z M0.4264,0.4476 L0.5695,0.4476 L0.5695,0.5596 L0.4264,0.5596 L0.4264,0.4476 Z M0.4392,0.7408 L0.4392,0.8326 L0.5561,0.8326 L0.5561,0.7408 L0.4392,0.7408 Z M0.4264,0.7206 L0.5695,0.7206 Q0.5747,0.7206,0.5783,0.724 T0.5819,0.7307 L0.5819,0.8427 Q0.5819,0.8469,0.5778,0.8498 T0.5695,0.8528 L0.4264,0.8528 Q0.4207,0.8528,0.4171,0.8496 T0.4135,0.8427 L0.4135,0.7307 Q0.4135,0.7265,0.4176,0.7236 T0.4264,0.7206 Z M0.2178,0.164 L0.3605,0.164 L0.3605,0.2756 L0.2178,0.2756 L0.2178,0.164 Z M0.2178,0.31 L0.3605,0.31 L0.3605,0.4216 L0.2178,0.4216 L0.2178,0.31 Z M0.2178,0.5893 L0.3605,0.5893 L0.3605,0.7013 L0.2178,0.7013 L0.2178,0.5893 Z M0.2178,0.7307 L0.3605,0.7307 L0.3605,0.8427 L0.2178,0.8427 L0.2178,0.7307 Z M0.2178,0.8758 L0.3605,0.8758 L0.3605,0.9878 L0.2178,0.9878 L0.2178,0.8758 Z M0,0.164 L0.1432,0.164 L0.1432,0.2756 L0,0.2756 L0,0.164 Z M0,0.31 L0.1432,0.31 L0.1432,0.4216 L0,0.4216 L0,0.31 Z M0,0.4476 L0.1432,0.4476 L0.1432,0.5596 L0,0.5596 L0,0.4476 Z M0,0.5893 L0.1432,0.5893 L0.1432,0.7013 L0,0.7013 L0,0.5893 Z M0,0.7307 L0.1432,0.7307 L0.1432,0.8427 L0,0.8427 L0,0.7307 Z M0,0.8758 L0.1432,0.8758 L0.1432,0.9878 L0,0.9878 L0,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M0.1432,0.3217 L0.1432,0.4098 L0.0309,0.4098 L0.0309,0.3217 L0.1432,0.3217 Z M0.1586,0.2978 L0.0154,0.2978 Q0.0088,0.2978,0.0044,0.3016 T0,0.31 L0,0.4216 Q0,0.427,0.0049,0.4304 T0.0154,0.4337 L0.1586,0.4337 Q0.1648,0.4337,0.1694,0.4299 T0.174,0.4216 L0.174,0.31 Q0.174,0.3062,0.1715,0.3035 T0.1653,0.2993 T0.1586,0.2978 Z M0.1432,0.888 L0.1432,0.9757 L0.0309,0.9757 L0.0309,0.888 L0.1432,0.888 Z M0.1586,0.8637 L0.0154,0.8637 Q0.0108,0.8637,0.0072,0.8658 T0.0018,0.8706 T0,0.8758 L0,0.9878 Q0,0.9929,0.0049,0.9964 T0.0154,1 L0.1586,1 Q0.1648,1,0.1694,0.996 T0.174,0.9878 L0.174,0.8758 Q0.174,0.8725,0.1715,0.8695 T0.1653,0.8651 T0.1586,0.8637 Z M0.3496,0.169 L0.3496,0.2706 L0.2194,0.2706 L0.2194,0.169 L0.3496,0.169 Z M0.3563,0.159 L0.2132,0.159 Q0.2106,0.159,0.2088,0.1604 T0.207,0.164 L0.207,0.2756 Q0.207,0.2781,0.2091,0.2796 T0.2132,0.281 L0.3563,0.281 Q0.3589,0.281,0.3607,0.2794 T0.3625,0.2756 L0.3625,0.164 Q0.3625,0.1619,0.3605,0.1604 T0.3563,0.159 Z M0.3368,0.6044 L0.3368,0.6858 L0.2327,0.6858 L0.2327,0.6044 L0.3368,0.6044 Z M0.3563,0.5742 L0.2132,0.5742 Q0.2049,0.5742,0.1993,0.5791 T0.1936,0.5893 L0.1936,0.7013 Q0.1936,0.7076,0.2001,0.712 T0.2132,0.7164 L0.3563,0.7164 Q0.3641,0.7164,0.3697,0.7114 T0.3754,0.7013 L0.3754,0.5893 Q0.3754,0.5847,0.3723,0.5812 T0.3648,0.5759 T0.3563,0.5742 Z M0.5736,0.164 L0.431,0.164 L0.431,0.2756 L0.5736,0.2756 L0.5736,0.164 Z M0.5675,0.315 L0.5675,0.4165 L0.4377,0.4165 L0.4377,0.315 L0.5675,0.315 Z M0.5736,0.3049 L0.431,0.3049 Q0.4284,0.3049,0.4264,0.3064 T0.4243,0.31 L0.4243,0.4216 Q0.4243,0.4237,0.4266,0.4251 T0.431,0.4266 L0.5736,0.4266 Q0.5767,0.4266,0.5785,0.4251 T0.5803,0.4216 L0.5803,0.31 Q0.5803,0.3087,0.5798,0.3077 T0.5783,0.306 T0.5762,0.3052 T0.5736,0.3049 Z M0.5736,0.4476 L0.431,0.4476 L0.431,0.5596 L0.5736,0.5596 L0.5736,0.4476 Z M0.5613,0.7408 L0.5613,0.8326 L0.4439,0.8326 L0.4439,0.7408 L0.5613,0.7408 Z M0.5736,0.7206 L0.431,0.7206 Q0.4253,0.7206,0.4217,0.724 T0.4181,0.7307 L0.4181,0.8427 Q0.4181,0.8469,0.4222,0.8498 T0.431,0.8528 L0.5736,0.8528 Q0.5778,0.8528,0.5808,0.8511 T0.5855,0.8471 T0.587,0.8427 L0.587,0.7307 Q0.587,0.7265,0.5826,0.7236 T0.5736,0.7206 Z M0.7827,0.164 L0.6395,0.164 L0.6395,0.2756 L0.7827,0.2756 L0.7827,0.164 Z M0.7827,0.31 L0.6395,0.31 L0.6395,0.4216 L0.7827,0.4216 L0.7827,0.31 Z M0.7827,0.5893 L0.6395,0.5893 L0.6395,0.7013 L0.7827,0.7013 L0.7827,0.5893 Z M0.7827,0.7307 L0.6395,0.7307 L0.6395,0.8427 L0.7827,0.8427 L0.7827,0.7307 Z M0.7827,0.8758 L0.6395,0.8758 L0.6395,0.9878 L0.7827,0.9878 L0.7827,0.8758 Z M1,0.164 L0.8574,0.164 L0.8574,0.2756 L1,0.2756 L1,0.164 Z M1,0.31 L0.8574,0.31 L0.8574,0.4216 L1,0.4216 L1,0.31 Z M1,0.4476 L0.8574,0.4476 L0.8574,0.5596 L1,0.5596 L1,0.4476 Z M1,0.5893 L0.8574,0.5893 L0.8574,0.7013 L1,0.7013 L1,0.5893 Z M1,0.7307 L0.8574,0.7307 L0.8574,0.8427 L1,0.8427 L1,0.7307 Z M1,0.8758 L0.8574,0.8758 L0.8574,0.9878 L1,0.9878 L1,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.0008,0.9979 Q0.0008,1,0.0032,1 Q0.0482,1,0.0602,0.9975 Q0.0667,0.9963,0.0847,0.9917 T0.1084,0.9859 Q0.1124,0.9851,0.1221,0.9851 L0.1253,0.9851 L0.1285,0.9851 Q0.1518,0.9851,0.1655,0.9809 Q0.1695,0.9801,0.1731,0.9788 T0.1791,0.9766 T0.1839,0.9747 T0.1888,0.9732 L0.1936,0.972 T0.1988,0.9712 T0.2048,0.971 L0.2096,0.971 Q0.2201,0.9714,0.2233,0.9714 T0.2345,0.9693 Q0.2386,0.9685,0.2502,0.9674 T0.2715,0.966 L0.2811,0.9656 Q0.2916,0.9647,0.2916,0.9573 Q0.2916,0.954,0.2892,0.9515 T0.2819,0.9486 Q0.2755,0.9482,0.2683,0.9482 Q0.2643,0.9482,0.2582,0.9484 T0.2498,0.9486 Q0.2426,0.9486,0.2402,0.9473 Q0.2386,0.9469,0.2317,0.943 T0.2161,0.937 Q0.2104,0.9357,0.1992,0.933 T0.1831,0.9291 Q0.1807,0.9287,0.1783,0.9285 T0.1739,0.928 T0.1703,0.9276 T0.1675,0.9272 T0.1655,0.9266 T0.1639,0.9253 Q0.1598,0.9216,0.1462,0.9216 L0.143,0.9216 L0.1398,0.9216 L0.1365,0.9216 Q0.1197,0.9195,0.1197,0.9154 L0.1197,0.9154 Q0.1205,0.9121,0.1373,0.9088 T0.1703,0.9054 Q0.1791,0.9054,0.1863,0.904 T0.1984,0.9011 T0.2064,0.8992 Q0.2402,0.8967,0.2506,0.8934 Q0.2538,0.8926,0.2602,0.8899 T0.2707,0.8872 Q0.2715,0.8872,0.2731,0.8876 Q0.2795,0.8884,0.2884,0.8884 Q0.3068,0.8884,0.3092,0.8826 L0.3092,0.8797 Q0.3092,0.8772,0.3076,0.8747 T0.3044,0.8706 T0.3028,0.8681 Q0.3076,0.8656,0.3237,0.8656 Q0.3317,0.8656,0.3622,0.8623 Q0.3655,0.8623,0.3867,0.8617 T0.4217,0.8602 Q0.4241,0.8598,0.4289,0.8598 L0.4345,0.8598 L0.441,0.8598 Q0.4578,0.8598,0.4667,0.8586 Q0.4739,0.8573,0.4851,0.8567 T0.5004,0.8552 Q0.5012,0.8552,0.5108,0.855 T0.5269,0.853 T0.5333,0.8461 Q0.5333,0.8445,0.5325,0.8432 T0.5309,0.8409 T0.5289,0.8391 T0.5261,0.8378 L0.5229,0.837 T0.5185,0.8362 T0.5133,0.8355 T0.5076,0.8349 T0.502,0.8341 Q0.4932,0.8324,0.4859,0.8324 Q0.4827,0.8324,0.4787,0.8328 T0.4731,0.8333 Q0.4707,0.8333,0.4683,0.8324 Q0.4482,0.825,0.4394,0.825 Q0.4369,0.825,0.4345,0.8254 Q0.4225,0.827,0.4153,0.827 Q0.4088,0.827,0.4064,0.8258 Q0.3976,0.8204,0.3751,0.8204 Q0.3703,0.8204,0.3667,0.8214 T0.3622,0.8225 Q0.3598,0.8225,0.3558,0.82 Q0.351,0.8175,0.3402,0.8167 T0.3177,0.8156 T0.3028,0.8146 L0.2956,0.8121 T0.2871,0.8094 T0.2771,0.8073 T0.2635,0.8063 Q0.261,0.8063,0.2586,0.8067 Q0.2378,0.8071,0.2273,0.8071 Q0.2072,0.8071,0.2024,0.8051 Q0.1976,0.8034,0.1932,0.8026 T0.1867,0.8013 T0.1847,0.8001 L0.1847,0.7993 Q0.1855,0.7984,0.1855,0.7978 T0.1859,0.7966 T0.1867,0.7959 T0.188,0.7955 T0.1896,0.7951 T0.1916,0.7949 T0.1944,0.7945 T0.1984,0.7941 T0.2032,0.7939 Q0.212,0.7926,0.2325,0.7918 T0.2562,0.7905 Q0.2691,0.7893,0.2787,0.7847 Q0.2835,0.7827,0.2968,0.7791 T0.3149,0.7756 Q0.3189,0.7756,0.3305,0.7733 T0.3446,0.771 L0.3454,0.771 Q0.3462,0.771,0.3498,0.7721 T0.3574,0.774 T0.3655,0.7748 Q0.3703,0.7748,0.3743,0.7731 T0.3823,0.7708 T0.3916,0.77 T0.3992,0.7694 Q0.4008,0.7694,0.4076,0.7688 T0.4205,0.7675 L0.4305,0.7665 Q0.4345,0.7657,0.4402,0.7617 T0.4458,0.7545 Q0.4458,0.7503,0.4353,0.7487 Q0.4297,0.7478,0.4209,0.7462 T0.4044,0.7433 T0.3904,0.7416 L0.3847,0.7416 Q0.3807,0.7416,0.3747,0.7422 T0.3663,0.7428 T0.3606,0.7424 Q0.3582,0.742,0.3562,0.7418 T0.3522,0.7412 T0.3482,0.7406 T0.3446,0.7401 T0.341,0.7397 T0.3373,0.7393 T0.3341,0.7391 L0.3293,0.7391 Q0.3277,0.7395,0.3253,0.7395 Q0.3205,0.7395,0.3052,0.7387 T0.2835,0.7379 Q0.2755,0.7379,0.253,0.7325 Q0.2506,0.7316,0.2462,0.731 T0.2382,0.7296 T0.2305,0.7271 T0.2181,0.7234 T0.2048,0.7196 T0.2,0.7155 Q0.2,0.7142,0.2016,0.713 Q0.208,0.7059,0.2285,0.6978 T0.2618,0.6898 Q0.2739,0.6898,0.2831,0.6848 T0.2956,0.6794 Q0.3028,0.6786,0.3289,0.6765 T0.3566,0.674 Q0.3582,0.674,0.3855,0.6723 T0.4406,0.6688 T0.4755,0.6653 Q0.5004,0.6607,0.5124,0.6599 L0.5165,0.6599 Q0.5213,0.6599,0.5277,0.6605 T0.5373,0.6611 Q0.543,0.6611,0.5494,0.6599 Q0.592,0.6524,0.6,0.6524 Q0.6064,0.6524,0.61,0.6499 T0.6161,0.6474 Q0.6177,0.6474,0.6261,0.6495 T0.6418,0.6516 L0.6482,0.6516 Q0.653,0.6512,0.6651,0.6506 T0.6876,0.6493 T0.7084,0.6477 T0.7229,0.645 Q0.7269,0.6437,0.7333,0.6437 Q0.7373,0.6437,0.7458,0.6443 T0.7574,0.645 Q0.7614,0.645,0.7631,0.6445 T0.7655,0.6441 Q0.7671,0.6441,0.7723,0.6448 T0.7823,0.6454 Q0.788,0.6454,0.7952,0.6445 Q0.8233,0.6408,0.8723,0.6367 Q0.9012,0.6342,0.9092,0.6342 L0.9165,0.6342 L0.9205,0.6342 Q0.9277,0.6342,0.9382,0.6323 T0.9526,0.6304 Q0.9534,0.6304,0.9594,0.6307 T0.9695,0.6309 Q0.9976,0.6309,0.9992,0.6234 Q1,0.6201,1,0.618 Q1,0.6089,0.9775,0.608 Q0.9703,0.6076,0.9582,0.6076 Q0.9518,0.6076,0.9394,0.6078 T0.9213,0.608 Q0.9036,0.608,0.8972,0.6072 Q0.894,0.6068,0.8871,0.6058 T0.8751,0.6039 T0.8627,0.6024 T0.8506,0.6018 L0.8474,0.6018 Q0.841,0.6022,0.8281,0.6041 T0.8112,0.606 Q0.8096,0.606,0.808,0.6056 Q0.7783,0.6002,0.7647,0.6002 Q0.7598,0.6002,0.7454,0.6006 T0.7261,0.601 Q0.7221,0.601,0.7145,0.6022 T0.7036,0.6035 Q0.6996,0.6035,0.6964,0.6022 Q0.6723,0.5902,0.6442,0.5902 Q0.6426,0.5902,0.6386,0.5904 T0.6337,0.5906 Q0.6273,0.5906,0.6249,0.5894 L0.6177,0.5856 T0.6116,0.5838 T0.6016,0.5825 T0.5855,0.5823 Q0.5719,0.5823,0.5574,0.5798 T0.5373,0.5774 L0.5341,0.5774 Q0.5293,0.5778,0.5213,0.5786 T0.51,0.5794 Q0.5036,0.5794,0.4988,0.5769 Q0.4932,0.574,0.4771,0.574 Q0.4723,0.574,0.4631,0.5745 T0.4498,0.5749 Q0.4418,0.5749,0.4394,0.5736 Q0.4353,0.5724,0.4305,0.5715 T0.4225,0.5707 T0.4145,0.5705 T0.4056,0.5699 Q0.4008,0.5691,0.398,0.5689 T0.394,0.5689 T0.3908,0.5686 T0.3855,0.5674 Q0.3831,0.567,0.3811,0.5662 T0.3779,0.5645 T0.3743,0.563 T0.3695,0.5624 Q0.3655,0.5624,0.3594,0.5635 T0.3494,0.5645 T0.3406,0.5635 T0.3325,0.5624 Q0.3309,0.5624,0.3301,0.5628 L0.3141,0.5628 Q0.2635,0.5628,0.2506,0.5612 Q0.2474,0.5608,0.2434,0.5608 Q0.2402,0.5608,0.2349,0.561 T0.2273,0.5612 Q0.2209,0.5612,0.2177,0.5595 Q0.2145,0.557,0.2145,0.5558 Q0.2169,0.5537,0.2345,0.5521 Q0.2876,0.5475,0.3028,0.5425 Q0.3068,0.5413,0.3149,0.5396 T0.3309,0.5367 T0.3478,0.5338 T0.3606,0.5309 Q0.3631,0.5305,0.3755,0.5259 T0.3968,0.5214 Q0.3992,0.5214,0.4024,0.5218 T0.4096,0.5222 Q0.4161,0.5222,0.4213,0.5209 T0.4313,0.5185 T0.4402,0.5172 Q0.4418,0.5168,0.447,0.5168 L0.4558,0.5168 T0.4639,0.5162 T0.4707,0.5141 T0.4731,0.5102 Q0.4731,0.5089,0.4723,0.5081 Q0.4699,0.5019,0.4627,0.4994 T0.4369,0.4965 Q0.4273,0.4961,0.4193,0.4946 T0.4048,0.4915 T0.3952,0.4894 Q0.392,0.4886,0.388,0.4886 Q0.3847,0.4886,0.3775,0.4892 T0.3647,0.4898 Q0.3598,0.4898,0.355,0.4894 Q0.3165,0.4861,0.3141,0.4853 Q0.2779,0.4774,0.2715,0.4774 Q0.2699,0.4774,0.2683,0.4776 T0.2627,0.4778 Q0.2498,0.4778,0.2281,0.4762 Q0.2241,0.4749,0.2233,0.4737 Q0.2249,0.472,0.2321,0.4708 Q0.2402,0.4695,0.2554,0.4695 L0.2675,0.4695 L0.2795,0.4695 Q0.294,0.4695,0.2996,0.4679 Q0.3141,0.4629,0.3373,0.4616 Q0.3414,0.4612,0.3466,0.4606 T0.3582,0.4589 T0.3687,0.4562 T0.3727,0.4525 Q0.3727,0.4509,0.3703,0.4492 Q0.3639,0.4455,0.3474,0.4419 T0.3213,0.4384 L0.3157,0.4384 L0.31,0.4384 Q0.294,0.4384,0.2859,0.4363 Q0.2795,0.4347,0.2691,0.4347 L0.2618,0.4347 L0.2546,0.4347 Q0.2458,0.4347,0.2394,0.4338 Q0.2257,0.4318,0.2193,0.4303 T0.2092,0.4266 T0.2052,0.4224 T0.2024,0.4154 T0.196,0.4052 Q0.1912,0.3994,0.1867,0.3949 T0.1807,0.3884 T0.1791,0.3849 Q0.1791,0.3824,0.1839,0.3745 Q0.1871,0.3687,0.1944,0.3637 T0.2072,0.3561 T0.2221,0.3501 T0.2321,0.3463 Q0.2353,0.3455,0.241,0.3445 T0.2522,0.3428 T0.2639,0.3411 T0.2731,0.3384 T0.2956,0.3351 T0.3197,0.3326 Q0.3213,0.3322,0.3237,0.3322 T0.3325,0.3331 T0.3414,0.3339 Q0.3478,0.3339,0.3518,0.3314 Q0.3606,0.3264,0.3703,0.3264 Q0.3775,0.3264,0.3847,0.3293 Q0.3888,0.331,0.396,0.331 Q0.4024,0.331,0.4116,0.3295 T0.4265,0.3266 T0.4414,0.3227 T0.4514,0.3202 Q0.4578,0.3181,0.4839,0.3152 T0.5112,0.3123 T0.5177,0.3119 T0.5285,0.3111 T0.5398,0.3096 T0.549,0.3065 T0.5526,0.3015 Q0.5526,0.2999,0.5518,0.2982 Q0.547,0.2903,0.5386,0.2883 T0.5165,0.2862 Q0.5116,0.2862,0.5056,0.2866 T0.4972,0.287 Q0.4884,0.287,0.4787,0.2833 Q0.4594,0.2758,0.4265,0.2758 Q0.4169,0.2758,0.4129,0.2766 Q0.4112,0.2771,0.4104,0.2771 Q0.4088,0.2771,0.4056,0.2748 T0.3968,0.2721 Q0.3695,0.2696,0.359,0.2659 Q0.355,0.2646,0.351,0.2646 T0.3426,0.2661 T0.3365,0.2675 T0.3333,0.2659 Q0.3277,0.2605,0.3116,0.2553 T0.2859,0.2501 L0.2843,0.2501 L0.2819,0.2501 Q0.2779,0.2501,0.2723,0.2493 T0.2586,0.2472 T0.2474,0.2455 Q0.1799,0.236,0.1719,0.236 Q0.1663,0.236,0.1606,0.235 T0.1502,0.2316 T0.1454,0.2265 Q0.1454,0.2223,0.1558,0.2173 Q0.1679,0.2115,0.1859,0.2092 T0.2185,0.207 T0.2353,0.2057 Q0.2369,0.2049,0.2502,0.2041 T0.2799,0.2022 T0.302,0.2007 Q0.3084,0.1999,0.3245,0.1954 T0.347,0.1908 Q0.3502,0.1908,0.3534,0.1916 Q0.3663,0.197,0.388,0.197 Q0.404,0.197,0.4088,0.1929 Q0.4096,0.192,0.4108,0.192 T0.4177,0.1945 T0.4277,0.197 T0.4369,0.1949 Q0.4442,0.1908,0.4843,0.1875 Q0.4892,0.1871,0.4968,0.1839 T0.5084,0.1808 L0.5108,0.1808 Q0.5213,0.1825,0.5454,0.1825 Q0.5823,0.1825,0.5992,0.1779 Q0.6096,0.1746,0.6217,0.1746 Q0.6249,0.1746,0.6309,0.1748 T0.6394,0.175 Q0.6434,0.175,0.6466,0.1746 Q0.6474,0.1746,0.6594,0.1738 T0.6787,0.1713 T0.6892,0.1659 Q0.6924,0.1634,0.6924,0.1593 Q0.6924,0.1547,0.6888,0.151 T0.6795,0.1472 Q0.6779,0.1472,0.6763,0.1477 Q0.6699,0.1493,0.6667,0.1493 Q0.661,0.1493,0.6506,0.1456 Q0.6329,0.1394,0.6129,0.1394 Q0.6048,0.1394,0.5992,0.1377 T0.5888,0.136 T0.5803,0.1377 T0.5751,0.1394 Q0.5711,0.1394,0.5639,0.1369 Q0.5365,0.1273,0.5116,0.1273 Q0.4876,0.1273,0.4667,0.1253 Q0.4627,0.1248,0.4542,0.1211 T0.4337,0.1174 L0.4305,0.1174 Q0.4217,0.1178,0.4153,0.119 T0.408,0.1203 Q0.4056,0.1203,0.4,0.117 Q0.3976,0.1153,0.396,0.1136 T0.3932,0.1112 T0.39,0.1099 T0.3847,0.1095 Q0.3799,0.1095,0.3687,0.1103 Q0.355,0.1112,0.3434,0.1141 T0.3301,0.117 Q0.3261,0.117,0.3229,0.1145 Q0.3181,0.1116,0.3052,0.1087 T0.2787,0.1043 T0.2546,0.1029 Q0.2506,0.1029,0.2474,0.1033 Q0.2418,0.1037,0.2349,0.1054 T0.2249,0.107 Q0.2225,0.107,0.2193,0.1066 Q0.2024,0.1024,0.1863,0.1024 Q0.1823,0.1024,0.1562,0.0971 T0.1269,0.0917 T0.1161,0.0896 T0.106,0.0854 Q0.1076,0.0846,0.11,0.0842 Q0.1173,0.0821,0.1261,0.0821 Q0.1301,0.0821,0.1369,0.0825 T0.1462,0.083 Q0.1494,0.083,0.151,0.0825 T0.1831,0.0813 T0.2201,0.0784 T0.243,0.074 T0.2667,0.0718 Q0.2707,0.0718,0.2755,0.0724 T0.2843,0.073 T0.2936,0.0724 T0.3024,0.0705 T0.3072,0.068 T0.3092,0.0657 T0.3108,0.0647 Q0.3116,0.0643,0.3133,0.0643 L0.3157,0.0643 L0.3181,0.0643 Q0.3446,0.0643,0.3446,0.0556 T0.3205,0.0469 L0.3189,0.0469 Q0.3157,0.0469,0.3157,0.0465 Q0.3084,0.0427,0.2835,0.0386 Q0.2739,0.0369,0.2675,0.0342 T0.2538,0.0299 Q0.249,0.029,0.245,0.029 T0.2378,0.0303 T0.2321,0.0315 Q0.2305,0.0315,0.2273,0.0307 Q0.2201,0.0286,0.2004,0.0272 T0.1775,0.0245 Q0.1735,0.0228,0.1663,0.0226 T0.1522,0.0197 T0.1345,0.0145 T0.1124,0.0079 Q0.0996,0.0033,0.0671,0.0044 T0.0297,0.0041 Q0.0169,0,0.0024,0.0017 Q0,0.0021,0,0.0037 Z M0.0064,0.9954 L0.0056,0.0058 Q0.0177,0.005,0.0281,0.0083 Q0.0345,0.01,0.0675,0.0089 T0.1108,0.012 Q0.1221,0.0162,0.1329,0.0187 T0.1498,0.0236 T0.1606,0.0268 T0.1691,0.0274 T0.1759,0.0286 Q0.1807,0.0303,0.2,0.0315 T0.2257,0.0348 Q0.2305,0.0361,0.2337,0.0361 Q0.2361,0.0361,0.2398,0.0348 T0.2466,0.0336 Q0.249,0.0336,0.2522,0.034 Q0.2586,0.0353,0.2655,0.0382 T0.2827,0.0427 Q0.3068,0.0469,0.3124,0.0502 Q0.3157,0.0523,0.3237,0.0523 L0.3277,0.0523 L0.3309,0.0523 Q0.3398,0.0523,0.3398,0.0556 Q0.3398,0.0581,0.3341,0.0587 T0.3205,0.0593 T0.3092,0.0603 T0.3036,0.0643 T0.2932,0.068 Q0.2884,0.0684,0.2843,0.0684 Q0.2811,0.0684,0.2763,0.0678 T0.2667,0.0672 Q0.2586,0.0672,0.2422,0.0697 T0.2177,0.0747 Q0.2137,0.0759,0.1831,0.0767 T0.1494,0.0784 L0.1474,0.0784 T0.1398,0.078 T0.1285,0.0776 Q0.1173,0.0776,0.1084,0.08 Q0.1004,0.0817,0.1004,0.0846 Q0.1004,0.0867,0.104,0.0888 T0.1124,0.0923 T0.1213,0.0948 T0.1269,0.0958 T0.155,0.1014 T0.1863,0.107 Q0.2016,0.107,0.2185,0.1107 Q0.2217,0.1116,0.2257,0.1116 Q0.2289,0.1116,0.2365,0.1099 T0.2482,0.1074 L0.2538,0.1074 Q0.2683,0.1074,0.2912,0.1107 T0.3197,0.1182 Q0.3245,0.1215,0.3301,0.1215 Q0.3333,0.1215,0.3454,0.1184 T0.3687,0.1145 Q0.3735,0.1141,0.3779,0.1139 T0.3843,0.1134 T0.3871,0.1132 Q0.3896,0.1132,0.39,0.1136 T0.392,0.1161 T0.3968,0.1203 Q0.404,0.1248,0.408,0.1248 Q0.4096,0.1248,0.4165,0.1234 T0.4305,0.1215 L0.4337,0.1215 Q0.4442,0.1215,0.4518,0.1251 T0.4659,0.1294 Q0.4876,0.1319,0.5116,0.1319 T0.5622,0.141 Q0.5703,0.1439,0.5751,0.1439 Q0.5791,0.1439,0.5831,0.1423 T0.5888,0.1406 Q0.592,0.1406,0.5944,0.1412 T0.6012,0.1427 T0.6129,0.1435 Q0.6313,0.1435,0.649,0.1497 Q0.6602,0.1535,0.6667,0.1535 Q0.6715,0.1535,0.6779,0.1522 Q0.6787,0.1518,0.6795,0.1518 Q0.6827,0.1518,0.6847,0.1541 T0.6867,0.1593 Q0.6867,0.1618,0.6851,0.1634 Q0.6835,0.1655,0.6775,0.1667 T0.6598,0.169 T0.6458,0.1705 T0.6402,0.1709 Q0.6378,0.1709,0.6321,0.1705 T0.6225,0.1701 Q0.6096,0.1701,0.5968,0.1738 Q0.5823,0.1779,0.5446,0.1779 Q0.5205,0.1779,0.5116,0.1767 Q0.51,0.1763,0.5084,0.1763 Q0.5028,0.1763,0.4944,0.1796 T0.4835,0.1829 L0.4771,0.1833 T0.4659,0.1844 L0.4546,0.1858 T0.443,0.1881 T0.4337,0.1912 Q0.4321,0.1916,0.4305,0.1916 Q0.4273,0.1916,0.4209,0.1895 T0.412,0.1875 Q0.408,0.1875,0.4056,0.19 Q0.4024,0.1925,0.388,0.1925 Q0.3663,0.1925,0.3558,0.1879 Q0.3518,0.1862,0.3462,0.1862 Q0.3373,0.1862,0.3213,0.191 T0.3012,0.1962 Q0.2964,0.1966,0.2799,0.1976 T0.2494,0.1997 T0.2313,0.2024 L0.2313,0.2024 L0.2293,0.2024 L0.2261,0.2024 L0.2217,0.2024 Q0.204,0.2024,0.1851,0.2047 T0.1526,0.2136 Q0.1406,0.2198,0.1406,0.226 Q0.1406,0.2319,0.1518,0.2362 T0.1719,0.2406 Q0.1791,0.2406,0.2466,0.2497 Q0.2498,0.2501,0.2574,0.2516 T0.2711,0.2538 T0.2819,0.2547 L0.2851,0.2547 Q0.2851,0.2543,0.2859,0.2543 Q0.294,0.2543,0.3096,0.2592 T0.3285,0.2684 Q0.3317,0.2721,0.3373,0.2721 Q0.3406,0.2721,0.3462,0.2708 T0.3542,0.2696 Q0.3558,0.2696,0.3574,0.27 Q0.3679,0.2737,0.3968,0.2762 Q0.3984,0.2766,0.4004,0.2779 L0.4044,0.2804 T0.4096,0.2816 Q0.412,0.2816,0.4145,0.2808 Q0.4177,0.2804,0.4249,0.2804 Q0.4586,0.2804,0.4763,0.287 Q0.4867,0.2912,0.4972,0.2912 Q0.5004,0.2912,0.5064,0.2908 T0.5165,0.2903 Q0.5293,0.2903,0.5361,0.2922 T0.547,0.3003 L0.547,0.3011 Q0.547,0.3036,0.5414,0.3049 T0.5249,0.3067 T0.51,0.3082 Q0.5092,0.3082,0.4831,0.3111 T0.4498,0.3161 Q0.408,0.3264,0.3952,0.3264 Q0.3896,0.3264,0.3863,0.3252 Q0.3783,0.3223,0.3703,0.3223 Q0.359,0.3223,0.3494,0.3277 Q0.347,0.3293,0.343,0.3293 Q0.3414,0.3293,0.3353,0.3285 T0.3253,0.3277 T0.3181,0.3285 Q0.3149,0.3289,0.2952,0.3306 T0.2699,0.3347 Q0.2675,0.3364,0.2598,0.3372 T0.2434,0.3395 T0.2297,0.3426 L0.2205,0.3461 T0.2048,0.3523 T0.1908,0.3606 T0.1791,0.3725 Q0.1743,0.3803,0.1743,0.3845 Q0.1743,0.3874,0.1763,0.3901 T0.1827,0.3976 T0.1912,0.4073 T0.198,0.4189 T0.2032,0.4276 T0.2137,0.4326 T0.2386,0.438 Q0.2458,0.4392,0.2562,0.4392 L0.2639,0.4392 L0.2699,0.4392 Q0.2795,0.4392,0.2843,0.4405 Q0.2932,0.443,0.3092,0.443 L0.3157,0.443 L0.3213,0.443 Q0.3293,0.443,0.3458,0.4463 T0.3671,0.4525 Q0.3598,0.4558,0.3373,0.4571 Q0.3124,0.4587,0.298,0.4637 Q0.294,0.465,0.2811,0.465 L0.2703,0.465 L0.2586,0.465 Q0.241,0.465,0.2305,0.4666 Q0.2185,0.4687,0.2185,0.4728 Q0.2185,0.4745,0.2205,0.4764 T0.2241,0.4791 L0.2265,0.4803 L0.2273,0.4803 Q0.2506,0.4824,0.261,0.4824 Q0.2675,0.4824,0.2699,0.482 Q0.2699,0.4815,0.2707,0.4815 Q0.2771,0.4815,0.3124,0.4898 Q0.3173,0.4907,0.3229,0.4913 T0.3386,0.4925 T0.3542,0.4936 T0.3663,0.494 Q0.3711,0.494,0.3787,0.4936 T0.3888,0.4932 Q0.392,0.4932,0.3944,0.4936 T0.4032,0.4959 T0.4181,0.499 T0.4369,0.5006 Q0.449,0.501,0.455,0.5021 T0.4639,0.5048 T0.4675,0.5093 L0.4675,0.5097 Q0.4675,0.5106,0.4663,0.511 T0.4622,0.5116 L0.4566,0.512 T0.4486,0.5124 T0.4402,0.5127 Q0.4345,0.5131,0.4257,0.5153 T0.4096,0.5176 Q0.4064,0.5176,0.4024,0.5172 L0.3968,0.5172 Q0.3863,0.5172,0.3731,0.522 T0.359,0.5268 Q0.355,0.528,0.3317,0.5321 T0.3004,0.5388 Q0.2867,0.5433,0.2337,0.5479 Q0.2096,0.55,0.2096,0.5562 Q0.2096,0.5591,0.2145,0.5628 Q0.2193,0.5662,0.2289,0.5662 Q0.2313,0.5662,0.2373,0.5657 T0.2458,0.5653 L0.2498,0.5653 Q0.2635,0.5674,0.3124,0.5674 Q0.3261,0.5674,0.3301,0.567 L0.3317,0.567 Q0.3341,0.567,0.3394,0.568 T0.3486,0.5691 Q0.3542,0.5691,0.3606,0.5678 T0.3699,0.5666 T0.3759,0.5686 T0.3839,0.5718 T0.3908,0.573 T0.3964,0.5734 T0.4048,0.5742 T0.4165,0.5751 T0.4277,0.5757 T0.4369,0.5778 Q0.4426,0.5794,0.4538,0.5794 Q0.4586,0.5794,0.4679,0.5792 T0.4811,0.579 Q0.4924,0.579,0.4956,0.5807 Q0.502,0.584,0.5108,0.584 L0.5137,0.584 T0.5173,0.5838 L0.5205,0.5834 L0.5237,0.583 L0.5269,0.5825 T0.5305,0.5821 T0.5341,0.5819 L0.5365,0.5819 Q0.5414,0.5819,0.5562,0.5842 T0.5855,0.5865 Q0.5912,0.5865,0.5948,0.5867 T0.6012,0.5869 T0.6064,0.5873 T0.61,0.5877 T0.6133,0.5886 T0.6157,0.5896 L0.6185,0.591 T0.6217,0.5931 Q0.6265,0.5952,0.6369,0.5952 L0.6418,0.5952 L0.6466,0.5952 Q0.6723,0.5952,0.6932,0.606 Q0.698,0.608,0.7044,0.608 Q0.7084,0.608,0.7161,0.6068 T0.7261,0.6056 Q0.7309,0.6056,0.7454,0.6051 T0.7647,0.6047 Q0.7775,0.6047,0.8072,0.6101 Q0.8096,0.6105,0.812,0.6105 Q0.8177,0.6105,0.8297,0.6085 T0.8474,0.606 L0.8506,0.606 Q0.8562,0.606,0.8647,0.607 T0.8831,0.6095 T0.8964,0.6118 Q0.9036,0.6122,0.9221,0.6122 L0.9406,0.6122 L0.959,0.6122 L0.9767,0.6122 Q0.9952,0.613,0.9952,0.6172 Q0.9952,0.6176,0.9944,0.6221 Q0.9936,0.6259,0.9574,0.6259 L0.955,0.6259 L0.9526,0.6259 Q0.9478,0.6259,0.9373,0.628 T0.9205,0.63 L0.9173,0.63 Q0.9133,0.6296,0.9084,0.6296 Q0.9004,0.6296,0.8715,0.6321 Q0.8233,0.6367,0.7944,0.64 Q0.7888,0.6408,0.7839,0.6408 T0.7739,0.6402 T0.7671,0.6396 Q0.7639,0.6396,0.7622,0.64 Q0.7614,0.6404,0.759,0.6404 Q0.7574,0.6404,0.749,0.6398 T0.7357,0.6392 Q0.7261,0.6392,0.7213,0.6412 Q0.7173,0.6425,0.7032,0.6437 T0.6711,0.6458 T0.6474,0.647 Q0.645,0.6474,0.6418,0.6474 Q0.6353,0.6474,0.6277,0.6454 T0.6161,0.6433 Q0.6104,0.6433,0.6072,0.6456 T0.6,0.6479 Q0.5912,0.6479,0.5486,0.6557 Q0.5422,0.657,0.5373,0.657 Q0.5349,0.657,0.5289,0.6564 T0.5173,0.6557 L0.5124,0.6557 Q0.4996,0.6562,0.4739,0.6611 Q0.4675,0.6624,0.4398,0.6645 T0.3851,0.668 T0.3558,0.6698 Q0.355,0.6698,0.3285,0.6721 T0.2948,0.6752 Q0.2892,0.6761,0.2843,0.6786 T0.2747,0.6831 T0.2618,0.6852 Q0.253,0.6852,0.2394,0.6893 T0.2141,0.6993 T0.1968,0.7105 Q0.1944,0.713,0.1944,0.7155 Q0.1944,0.7196,0.2012,0.7229 T0.2177,0.7285 T0.2281,0.7308 Q0.2321,0.7329,0.2365,0.7339 T0.245,0.7356 T0.2514,0.7366 Q0.2522,0.737,0.2554,0.7379 T0.2614,0.7393 T0.2679,0.7406 T0.2755,0.7416 T0.2835,0.742 Q0.29,0.742,0.3048,0.7428 T0.3253,0.7437 L0.3301,0.7437 L0.3333,0.7437 Q0.3341,0.7437,0.3598,0.7466 Q0.3639,0.7474,0.3671,0.7474 T0.3759,0.7466 T0.3855,0.7457 Q0.388,0.7457,0.3904,0.7462 Q0.3984,0.7466,0.4137,0.7495 T0.4349,0.7532 T0.441,0.7557 Q0.441,0.7599,0.4289,0.7619 Q0.4257,0.7628,0.4197,0.7634 T0.4072,0.7644 T0.3984,0.7652 Q0.3968,0.7652,0.3912,0.7655 T0.3811,0.7665 T0.3719,0.7694 Q0.3695,0.7702,0.3655,0.7702 T0.3546,0.7686 T0.3462,0.7665 L0.3446,0.7665 Q0.3406,0.7665,0.3293,0.769 T0.3149,0.7715 Q0.3084,0.7715,0.2952,0.7748 T0.2763,0.781 Q0.2675,0.7847,0.2554,0.7864 Q0.253,0.7864,0.2325,0.7874 T0.2024,0.7893 Q0.2008,0.7893,0.1984,0.7897 T0.1948,0.7901 T0.1916,0.7903 T0.1888,0.7908 T0.1867,0.7914 T0.1847,0.7922 L0.1831,0.793 T0.1819,0.7943 L0.1811,0.7959 T0.1799,0.7976 Q0.1791,0.7993,0.1791,0.8001 Q0.1791,0.8034,0.1831,0.8051 T0.1932,0.8078 T0.2,0.8092 Q0.2072,0.8117,0.2257,0.8117 Q0.2337,0.8117,0.2586,0.8109 L0.2635,0.8109 Q0.2707,0.8109,0.2767,0.8117 T0.2859,0.8136 T0.2936,0.8163 T0.3012,0.8187 Q0.3068,0.82,0.3277,0.8206 T0.3526,0.8237 Q0.3582,0.8266,0.3622,0.8266 Q0.3647,0.8266,0.3687,0.8258 T0.3751,0.825 Q0.396,0.825,0.404,0.8291 Q0.408,0.8316,0.4161,0.8316 Q0.4217,0.8316,0.4353,0.8295 L0.4394,0.8295 Q0.4466,0.8295,0.4659,0.8366 Q0.4699,0.8378,0.4747,0.8378 Q0.4763,0.8378,0.4807,0.8374 T0.4884,0.837 Q0.494,0.837,0.5004,0.8382 Q0.5052,0.8391,0.5124,0.8399 Q0.5221,0.8411,0.5249,0.8422 T0.5277,0.8461 Q0.5277,0.8482,0.5233,0.849 T0.5112,0.8501 T0.4996,0.8511 Q0.4964,0.8519,0.4851,0.8523 T0.4659,0.8544 Q0.4578,0.8557,0.4402,0.8557 L0.4349,0.8557 L0.4297,0.8557 L0.4209,0.8557 Q0.408,0.8565,0.3867,0.8571 T0.3614,0.8582 Q0.3317,0.8611,0.3237,0.8611 L0.3225,0.8611 L0.3213,0.8611 Q0.2972,0.8611,0.2972,0.8681 Q0.2972,0.8714,0.3008,0.8747 T0.3044,0.8805 Q0.3044,0.881,0.3036,0.8814 Q0.3012,0.8839,0.2851,0.8839 Q0.2779,0.8839,0.2739,0.883 Q0.2723,0.8826,0.2699,0.8826 T0.2643,0.8835 T0.259,0.8853 T0.2542,0.8876 T0.249,0.8893 Q0.2386,0.8922,0.2056,0.8946 Q0.2016,0.8951,0.1916,0.898 T0.1703,0.9009 Q0.1518,0.9009,0.1337,0.9044 T0.1149,0.9146 L0.1149,0.9158 Q0.1149,0.9175,0.1157,0.9187 T0.1173,0.921 T0.1201,0.9226 T0.1233,0.9237 T0.1269,0.9245 L0.1305,0.9251 T0.1337,0.9255 L0.1357,0.9258 Q0.139,0.9262,0.1446,0.9262 T0.1542,0.9266 T0.1598,0.9282 Q0.1614,0.9303,0.1647,0.9314 T0.1739,0.9328 T0.1815,0.9336 Q0.1863,0.9345,0.1976,0.9372 T0.2145,0.9415 Q0.2225,0.9432,0.2285,0.9467 T0.2378,0.9515 Q0.2426,0.9531,0.2514,0.9531 Q0.2538,0.9531,0.2602,0.9529 T0.2707,0.9527 Q0.2763,0.9527,0.2815,0.9531 T0.2867,0.9569 T0.2811,0.9614 Q0.2434,0.9627,0.2329,0.9652 Q0.2249,0.9672,0.2241,0.9672 L0.2096,0.9668 L0.2056,0.9668 Q0.2,0.9668,0.196,0.9672 T0.1884,0.9687 T0.1815,0.9708 T0.1731,0.9737 T0.1639,0.9772 Q0.1518,0.9805,0.1285,0.9805 L0.1253,0.9805 Q0.1124,0.9805,0.1076,0.9818 Q0.1012,0.983,0.0831,0.9876 T0.0594,0.9934 Q0.049,0.9954,0.0064,0.9954 Z M0.0032,0.9979 L0.0024,0.0037 Q0.0161,0.0021,0.0289,0.0062 Q0.0337,0.0075,0.0671,0.0066 T0.1116,0.01 T0.1337,0.0166 T0.151,0.022 Q0.1582,0.0249,0.1659,0.0251 T0.1767,0.0265 Q0.1807,0.0278,0.2,0.0292 T0.2265,0.0328 Q0.2305,0.034,0.2329,0.034 T0.239,0.0326 T0.2458,0.0311 T0.253,0.0319 Q0.2578,0.0332,0.2614,0.0342 T0.2667,0.0363 T0.2723,0.0384 T0.2827,0.0406 Q0.3084,0.0448,0.3141,0.0485 Q0.3149,0.0494,0.3225,0.0494 T0.3361,0.0504 T0.3422,0.0554 T0.3357,0.0606 T0.3205,0.0618 T0.31,0.0626 Q0.3092,0.0626,0.3056,0.0659 T0.2932,0.0701 Q0.2884,0.0709,0.2843,0.0709 Q0.2811,0.0709,0.2759,0.0701 T0.2667,0.0693 Q0.2586,0.0693,0.2422,0.0718 T0.2193,0.0763 Q0.2137,0.0784,0.1831,0.079 T0.1502,0.0805 Q0.1494,0.0809,0.147,0.0809 Q0.1454,0.0809,0.1386,0.0803 T0.1277,0.0796 Q0.1173,0.0796,0.1092,0.0821 Q0.1036,0.0834,0.1036,0.0854 Q0.1036,0.0879,0.1129,0.0908 T0.1269,0.0937 Q0.1293,0.0937,0.1558,0.0991 T0.1863,0.1045 Q0.2016,0.1045,0.2193,0.1087 Q0.2225,0.1091,0.2249,0.1091 Q0.2289,0.1091,0.2357,0.1076 T0.2482,0.1054 Q0.2506,0.1049,0.2546,0.1049 Q0.2699,0.1049,0.2924,0.1085 T0.3213,0.1165 Q0.3253,0.119,0.3301,0.119 Q0.3325,0.119,0.3446,0.1161 T0.3687,0.1124 Q0.3831,0.1112,0.3863,0.1112 T0.3908,0.1118 T0.3936,0.1145 T0.3984,0.1186 Q0.4048,0.1228,0.408,0.1228 Q0.4096,0.1228,0.4161,0.1213 T0.4305,0.1195 L0.4337,0.1195 Q0.4426,0.1195,0.4474,0.1211 T0.4566,0.1246 T0.4667,0.1273 Q0.4876,0.1298,0.5116,0.1298 Q0.5365,0.1298,0.5631,0.1389 Q0.5703,0.1414,0.5751,0.1414 Q0.5783,0.1414,0.5819,0.1398 T0.5888,0.1381 Q0.5928,0.1381,0.5984,0.1398 T0.6129,0.1414 Q0.6321,0.1414,0.6498,0.1477 Q0.661,0.1514,0.6667,0.1514 Q0.6707,0.1514,0.6771,0.1501 Q0.6787,0.1497,0.6795,0.1497 Q0.6835,0.1497,0.6863,0.1526 T0.6892,0.1593 Q0.6892,0.1626,0.6876,0.1647 Q0.6851,0.1672,0.6799,0.1686 T0.6695,0.1705 T0.6566,0.1715 T0.6458,0.1725 Q0.6434,0.173,0.6402,0.173 Q0.6378,0.173,0.6317,0.1727 T0.6225,0.1725 Q0.6096,0.1725,0.5984,0.1759 Q0.5823,0.1804,0.5454,0.1804 Q0.5205,0.1804,0.5116,0.1788 L0.5084,0.1788 Q0.5036,0.1788,0.4956,0.1819 T0.4843,0.185 Q0.4434,0.1887,0.4353,0.1929 Q0.4321,0.1945,0.4289,0.1945 T0.4197,0.1922 T0.4112,0.19 T0.4072,0.1912 Q0.4032,0.1949,0.388,0.1949 Q0.3663,0.1949,0.3542,0.19 Q0.351,0.1883,0.3462,0.1883 Q0.3406,0.1883,0.3321,0.1906 T0.3153,0.1954 T0.302,0.1983 Q0.2972,0.1991,0.2859,0.1997 T0.2643,0.201 T0.245,0.2024 T0.2337,0.2041 Q0.2321,0.2045,0.2181,0.2045 T0.1851,0.207 T0.1542,0.2157 Q0.143,0.2211,0.143,0.226 Q0.143,0.2314,0.153,0.2348 T0.1719,0.2381 Q0.1791,0.2381,0.2474,0.2476 Q0.2506,0.248,0.2558,0.2489 T0.2651,0.2505 T0.2739,0.252 T0.2819,0.2526 Q0.2835,0.2526,0.2851,0.2522 L0.2859,0.2522 Q0.2948,0.2522,0.3104,0.2574 T0.3309,0.2671 Q0.3333,0.2696,0.3365,0.2696 Q0.339,0.2696,0.3442,0.2684 T0.3526,0.2671 T0.3582,0.2679 Q0.3687,0.2717,0.3968,0.2742 Q0.4,0.2746,0.4036,0.2771 T0.4096,0.2796 Q0.4112,0.2796,0.4137,0.2787 Q0.4169,0.2779,0.4257,0.2779 Q0.4594,0.2779,0.4779,0.2849 Q0.4876,0.2891,0.4972,0.2891 Q0.4996,0.2891,0.506,0.2887 T0.5165,0.2883 Q0.5253,0.2883,0.5305,0.2889 T0.541,0.292 T0.5494,0.299 L0.5494,0.3015 Q0.5494,0.3036,0.5466,0.3051 T0.5386,0.3073 T0.5285,0.3086 T0.5181,0.3094 T0.5108,0.3102 Q0.5068,0.3107,0.494,0.3121 L0.4683,0.315 T0.4506,0.3181 Q0.449,0.3181,0.4426,0.32 T0.4313,0.3229 T0.4197,0.3256 T0.4064,0.3281 T0.3952,0.3289 Q0.3888,0.3289,0.3855,0.3273 Q0.3783,0.3243,0.3703,0.3243 Q0.3598,0.3243,0.351,0.3297 Q0.3478,0.3314,0.3422,0.3314 Q0.3398,0.3314,0.3337,0.3306 T0.3245,0.3297 T0.3189,0.3306 Q0.3149,0.3314,0.2956,0.3328 T0.2715,0.3368 Q0.2691,0.338,0.2635,0.3391 T0.2522,0.3407 T0.2406,0.3424 T0.2313,0.3447 Q0.2305,0.3447,0.2213,0.348 T0.206,0.354 T0.1924,0.3621 T0.1815,0.3733 Q0.1767,0.3816,0.1767,0.3849 Q0.1767,0.387,0.1783,0.3893 T0.1847,0.3961 T0.1936,0.4063 T0.2004,0.4181 T0.2052,0.4264 T0.2145,0.4309 T0.2386,0.4359 Q0.2458,0.4372,0.2554,0.4372 Q0.2578,0.4372,0.2627,0.437 T0.2699,0.4367 Q0.2795,0.4367,0.2851,0.4384 Q0.294,0.4409,0.3092,0.4409 Q0.3116,0.4409,0.3157,0.4407 T0.3213,0.4405 Q0.3301,0.4405,0.3466,0.444 T0.3687,0.4509 Q0.3703,0.4517,0.3703,0.4525 Q0.3703,0.4575,0.3373,0.4596 Q0.3133,0.4608,0.2988,0.4658 Q0.294,0.4674,0.2803,0.4674 Q0.2763,0.4674,0.2687,0.4672 T0.257,0.467 Q0.2402,0.467,0.2313,0.4687 Q0.2209,0.4708,0.2209,0.4737 Q0.2209,0.4741,0.2213,0.4749 T0.2229,0.4764 L0.2249,0.4774 T0.2265,0.4778 L0.2273,0.4782 Q0.2498,0.4803,0.2618,0.4803 Q0.2667,0.4803,0.2691,0.4799 Q0.2699,0.4795,0.2715,0.4795 Q0.2779,0.4795,0.3133,0.4873 Q0.3181,0.4886,0.3233,0.4892 T0.3386,0.4905 T0.3542,0.4915 T0.3655,0.4919 Q0.3703,0.4919,0.3779,0.4915 T0.3888,0.4911 T0.3952,0.4915 Q0.3976,0.4919,0.4008,0.4927 L0.4072,0.4944 T0.4149,0.4961 T0.4249,0.4975 T0.4369,0.4985 Q0.449,0.499,0.4554,0.5 T0.4651,0.5031 T0.4699,0.5085 Q0.4707,0.5093,0.4707,0.5097 Q0.4707,0.5114,0.4687,0.5124 T0.4631,0.5139 T0.4562,0.5145 T0.4478,0.5147 L0.4402,0.5147 Q0.4353,0.5151,0.4265,0.5174 T0.4096,0.5197 L0.4024,0.5197 Q0.4,0.5193,0.3968,0.5193 Q0.3896,0.5193,0.3831,0.5209 T0.3703,0.5253 T0.3598,0.5288 Q0.3558,0.5301,0.3321,0.5342 T0.302,0.5409 Q0.2876,0.5454,0.2337,0.55 Q0.2112,0.5521,0.2112,0.5558 Q0.2112,0.5574,0.2161,0.5612 Q0.2201,0.5637,0.2281,0.5637 Q0.2305,0.5637,0.2361,0.5633 T0.2442,0.5628 Q0.2474,0.5628,0.2498,0.5633 Q0.2635,0.5653,0.3133,0.5653 Q0.3261,0.5653,0.3301,0.5649 L0.3325,0.5649 Q0.3349,0.5649,0.3402,0.5657 T0.3494,0.5666 Q0.3542,0.5666,0.3598,0.5657 Q0.3663,0.5645,0.3695,0.5645 Q0.3735,0.5645,0.3775,0.5666 T0.3847,0.5695 Q0.3888,0.5707,0.3912,0.5709 T0.3968,0.5711 T0.4048,0.572 Q0.4096,0.5724,0.4165,0.5726 T0.4281,0.5734 T0.4378,0.5757 Q0.4418,0.5774,0.4522,0.5774 Q0.4562,0.5774,0.4655,0.5769 T0.4795,0.5765 Q0.4932,0.5765,0.4972,0.579 Q0.5028,0.5819,0.51,0.5819 Q0.5141,0.5819,0.5221,0.5809 T0.5341,0.5798 L0.5373,0.5798 Q0.5422,0.5798,0.557,0.5821 T0.5855,0.5844 L0.5936,0.5844 L0.5992,0.5844 T0.604,0.5848 T0.6076,0.5852 T0.6108,0.5859 T0.6137,0.5865 T0.6161,0.5873 T0.6181,0.5881 T0.6209,0.5896 L0.6233,0.591 Q0.6265,0.5927,0.6353,0.5927 L0.6402,0.5927 L0.645,0.5927 Q0.6723,0.5927,0.6948,0.6039 Q0.6988,0.606,0.7036,0.606 Q0.7076,0.606,0.7153,0.6045 T0.7261,0.6031 Q0.7309,0.6031,0.7454,0.6027 T0.7647,0.6022 Q0.7783,0.6022,0.808,0.608 L0.812,0.608 Q0.8169,0.608,0.8293,0.6062 T0.8474,0.6039 L0.8506,0.6039 Q0.857,0.6039,0.8655,0.6049 T0.8835,0.6074 T0.8972,0.6093 Q0.9036,0.6101,0.9213,0.6101 L0.9398,0.6101 L0.959,0.6101 L0.9767,0.6101 Q0.9976,0.6109,0.9976,0.6176 Q0.9976,0.6188,0.9968,0.6226 Q0.9952,0.6284,0.9671,0.6284 L0.959,0.6284 L0.9526,0.6284 Q0.9486,0.6284,0.9382,0.6302 T0.9205,0.6321 L0.9173,0.6321 Q0.9133,0.6317,0.9092,0.6317 Q0.9012,0.6317,0.8715,0.6346 Q0.8233,0.6387,0.7944,0.6421 Q0.7888,0.6429,0.7831,0.6429 Q0.7783,0.6429,0.7731,0.6425 T0.7663,0.6421 L0.7622,0.6421 Q0.7614,0.6425,0.7586,0.6425 T0.7474,0.6421 T0.7349,0.6416 Q0.7261,0.6416,0.7221,0.6429 Q0.7173,0.645,0.7032,0.646 T0.6711,0.6479 T0.6482,0.6491 Q0.645,0.6495,0.6418,0.6495 Q0.6353,0.6495,0.6273,0.6474 T0.6161,0.6454 Q0.612,0.6454,0.6088,0.6477 T0.6,0.6499 Q0.5912,0.6499,0.5494,0.6578 Q0.5422,0.6591,0.5373,0.6591 Q0.5341,0.6591,0.5281,0.6584 T0.5173,0.6578 L0.5124,0.6578 Q0.5004,0.6582,0.4747,0.6632 Q0.4675,0.6649,0.4398,0.6667 T0.3851,0.6703 T0.3566,0.6719 Q0.3534,0.6723,0.3406,0.6734 T0.3141,0.6757 T0.2948,0.6773 Q0.2908,0.6781,0.2819,0.6829 T0.2618,0.6877 Q0.2482,0.6877,0.2273,0.6958 T0.1992,0.7117 Q0.1976,0.7138,0.1976,0.7155 Q0.1976,0.7188,0.2032,0.7213 T0.2181,0.7258 T0.2289,0.7292 Q0.2337,0.7308,0.2378,0.7316 L0.2458,0.7333 T0.2522,0.7345 Q0.2747,0.7399,0.2835,0.7399 Q0.29,0.7399,0.3048,0.7408 T0.3253,0.7416 Q0.3277,0.7416,0.3297,0.7414 T0.3341,0.7412 L0.3598,0.7445 Q0.3639,0.7449,0.3663,0.7449 Q0.3695,0.7449,0.3751,0.7443 T0.3855,0.7437 L0.3904,0.7437 Q0.3984,0.7441,0.4141,0.7472 T0.4353,0.7507 Q0.4434,0.752,0.4434,0.7553 Q0.4434,0.7578,0.439,0.7605 T0.4297,0.764 Q0.4257,0.7648,0.4201,0.7655 T0.4076,0.7665 T0.3984,0.7673 Q0.3968,0.7673,0.3912,0.7677 T0.3815,0.7688 T0.3735,0.771 T0.3655,0.7727 Q0.3606,0.7727,0.3534,0.7708 T0.3458,0.769 L0.3446,0.769 Q0.3414,0.769,0.3297,0.7713 T0.3149,0.7735 Q0.3092,0.7735,0.296,0.7771 T0.2771,0.7831 Q0.2683,0.7872,0.2554,0.7885 Q0.253,0.7889,0.2325,0.7897 T0.2032,0.7914 Q0.2008,0.7918,0.198,0.792 T0.194,0.7924 T0.1912,0.7928 T0.1888,0.793 T0.1867,0.7934 T0.1851,0.7943 T0.1843,0.7953 T0.1835,0.7968 T0.1823,0.7984 T0.1815,0.8001 Q0.1815,0.8022,0.1847,0.8032 T0.1932,0.8053 T0.2008,0.8071 Q0.2072,0.8092,0.2265,0.8092 Q0.2361,0.8092,0.2586,0.8088 L0.2635,0.8088 Q0.2731,0.8088,0.2795,0.8098 T0.2928,0.8134 T0.302,0.8167 Q0.306,0.8179,0.3277,0.8183 T0.3542,0.8221 Q0.359,0.8246,0.3622,0.8246 Q0.3639,0.8246,0.3675,0.8237 T0.3751,0.8229 Q0.3968,0.8229,0.4048,0.8275 Q0.4088,0.8291,0.4161,0.8291 Q0.4225,0.8291,0.4353,0.8275 Q0.4369,0.827,0.4394,0.827 Q0.4474,0.827,0.4675,0.8345 Q0.4707,0.8358,0.4739,0.8358 Q0.4755,0.8358,0.4799,0.8353 T0.4876,0.8349 Q0.494,0.8349,0.5012,0.8362 L0.5076,0.837 T0.5133,0.8376 T0.5181,0.8382 T0.5221,0.8391 L0.5253,0.8399 T0.5277,0.8409 L0.5293,0.8422 T0.5301,0.8438 L0.5301,0.8461 Q0.5301,0.8499,0.5249,0.8511 T0.5112,0.8526 T0.5004,0.8532 Q0.4964,0.854,0.4851,0.8546 T0.4667,0.8565 Q0.4578,0.8577,0.441,0.8577 L0.4345,0.8577 L0.4289,0.8577 L0.4209,0.8577 Q0.4153,0.8582,0.4088,0.8586 T0.3968,0.8592 T0.3855,0.8594 T0.3755,0.8596 T0.3675,0.86 T0.3622,0.8602 Q0.3317,0.8635,0.3237,0.8635 Q0.2996,0.8635,0.2996,0.8681 Q0.2996,0.8698,0.3032,0.8733 T0.3068,0.8797 L0.3068,0.8822 Q0.3052,0.8859,0.2859,0.8859 Q0.2787,0.8859,0.2739,0.8851 L0.2699,0.8851 Q0.2659,0.8851,0.259,0.888 T0.2498,0.8913 Q0.2394,0.8946,0.2056,0.8971 Q0.2032,0.8971,0.1928,0.9002 T0.1703,0.9034 Q0.1526,0.9034,0.1353,0.9067 T0.1173,0.915 L0.1173,0.9158 Q0.1173,0.9166,0.1177,0.9175 T0.1189,0.9191 T0.1205,0.9204 T0.1229,0.9212 T0.1257,0.922 T0.1285,0.9226 T0.1309,0.9231 T0.1337,0.9235 T0.1365,0.9237 L0.1446,0.9237 T0.1554,0.9243 T0.1614,0.9268 T0.1659,0.9293 T0.1743,0.9303 T0.1823,0.9316 Q0.1863,0.932,0.1912,0.9332 T0.2028,0.9361 T0.2153,0.939 Q0.2233,0.9411,0.2297,0.9448 T0.2386,0.9494 Q0.2426,0.9506,0.2506,0.9506 Q0.253,0.9506,0.2594,0.9504 T0.2699,0.9502 Q0.2763,0.9502,0.2819,0.9511 Q0.2851,0.9511,0.2871,0.9529 T0.2892,0.9573 Q0.2892,0.9627,0.2811,0.9635 Q0.2434,0.9652,0.2337,0.9672 Q0.2257,0.9693,0.2241,0.9693 Q0.2217,0.9693,0.2096,0.9689 L0.2056,0.9689 Q0.1976,0.9689,0.1924,0.9699 T0.1787,0.9741 L0.1647,0.9793 Q0.1518,0.983,0.1285,0.983 L0.1261,0.983 L0.1237,0.983 Q0.1124,0.983,0.1076,0.9838 Q0.102,0.9851,0.0839,0.9896 T0.0602,0.9954 T0.0406,0.9971 T0.0153,0.9979 L0.0032,0.9979 Z",type:0}},"":{type:5,data:{d:"M0.9992,0.998 Q0.9992,1,0.9968,1 Q0.9521,1,0.9392,0.998 Q0.9335,0.997,0.9152,0.9921 T0.8913,0.9862 Q0.8873,0.9852,0.8783,0.9852 L0.8747,0.9852 L0.871,0.9852 Q0.8483,0.9852,0.8345,0.9812 Q0.8289,0.9792,0.8228,0.9773 T0.8135,0.9743 T0.8054,0.9723 T0.7948,0.9713 L0.7908,0.9713 L0.777,0.9713 T0.7656,0.9693 Q0.7616,0.9683,0.7498,0.9674 T0.7283,0.9664 L0.7186,0.9654 Q0.708,0.9644,0.708,0.9575 Q0.708,0.9545,0.7109,0.952 T0.7178,0.9486 L0.7315,0.9486 L0.7417,0.9486 L0.7502,0.9486 Q0.7575,0.9486,0.7599,0.9476 Q0.7616,0.9466,0.7685,0.9426 T0.7843,0.9377 Q0.7899,0.9357,0.8009,0.9332 T0.8175,0.9298 Q0.82,0.9288,0.8228,0.9283 T0.8277,0.9278 T0.8313,0.9273 T0.8341,0.9263 T0.8362,0.9258 Q0.8402,0.9219,0.854,0.9219 L0.8569,0.9219 L0.8597,0.9219 L0.8637,0.9219 Q0.88,0.9199,0.88,0.9159 L0.88,0.9149 Q0.88,0.913,0.8719,0.911 T0.852,0.9075 T0.8297,0.906 Q0.8224,0.906,0.8167,0.905 T0.8074,0.9031 T0.8001,0.9006 T0.794,0.8991 Q0.7599,0.8971,0.7494,0.8942 Q0.7461,0.8932,0.7397,0.8902 T0.7299,0.8872 L0.7267,0.8872 Q0.721,0.8882,0.7121,0.8882 Q0.6926,0.8882,0.691,0.8833 L0.691,0.8803 Q0.691,0.8773,0.6922,0.8749 T0.6955,0.8704 L0.6975,0.8684 Q0.6926,0.8655,0.6764,0.8655 Q0.6683,0.8655,0.6375,0.8625 Q0.6358,0.8625,0.629,0.862 T0.6148,0.8615 T0.5973,0.861 T0.5791,0.8605 L0.5718,0.8605 L0.5661,0.8605 L0.5596,0.8605 Q0.5426,0.8605,0.5337,0.8586 Q0.5264,0.8576,0.515,0.8571 T0.4996,0.8556 Q0.498,0.8556,0.4947,0.8551 T0.4882,0.8546 T0.4809,0.8541 T0.474,0.8531 T0.4692,0.8506 T0.4672,0.8467 Q0.4672,0.8437,0.468,0.8422 T0.4704,0.8398 T0.4736,0.8383 T0.4781,0.8368 T0.4838,0.8358 T0.4911,0.8353 T0.4988,0.8338 Q0.5069,0.8328,0.5142,0.8328 Q0.5174,0.8328,0.5215,0.8333 T0.5264,0.8338 Q0.5296,0.8338,0.532,0.8328 Q0.5523,0.8249,0.5604,0.8249 L0.5653,0.8249 Q0.5775,0.8269,0.5848,0.8269 Q0.5912,0.8269,0.5937,0.8259 Q0.6026,0.821,0.6253,0.821 Q0.6294,0.821,0.6334,0.822 T0.6383,0.8229 Q0.6399,0.8229,0.644,0.82 Q0.6504,0.817,0.6723,0.816 T0.6975,0.814 Q0.6999,0.814,0.7048,0.8121 T0.7129,0.8091 T0.7226,0.8071 T0.7372,0.8061 L0.7421,0.8061 Q0.7624,0.8071,0.7721,0.8071 Q0.7924,0.8071,0.7981,0.8051 Q0.8021,0.8032,0.807,0.8027 T0.8135,0.8017 T0.8151,0.8002 L0.8151,0.7992 Q0.8143,0.7962,0.8127,0.7957 T0.8045,0.7943 Q0.7997,0.7943,0.7964,0.7933 Q0.7883,0.7933,0.7676,0.7923 T0.7437,0.7903 Q0.7307,0.7893,0.7218,0.7844 Q0.7194,0.7834,0.7141,0.7819 L0.7036,0.7789 T0.693,0.7765 T0.6853,0.7755 Q0.6805,0.7755,0.6691,0.773 T0.6553,0.7705 Q0.6545,0.7705,0.6545,0.7715 Q0.6537,0.7715,0.65,0.7725 T0.6423,0.774 T0.635,0.7745 Q0.6294,0.7745,0.6257,0.773 T0.618,0.771 T0.6083,0.77 T0.601,0.7695 Q0.5985,0.7695,0.5921,0.769 T0.5795,0.7676 T0.5702,0.7666 Q0.5653,0.7656,0.5596,0.7616 T0.5539,0.7547 Q0.5539,0.7498,0.5645,0.7488 Q0.5693,0.7478,0.5852,0.7448 T0.6099,0.7418 L0.6148,0.7418 Q0.6196,0.7418,0.6253,0.7423 T0.6338,0.7428 L0.6399,0.7428 Q0.6431,0.7418,0.6488,0.7409 T0.6586,0.7394 T0.6659,0.7389 L0.6707,0.7389 Q0.6723,0.7399,0.6748,0.7399 Q0.6796,0.7399,0.6946,0.7389 T0.7161,0.7379 Q0.7251,0.7379,0.7478,0.7319 Q0.7494,0.7319,0.7539,0.7315 T0.762,0.73 T0.7697,0.727 T0.7818,0.723 T0.7948,0.7196 T0.7997,0.7151 Q0.7997,0.7141,0.7989,0.7132 Q0.7924,0.7062,0.7717,0.6978 T0.738,0.6894 Q0.7267,0.6894,0.717,0.6845 T0.7048,0.6795 Q0.6975,0.6785,0.6711,0.6766 T0.6431,0.6746 Q0.6415,0.6736,0.6144,0.6721 T0.5596,0.6686 T0.5247,0.6657 Q0.4996,0.6607,0.4874,0.6597 L0.4834,0.6597 Q0.4785,0.6597,0.4724,0.6607 T0.4631,0.6617 Q0.4574,0.6617,0.4501,0.6597 Q0.4088,0.6518,0.4006,0.6518 Q0.3933,0.6518,0.3901,0.6499 T0.3844,0.6479 T0.3739,0.6499 T0.3585,0.6518 L0.352,0.6518 Q0.3471,0.6508,0.335,0.6503 T0.3122,0.6494 T0.2912,0.6479 T0.2766,0.6449 Q0.2733,0.6439,0.2668,0.6439 Q0.2628,0.6439,0.2543,0.6444 T0.2425,0.6449 T0.2368,0.6439 L0.2344,0.6439 Q0.2336,0.6439,0.2283,0.6444 T0.2182,0.6449 Q0.2117,0.6449,0.2052,0.6439 Q0.1768,0.6409,0.1281,0.637 Q0.0989,0.634,0.0916,0.634 L0.0835,0.634 L0.0795,0.634 Q0.0722,0.634,0.0616,0.632 T0.0479,0.6301 Q0.0462,0.6301,0.0406,0.6306 T0.0308,0.6311 Q0.0032,0.6311,0.0008,0.6231 Q0,0.6202,0,0.6182 Q0,0.6083,0.0227,0.6083 Q0.03,0.6073,0.0422,0.6073 Q0.0487,0.6073,0.0608,0.6078 T0.0787,0.6083 Q0.0973,0.6083,0.103,0.6073 Q0.1062,0.6063,0.1131,0.6053 T0.1253,0.6039 T0.1375,0.6024 T0.1492,0.6014 L0.1533,0.6014 Q0.1598,0.6024,0.1723,0.6044 T0.189,0.6063 Q0.1906,0.6063,0.1922,0.6053 Q0.2214,0.6004,0.236,0.6004 Q0.2409,0.6004,0.2551,0.6009 T0.2741,0.6014 Q0.2782,0.6014,0.2859,0.6024 T0.2968,0.6034 Q0.3009,0.6034,0.3041,0.6024 Q0.3277,0.5905,0.356,0.5905 L0.3613,0.5905 L0.3666,0.5905 Q0.3731,0.5905,0.3755,0.5895 Q0.382,0.5856,0.3844,0.5846 T0.3938,0.5831 T0.4144,0.5826 T0.4428,0.5801 T0.4631,0.5776 L0.4663,0.5776 Q0.4704,0.5776,0.4785,0.5786 T0.4899,0.5796 Q0.4964,0.5796,0.5012,0.5767 Q0.5069,0.5737,0.5223,0.5737 Q0.5272,0.5737,0.5369,0.5742 T0.5499,0.5747 Q0.558,0.5747,0.5612,0.5737 Q0.5661,0.5717,0.5714,0.5712 T0.5835,0.5702 T0.5945,0.5692 T0.6018,0.5687 L0.6062,0.5687 T0.6095,0.5682 T0.6148,0.5678 Q0.6172,0.5668,0.6196,0.5653 T0.6245,0.5633 T0.631,0.5628 T0.6407,0.5638 T0.6504,0.5648 T0.6594,0.5638 T0.6675,0.5628 L0.6707,0.5628 L0.6861,0.5628 Q0.7364,0.5628,0.7494,0.5608 L0.7567,0.5608 L0.7652,0.5608 L0.7729,0.5608 Q0.7794,0.5608,0.7818,0.5589 Q0.7851,0.5569,0.7859,0.5559 Q0.7835,0.5539,0.7656,0.5519 Q0.7121,0.547,0.6975,0.543 Q0.6934,0.541,0.6853,0.5396 L0.6691,0.5366 T0.6525,0.5336 T0.6391,0.5312 Q0.6367,0.5302,0.6245,0.5257 T0.6034,0.5213 L0.5977,0.5213 Q0.5945,0.5223,0.5904,0.5223 Q0.5839,0.5223,0.5787,0.5208 T0.5685,0.5183 T0.5596,0.5173 Q0.558,0.5173,0.5552,0.5168 T0.5495,0.5163 L0.5434,0.5163 T0.5373,0.5158 T0.532,0.5148 T0.5284,0.5129 T0.5272,0.5104 L0.5272,0.5074 Q0.5304,0.5015,0.5377,0.499 T0.5629,0.4965 Q0.5734,0.4955,0.5811,0.4946 T0.5953,0.4916 T0.605,0.4896 Q0.6083,0.4886,0.6123,0.4886 Q0.6148,0.4886,0.6225,0.4891 T0.635,0.4896 L0.6456,0.4896 Q0.6837,0.4857,0.6861,0.4857 Q0.7226,0.4768,0.7283,0.4768 Q0.7307,0.4768,0.732,0.4773 T0.7372,0.4777 Q0.7502,0.4777,0.7721,0.4758 Q0.7753,0.4748,0.777,0.4738 Q0.7745,0.4718,0.768,0.4708 Q0.7599,0.4688,0.7445,0.4688 Q0.7405,0.4688,0.7324,0.4693 T0.721,0.4698 Q0.7056,0.4698,0.6999,0.4679 Q0.6861,0.4629,0.6626,0.4619 Q0.6586,0.4609,0.6533,0.4604 T0.6419,0.459 T0.6314,0.456 T0.6269,0.452 Q0.6269,0.451,0.6302,0.4491 Q0.6358,0.4451,0.6525,0.4416 T0.6788,0.4382 L0.6845,0.4382 L0.6902,0.4382 Q0.7064,0.4382,0.7145,0.4362 Q0.7202,0.4342,0.7307,0.4342 L0.7356,0.4342 L0.7409,0.4342 L0.7453,0.4342 Q0.7543,0.4342,0.7607,0.4332 Q0.7778,0.4313,0.7847,0.4288 T0.7932,0.4248 T0.7972,0.4169 T0.8045,0.4045 L0.8094,0.3986 T0.8135,0.3942 L0.8167,0.3912 T0.8191,0.3887 T0.8204,0.3867 T0.8208,0.3848 Q0.8208,0.3818,0.8167,0.3739 Q0.8127,0.3689,0.8054,0.3635 T0.7924,0.3556 T0.7778,0.3497 T0.768,0.3462 Q0.764,0.3442,0.7478,0.3427 T0.7267,0.3383 Q0.7234,0.3363,0.7044,0.3348 T0.6805,0.3323 L0.6764,0.3323 T0.6679,0.3328 T0.6586,0.3333 Q0.6521,0.3333,0.648,0.3314 Q0.6391,0.3264,0.6294,0.3264 Q0.6229,0.3264,0.6156,0.3294 Q0.6115,0.3304,0.6042,0.3304 Q0.5977,0.3304,0.5888,0.3289 T0.5738,0.3264 T0.5584,0.3225 L0.5491,0.3195 Q0.5418,0.3185,0.5162,0.3155 T0.4895,0.3121 T0.4826,0.3116 T0.4716,0.3111 T0.4607,0.3096 T0.4513,0.3066 T0.4477,0.3017 Q0.4477,0.2997,0.4485,0.2977 Q0.4526,0.2898,0.4611,0.2878 T0.4842,0.2859 Q0.4882,0.2859,0.4943,0.2864 T0.5028,0.2868 Q0.5118,0.2868,0.5215,0.2829 Q0.5401,0.276,0.5734,0.276 Q0.5831,0.276,0.5872,0.277 L0.5904,0.277 T0.5949,0.2745 T0.6034,0.272 Q0.631,0.269,0.6407,0.2661 Q0.6448,0.2641,0.6488,0.2641 T0.6573,0.2656 T0.6634,0.2671 Q0.6659,0.2671,0.6675,0.2651 Q0.6723,0.2601,0.6886,0.2552 T0.7137,0.2502 L0.7153,0.2502 L0.7178,0.2502 Q0.7218,0.2502,0.7279,0.2493 T0.7417,0.2468 T0.7526,0.2453 Q0.8208,0.2354,0.8281,0.2354 Q0.8337,0.2354,0.8394,0.2344 T0.8496,0.2315 T0.854,0.2265 Q0.854,0.2216,0.8443,0.2176 Q0.8354,0.2127,0.8216,0.2102 T0.7972,0.2072 T0.777,0.2067 T0.7648,0.2057 Q0.7632,0.2047,0.7498,0.2038 T0.7202,0.2018 T0.6983,0.1998 Q0.6918,0.1998,0.676,0.1949 T0.6537,0.1899 Q0.6496,0.1899,0.6472,0.1919 Q0.6342,0.1968,0.6123,0.1968 Q0.5961,0.1968,0.5912,0.1929 Q0.5904,0.1919,0.5892,0.1919 T0.5823,0.1944 T0.5722,0.1968 T0.5637,0.1949 Q0.5556,0.1909,0.5158,0.1869 Q0.5109,0.1869,0.5032,0.1835 T0.4915,0.18 Q0.4907,0.18,0.4891,0.181 Q0.4793,0.182,0.455,0.182 Q0.4177,0.182,0.4015,0.1771 Q0.3901,0.1741,0.3779,0.1741 Q0.3747,0.1741,0.369,0.1746 T0.3609,0.1751 Q0.3569,0.1751,0.3536,0.1741 Q0.3512,0.1741,0.3459,0.1736 T0.3358,0.1726 T0.3256,0.1716 T0.3167,0.1696 T0.3106,0.1662 Q0.3082,0.1632,0.3082,0.1592 Q0.3082,0.1543,0.3114,0.1508 T0.3204,0.1474 L0.3236,0.1474 Q0.3301,0.1494,0.3333,0.1494 Q0.339,0.1494,0.3496,0.1454 Q0.3674,0.1385,0.3869,0.1385 Q0.395,0.1385,0.4011,0.137 T0.4116,0.1355 T0.4197,0.1375 T0.425,0.1395 Q0.429,0.1395,0.4355,0.1365 Q0.4631,0.1266,0.4882,0.1266 Q0.5126,0.1266,0.5337,0.1246 Q0.5369,0.1246,0.5458,0.1207 T0.5661,0.1167 L0.5693,0.1167 Q0.5791,0.1177,0.5852,0.1187 T0.5921,0.1197 Q0.5945,0.1197,0.6002,0.1167 Q0.6026,0.1147,0.605,0.1123 T0.6091,0.1093 T0.6156,0.1088 T0.6318,0.1098 Q0.6456,0.1108,0.6569,0.1137 T0.6707,0.1167 Q0.674,0.1167,0.6772,0.1147 Q0.6821,0.1108,0.6946,0.1083 T0.721,0.1044 T0.7453,0.1029 L0.7526,0.1029 Q0.7583,0.1039,0.7652,0.1053 T0.7753,0.1068 Q0.7778,0.1068,0.7802,0.1058 Q0.7981,0.1019,0.8143,0.1019 Q0.8175,0.1019,0.8439,0.0964 T0.8735,0.091 Q0.8759,0.091,0.8836,0.089 T0.8938,0.0851 Q0.8929,0.0841,0.8905,0.0841 Q0.8832,0.0821,0.8735,0.0821 Q0.8694,0.0821,0.8629,0.0826 T0.854,0.0831 Q0.8508,0.0831,0.8491,0.0821 Q0.8475,0.0821,0.8171,0.0811 T0.7802,0.0781 T0.7571,0.0737 T0.7332,0.0712 Q0.7299,0.0712,0.7247,0.0717 T0.7161,0.0722 L0.7064,0.0722 Q0.7032,0.0712,0.7003,0.0707 T0.6959,0.0692 T0.693,0.0678 T0.6914,0.0663 T0.6902,0.0648 T0.689,0.0643 L0.6869,0.0643 L0.6845,0.0643 L0.6821,0.0643 Q0.6553,0.0643,0.6553,0.0554 T0.6796,0.0465 L0.6813,0.0465 L0.6845,0.0465 Q0.6918,0.0425,0.717,0.0376 Q0.7226,0.0366,0.7267,0.0356 T0.7324,0.0336 T0.7376,0.0317 T0.747,0.0297 Q0.7518,0.0287,0.7551,0.0287 T0.762,0.0297 T0.768,0.0307 L0.7729,0.0307 Q0.7802,0.0277,0.7997,0.0267 T0.8224,0.0237 Q0.8273,0.0227,0.8341,0.0223 T0.8475,0.0193 T0.8654,0.0143 T0.8873,0.0079 Q0.9002,0.003,0.9331,0.004 T0.97,0.004 Q0.9838,0,0.9976,0.001 Q1,0.001,1,0.003 Z M0.9935,0.996 L0.9951,0.0049 Q0.9822,0.0049,0.9716,0.0079 Q0.9659,0.0099,0.9327,0.0089 T0.8897,0.0119 Q0.8775,0.0158,0.8666,0.0183 T0.85,0.0237 Q0.8418,0.0267,0.8341,0.0272 T0.8248,0.0277 Q0.82,0.0297,0.8001,0.0312 T0.7745,0.0346 Q0.7697,0.0356,0.7664,0.0356 Q0.764,0.0356,0.7603,0.0341 T0.7534,0.0326 Q0.751,0.0326,0.7478,0.0336 Q0.7429,0.0346,0.7397,0.0356 T0.7344,0.0376 T0.7283,0.0401 T0.7178,0.0425 Q0.6926,0.0465,0.6878,0.0495 Q0.6845,0.0514,0.6764,0.0514 L0.6723,0.0514 L0.6691,0.0514 Q0.6602,0.0514,0.6602,0.0554 Q0.6602,0.0574,0.6659,0.0579 T0.6796,0.0589 T0.691,0.0603 T0.6967,0.0643 T0.7072,0.0673 Q0.7121,0.0682,0.7153,0.0682 T0.7238,0.0678 T0.7332,0.0673 Q0.7413,0.0673,0.7579,0.0692 T0.7818,0.0742 Q0.7867,0.0752,0.8171,0.0762 T0.8508,0.0781 L0.852,0.0781 T0.8601,0.0776 T0.8719,0.0772 Q0.8832,0.0772,0.8921,0.0791 Q0.8994,0.0811,0.8994,0.0841 T0.8933,0.0895 T0.8812,0.094 T0.8735,0.0959 T0.8451,0.1014 T0.8143,0.1068 Q0.7989,0.1068,0.7818,0.1098 Q0.7778,0.1108,0.7745,0.1108 T0.768,0.1103 T0.7603,0.1088 T0.7518,0.1068 L0.7461,0.1068 Q0.7315,0.1068,0.7088,0.1103 T0.6805,0.1177 Q0.6756,0.1207,0.6699,0.1207 Q0.6667,0.1207,0.6545,0.1177 T0.631,0.1147 Q0.6269,0.1137,0.6225,0.1133 T0.616,0.1128 L0.6123,0.1128 T0.6103,0.1133 L0.6083,0.1157 T0.6034,0.1197 Q0.5961,0.1246,0.5921,0.1246 Q0.5904,0.1246,0.5835,0.1231 T0.5693,0.1217 L0.5661,0.1217 Q0.558,0.1217,0.5535,0.1231 T0.5446,0.1266 T0.5345,0.1296 Q0.5126,0.1316,0.4882,0.1316 T0.438,0.1405 Q0.4298,0.1434,0.425,0.1434 Q0.4209,0.1434,0.4173,0.1419 T0.4112,0.1405 T0.4027,0.1419 T0.3869,0.1434 Q0.3682,0.1434,0.3512,0.1494 Q0.3398,0.1533,0.3333,0.1533 Q0.3285,0.1533,0.3228,0.1513 L0.3212,0.1513 Q0.3179,0.1513,0.3155,0.1538 T0.3131,0.1592 Q0.3131,0.1612,0.3147,0.1632 Q0.3171,0.1652,0.3232,0.1667 T0.3406,0.1691 T0.3544,0.1701 L0.3601,0.1701 L0.3678,0.1701 L0.3771,0.1701 Q0.3909,0.1701,0.4031,0.1731 Q0.4177,0.178,0.4558,0.178 Q0.4793,0.178,0.4882,0.1761 L0.4915,0.1761 Q0.498,0.1761,0.5061,0.179 T0.5158,0.183 Q0.5166,0.183,0.5231,0.1835 T0.5341,0.1845 T0.545,0.1855 T0.5572,0.1874 T0.5661,0.1909 Q0.5677,0.1919,0.5702,0.1919 T0.5791,0.1894 T0.588,0.1869 Q0.5921,0.1869,0.5945,0.1899 Q0.5977,0.1919,0.6123,0.1919 Q0.6334,0.1919,0.6448,0.1879 Q0.6488,0.186,0.6537,0.186 Q0.6626,0.186,0.6784,0.1909 T0.6983,0.1958 Q0.704,0.1968,0.7206,0.1973 T0.7506,0.1993 T0.7689,0.2018 L0.7705,0.2018 L0.7741,0.2018 L0.7786,0.2018 Q0.8248,0.2018,0.8475,0.2136 Q0.8597,0.2196,0.8597,0.2255 T0.8483,0.2359 T0.8281,0.2404 Q0.8208,0.2404,0.7534,0.2493 Q0.7502,0.2502,0.7425,0.2512 T0.7287,0.2532 T0.7178,0.2542 L0.7153,0.2542 L0.7137,0.2542 Q0.7064,0.2542,0.6906,0.2591 T0.6715,0.2681 T0.6626,0.272 Q0.6594,0.272,0.6541,0.2705 T0.6464,0.269 T0.6431,0.27 Q0.6318,0.273,0.6034,0.276 Q0.6018,0.276,0.5998,0.2774 T0.5957,0.2799 T0.5904,0.2809 L0.5856,0.2809 Q0.5823,0.2799,0.575,0.2799 Q0.541,0.2799,0.5239,0.2868 Q0.5134,0.2908,0.5028,0.2908 Q0.4996,0.2908,0.4935,0.2903 T0.4842,0.2898 Q0.4704,0.2898,0.4635,0.2918 T0.4534,0.2997 L0.4534,0.3007 Q0.4534,0.3037,0.459,0.3046 T0.4753,0.3066 T0.4899,0.3076 Q0.4907,0.3076,0.5166,0.3106 T0.5507,0.3155 Q0.5921,0.3264,0.605,0.3264 Q0.6107,0.3264,0.6131,0.3254 Q0.6212,0.3225,0.6294,0.3225 Q0.6407,0.3225,0.6513,0.3274 Q0.6529,0.3294,0.6569,0.3294 Q0.6594,0.3294,0.665,0.3284 T0.6748,0.3274 T0.6821,0.3284 Q0.6861,0.3294,0.6967,0.3299 T0.7165,0.3314 T0.7299,0.3343 Q0.7324,0.3363,0.7486,0.3383 T0.7697,0.3422 Q0.7713,0.3432,0.7774,0.3452 T0.7887,0.3492 T0.8005,0.3546 T0.8127,0.3625 T0.8208,0.3719 Q0.8256,0.3798,0.8256,0.3848 Q0.8256,0.3877,0.8236,0.3902 T0.8171,0.3976 T0.809,0.4075 T0.8021,0.4189 T0.7968,0.4273 T0.7867,0.4322 T0.7616,0.4382 Q0.7543,0.4392,0.7437,0.4392 L0.7364,0.4392 L0.7299,0.4392 Q0.7202,0.4392,0.7161,0.4402 Q0.7072,0.4431,0.691,0.4431 L0.6845,0.4431 L0.6788,0.4431 Q0.6707,0.4431,0.6545,0.4461 T0.6334,0.452 Q0.6399,0.456,0.6626,0.457 Q0.6878,0.458,0.7024,0.4639 Q0.7064,0.4649,0.7186,0.4649 L0.7295,0.4649 L0.7413,0.4649 Q0.7591,0.4649,0.7697,0.4669 Q0.7818,0.4688,0.7818,0.4728 Q0.7818,0.4748,0.7798,0.4763 T0.7753,0.4787 L0.7737,0.4797 L0.7729,0.4797 Q0.7494,0.4827,0.7388,0.4827 Q0.7332,0.4827,0.7307,0.4817 L0.7291,0.4817 Q0.7226,0.4817,0.6878,0.4896 Q0.6829,0.4906,0.6772,0.4911 T0.6618,0.4921 T0.646,0.4931 T0.6342,0.4936 T0.6212,0.4931 T0.6115,0.4926 Q0.6083,0.4926,0.6058,0.4931 T0.5969,0.4955 T0.5819,0.499 T0.5629,0.5005 Q0.5572,0.5005,0.5527,0.501 T0.545,0.502 T0.5397,0.503 T0.5361,0.5045 T0.5337,0.5064 T0.5328,0.5094 L0.532,0.5094 Q0.532,0.5104,0.5337,0.5109 T0.5381,0.5114 T0.5438,0.5119 T0.5515,0.5124 L0.5604,0.5124 Q0.5661,0.5134,0.5746,0.5153 T0.5912,0.5173 L0.5977,0.5173 L0.6026,0.5173 Q0.6139,0.5173,0.6273,0.5218 T0.6407,0.5272 Q0.6448,0.5282,0.6683,0.5321 T0.6991,0.5381 Q0.7137,0.543,0.7664,0.548 Q0.7908,0.55,0.7908,0.5559 Q0.7908,0.5589,0.7859,0.5628 Q0.781,0.5658,0.7713,0.5658 Q0.7689,0.5658,0.7628,0.5653 T0.7543,0.5648 T0.7502,0.5658 Q0.7364,0.5678,0.6878,0.5678 Q0.674,0.5678,0.6699,0.5668 L0.6683,0.5668 Q0.6659,0.5668,0.6606,0.5678 T0.6513,0.5687 Q0.6464,0.5687,0.6391,0.5678 Q0.6334,0.5668,0.6302,0.5668 T0.6241,0.5687 T0.6164,0.5717 Q0.6123,0.5727,0.6099,0.5727 L0.6062,0.5727 T0.6022,0.5732 T0.5953,0.5737 Q0.5904,0.5747,0.5839,0.5747 T0.5726,0.5752 T0.5629,0.5776 T0.5458,0.5796 Q0.5418,0.5796,0.5324,0.5791 T0.5191,0.5786 Q0.5077,0.5786,0.5045,0.5806 Q0.498,0.5836,0.4899,0.5836 Q0.4858,0.5836,0.4777,0.5826 T0.4655,0.5816 L0.4631,0.5816 Q0.4582,0.5816,0.4436,0.5841 T0.4144,0.5865 L0.4051,0.5865 L0.3986,0.5865 T0.3933,0.587 T0.3897,0.5875 T0.3869,0.5885 T0.3844,0.5895 T0.3812,0.591 T0.3779,0.5925 Q0.3739,0.5955,0.3633,0.5955 L0.3585,0.5955 L0.3536,0.5955 Q0.3277,0.5955,0.3066,0.6053 Q0.3017,0.6083,0.296,0.6083 Q0.2912,0.6083,0.2839,0.6068 T0.2741,0.6053 Q0.2693,0.6053,0.2547,0.6048 T0.236,0.6044 Q0.2222,0.6044,0.193,0.6103 L0.1882,0.6103 Q0.1825,0.6103,0.1703,0.6083 T0.1525,0.6063 L0.15,0.6063 Q0.1436,0.6063,0.135,0.6073 T0.1168,0.6098 T0.1038,0.6113 Q0.0973,0.6123,0.0787,0.6123 L0.06,0.6123 L0.0414,0.6123 L0.0235,0.6123 Q0.0049,0.6133,0.0049,0.6172 L0.0057,0.6222 Q0.0073,0.6261,0.043,0.6261 L0.0454,0.6261 L0.0479,0.6261 Q0.0527,0.6261,0.0633,0.6281 T0.0803,0.6301 L0.0835,0.6301 L0.0916,0.6301 Q0.0998,0.6301,0.129,0.632 Q0.1768,0.636,0.206,0.64 Q0.2117,0.6409,0.2165,0.6409 Q0.2206,0.6409,0.2263,0.6405 T0.2336,0.64 L0.2384,0.64 L0.2409,0.64 T0.251,0.6395 T0.2644,0.639 Q0.2741,0.639,0.279,0.6409 Q0.283,0.6429,0.2972,0.6439 T0.3293,0.6459 T0.3528,0.6469 L0.3577,0.6469 Q0.3642,0.6469,0.3723,0.6449 T0.3844,0.6429 Q0.3901,0.6429,0.3933,0.6454 T0.4006,0.6479 Q0.4088,0.6479,0.4517,0.6558 Q0.4582,0.6568,0.4623,0.6568 Q0.4655,0.6568,0.4716,0.6563 T0.4826,0.6558 L0.4882,0.6558 Q0.5004,0.6558,0.5255,0.6607 Q0.5328,0.6627,0.5604,0.6647 T0.6152,0.6682 T0.644,0.6696 T0.6719,0.6721 T0.7056,0.6756 Q0.7113,0.6756,0.7198,0.6805 T0.738,0.6855 Q0.7453,0.6855,0.7551,0.6879 T0.7741,0.6939 T0.7912,0.7018 T0.8029,0.7102 Q0.8054,0.7132,0.8054,0.7151 Q0.8054,0.7191,0.8009,0.7216 T0.7908,0.7255 T0.779,0.7285 T0.7721,0.731 Q0.7672,0.7329,0.7632,0.7339 T0.7551,0.7354 T0.7486,0.7369 Q0.7478,0.7369,0.7445,0.7379 T0.7388,0.7394 T0.7324,0.7404 L0.7242,0.7413 T0.7161,0.7418 Q0.7105,0.7418,0.6955,0.7428 T0.6748,0.7438 L0.6699,0.7438 L0.6667,0.7438 Q0.6659,0.7438,0.6407,0.7468 Q0.6367,0.7478,0.6334,0.7478 T0.6245,0.7468 T0.6148,0.7458 L0.6099,0.7458 Q0.6018,0.7468,0.5864,0.7498 T0.5653,0.7532 T0.5596,0.7557 Q0.5596,0.7596,0.571,0.7616 Q0.5742,0.7626,0.5803,0.7631 T0.5929,0.7641 T0.6018,0.7656 L0.6087,0.7656 T0.6192,0.7666 T0.6277,0.7695 Q0.631,0.7705,0.6342,0.7705 Q0.6383,0.7705,0.6452,0.7685 T0.6537,0.7666 L0.6553,0.7666 Q0.6602,0.7666,0.6711,0.769 T0.6853,0.7715 Q0.691,0.7715,0.7048,0.775 T0.7242,0.7814 Q0.7324,0.7854,0.7445,0.7864 Q0.7478,0.7864,0.768,0.7873 T0.7972,0.7893 Q0.7989,0.7893,0.8017,0.7898 T0.8054,0.7903 L0.8082,0.7903 T0.811,0.7908 T0.8131,0.7913 T0.8151,0.7918 L0.8167,0.7928 T0.8179,0.7943 T0.8191,0.7957 T0.8204,0.7977 T0.8208,0.8002 Q0.8208,0.8032,0.8171,0.8051 T0.8074,0.8081 T0.7997,0.8091 Q0.7932,0.8121,0.7737,0.8121 Q0.7664,0.8121,0.7413,0.8111 L0.7364,0.8111 Q0.7275,0.8111,0.7214,0.8121 T0.7084,0.8155 T0.6991,0.819 Q0.6934,0.82,0.6723,0.8205 T0.6472,0.8239 Q0.6423,0.8269,0.6375,0.8269 Q0.6358,0.8269,0.6338,0.8264 T0.6294,0.8254 T0.6253,0.8249 Q0.6042,0.8249,0.5961,0.8289 Q0.5921,0.8318,0.5839,0.8318 Q0.5783,0.8318,0.5645,0.8299 L0.5604,0.8299 Q0.5531,0.8299,0.5337,0.8368 Q0.5296,0.8378,0.5255,0.8378 Q0.5239,0.8378,0.5195,0.8373 T0.5118,0.8368 Q0.5061,0.8368,0.4996,0.8388 Q0.4947,0.8388,0.4882,0.8398 Q0.4777,0.8417,0.4753,0.8427 T0.4728,0.8467 Q0.4728,0.8487,0.4769,0.8492 T0.4886,0.8501 T0.5004,0.8516 Q0.5036,0.8516,0.515,0.8521 T0.5345,0.8546 Q0.5426,0.8556,0.5596,0.8556 L0.5649,0.8556 L0.5702,0.8556 L0.5791,0.8556 Q0.5921,0.8566,0.6135,0.8571 T0.6383,0.8586 Q0.6683,0.8615,0.6764,0.8615 L0.6776,0.8615 L0.6788,0.8615 Q0.7032,0.8615,0.7032,0.8684 Q0.7032,0.8714,0.6995,0.8749 T0.6959,0.8803 L0.6959,0.8813 Q0.6991,0.8843,0.7153,0.8843 Q0.7218,0.8843,0.7259,0.8833 L0.7299,0.8833 Q0.7324,0.8833,0.7348,0.8838 T0.7393,0.8848 T0.7433,0.8863 L0.7474,0.8882 T0.751,0.8892 Q0.7616,0.8922,0.7948,0.8952 Q0.7981,0.8952,0.8082,0.8981 T0.8297,0.9011 T0.8528,0.9026 T0.8747,0.907 T0.8856,0.9149 L0.8856,0.9159 Q0.8856,0.9179,0.8848,0.9189 T0.8828,0.9209 T0.88,0.9228 T0.8767,0.9243 T0.8731,0.9248 T0.8698,0.9253 T0.8666,0.9258 L0.8646,0.9258 Q0.8621,0.9258,0.8577,0.9263 T0.85,0.9268 T0.8439,0.9273 T0.8402,0.9278 Q0.8386,0.9298,0.8358,0.9308 T0.8305,0.9322 T0.8244,0.9332 T0.8183,0.9337 Q0.8135,0.9347,0.8025,0.9372 T0.7859,0.9416 Q0.7778,0.9436,0.7717,0.9471 T0.7624,0.9515 Q0.7575,0.9535,0.7486,0.9535 Q0.7461,0.9535,0.7397,0.953 T0.7291,0.9525 Q0.7234,0.9525,0.7186,0.953 T0.7137,0.957 T0.7194,0.9614 Q0.7567,0.9634,0.7664,0.9654 Q0.7745,0.9674,0.7762,0.9674 L0.7908,0.9674 Q0.7924,0.9664,0.7948,0.9664 Q0.8029,0.9664,0.8082,0.9679 T0.8224,0.9723 T0.8362,0.9773 Q0.8483,0.9812,0.871,0.9812 L0.8751,0.9812 Q0.8873,0.9812,0.8929,0.9822 T0.9165,0.9876 T0.9408,0.9931 Q0.9513,0.996,0.9935,0.996 Z M0.9968,0.998 L0.9976,0.003 Q0.9838,0.002,0.9708,0.0059 Q0.9659,0.0069,0.9327,0.0059 T0.8881,0.0099 Q0.8767,0.0138,0.8658,0.0163 T0.8491,0.0218 Q0.8418,0.0247,0.8341,0.0247 T0.8232,0.0257 Q0.8191,0.0277,0.7997,0.0292 T0.7737,0.0326 Q0.7697,0.0336,0.7672,0.0336 T0.7612,0.0321 T0.7543,0.0307 T0.747,0.0317 Q0.7397,0.0326,0.7336,0.0356 T0.717,0.0406 Q0.6918,0.0445,0.6861,0.0475 Q0.6845,0.0485,0.6772,0.0485 T0.6638,0.05 T0.6577,0.0549 T0.6642,0.0598 T0.6796,0.0613 T0.6902,0.0623 Q0.691,0.0623,0.6946,0.0658 T0.7064,0.0702 L0.7161,0.0702 Q0.7194,0.0702,0.7242,0.0697 T0.7332,0.0692 Q0.7413,0.0692,0.7579,0.0717 T0.781,0.0762 Q0.7867,0.0781,0.8171,0.0786 T0.85,0.0801 L0.8532,0.0801 Q0.8548,0.0801,0.8613,0.0796 T0.8727,0.0791 Q0.8832,0.0791,0.8913,0.0811 Q0.897,0.0831,0.897,0.0851 Q0.897,0.088,0.8873,0.0905 T0.8735,0.093 Q0.871,0.093,0.8447,0.0984 T0.8143,0.1039 Q0.7981,0.1039,0.781,0.1078 Q0.7778,0.1088,0.7745,0.1088 T0.7644,0.1073 T0.7518,0.1048 L0.7453,0.1048 Q0.7299,0.1048,0.7076,0.1083 T0.6788,0.1157 Q0.6748,0.1187,0.6699,0.1187 Q0.6675,0.1187,0.6557,0.1157 T0.6318,0.1118 Q0.6164,0.1108,0.6135,0.1108 T0.6095,0.1113 T0.6067,0.1142 T0.6018,0.1187 Q0.5953,0.1227,0.5921,0.1227 Q0.5904,0.1227,0.5839,0.1212 T0.5693,0.1187 L0.5661,0.1187 Q0.558,0.1187,0.5527,0.1207 T0.5434,0.1246 T0.5337,0.1266 Q0.5126,0.1296,0.4882,0.1296 T0.4371,0.1385 Q0.4298,0.1414,0.425,0.1414 Q0.4225,0.1414,0.4189,0.1395 T0.4112,0.1375 Q0.4079,0.1375,0.4019,0.1395 T0.3869,0.1414 Q0.3682,0.1414,0.3504,0.1474 Q0.3398,0.1513,0.3333,0.1513 Q0.3293,0.1513,0.3228,0.1494 L0.3204,0.1494 Q0.3179,0.1494,0.3155,0.1508 T0.3118,0.1543 T0.3106,0.1592 T0.3127,0.1642 T0.32,0.1677 T0.3305,0.1701 T0.3435,0.1716 T0.3544,0.1721 Q0.3569,0.1731,0.3601,0.1731 Q0.3625,0.1731,0.3686,0.1726 T0.3779,0.1721 Q0.3901,0.1721,0.4023,0.1751 Q0.4177,0.18,0.455,0.18 Q0.4793,0.18,0.4882,0.178 L0.4915,0.178 Q0.4964,0.178,0.5045,0.1815 T0.5158,0.185 Q0.5564,0.1889,0.5645,0.1929 Q0.5677,0.1939,0.571,0.1939 Q0.575,0.1939,0.5807,0.1919 T0.5888,0.1899 T0.5929,0.1909 Q0.5969,0.1949,0.6123,0.1949 Q0.6334,0.1949,0.6456,0.1899 Q0.6488,0.1879,0.6537,0.1879 Q0.661,0.1879,0.6772,0.1929 T0.6983,0.1978 Q0.704,0.1988,0.7206,0.1998 T0.7502,0.2018 T0.7664,0.2038 Q0.7672,0.2038,0.7818,0.2043 T0.8151,0.2067 T0.8459,0.2156 Q0.8573,0.2206,0.8573,0.2255 Q0.8573,0.2295,0.8516,0.2324 T0.8394,0.2369 T0.8281,0.2384 Q0.8208,0.2384,0.7526,0.2473 Q0.7494,0.2483,0.7421,0.2493 T0.7287,0.2512 T0.7178,0.2522 L0.7153,0.2522 L0.7137,0.2522 Q0.7056,0.2522,0.6898,0.2572 T0.6691,0.2671 Q0.6667,0.269,0.6634,0.269 Q0.661,0.269,0.6557,0.2681 T0.6472,0.2671 Q0.6448,0.2671,0.6415,0.2681 Q0.631,0.271,0.6034,0.274 Q0.5994,0.274,0.5961,0.2765 T0.5904,0.2789 L0.5864,0.2789 Q0.5831,0.2779,0.5742,0.2779 Q0.541,0.2779,0.5223,0.2849 Q0.5126,0.2888,0.5028,0.2888 Q0.5004,0.2888,0.4943,0.2883 T0.4842,0.2878 Q0.4704,0.2878,0.4627,0.2898 T0.4509,0.2987 Q0.4501,0.2997,0.4501,0.3007 Q0.4501,0.3046,0.4574,0.3066 T0.4765,0.3091 T0.4899,0.3096 Q0.4931,0.3106,0.5061,0.3121 T0.5316,0.315 T0.5499,0.3175 Q0.5507,0.3175,0.5572,0.3195 T0.5685,0.3225 T0.5803,0.3254 T0.5933,0.3279 T0.605,0.3284 T0.6148,0.3274 Q0.6221,0.3244,0.6294,0.3244 Q0.6399,0.3244,0.6496,0.3294 Q0.6529,0.3314,0.6577,0.3314 Q0.6602,0.3314,0.6663,0.3304 T0.6756,0.3294 T0.6813,0.3304 Q0.6837,0.3304,0.6873,0.3309 L0.6946,0.3318 T0.7024,0.3323 T0.7105,0.3328 T0.7178,0.3338 T0.7238,0.3348 T0.7283,0.3363 Q0.7324,0.3383,0.7482,0.3403 T0.7689,0.3442 Q0.7697,0.3442,0.779,0.3477 T0.794,0.3536 T0.8074,0.3615 T0.8183,0.3729 Q0.8232,0.3808,0.8232,0.3848 Q0.8232,0.3867,0.8216,0.3892 T0.8151,0.3961 T0.8062,0.406 T0.7997,0.4179 T0.7952,0.4263 T0.7855,0.4308 T0.7616,0.4362 Q0.7543,0.4372,0.7445,0.4372 L0.7372,0.4372 L0.7307,0.4372 Q0.7202,0.4372,0.7153,0.4382 Q0.7064,0.4402,0.691,0.4402 L0.6845,0.4402 L0.6788,0.4402 Q0.6699,0.4402,0.6533,0.4436 T0.6318,0.451 Q0.6302,0.451,0.6302,0.452 Q0.6302,0.457,0.6626,0.459 Q0.6869,0.4609,0.7015,0.4659 Q0.7064,0.4669,0.7202,0.4669 L0.7311,0.4669 L0.7429,0.4669 Q0.7599,0.4669,0.7689,0.4688 Q0.7794,0.4708,0.7794,0.4738 L0.7786,0.4748 T0.777,0.4763 T0.7749,0.4773 L0.7737,0.4777 L0.7729,0.4777 Q0.7502,0.4797,0.738,0.4797 L0.7315,0.4797 L0.7291,0.4797 Q0.7226,0.4797,0.6869,0.4876 Q0.6821,0.4886,0.6768,0.4891 T0.6614,0.4901 T0.6456,0.4916 L0.6342,0.4916 Q0.6294,0.4916,0.6217,0.4911 T0.6115,0.4906 Q0.6083,0.4906,0.6054,0.4911 T0.5961,0.4936 T0.5815,0.4965 T0.5629,0.4985 Q0.5507,0.4985,0.5442,0.4995 T0.5345,0.5025 T0.5304,0.5084 L0.5296,0.5094 Q0.5296,0.5114,0.5316,0.5124 T0.5373,0.5138 T0.5442,0.5143 L0.5523,0.5143 L0.5604,0.5143 Q0.5637,0.5153,0.5677,0.5158 T0.5742,0.5173 T0.5811,0.5188 T0.5904,0.5193 L0.5977,0.5193 L0.6026,0.5193 Q0.6107,0.5193,0.6168,0.5208 T0.6294,0.5252 T0.6399,0.5292 Q0.6448,0.5302,0.6679,0.5341 T0.6983,0.541 Q0.7129,0.545,0.7664,0.55 Q0.7883,0.5519,0.7883,0.5559 Q0.7883,0.5579,0.7843,0.5608 T0.7721,0.5638 Q0.7697,0.5638,0.764,0.5633 T0.7559,0.5628 L0.7502,0.5628 Q0.7364,0.5648,0.6869,0.5648 L0.6699,0.5648 L0.6675,0.5648 Q0.665,0.5648,0.6602,0.5658 T0.6513,0.5668 Q0.6464,0.5668,0.6399,0.5658 Q0.6342,0.5648,0.6302,0.5648 T0.6225,0.5668 T0.6156,0.5697 Q0.6107,0.5707,0.6087,0.5707 T0.6034,0.5712 T0.5953,0.5717 Q0.5904,0.5727,0.5835,0.5727 T0.5718,0.5732 T0.562,0.5757 Q0.558,0.5767,0.5483,0.5767 L0.5349,0.5767 L0.5207,0.5767 Q0.5069,0.5767,0.5028,0.5786 Q0.4972,0.5816,0.4899,0.5816 Q0.4858,0.5816,0.4781,0.5806 T0.4663,0.5796 L0.4631,0.5796 Q0.4582,0.5796,0.4432,0.5821 T0.4144,0.5846 L0.4071,0.5846 L0.4011,0.5846 T0.3958,0.5851 T0.3921,0.5856 T0.3889,0.5861 T0.3865,0.5865 T0.384,0.5875 L0.382,0.5885 L0.3796,0.5895 L0.3771,0.5905 Q0.3731,0.5925,0.365,0.5925 L0.3601,0.5925 L0.3552,0.5925 Q0.3277,0.5925,0.3049,0.6044 Q0.3017,0.6053,0.296,0.6053 Q0.292,0.6053,0.2847,0.6044 T0.2741,0.6034 Q0.2693,0.6034,0.2547,0.6029 T0.236,0.6024 Q0.2222,0.6024,0.1922,0.6073 Q0.1906,0.6083,0.1882,0.6083 Q0.1833,0.6083,0.1711,0.6063 T0.1533,0.6044 Q0.1517,0.6034,0.1492,0.6034 Q0.1436,0.6034,0.135,0.6048 T0.1168,0.6078 T0.103,0.6093 Q0.0973,0.6103,0.0787,0.6103 L0.0604,0.6103 L0.0414,0.6103 L0.0235,0.6103 Q0.0024,0.6113,0.0024,0.6172 Q0.0024,0.6192,0.0032,0.6231 Q0.0049,0.6281,0.0333,0.6281 L0.0414,0.6281 L0.0479,0.6281 Q0.0519,0.6281,0.0624,0.6301 T0.0803,0.632 L0.0835,0.632 L0.0916,0.632 Q0.0989,0.632,0.1281,0.634 L0.206,0.6419 Q0.2117,0.6429,0.2174,0.6429 Q0.2214,0.6429,0.2271,0.6424 T0.2344,0.6419 L0.2376,0.6419 Q0.2393,0.6429,0.2417,0.6429 T0.2526,0.6419 T0.2652,0.6409 Q0.2741,0.6409,0.2782,0.6429 T0.2968,0.6459 T0.3293,0.6479 T0.352,0.6489 Q0.3552,0.6499,0.3585,0.6499 Q0.365,0.6499,0.3731,0.6474 T0.3844,0.6449 Q0.3885,0.6449,0.3917,0.6474 T0.4006,0.6499 Q0.4088,0.6499,0.4509,0.6578 Q0.4574,0.6588,0.4631,0.6588 Q0.4655,0.6588,0.4716,0.6583 T0.4834,0.6578 L0.4874,0.6578 Q0.5004,0.6588,0.5255,0.6637 Q0.532,0.6647,0.56,0.6667 T0.6148,0.6701 T0.644,0.6716 Q0.6464,0.6726,0.6594,0.6736 T0.6861,0.6756 T0.7048,0.6775 Q0.7097,0.6775,0.7186,0.6825 T0.738,0.6874 Q0.7518,0.6874,0.7729,0.6958 T0.8005,0.7122 Q0.8029,0.7141,0.8029,0.7151 Q0.8029,0.7191,0.7968,0.7216 T0.7818,0.726 T0.7705,0.729 L0.764,0.731 T0.7583,0.7324 T0.753,0.7334 T0.7486,0.7349 Q0.7251,0.7399,0.7161,0.7399 Q0.7097,0.7399,0.6951,0.7409 T0.6748,0.7418 L0.6703,0.7418 L0.6667,0.7418 Q0.6659,0.7418,0.6399,0.7448 L0.6334,0.7448 Q0.631,0.7448,0.6249,0.7443 T0.6148,0.7438 L0.6099,0.7438 Q0.601,0.7448,0.5856,0.7473 T0.5645,0.7507 Q0.5572,0.7517,0.5572,0.7547 T0.5616,0.7606 T0.5702,0.7646 Q0.5734,0.7646,0.5779,0.7651 T0.5864,0.7661 T0.5949,0.7671 T0.6018,0.7676 L0.6071,0.7676 T0.6135,0.7681 T0.62,0.769 T0.6269,0.771 T0.6342,0.7725 Q0.6391,0.7725,0.6464,0.7705 T0.6541,0.7685 L0.6553,0.7685 Q0.6586,0.7685,0.6699,0.771 T0.6853,0.7735 Q0.6902,0.7735,0.704,0.777 T0.7226,0.7834 Q0.7315,0.7873,0.7445,0.7883 Q0.747,0.7883,0.7676,0.7893 T0.7972,0.7913 Q0.7989,0.7913,0.8017,0.7918 T0.8058,0.7923 T0.809,0.7928 T0.8114,0.7933 T0.8131,0.7938 T0.8147,0.7943 T0.8159,0.7953 L0.8171,0.7967 T0.8175,0.7982 Q0.8183,0.7992,0.8183,0.8002 Q0.8183,0.8022,0.8155,0.8032 T0.807,0.8051 T0.7989,0.8071 Q0.7932,0.8091,0.7737,0.8091 L0.7413,0.8091 L0.7372,0.8091 Q0.7275,0.8091,0.721,0.8101 T0.7072,0.8136 T0.6983,0.817 Q0.6942,0.818,0.6825,0.818 T0.6602,0.819 T0.6456,0.822 Q0.6415,0.8249,0.6375,0.8249 Q0.6367,0.8249,0.6326,0.8239 T0.6253,0.8229 Q0.6034,0.8229,0.5953,0.8279 Q0.5912,0.8289,0.5848,0.8289 Q0.5775,0.8289,0.5645,0.8279 Q0.5629,0.8269,0.5604,0.8269 Q0.5523,0.8269,0.5328,0.8348 Q0.5296,0.8358,0.5264,0.8358 Q0.5247,0.8358,0.5203,0.8353 T0.5126,0.8348 Q0.5061,0.8348,0.4988,0.8358 Q0.4947,0.8368,0.4882,0.8378 Q0.4826,0.8388,0.4797,0.8388 T0.474,0.8398 T0.4704,0.8422 T0.4696,0.8462 T0.472,0.8501 T0.4781,0.8521 T0.4854,0.8526 L0.4935,0.8526 T0.4996,0.8536 Q0.5036,0.8536,0.515,0.8546 T0.5337,0.8566 Q0.5426,0.8576,0.5596,0.8576 L0.5657,0.8576 L0.571,0.8576 L0.5791,0.8576 Q0.5921,0.8586,0.6135,0.8591 T0.6383,0.8605 Q0.6683,0.8635,0.6764,0.8635 Q0.6999,0.8635,0.6999,0.8684 Q0.6999,0.8694,0.6967,0.8734 T0.6934,0.8803 L0.6934,0.8823 Q0.6951,0.8863,0.7137,0.8863 Q0.7218,0.8863,0.7267,0.8853 L0.7299,0.8853 Q0.734,0.8853,0.7409,0.8882 T0.7502,0.8912 Q0.7607,0.8942,0.794,0.8971 Q0.7972,0.8971,0.8078,0.9001 T0.8297,0.9031 Q0.8475,0.9031,0.8646,0.9065 T0.8824,0.9149 L0.8824,0.9159 Q0.8824,0.9169,0.882,0.9179 T0.8808,0.9194 L0.8792,0.9204 T0.8767,0.9214 T0.8743,0.9224 T0.8715,0.9228 L0.8686,0.9228 T0.8662,0.9233 T0.8637,0.9238 L0.8556,0.9238 T0.8447,0.9243 T0.8386,0.9268 Q0.837,0.9278,0.835,0.9288 T0.8305,0.9303 T0.8244,0.9308 T0.8175,0.9318 Q0.8127,0.9327,0.8017,0.9352 T0.7851,0.9397 Q0.777,0.9416,0.7705,0.9451 T0.7616,0.9496 Q0.7575,0.9505,0.7494,0.9505 L0.7405,0.9505 L0.7307,0.9505 Q0.7234,0.9505,0.7178,0.9515 Q0.7145,0.9515,0.7125,0.9535 T0.7105,0.9575 Q0.7105,0.9624,0.7194,0.9634 Q0.7567,0.9654,0.7664,0.9674 Q0.7745,0.9693,0.7762,0.9693 L0.7908,0.9693 L0.7948,0.9693 Q0.8005,0.9693,0.8058,0.9698 T0.8143,0.9718 T0.824,0.9753 T0.8354,0.9792 Q0.8483,0.9832,0.871,0.9832 L0.8739,0.9832 L0.8767,0.9832 Q0.8873,0.9832,0.8921,0.9842 Q0.8978,0.9852,0.9157,0.9896 T0.94,0.996 Q0.9424,0.996,0.9469,0.9965 T0.9566,0.997 T0.9676,0.9975 T0.9781,0.998 L0.9874,0.998 L0.9943,0.998 L0.9968,0.998 Z",type:0}},"":{type:5,data:{d:"M1,0.5071 L0.8623,0.7102 L0.5865,0.7102 L0.4489,0.5071 L0.5865,0.3045 L0.8623,0.3045 Z M0.4135,0.7974 L0.2758,1 L0,1 L0,0.5944 L0.2758,0.5944 Z M0.4135,0.2026 L0.2758,0.4056 L0,0.4056 L0,0 L0.2758,0 Z",type:0}},"":{type:5,data:{d:"M0.9726,0.5071 L0.8451,0.3184 L0.59,0.3184 L0.4624,0.5071 L0.59,0.6955 L0.8451,0.6955 Z M1,0.5071 L0.8588,0.716 L0.5768,0.716 L0.4355,0.5071 L0.5768,0.2982 L0.8588,0.2982 Z M0.401,0.7911 L0.2735,0.6028 L0.0236,0.6028 L0.0236,0.9799 L0.2735,0.9799 Z M0.4284,0.7911 L0.2872,1 L0,1 L0,0.5826 L0.2872,0.5826 Z M0.401,0.2089 L0.2735,0.0201 L0.0236,0.0201 L0.0236,0.3972 L0.2735,0.3972 Z M0.4284,0.2089 L0.2872,0.4174 L0,0.4174 L0,0 L0.2872,0 Z",type:0}},"":{type:5,data:{d:"M1,0.4996 Q1,0.5365,0.9924,0.568 T0.9718,0.6177 T0.9433,0.6363 L0.9425,0.6367 Q0.9355,0.6367,0.9285,0.6326 L0.9308,0.6326 Q0.9129,0.6195,0.9013,0.587 T0.8871,0.5164 T0.8943,0.4373 T0.9254,0.3687 L0.9254,0.3691 Q0.9339,0.3628,0.9425,0.3628 Q0.9584,0.3628,0.9716,0.3811 T0.9924,0.431 T1,0.4996 Z M0.9234,0.6321 L0.8776,0.7169 Q0.8791,0.732,0.8791,0.7441 Q0.8791,0.781,0.8716,0.8123 T0.8508,0.862 T0.8224,0.8809 L0.8216,0.8809 Q0.8154,0.8809,0.8092,0.8775 L0.8088,0.8775 Q0.7905,0.8633,0.7792,0.8297 T0.766,0.7571 T0.775,0.6768 T0.8088,0.6086 L0.7431,0.6065 Q0.719,0.6313,0.7072,0.6705 T0.6965,0.7487 T0.7097,0.823 T0.7423,0.8763 L0.7913,0.8775 L0.7314,0.9895 L0.4648,0.5038 L0.7299,0.0176 L0.7882,0.1229 L0.7404,0.1212 Q0.7244,0.138,0.7136,0.1617 T0.6982,0.2108 T0.6943,0.2632 T0.7003,0.3144 T0.7159,0.3586 T0.7396,0.3909 L0.8072,0.3926 Q0.7894,0.3796,0.7777,0.3479 T0.7635,0.2789 T0.7697,0.2011 T0.7987,0.1326 Q0.8092,0.1225,0.8204,0.1225 Q0.8321,0.1225,0.8426,0.1332 T0.8609,0.1623 T0.8731,0.2062 T0.8776,0.2592 Q0.8776,0.268,0.8768,0.2815 L0.9219,0.3628 L0.8644,0.3612 Q0.8403,0.3855,0.8284,0.4247 T0.8177,0.5029 T0.8309,0.5772 T0.8632,0.6305 Z M0.6759,0.6384 Q0.6576,0.625,0.6457,0.5914 T0.6319,0.5185 T0.6407,0.4375 T0.6751,0.3691 L0.609,0.367 Q0.5849,0.3918,0.5731,0.431 T0.5626,0.509 T0.5758,0.5833 T0.6082,0.6367 Z M0.6459,0.0025 L0.391,0.4551 L0.0035,0.4551 L0.246,0 Z M0.6553,1 L0.2421,1 L0,0.5449 L0.3875,0.5449 Z",type:0}},"":{type:5,data:{d:"M0.4996,0 Q0.5562,0,0.5961,0.0166 T0.6364,0.0566 L0.6368,0.0575 Q0.6368,0.0646,0.6324,0.0713 L0.6324,0.0692 Q0.6196,0.0868,0.5872,0.0986 T0.5166,0.1128 T0.4373,0.1057 T0.3689,0.0747 L0.3694,0.0747 Q0.3627,0.0663,0.3627,0.0575 Q0.3627,0.0336,0.4028,0.0168 T0.4996,0 Z M0.6324,0.0768 L0.717,0.1225 Q0.7321,0.1212,0.744,0.1212 Q0.8007,0.1212,0.8406,0.1378 T0.8809,0.1779 L0.8813,0.1783 Q0.8813,0.1846,0.8778,0.1909 Q0.8636,0.2093,0.8299,0.2208 T0.7573,0.2343 T0.6769,0.2253 T0.6089,0.1913 L0.6067,0.2567 Q0.6315,0.281,0.6707,0.2928 T0.7489,0.3035 T0.8231,0.2903 T0.8764,0.258 L0.8778,0.2085 L0.9898,0.2685 L0.504,0.5352 L0.0177,0.2701 L0.1227,0.2118 L0.1213,0.2596 Q0.1457,0.2836,0.1851,0.2955 T0.2635,0.3062 T0.3377,0.293 T0.3911,0.2605 L0.3928,0.193 Q0.38,0.2106,0.3483,0.2221 T0.2792,0.2364 T0.2013,0.2303 T0.1324,0.2013 Q0.1222,0.1909,0.1222,0.1795 Q0.1222,0.156,0.1623,0.1393 T0.2591,0.1225 Q0.2679,0.1225,0.2817,0.1233 L0.3627,0.078 L0.3609,0.1359 Q0.3857,0.1598,0.4249,0.1718 T0.5031,0.1825 T0.5773,0.1693 T0.6306,0.1367 Z M0.6386,0.3242 Q0.6253,0.3427,0.5917,0.3544 T0.5186,0.3683 T0.4376,0.3595 T0.3689,0.3251 L0.3671,0.3909 Q0.3915,0.4148,0.4309,0.4268 T0.5093,0.4375 T0.5835,0.4243 T0.6368,0.3918 Z M0.0027,0.354 L0.4553,0.6091 L0.4553,0.9962 L0,0.7542 Z M1,0.3448 L1,0.7576 L0.5447,1 L0.5447,0.6128 Z",type:0}},"":{type:5,data:{d:"M0,0.7699 Q0,0.7172,0.0277,0.6731 T0.103,0.6032 T0.2066,0.5774 Q0.2487,0.5774,0.2865,0.5922 T0.3526,0.6339 L0.3935,0.6715 Q0.4218,0.6984,0.438,0.7336 T0.4541,0.8075 Q0.4541,0.8602,0.4264,0.9046 T0.3511,0.9745 T0.2475,1 Q0.2054,1,0.1676,0.9852 T0.1016,0.9435 L0.0606,0.9059 Q0.0317,0.879,0.0159,0.8438 T0,0.7699 Z M0.5459,0.7699 Q0.5459,0.7172,0.5736,0.6731 T0.6489,0.6032 T0.7525,0.5774 Q0.7946,0.5774,0.8324,0.5922 T0.8984,0.6339 L0.9394,0.6715 Q0.9579,0.6892,0.9714,0.7105 T0.9925,0.7567 T1,0.8075 Q1,0.8602,0.9723,0.9046 T0.897,0.9745 T0.7934,1 Q0.7513,1,0.7135,0.9852 T0.6474,0.9435 L0.6065,0.9059 Q0.5776,0.879,0.5617,0.8438 T0.5459,0.7699 Z M0,0.1925 Q0,0.1398,0.0277,0.0957 T0.103,0.0258 T0.2066,0 Q0.2487,0,0.2865,0.0148 T0.3526,0.0565 L0.3935,0.0941 Q0.4218,0.121,0.438,0.1562 T0.4541,0.2301 Q0.4541,0.2828,0.4264,0.3269 T0.3511,0.3968 T0.2475,0.4226 Q0.2054,0.4226,0.1676,0.4078 T0.1016,0.3661 L0.0606,0.3285 Q0.0317,0.3016,0.0159,0.2664 T0,0.1925 Z M0.5459,0.1925 Q0.5459,0.1398,0.5736,0.0957 T0.6489,0.0258 T0.7525,0 Q0.7946,0,0.8324,0.0148 T0.8984,0.0565 L0.9394,0.0941 Q0.9677,0.121,0.9838,0.1562 T1,0.2301 Q1,0.2828,0.9723,0.3269 T0.897,0.3968 T0.7934,0.4226 Q0.7513,0.4226,0.7135,0.4078 T0.6474,0.3661 L0.6065,0.3285 Q0.5874,0.3108,0.5741,0.2895 T0.5534,0.2433 T0.5459,0.1925 Z M0.3301,0.6403 Q0.3306,0.6403,0.3312,0.6409 L0.3272,0.6371 L0.3272,0.6371 Z M0.8759,0.6403 Q0.8765,0.6403,0.8771,0.6409 Q0.8748,0.6387,0.8725,0.6371 L0.8731,0.6371 Z M0.3301,0.0629 Q0.3306,0.0629,0.3312,0.0634 L0.3272,0.0597 L0.3272,0.0602 Z M0.8759,0.0629 Q0.8765,0.0629,0.8771,0.0634 Q0.8748,0.0613,0.8725,0.0597 Q0.8731,0.0597,0.8731,0.0602 Z M0.779,0.4113 L0.7501,0.3849 Q0.7046,0.3844,0.6636,0.3661 Q0.7132,0.407,0.779,0.4113 Z M0.7957,0.4118 Q0.8477,0.4113,0.8918,0.3874 T0.9619,0.3223 T0.9885,0.2323 L0.9585,0.2048 Q0.9538,0.2769,0.8987,0.3282 T0.7657,0.3844 Z M0.9879,0.2167 Q0.9827,0.1554,0.9388,0.1097 Q0.9585,0.1478,0.959,0.1898 Z M0.2331,0.4113 L0.2043,0.3849 Q0.1587,0.3844,0.1177,0.3661 Q0.1673,0.407,0.2331,0.4113 Z M0.2499,0.4118 Q0.2885,0.4118,0.324,0.3973 T0.3852,0.3589 T0.4264,0.3019 T0.4426,0.2323 L0.4126,0.2048 Q0.408,0.2769,0.3529,0.3282 T0.2198,0.3844 Z M0.442,0.2167 Q0.4368,0.1554,0.393,0.1097 Q0.4126,0.1478,0.4132,0.1903 Z M0.779,0.9887 L0.7501,0.9624 Q0.7046,0.9618,0.6636,0.9435 Q0.7132,0.9844,0.779,0.9887 Z M0.7957,0.9892 Q0.8477,0.9887,0.8918,0.9648 T0.9619,0.8997 T0.9885,0.8097 L0.9585,0.7823 Q0.9538,0.8543,0.8987,0.9056 T0.7657,0.9618 Z M0.9879,0.7941 Q0.9827,0.7328,0.9388,0.6871 Q0.9585,0.7253,0.959,0.7672 Z M0.2331,0.9887 L0.2043,0.9624 Q0.1587,0.9618,0.1177,0.9435 Q0.1673,0.9844,0.2331,0.9887 Z M0.2499,0.9892 Q0.3018,0.9887,0.3459,0.9648 T0.416,0.8997 T0.4426,0.8102 L0.4126,0.7823 Q0.408,0.8543,0.3529,0.9056 T0.2198,0.9618 Z M0.442,0.7941 Q0.4368,0.7328,0.393,0.6871 Q0.4126,0.7253,0.4132,0.7672 Z M0.3191,0.6452 Q0.2943,0.622,0.2643,0.6091 T0.2066,0.5962 Q0.1795,0.5962,0.1497,0.6091 T0.0949,0.6441 T0.0537,0.6995 T0.0375,0.7699 Q0.0375,0.8419,0.0941,0.8941 Q0.1189,0.9172,0.1489,0.9304 T0.2066,0.9435 Q0.2337,0.9435,0.2634,0.9304 T0.3182,0.8952 T0.3595,0.8398 T0.3756,0.7699 Q0.3756,0.6978,0.3191,0.6452 Z M0.026,0.7731 L0.026,0.7699 Q0.026,0.6935,0.0854,0.6376 Q0.0658,0.6538,0.0514,0.6739 T0.0289,0.718 T0.0202,0.7677 Z M0.0271,0.7892 L0.0208,0.7833 Q0.0254,0.8419,0.0681,0.886 Q0.0664,0.8828,0.0646,0.879 Q0.0329,0.8382,0.0271,0.7892 Z M0.3866,0.7823 Q0.382,0.8511,0.3278,0.9016 Q0.3843,0.8565,0.3918,0.7871 Z M0.3872,0.7677 L0.393,0.7726 L0.393,0.7699 Q0.393,0.7349,0.3791,0.7038 Q0.3739,0.6973,0.3681,0.6914 Q0.3866,0.7274,0.3872,0.7677 Z M0.865,0.6452 Q0.8402,0.622,0.8102,0.6091 T0.7525,0.5962 Q0.7305,0.5962,0.7072,0.6043 T0.6619,0.628 T0.6223,0.6642 T0.5941,0.7124 T0.5834,0.7699 Q0.5834,0.8419,0.6399,0.8941 Q0.6561,0.9097,0.6757,0.9207 T0.7149,0.9376 T0.7525,0.9435 Q0.7796,0.9435,0.8093,0.9304 T0.8641,0.8952 T0.9054,0.8398 T0.9215,0.7699 Q0.9215,0.6978,0.865,0.6452 Z M0.5718,0.7731 L0.5718,0.7699 Q0.5718,0.6935,0.6313,0.6376 Q0.6013,0.6618,0.584,0.6954 T0.5661,0.7677 Z M0.573,0.7892 L0.5666,0.7833 Q0.5713,0.8419,0.614,0.886 Q0.6122,0.8828,0.6105,0.879 Q0.5788,0.8382,0.573,0.7892 Z M0.9325,0.7823 Q0.9279,0.8511,0.8736,0.9016 Q0.9308,0.8565,0.9377,0.7871 Z M0.9331,0.7672 L0.9388,0.7726 L0.9388,0.7699 Q0.9388,0.7349,0.925,0.7038 Q0.9198,0.6973,0.914,0.6914 Q0.9325,0.7274,0.9331,0.7672 Z M0.3191,0.0677 Q0.2943,0.0446,0.2643,0.0317 T0.2066,0.0188 Q0.1795,0.0188,0.1497,0.0317 T0.0949,0.0667 T0.0537,0.122 T0.0375,0.1925 Q0.0375,0.2645,0.0941,0.3167 Q0.1189,0.3398,0.1489,0.353 T0.2066,0.3661 Q0.2337,0.3661,0.2634,0.353 T0.3182,0.3177 T0.3595,0.2624 T0.3756,0.1925 Q0.3756,0.1204,0.3191,0.0677 Z M0.026,0.1957 L0.026,0.1925 Q0.026,0.1161,0.0854,0.0602 Q0.0554,0.0844,0.0381,0.118 T0.0202,0.1903 Z M0.0271,0.2118 L0.0208,0.2059 Q0.0254,0.2645,0.0681,0.3086 Q0.0664,0.3054,0.0646,0.3016 Q0.0329,0.2608,0.0271,0.2118 Z M0.3866,0.2048 Q0.382,0.2737,0.3278,0.3242 Q0.3849,0.279,0.3918,0.2097 Z M0.3872,0.1903 L0.393,0.1952 L0.393,0.1925 Q0.393,0.1581,0.3791,0.1263 Q0.3739,0.1199,0.3681,0.114 Q0.3866,0.15,0.3872,0.1903 Z M0.865,0.0677 Q0.8402,0.0446,0.8102,0.0317 T0.7525,0.0188 Q0.7253,0.0188,0.6956,0.0317 T0.6408,0.0667 T0.5995,0.122 T0.5834,0.1925 Q0.5834,0.2645,0.6399,0.3167 Q0.6647,0.3398,0.6947,0.353 T0.7525,0.3661 Q0.7796,0.3661,0.8093,0.353 T0.8641,0.3177 T0.9054,0.2624 T0.9215,0.1925 Q0.9215,0.1204,0.865,0.0677 Z M0.5718,0.1957 L0.5718,0.1925 Q0.5718,0.1161,0.6313,0.0602 Q0.6013,0.0844,0.584,0.118 T0.5661,0.1903 Z M0.573,0.2118 L0.5666,0.2059 Q0.5713,0.2645,0.614,0.3086 Q0.6122,0.3054,0.6105,0.3016 Q0.5788,0.2608,0.573,0.2118 Z M0.9325,0.2048 Q0.9279,0.2737,0.8736,0.3242 Q0.9308,0.279,0.9377,0.2097 Z M0.9331,0.1898 L0.9388,0.1952 L0.9388,0.1925 Q0.9388,0.1575,0.925,0.1263 Q0.9198,0.1199,0.914,0.114 Q0.9325,0.15,0.9331,0.1898 Z",type:0}},"":{type:5,data:{d:"M0.7639,0.1974 L0.9593,0.1974 L0.9593,0.1443 L0.7639,0.1443 L0.7639,0.1974 Z M0.7586,0.109 L0.9727,0.109 Q0.9839,0.109,0.992,0.1153 T1,0.1291 L1,0.382 Q1,0.3906,0.9912,0.3964 T0.9727,0.4021 L0.7586,0.4021 Q0.7468,0.4021,0.739,0.3956 T0.7313,0.382 L0.7313,0.1291 Q0.7313,0.1246,0.7339,0.1207 T0.7406,0.1143 T0.7495,0.1104 T0.7586,0.109 Z M0.7639,0.6575 L0.9593,0.6575 L0.9593,0.6044 L0.7639,0.6044 L0.7639,0.6575 Z M0.7313,0.8347 L0.7313,0.5843 Q0.7313,0.5757,0.7401,0.5699 T0.7586,0.5641 L0.9706,0.5641 Q0.9818,0.5641,0.9898,0.5705 T0.9979,0.5843 L0.9979,0.8347 Q0.9979,0.8433,0.989,0.8491 T0.9706,0.8549 L0.7586,0.8549 Q0.7468,0.8549,0.739,0.8483 T0.7313,0.8347 Z M0,1 L0,0 L0.7607,0 L0.7607,1 L0,1 Z",type:0}},"":{type:5,data:{d:"M0.9764,0.0029 L0.3179,0.4552 L0,0.4552 L0,0 Z M1,1 L0,1 L0,0.5448 L0.3091,0.5448 Z",type:0}},"":{type:5,data:{d:"M0.0236,0.0029 L0.6824,0.4552 L1,0.4552 L1,0 Z M0,1 L1,1 L1,0.5448 L0.6912,0.5448 Z",type:0}},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z M.25,.15 L1,.15 L1,.85 L.25,.85 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z M0,.15 L1,.15 L1,.85 L0,.85 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z M0,.15 L.75,.15 L.75,.85 L0,.85 Z"},"":{type:5,data:{d:"M0.6574,0.303 Q0.623,0.291,0.5852,0.285 T0.5082,0.279 T0.4311,0.285 T0.359,0.303 L0.3082,0.248 Q0.3525,0.232,0.4041,0.2235 T0.5082,0.215 T0.6123,0.2235 T0.7082,0.248 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.8557,0.582 L0.7656,0.551 Q0.7852,0.53,0.7951,0.507 T0.8049,0.46 Q0.8049,0.424,0.782,0.3905 T0.718,0.332 T0.6221,0.293 T0.5082,0.279 L0.5082,0.215 Q0.5607,0.215,0.6123,0.2235 T0.709,0.248 T0.7918,0.287 T0.8557,0.3375 T0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.582 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.482,0.705,0.4557,0.703 T0.4049,0.697 L0.4311,0.635 Q0.4508,0.638,0.4697,0.6395 T0.5082,0.641 Q0.5672,0.641,0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 Q0.8049,0.436,0.7951,0.413 T0.7656,0.369 L0.8557,0.338 Q0.882,0.365,0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 L0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 T0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 L0.9098,0.46 Q0.9098,0.492,0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.338 L0.2508,0.369 Q0.2311,0.39,0.2213,0.413 T0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 Q0.5279,0.641,0.5467,0.6395 T0.5852,0.635 L0.6115,0.697 Q0.5869,0.701,0.5607,0.703 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.1607,0.582 Q0.1344,0.555,0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.3375 T0.2246,0.287 T0.3074,0.248 T0.4041,0.2235 T0.5082,0.215 L0.5082,0.279 Q0.4492,0.279,0.3943,0.293 T0.2984,0.332 T0.2344,0.3905 T0.2115,0.46 Q0.2115,0.484,0.2213,0.507 T0.2508,0.551 Z",type:0},scaleType:1},"":s.LINE_H,"":s.LINE_V,"":s.FADE_RIGHT,"":s.FADE_LEFT,"":s.FADE_DOWN,"":s.FADE_UP,"":s.CURVE_DOWN_RIGHT,"":s.CURVE_DOWN_LEFT,"":s.CURVE_UP_RIGHT,"":s.CURVE_UP_LEFT,"":[s.LINE_V,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_RIGHT],"":[s.CURVE_DOWN_RIGHT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_UP_LEFT],"":[s.LINE_V,s.CURVE_DOWN_LEFT],"":[s.CURVE_DOWN_LEFT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_LEFT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT],"":[s.CURVE_DOWN_LEFT,s.CURVE_DOWN_RIGHT],"":[s.LINE_H,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_UP_RIGHT],"":[s.CURVE_UP_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_UP_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_LEFT,s.CURVE_DOWN_RIGHT],"":[s.LINE_H,s.CURVE_DOWN_LEFT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_RIGHT,s.CURVE_UP_LEFT],"":[s.LINE_V,s.CURVE_DOWN_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_LEFT,s.CURVE_UP_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE],"":s.NODE_STROKE,"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN],"":[s.NODE_STROKE,s.NODE_LINE_DOWN],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP],"":[s.NODE_STROKE,s.NODE_LINE_UP],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_UP],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_UP],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"🬀":r(1),"🬁":r(2),"🬂":r(3),"🬃":r(4),"🬄":r(5),"🬅":r(6),"🬆":r(7),"🬇":r(8),"🬈":r(9),"🬉":r(10),"🬊":r(11),"🬋":r(12),"🬌":r(13),"🬍":r(14),"🬎":r(15),"🬏":r(16),"🬐":r(17),"🬑":r(18),"🬒":r(19),"🬓":r(20),"🬔":r(22),"🬕":r(23),"🬖":r(24),"🬗":r(25),"🬘":r(26),"🬙":r(27),"🬚":r(28),"🬛":r(29),"🬜":r(30),"🬝":r(31),"🬞":r(32),"🬟":r(33),"🬠":r(34),"🬡":r(35),"🬢":r(36),"🬣":r(37),"🬤":r(38),"🬥":r(39),"🬦":r(40),"🬧":r(41),"🬨":r(43),"🬩":r(44),"🬪":r(45),"🬫":r(46),"🬬":r(47),"🬭":r(48),"🬮":r(49),"🬯":r(50),"🬰":r(51),"🬱":r(52),"🬲":r(53),"🬳":r(54),"🬴":r(55),"🬵":r(56),"🬶":r(57),"🬷":r(58),"🬸":r(59),"🬹":r(60),"🬺":r(61),"🬻":r(62),"🬼":{type:3,data:"M0,0.6667 L0,1 L0.5,1 Z"},"🬽":{type:3,data:"M0,0.6667 L0,1 L1,1 Z"},"🬾":{type:3,data:"M0,0.3333 L0,1 L0.5,1 Z"},"🬿":{type:3,data:"M0,0.3333 L0,1 L1,1 Z"},"🭀":{type:3,data:"M0,0 L0,1 L0.5,1 Z"},"🭁":{type:3,data:"M0,0.3333 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭂":{type:3,data:"M0,0.3333 L1,0 L1,1 L0,1 Z"},"🭃":{type:3,data:"M0,0.6667 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭄":{type:3,data:"M0,0.6667 L1,0 L1,1 L0,1 Z"},"🭅":{type:3,data:"M0,1 L0.5,0 L1,0 L1,1 Z"},"🭆":{type:3,data:"M0,0.6667 L1,0.3333 L1,1 L0,1 Z"},"🭇":{type:3,data:"M0.5,1 L1,0.6667 L1,1 Z"},"🭈":{type:3,data:"M0,1 L1,0.6667 L1,1 Z"},"🭉":{type:3,data:"M0.5,1 L1,0.3333 L1,1 Z"},"🭊":{type:3,data:"M0,1 L1,0.3333 L1,1 Z"},"🭋":{type:3,data:"M0.5,1 L1,0 L1,1 Z"},"🭌":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.3333 Z"},"🭍":{type:3,data:"M0,0 L0,1 L1,1 L1,0.3333 Z"},"🭎":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.6667 Z"},"🭏":{type:3,data:"M0,0 L0,1 L1,1 L1,0.6667 Z"},"🭐":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 Z"},"🭑":{type:3,data:"M0,0.3333 L0,1 L1,1 L1,0.6667 Z"},"🭒":{type:3,data:"M0,0.6667 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭓":{type:3,data:"M0,0.6667 L1,1 L1,0 L0,0 Z"},"🭔":{type:3,data:"M0,0.3333 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭕":{type:3,data:"M0,0.3333 L1,1 L1,0 L0,0 Z"},"🭖":{type:3,data:"M0,0 L0.5,1 L1,1 L1,0 Z"},"🭗":{type:3,data:"M0,0.3333 L0,0 L0.5,0 Z"},"🭘":{type:3,data:"M0,0.3333 L0,0 L1,0 Z"},"🭙":{type:3,data:"M0,0.6667 L0,0 L0.5,0 Z"},"🭚":{type:3,data:"M0,0.6667 L0,0 L1,0 Z"},"🭛":{type:3,data:"M0,1 L0,0 L0.5,0 Z"},"🭜":{type:3,data:"M0,0.6667 L0,0 L1,0 L1,0.3333 Z"},"🭝":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.6667 Z"},"🭞":{type:3,data:"M0,1 L0,0 L1,0 L1,0.6667 Z"},"🭟":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.3333 Z"},"🭠":{type:3,data:"M0,1 L0,0 L1,0 L1,0.3333 Z"},"🭡":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 Z"},"🭢":{type:3,data:"M0.5,0 L1,0 L1,0.3333 Z"},"🭣":{type:3,data:"M0,0 L1,0 L1,0.3333 Z"},"🭤":{type:3,data:"M0.5,0 L1,0 L1,0.6667 Z"},"🭥":{type:3,data:"M0,0 L1,0 L1,0.6667 Z"},"🭦":{type:3,data:"M0.5,0 L1,0 L1,1 Z"},"🭧":{type:3,data:"M0,0.3333 L1,0.6667 L1,0 L0,0 Z"},"🭨":{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0.5,0.5 Z"},"🭩":{type:3,data:"M0,0 L0.5,0.5 L1,0 L1,1 L0,1 Z"},"🭪":{type:3,data:"M0,0 L1,0 L0.5,0.5 L1,1 L0,1 Z"},"🭫":{type:3,data:"M0,0 L1,0 L1,1 L0.5,0.5 L0,1 Z"},"🭬":{type:3,data:"M0,0 L0.5,0.5 L0,1 Z"},"🭭":{type:3,data:"M0,0 L1,0 L0.5,0.5 Z"},"🭮":{type:3,data:"M1,0 L1,1 L0.5,0.5 Z"},"🭯":{type:3,data:"M0,1 L1,1 L0.5,0.5 Z"},"🭰":{type:0,data:[{x:1,y:0,w:1,h:8}]},"🭱":{type:0,data:[{x:2,y:0,w:1,h:8}]},"🭲":{type:0,data:[{x:3,y:0,w:1,h:8}]},"🭳":{type:0,data:[{x:4,y:0,w:1,h:8}]},"🭴":{type:0,data:[{x:5,y:0,w:1,h:8}]},"🭵":{type:0,data:[{x:6,y:0,w:1,h:8}]},"🭶":{type:0,data:[{x:0,y:1,w:8,h:1}]},"🭷":{type:0,data:[{x:0,y:2,w:8,h:1}]},"🭸":{type:0,data:[{x:0,y:3,w:8,h:1}]},"🭹":{type:0,data:[{x:0,y:4,w:8,h:1}]},"🭺":{type:0,data:[{x:0,y:5,w:8,h:1}]},"🭻":{type:0,data:[{x:0,y:6,w:8,h:1}]},"🭼":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🭽":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭾":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭿":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🮀":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮁":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮂":{type:0,data:[{x:0,y:0,w:8,h:2}]},"🮃":{type:0,data:[{x:0,y:0,w:8,h:3}]},"🮄":{type:0,data:[{x:0,y:0,w:8,h:5}]},"🮅":{type:0,data:[{x:0,y:0,w:8,h:6}]},"🮆":{type:0,data:[{x:0,y:0,w:8,h:7}]},"🮇":{type:0,data:[{x:6,y:0,w:2,h:8}]},"🮈":{type:0,data:[{x:5,y:0,w:3,h:8}]},"🮉":{type:0,data:[{x:3,y:0,w:5,h:8}]},"🮊":{type:0,data:[{x:2,y:0,w:6,h:8}]},"🮋":{type:0,data:[{x:1,y:0,w:7,h:8}]},"🮌":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},"🮍":{type:1,data:[[1,0],[0,1]],clipPath:"M0.5,0 L1,0 L1,1 L0.5,1 Z"},"🮎":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},"🮏":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},"🮐":{type:1,data:[[0,1],[1,0]]},"🮑":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},{type:0,data:[{x:0,y:0,w:8,h:4}]}],"🮒":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},{type:0,data:[{x:0,y:4,w:8,h:4}]}],"🮔":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},{type:0,data:[{x:4,y:0,w:4,h:8}]}],"🮕":{type:0,data:[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}]},"🮖":{type:0,data:[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}]},"🮗":{type:0,data:[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},"🮘":{type:2,data:"M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25",strokeWidth:1},"🮙":{type:2,data:"M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75",strokeWidth:1},"🮚":{type:5,data:{d:"M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0",type:0}},"🮛":{type:5,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1",type:0}},"🮜":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L0,1 Z"},"🮝":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,1 Z"},"🮞":{type:1,data:[[1,0],[0,1]],clipPath:"M1,0 L1,1 L0,1 Z"},"🮟":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,1 L0,1 Z"},"🮠":{type:2,data:"M.5,0 L0,.5",strokeWidth:1},"🮡":{type:2,data:"M.5,0 L1,.5",strokeWidth:1},"🮢":{type:2,data:"M0,.5 L.5,1",strokeWidth:1},"🮣":{type:2,data:"M1,.5 L.5,1",strokeWidth:1},"🮤":{type:2,data:"M.5,0 L0,.5 L.5,1",strokeWidth:1},"🮥":{type:2,data:"M.5,0 L1,.5 L.5,1",strokeWidth:1},"🮦":{type:2,data:"M0,.5 L.5,1 L1,.5",strokeWidth:1},"🮧":{type:2,data:"M0,.5 L.5,0 L1,.5",strokeWidth:1},"🮨":{type:2,data:"M.5,0 L0,.5 M1,.5 L.5,1",strokeWidth:1},"🮩":{type:2,data:"M.5,0 L1,.5 M0,.5 L.5,1",strokeWidth:1},"🮪":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5",strokeWidth:1},"🮫":{type:2,data:"M.5,0 L0,.5 L.5,1 L1,.5",strokeWidth:1},"🮬":{type:2,data:"M0,.5 L.5,0 L1,.5 L.5,1",strokeWidth:1},"🮭":{type:2,data:"M1,.5 L.5,0 L0,.5 L.5,1",strokeWidth:1},"🮮":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5 Z",strokeWidth:1},"🮯":{type:2,data:"M0,.5 L1,.5 M.5,.35 L.5,.65",strokeWidth:1},"🮰":{type:3,data:"M0.1,0.2 L0.1,.8 L.4,.6 L.9,0.6 Z",scaleType:1},"🮱":{type:4,data:{d:"M.1,.525 L.35,.675 L.9,.35",type:1},scaleType:1},"🮲":{type:3,data:"M.29,.27 L0.13,.56 L.22,.59 L.35,0.35 L.67,.35 L.57,.57 L.71,.76 L.22,.76 L.42,1 L.53,.98 L.43,.86 L.9,.86 L.71,.6 L1,.6 L1,.52 L.83,.52 L.92,.36 L1,.36 L1,.27Z M.99,.13 A.12,.12,0,1,1,.75,.13 A.12,.12,0,1,1,.99,.13"},"🮳":{type:3,data:"M0,.27 L.3,.27 L.55,.12 L.63,.18 L.33,.36 L0,.36 M0,.52 L.33,.52 L.59,.89 L.73,.89 L.73,.98 L.53,.98 L.28,.6 L0,.6"},"🮴":{type:4,data:{d:"M.15,.55 L.5,.4 L.5,.45 L.65,.45 L.65,.35 L.85,.35 L.85,.625 L.5,.625 L.5,.7 Z",type:0},scaleType:1},"🮵":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.15,.5 L.5,.35 L.5,.425 L.85,.425 L.85,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮶":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.85,.5 L.5,.35 L.5,.425 L.15,.425 L.15,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮷":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.675 L.2,.5 L.35,.5 L.35,.325 L.65,.325 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮸":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.325 L.2,.5 L.35,.5 L.35,.675 L.65,.675 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮹":{type:5,data:{d:"M1,.89 L.11,.89 L.11,.37 L.36,.12 L.74,.12 L.96,.34 L1,.34 L1,.405 L.92,.405 L.69,.185 L.41,.185 L.21,.42 L.21,.825 L1,.825 Z",type:0}},"🮺":{type:5,data:{d:"M0,.89 L0,.825 L.78,.825 L.78,.53 L.7,.415 L0,.415 L0,.35 L.75,.35 L.88,.48 L.88,.89 Z",type:0}},"🮻":{type:5,data:{d:"M.31,.275 L.44,.275 L.44,.47 L.05,.47 L.05,.405 L.31,.405 Z M.56,.275 L.69,.275 L.69,.405 L.95,.405 L.95,.47 L.56,.47 Z M.05,.53 L.44,.53 L.44,.725 L.31,.725 L.31,.595 L.05,.595 Z M.56,.53 L.95,.53 L.95,.595 L.69,.595 L.69,.725 L.56,.725 Z",type:0},scaleType:1},"🮼":[{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0,.935 L.885,.935 L.885,.065 L0,.065 Z"},{type:3,data:"M.67,.5 A.17,.085,0,1,1,.33,.5 A.17,.085,0,1,1,.67,.5",scaleType:1}],"🮽":{type:4,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1 Z",type:1}},"🮾":{type:4,data:{d:"M1,.5 L.5,1",type:1}},"🮿":{type:4,data:{d:"M.5,0 L1,.5 L.5,1 L0,.5 Z",type:1}},"🯀":{type:3,data:"M.16,.445 A.02,.01,0,0,1,.39,.33 L.5,.375 L.61,.33 A.02,.01,0,0,1,.84,.445 L.75,.5 L.84,.555 A.02,.01,0,0,1,.61,.67 L.5,.625 L.39,.67 A.02,.01,0,0,1,.16,.555 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",scaleType:1},"🯁":{type:3,data:"M0.142308,0.924 Q0.130770,0.924,0.120193,0.922 T0.101924,0.916 T0.089424,0.9065 T0.084616,0.895 V0.025 Q0.084616,0.019,0.089424,0.0135 T0.101924,0.004 T0.120193,-0.002 T0.142308,-0.004 Q0.150000,-0.004,0.157693,-0.003 T0.173078,0.000 L0.430770,0.085 Q0.451924,0.059,0.494232,0.041 T0.587501,0.013 T0.692309,-0.0005 T0.800001,-0.004 H1 V0.055 H0.800001 Q0.771155,0.055,0.742309,0.056 T0.685578,0.060 T0.630770,0.0685 T0.580770,0.0825 T0.543270,0.1045 T0.528847,0.133 V0.647 H0.530770 Q0.596155,0.645,0.659616,0.638 T0.782693,0.6165 T0.893270,0.5805 T1,0.531 V0.623 Q0.934616,0.644,0.880770,0.6595 T0.769232,0.685 T0.650963,0.700 T0.528847,0.707 V0.787 Q0.528847,0.802,0.543270,0.8155 T0.580770,0.8375 T0.630770,0.8515 T0.685578,0.860 T0.742309,0.864 T0.800001,0.865 H1 V0.924 H0.800001 Q0.746155,0.924,0.692309,0.9205 T0.587501,0.907 T0.494232,0.879 T0.430770,0.835 L0.173078,0.920 Q0.165386,0.922,0.157693,0.923 T0.142308,0.924 Z M0.2,0.841 L0.415385,0.770 V0.150 L0.2,0.079 V0.841 Z M1,0.698 Q0.973077,0.694,0.969231,0.6885 T0.965385,0.677 Q0.965385,0.672,0.969231,0.6665 T1,0.657 V0.698 Z"},"🯂":{type:3,data:"M0,0.623 V0.531 Q0.044231,0.512,0.0625,0.4905 T0.092308,0.4465 T0.108654,0.4005 T0.113462,0.354 V0.351 Q0.113462,0.345,0.117308,0.3395 T0.129808,0.3305 T0.148846,0.3245 T0.171154,0.323 H0.501923 Q0.532692,0.323,0.563462,0.3185 T0.617308,0.303 T0.651923,0.2765 T0.663462,0.2435 V0.241 Q0.663462,0.219,0.657692,0.197 T0.639423,0.1545 T0.604808,0.115 T0.549615,0.082 T0.475577,0.0615 T0.392308,0.055 H0 V-0.004 H1 V0.055 H0.667308 Q0.694231,0.071,0.713462,0.09 T0.746154,0.129 T0.766346,0.1705 T0.775,0.213 H1 V0.316 Q0.971154,0.305,0.955769,0.2965 T0.920192,0.2825 T0.876923,0.2745 T0.830769,0.272 H0.773077 Q0.767308,0.297,0.743269,0.319 T0.680769,0.355 T0.595192,0.375 T0.501923,0.381 H0.227308 Q0.223462,0.415,0.211,0.4485 T0.172692,0.5135 T0.108269,0.573 T0,0.623 Z M0.611538,0.924 H0 V0.865 H0.611538 Q0.642308,0.865,0.673077,0.8605 T0.727885,0.845 T0.762308,0.8185 T0.773077,0.787 V0.785 Q0.773077,0.769,0.762308,0.7535 T0.727885,0.727 T0.673077,0.7115 T0.611538,0.707 H0.059615 Q0.048077,0.707,0.037115,0.7045 T0,0.694 V0.653 Q0.026923,0.648,0.037115,0.646 T0.059615,0.644 H0.722692 Q0.753462,0.644,0.784231,0.6395 T0.839038,0.624 T0.873846,0.5975 T0.884615,0.566 V0.564 Q0.884615,0.548,0.873846,0.532 T0.839038,0.5055 T0.784231,0.49 T0.722692,0.4855 H0.392308 Q0.380769,0.4855,0.370192,0.483 T0.351923,0.4765 T0.339423,0.467 T0.334615,0.4555 T0.339423,0.444 T0.351923,0.4345 T0.370192,0.428 T0.392308,0.4255 H0.832692 Q0.855769,0.4255,0.878846,0.4235 T0.922115,0.416 T0.957692,0.4015 T1,0.3815 V0.4685 Q0.975,0.4705,0.966346,0.4725 T0.95,0.4765 Q0.961538,0.4835,0.969231,0.4915 T1,0.5075 V0.6205 Q0.965385,0.6455,0.926923,0.665 T0.84,0.6935 Q0.866923,0.7115,0.877596,0.7345 T0.894231,0.7855 V0.787 Q0.894231,0.815,0.876923,0.8425 T0.820192,0.8895 T0.726923,0.916 T0.611538,0.924 Z"},"🯃":{type:3,data:"M0,0.473 V0.386 Q0.025490,0.378,0.028431,0.3695 T0.031373,0.352 V0.35 Q0.031373,0.342,0.028431,0.333 T0,0.316 V0.213 H0.652941 Q0.684314,0.213,0.715686,0.2085 T0.770588,0.193 T0.804902,0.1665 T0.815686,0.135 V0.133 Q0.815686,0.117,0.804902,0.101 T0.770588,0.0745 T0.715686,0.0595 T0.652941,0.055 H0 V-0.004 H0.652941 Q0.707843,-0.004,0.761765,0.004 T0.856863,0.031 T0.915686,0.0775 T0.933333,0.133 V0.135 Q0.933333,0.163,0.915686,0.1905 T0.856863,0.237 T0.761765,0.264 T0.652941,0.272 H0.109804 Q0.131373,0.29,0.140196,0.31 T0.149020,0.35 V0.352 Q0.149020,0.37,0.142157,0.388 T0.118627,0.4225 T0.076471,0.452 T0,0.473 Z M0,0.625 V0.513 Q0.029412,0.526,0.032353,0.54 T0.035294,0.568 V0.57 Q0.035294,0.584,0.032353,0.598 T0,0.625 Z"},"🯄":{type:3,data:"M0.019231,1.085 V-0.165 H0.980769 V1.085 H0.019231 Z M0.446154,0.527 H0.553846 Q0.553846,0.511,0.5625,0.495 T0.591346,0.466 T0.631731,0.4405 T0.666346,0.4135 T0.688462,0.3825 T0.696154,0.35 Q0.696154,0.33,0.682692,0.311 T0.641346,0.2785 T0.575,0.259 T0.498077,0.253 T0.421154,0.259 T0.356731,0.279 T0.315385,0.3125 T0.301923,0.352 V0.357 H0.409615 V0.354 Q0.409615,0.345,0.414423,0.335 T0.431731,0.318 T0.4625,0.3075 T0.498077,0.304 Q0.517308,0.304,0.534615,0.307 T0.564423,0.3165 T0.582692,0.332 T0.588462,0.35 Q0.588462,0.366,0.572115,0.3805 T0.535577,0.4075 T0.496154,0.4335 T0.465385,0.4625 T0.45,0.4945 T0.446154,0.527 Z M0.5,0.667 Q0.519231,0.667,0.5375,0.664 T0.569231,0.654 T0.588462,0.637 T0.594231,0.617 T0.588462,0.5975 T0.569231,0.581 T0.5375,0.571 T0.5,0.568 T0.4625,0.571 T0.430769,0.581 T0.411538,0.5975 T0.405769,0.617 T0.411538,0.637 T0.430769,0.654 T0.4625,0.664 T0.5,0.667 Z",scaleType:1},"🯅":{type:5,data:{d:"M.44,.425 L.44,.51 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.51 L.56,.425 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯆":{type:5,data:{d:"M.44,.425 L.44,.46 L.23,.385 L.19,.43 L.44,.51 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.61,.735 L.7,.71 L.56,.56 L.56,.51 L.81,.43 L.77,.385 L.56,.46 L.56,.425 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯇":{type:5,data:{d:"M.44,.425 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.425 Z M.18,.53 L.23,.575 L.81,.43 L.77,.385 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯈":{type:5,data:{d:"M.44,.425 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.62,.735 L.71,.71 L.56,.56 L.56,.425 Z M.23,.385 L.18,.43 L.77,.575 L.81,.53 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯉":{type:5,data:{d:"M.44,.425 L.45,.495 L.15,.645 L.34,.645 L.34,.7 L.44,.7 L.44,.645 L.56,.645 L.56,.7 L.66,.7 L.66,.645 L.84,.645 L.54,.495 L.56,.425 Z M.39,.6 L.5,.55 L.60,.6 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯊":{type:5,data:{d:"M.26,.375 L.5,.255 L.74,.375 L.74,.665 L.5,.55 L.26,.665 Z M.37,.395 L.37,.54 L.5,.475 L.63,.54 L.63,.395 L.5,.33 Z",type:0},scaleType:1},"🯋":{type:5,data:{d:"M.09,.41 L.32,.295 L.5,.375 L.68,.295 L.91,.41 L.75,.5 L.91,.59 L.68,.705 L.5,.625 L.32,.705 L.09,.59 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",type:0},scaleType:1},"🯌":{type:5,data:{d:"M.55,.305 L.88,.305 L.88,.355 L.65,.355 L.65,.47 L.88,.47 L.88,.52 L.55,.52 Z",type:0},scaleType:1},"🯍":{type:5,data:{d:"M.38,.39 L.5,.33 L.62,.39 L.62,.53 L.5,.47 L.38,.53 Z",type:0},scaleType:1},"🯎":{type:3,data:"M0,0 L0.6667,0 L0.6667,1 L0,1 Z"},"🯏":{type:3,data:"M0,0 L0.3333,0 L0.3333,1 L0,1 Z"},"🯐":{type:2,data:"M1,.5 L0,1",strokeWidth:1},"🯑":{type:2,data:"M1,0 L0,.5",strokeWidth:1},"🯒":{type:2,data:"M0,0 L1,.5",strokeWidth:1},"🯓":{type:2,data:"M0,.5 L1,1",strokeWidth:1},"🯔":{type:2,data:"M0,0 L.5,1",strokeWidth:1},"🯕":{type:2,data:"M.5,0 L1,1",strokeWidth:1},"🯖":{type:2,data:"M1,0 L.5,1",strokeWidth:1},"🯗":{type:2,data:"M.5,0 L0,1",strokeWidth:1},"🯘":{type:2,data:"M0,0 L.5,.5 L1,0",strokeWidth:1},"🯙":{type:2,data:"M1,0 L.5,.5 L1,1",strokeWidth:1},"🯚":{type:2,data:"M0,1 L.5,.5 L1,1",strokeWidth:1},"🯛":{type:2,data:"M0,0 L.5,.5 L0,1",strokeWidth:1},"🯜":{type:2,data:"M0,0 L.5,1 L1,0",strokeWidth:1},"🯝":{type:2,data:"M1,0 L0,.5 L1,1",strokeWidth:1},"🯞":{type:2,data:"M0,1 L.5,0 L1,1",strokeWidth:1},"🯟":{type:2,data:"M0,0 L1,.5 L0,1",strokeWidth:1},"🯠":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0",type:1}},"🯡":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1",type:1}},"🯢":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1",type:1}},"🯣":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1",type:1}},"🯤":{type:0,data:[{x:2,y:0,w:4,h:4}]},"🯥":{type:0,data:[{x:2,y:4,w:4,h:4}]},"🯦":{type:0,data:[{x:0,y:2,w:4,h:4}]},"🯧":{type:0,data:[{x:4,y:2,w:4,h:4}]},"🯨":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0 Z",type:0}},"🯩":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1 Z",type:0}},"🯪":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1 Z",type:0}},"🯫":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1 Z",type:0}},"🯬":{type:5,data:{d:"M1,0 L.5,0 C.5,.276,.724,.5,1,.5 Z",type:0}},"🯭":{type:5,data:{d:"M0,1 L.5,1 C.5,.724,.276,.5,0,.5 Z",type:0}},"🯮":{type:5,data:{d:"M1,1 L1,.5 C.724,.5,.5,.724,.5,1 Z",type:0}},"🯯":{type:5,data:{d:"M0,0 L0,.5 C.276,.5,.5,.276,.5,0 Z",type:0}},"🯰":{type:5,data:{d:a(126),type:0},scaleType:1},"🯱":{type:5,data:{d:a(48),type:0},scaleType:1},"🯲":{type:5,data:{d:a(109),type:0},scaleType:1},"🯳":{type:5,data:{d:a(121),type:0},scaleType:1},"🯴":{type:5,data:{d:a(51),type:0},scaleType:1},"🯵":{type:5,data:{d:a(91),type:0},scaleType:1},"🯶":{type:5,data:{d:a(95),type:0},scaleType:1},"🯷":{type:5,data:{d:a(114),type:0},scaleType:1},"🯸":{type:5,data:{d:a(127),type:0},scaleType:1},"🯹":{type:5,data:{d:a(123),type:0},scaleType:1},"🯺":{type:5,data:{d:"M.5,.175 C.2,.175,.15,.305,.15,.435 L.05,.63 L.35,.63 C.35,.682,.42,.76,.5,.76 C.58,.76,.65,.682,.65,.63 L.95,.63 L.85,.435 C.85,.305,.8,.175,.5,.175 Z",type:0},scaleType:1},...Object.fromEntries(Array.from({length:256},(t,e)=>[String.fromCodePoint(10240+e),{type:6,data:e}]))},e.blockPatternCodepoints=new Set([9617,9618,9619,129932,129933,129934,129935,129936,129937,129938,129940,129948,129949,129950,129951])},917(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.ExtendedAttrs=e.AttributeData=void 0;class i{static toColorRGB(t){return[t>>>16&255,t>>>8&255,255&t]}static fromColorRGB(t){return(255&t[0])<<16|(255&t[1])<<8|255&t[2]}clone(){const t=new i;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}fg=0;bg=0;extended=new s;isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}e.AttributeData=i;class s{_ext=0;get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(t){this._ext=t}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(t){this._ext&=-469762049,this._ext|=t<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(t){this._ext&=-67108864,this._ext|=67108863&t}_urlId=0;get urlId(){return this._urlId}set urlId(t){this._urlId=t}get underlineVariantOffset(){const t=(3758096384&this._ext)>>29;return t<0?4294967288^t:t}set underlineVariantOffset(t){this._ext&=536870911,this._ext|=t<<29&3758096384}constructor(t=0,e=0){this._ext=t,this._urlId=e}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}e.ExtendedAttrs=s},930(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.DebouncedIdleTask=e.IdleTaskQueue=e.PriorityTaskQueue=void 0;class i{_tasks=[];_idleCallback;_i=0;_logService;constructor(t){this._logService=t}enqueue(t){this._tasks.push(t),this._start()}flush(){for(;this._ir)return s-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-e))}ms`),void this._start();s=r}this.clear()}}class s extends i{_requestCallback(t){return setTimeout(()=>t(this._createDeadline(16)))}_cancelCallback(t){clearTimeout(t)}_createDeadline(t){const e=performance.now()+t;return{timeRemaining:()=>Math.max(0,e-performance.now())}}}e.PriorityTaskQueue=s,e.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(t){return requestIdleCallback(t)}_cancelCallback(t){cancelIdleCallback(t)}}:s,e.DebouncedIdleTask=class{_queue;constructor(t){this._queue=new e.IdleTaskQueue(t)}set(t){this._queue.clear(),this._queue.enqueue(t)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},948(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.createSelectionRenderModel=function(){return new i};class i{hasSelection;columnSelectMode;viewportStartRow;viewportEndRow;viewportCappedStartRow;viewportCappedEndRow;startCol;endCol;selectionStart;selectionEnd;constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(t,e,i,s=!1){if(this.selectionStart=e,this.selectionEnd=i,!e||!i||e[0]===i[0]&&e[1]===i[1])return void this.clear();const r=t.buffers.active.ydisp,a=e[1]-r,o=i[1]-r,n=Math.max(a,0),L=Math.min(o,t.rows-1);n>=t.rows||L<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=a,this.viewportEndRow=o,this.viewportCappedStartRow=n,this.viewportCappedEndRow=L,this.startCol=e[0],this.endCol=i[0])}isCellSelected(t,e,i){return!!this.hasSelection&&(i-=t.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&i>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&e=this.startCol)}}},959(t,e){function i(t){return 57508<=t&&t<=57558}function s(t){return t>=128512&&t<=128591||t>=127744&&t<=128511||t>=128640&&t<=128767||t>=9728&&t<=9983||t>=9984&&t<=10175||t>=65024&&t<=65039||t>=129280&&t<=129535||t>=127462&&t<=127487}Object.defineProperty(e,"__esModule",{value:!0}),e.throwIfFalsy=function(t){if(!t)throw new Error("value must not be falsy");return t},e.isPowerlineGlyph=i,e.isRestrictedPowerlineGlyph=function(t){return 57520<=t&&t<=57527},e.isEmoji=s,e.allowRescaling=function(t,e,r,a){return 1===e&&r>Math.ceil(1.5*a)&&void 0!==t&&t>255&&!s(t)&&!i(t)&&!function(t){return 57344<=t&&t<=63743}(t)},e.treatGlyphAsBackgroundColor=function(t){return i(t)||function(t){return 9472<=t&&t<=9631}(t)},e.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},e.computeNextVariantOffset=function(t,e,i=0){return(t-(2*Math.round(e)-i))%(2*Math.round(e))}},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=l,e.contrastRatio=d;let i=0,s=0,r=0,a=0;var o,n,L,T,h;function l(t){const e=t.toString(16);return e.length<2?"0"+e:e}function d(t,e){return t>>0},t.toColor=function(e,i,s,r){return{css:t.toCss(e,i,s,r),rgba:t.toRgba(e,i,s,r)}}}(o||(e.channels=o={})),function(t){function e(t,e){return a=Math.round(255*e),[i,s,r]=h.toChannels(t.rgba),{css:o.toCss(i,s,r,a),rgba:o.toRgba(i,s,r,a)}}t.blend=function(t,e){if(a=(255&e.rgba)/255,1===a)return{css:e.css,rgba:e.rgba};const n=e.rgba>>24&255,L=e.rgba>>16&255,T=e.rgba>>8&255,h=t.rgba>>24&255,l=t.rgba>>16&255,d=t.rgba>>8&255;return i=h+Math.round((n-h)*a),s=l+Math.round((L-l)*a),r=d+Math.round((T-d)*a),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,i){const s=h.ensureContrastRatio(t.rgba,e.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[i,s,r]=h.toChannels(e),{css:o.toCss(i,s,r),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,i){return a=255&t.rgba,e(t,a*i/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(n||(e.color=n={})),function(t){let e,n;try{const t=document.createElement("canvas");t.width=1,t.height=1;const i=t.getContext("2d",{willReadFrequently:!0});i&&(e=i,e.globalCompositeOperation="copy",n=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),a=parseInt(t.slice(4,5).repeat(2),16),o.toColor(i,s,r,a);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const L=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(L)return i=parseInt(L[1]),s=parseInt(L[2]),r=parseInt(L[3]),a=Math.round(255*(void 0===L[5]?1:parseFloat(L[5]))),o.toColor(i,s,r,a);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!n)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=n,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[i,s,r,a]=e.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,a),css:t}}}(L||(e.css=L={})),function(t){function e(t,e,i){const s=t/255,r=e/255,a=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(T||(e.rgb=T={})),function(t){function e(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));for(;h0||n>0||L>0);)o-=Math.max(0,Math.ceil(.1*o)),n-=Math.max(0,Math.ceil(.1*n)),L-=Math.max(0,Math.ceil(.1*L)),h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));return(o<<24|n<<16|L<<8|255)>>>0}function n(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));for(;h>>0}t.blend=function(t,e){if(a=(255&e)/255,1===a)return e;const n=e>>24&255,L=e>>16&255,T=e>>8&255,h=t>>24&255,l=t>>16&255,d=t>>8&255;return i=h+Math.round((n-h)*a),s=l+Math.round((L-l)*a),r=d+Math.round((T-d)*a),o.toRgba(i,s,r)},t.ensureContrastRatio=function(t,i,s){const r=T.relativeLuminance(t>>8),a=T.relativeLuminance(i>>8);if(d(r,a)>8));if(od(r,T.relativeLuminance(e>>8))?a:e}return a}const o=n(t,i,s),L=d(r,T.relativeLuminance(o>>8));if(Ld(r,T.relativeLuminance(a>>8))?o:a}return o}},t.reduceLuminance=e,t.increaseLuminance=n,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function i(s){var r=e[s];if(void 0!==r)return r.exports;var a=e[s]={exports:{}};return t[s](a,a.exports,i),a.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.WebglAddon=void 0;const e=i(426),r=i(95),a=i(399),o=i(414);class n extends e.Disposable{_terminal;_renderer;_onChangeTextureAtlas=this._register(new o.Emitter);onChangeTextureAtlas=this._onChangeTextureAtlas.event;_onAddTextureAtlasCanvas=this._register(new o.Emitter);onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=this._register(new o.Emitter);onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;_onContextLoss=this._register(new o.Emitter);onContextLoss=this._onContextLoss.event;_customGlyphs;_preserveDrawingBuffer;constructor(t){if(r.isSafari&&(0,r.getSafariVersion)()<16){const t={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",t))throw new Error("Webgl2 is only supported on Safari 16 and above")}super(),this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){const i=t._core;if(!t.element)return void this._register(i.onWillOpen(()=>this.activate(t)));this._terminal=t;const s=i.coreService,r=i.optionsService,n=i,L=n._renderService,T=n._characterJoinerService,h=n._charSizeService,l=n._coreBrowserService,d=n._decorationService,c=n._themeService;this._renderer=this._register(new a.WebglRenderer(t,T,h,l,s,d,r,c,this._customGlyphs,this._preserveDrawingBuffer)),this._register(o.EventUtils.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(o.EventUtils.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(o.EventUtils.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(o.EventUtils.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),L.setRenderer(this._renderer),this._register((0,e.toDisposable)(()=>{if(this._terminal._core._store._isDisposed)return;const e=this._terminal._core._renderService;e.setRenderer(this._terminal._core._createRenderer()),e.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}}t.WebglAddon=n})(),s})()); ++!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.WebglAddon=e():t.WebglAddon=e()}(globalThis,()=>(()=>{"use strict";var t={0(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TEXT_BASELINE=e.DIM_OPACITY=void 0;const s=i(95);e.DIM_OPACITY=.5,e.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},28(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GlyphRenderer=void 0;const s=i(115),r=i(564),a=i(426);i(816);const o=i(719),n=i(959);let L,T=0,h=0,l=0;class d extends a.Disposable{_terminal;_gl;_dimensions;_optionsService;_program;_vertexArrayObject;_projectionLocation;_resolutionLocation;_textureLocation;_atlasTextures;_attributesBuffer;_atlas;_activeBuffer=0;_vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};constructor(t,e,i,r){super(),this._terminal=t,this._gl=e,this._dimensions=i,this._optionsService=r;const L=this._gl;void 0===s.TextureAtlas.maxAtlasPages&&(s.TextureAtlas.maxAtlasPages=Math.min(32,(0,n.throwIfFalsy)(L.getParameter(L.MAX_TEXTURE_IMAGE_UNITS))),s.TextureAtlas.maxTextureSize=(0,n.throwIfFalsy)(L.getParameter(L.MAX_TEXTURE_SIZE))),this._program=(0,n.throwIfFalsy)((0,o.createProgram)(L,"#version 300 es\nlayout (location = 0) in vec2 a_unitquad;\nlayout (location = 1) in vec2 a_cellpos;\nlayout (location = 2) in vec2 a_offset;\nlayout (location = 3) in vec2 a_size;\nlayout (location = 4) in float a_texpage;\nlayout (location = 5) in vec2 a_texcoord;\nlayout (location = 6) in vec2 a_texsize;\n\nuniform mat4 u_projection;\nuniform vec2 u_resolution;\n\nout vec2 v_texcoord;\nflat out int v_texpage;\n\nvoid main() {\n vec2 zeroToOne = (a_offset / u_resolution) + a_cellpos + (a_unitquad * a_size);\n gl_Position = u_projection * vec4(zeroToOne, 0.0, 1.0);\n v_texpage = int(a_texpage);\n v_texcoord = a_texcoord + a_unitquad * a_texsize;\n}",function(t){let e="";for(let i=1;iL.deleteProgram(this._program))),this._projectionLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_resolution")),this._textureLocation=(0,n.throwIfFalsy)(L.getUniformLocation(this._program,"u_texture")),this._vertexArrayObject=L.createVertexArray(),L.bindVertexArray(this._vertexArrayObject);const T=new Float32Array([0,0,1,0,0,1,1,1]),h=L.createBuffer();this._register((0,a.toDisposable)(()=>L.deleteBuffer(h))),L.bindBuffer(L.ARRAY_BUFFER,h),L.bufferData(L.ARRAY_BUFFER,T,L.STATIC_DRAW),L.enableVertexAttribArray(0),L.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);const l=new Uint8Array([0,1,2,3]),d=L.createBuffer();this._register((0,a.toDisposable)(()=>L.deleteBuffer(d))),L.bindBuffer(L.ELEMENT_ARRAY_BUFFER,d),L.bufferData(L.ELEMENT_ARRAY_BUFFER,l,L.STATIC_DRAW),this._attributesBuffer=(0,n.throwIfFalsy)(L.createBuffer()),this._register((0,a.toDisposable)(()=>L.deleteBuffer(this._attributesBuffer))),L.bindBuffer(L.ARRAY_BUFFER,this._attributesBuffer),L.enableVertexAttribArray(2),L.vertexAttribPointer(2,2,L.FLOAT,!1,44,0),L.vertexAttribDivisor(2,1),L.enableVertexAttribArray(3),L.vertexAttribPointer(3,2,L.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(3,1),L.enableVertexAttribArray(4),L.vertexAttribPointer(4,1,L.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(4,1),L.enableVertexAttribArray(5),L.vertexAttribPointer(5,2,L.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(5,1),L.enableVertexAttribArray(6),L.vertexAttribPointer(6,2,L.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(6,1),L.enableVertexAttribArray(1),L.vertexAttribPointer(1,2,L.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(1,1),L.useProgram(this._program);const c=new Int32Array(s.TextureAtlas.maxAtlasPages);for(let t=0;tL.deleteTexture(e.texture))),L.activeTexture(L.TEXTURE0+t),L.bindTexture(L.TEXTURE_2D,e.texture),L.texParameteri(L.TEXTURE_2D,L.TEXTURE_WRAP_S,L.CLAMP_TO_EDGE),L.texParameteri(L.TEXTURE_2D,L.TEXTURE_WRAP_T,L.CLAMP_TO_EDGE),L.texImage2D(L.TEXTURE_2D,0,L.RGBA,1,1,0,L.RGBA,L.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[t]=e}L.enable(L.BLEND),L.blendFunc(L.SRC_ALPHA,L.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return!this._atlas||this._atlas.beginFrame()}updateCell(t,e,i,s,r,a,o,n,L){this._updateCell(this._vertices.attributes,t,e,i,s,r,a,o,n,L)}_updateCell(t,e,i,s,a,o,d,c,_,Q){T=11*(i*this._terminal.cols+e),s!==r.NULL_CELL_CODE&&void 0!==s?this._atlas&&(L=c&&c.length>1?this._atlas.getRasterizedGlyphCombinedChar(c,a,o,d,!1,this._terminal.element):this._atlas.getRasterizedGlyph(s,a,o,d,!1,this._terminal.element),h=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),a!==Q&&L.offset.x>h?(l=L.offset.x-h,t[T]=-(L.offset.x-l)+this._dimensions.device.char.left,t[T+1]=-L.offset.y+this._dimensions.device.char.top,t[T+2]=(L.size.x-l)/this._dimensions.device.canvas.width,t[T+3]=L.size.y/this._dimensions.device.canvas.height,t[T+4]=L.texturePage,t[T+5]=L.texturePositionClipSpace.x+l/this._atlas.pages[L.texturePage].canvas.width,t[T+6]=L.texturePositionClipSpace.y,t[T+7]=L.sizeClipSpace.x-l/this._atlas.pages[L.texturePage].canvas.width,t[T+8]=L.sizeClipSpace.y):(t[T]=-L.offset.x+this._dimensions.device.char.left,t[T+1]=-L.offset.y+this._dimensions.device.char.top,t[T+2]=L.size.x/this._dimensions.device.canvas.width,t[T+3]=L.size.y/this._dimensions.device.canvas.height,t[T+4]=L.texturePage,t[T+5]=L.texturePositionClipSpace.x,t[T+6]=L.texturePositionClipSpace.y,t[T+7]=L.sizeClipSpace.x,t[T+8]=L.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&(0,n.allowRescaling)(s,_,L.size.x,this._dimensions.device.cell.width)&&(t[T+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width)):t.fill(0,T,T+11-1-2)}clear(){const t=this._terminal,e=t.cols*t.rows*11;this._vertices.count!==e?this._vertices.attributes=new Float32Array(e):this._vertices.attributes.fill(0);let i=0;for(;i=0,e.isChromeOS=/\bCrOS\b/.test(i)},105(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.WindowIntervalTimer=e.eventType=void 0,e.getWindow=r,e.addDisposableListener=o,e.addStandardDisposableListener=function(t,e,i,s){return o(t,e,i,s)},e.getDomNodePagePosition=function(t){const e=t.getBoundingClientRect(),i=r(t);return{left:e.left+i.scrollX,top:e.top+i.scrollY,width:e.width,height:e.height}},e.scheduleAtNextAnimationFrame=function(t,e,i=0){const s=T(t),r=new n(e,i);return s.next.push(r),s.animFrameRequested||(s.animFrameRequested=!0,t.requestAnimationFrame(()=>function(t){const e=T(t);for(e.animFrameRequested=!1,e.current=e.next,e.next=[],e.inAnimationFrameRunner=!0;e.current.length>0;)e.current.sort(n.sort),e.current.shift().execute();e.inAnimationFrameRunner=!1}(t))),r};const s=i(578);function r(t){const e=t;if(e?.ownerDocument?.defaultView)return e.ownerDocument.defaultView;const i=t;return i?.view?i.view:window}class a{_handler;_node;_type;_options;constructor(t,e,i,s){this._node=t,this._type=e,this._handler=i,this._options=s,t.addEventListener(e,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function o(t,e,i,s){return new a(t,e,i,s)}e.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class n{_runner;priority;_canceled=!1;constructor(t,e){this._runner=t,this.priority=e}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(t){console.error(t)}}static sort(t,e){return e.priority-t.priority}}const L=new Map;function T(t){let e=L.get(t);return e||(e={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},L.set(t,e)),e}class h extends s.IntervalTimer{_defaultTarget;constructor(t){super(),this._defaultTarget=t?r(t):void 0}cancelAndSet(t,e,i){super.cancelAndSet(t,e,i??this._defaultTarget??window)}}e.WindowIntervalTimer=h},115(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextureAtlas=void 0;const s=i(0),r=i(183),a=i(959),o=i(993),n=i(836),L=i(930),T=i(917),h=i(564),l=i(414),d={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}};let c;class _{_document;_config;_unicodeService;_logService;_didWarmUp=!1;_cacheMap=new n.FourKeyMap;_cacheMapCombined=new n.FourKeyMap;_pages=[];get pages(){return this._pages}_activePages=[];_overflowSizePage;_tmpCanvas;_tmpCtx;_workBoundingBox={top:0,left:0,bottom:0,right:0};_workAttributeData=new T.AttributeData;_textureSize=512;static maxAtlasPages;static maxTextureSize;_onAddTextureAtlasCanvas=new l.Emitter;onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=new l.Emitter;onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;constructor(t,e,i,s){this._document=t,this._config=e,this._unicodeService=i,this._logService=s,this._createNewPage(),this._tmpCanvas=p(t,4*this._config.deviceCellWidth+4,this._config.deviceCellHeight+4),this._tmpCtx=(0,a.throwIfFalsy)(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:!0}))}dispose(){this._tmpCanvas.remove();for(const t of this.pages)t.canvas.remove();this._onAddTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)}_doWarmUp(){const t=new L.IdleTaskQueue(this._logService);for(let e=33;e<126;e++)t.enqueue(()=>{if(!this._cacheMap.get(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT)){const t=this._drawToCache(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT,!1,void 0);this._cacheMap.set(e,h.DEFAULT_COLOR,h.DEFAULT_COLOR,h.DEFAULT_EXT,t)}})}_requestClearModel=!1;beginFrame(){const t=this._requestClearModel;return this._requestClearModel=!1,t}clearTexture(){if(0!==this._pages[0].currentRow.x||0!==this._pages[0].currentRow.y){for(const t of this._pages)t.clear();this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1}}_createNewPage(){if(_.maxAtlasPages&&this._pages.length>=Math.max(4,_.maxAtlasPages)){const t=this._pages.filter(t=>2*t.canvas.width<=(_.maxTextureSize||4096)).sort((t,e)=>e.canvas.width!==t.canvas.width?e.canvas.width-t.canvas.width:e.percentageUsed-t.percentageUsed);let e=-1,i=0;for(let s=0;st.canvas.width!==s[0].canvas.width)){const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}const r=s.map(t=>t.glyphs[0].texturePage).sort((t,e)=>t>e?1:-1),a=this.pages.length-s.length,o=this._mergePages(s,a);o.version=++Q.nextVersion;for(let t=r.length-1;t>=0;t--)this._deletePage(r[t]);this.pages.push(o),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(o.canvas)}const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}_mergePages(t,e){const i=2*t[0].canvas.width,s=new Q(this._document,i,t);for(const[r,a]of t.entries()){const t=r*a.canvas.width%i,o=Math.floor(r/2)*a.canvas.height;s.ctx.drawImage(a.canvas,t,o);for(const s of a.glyphs)s.texturePage=e,s.sizeClipSpace.x=s.size.x/i,s.sizeClipSpace.y=s.size.y/i,s.texturePosition.x+=t,s.texturePosition.y+=o,s.texturePositionClipSpace.x=s.texturePosition.x/i,s.texturePositionClipSpace.y=s.texturePosition.y/i;this._onRemoveTextureAtlasCanvas.fire(a.canvas);const n=this._activePages.indexOf(a);-1!==n&&this._activePages.splice(n,1)}return s}_deletePage(t){this._pages.splice(t,1);for(let e=t;e=this._config.colors.ansi.length)throw new Error("No color found for idx "+t);return this._config.colors.ansi[t]}_getBackgroundColor(t,e,i,s){if(this._config.allowTransparency)return o.NULL_COLOR;let r;switch(t){case 16777216:case 33554432:r=this._getColorFromAnsiIndex(e);break;case 50331648:const t=T.AttributeData.toColorRGB(e);r=o.channels.toColor(t[0],t[1],t[2]);break;default:r=i?o.color.opaque(this._config.colors.foreground):this._config.colors.background}return this._config.allowTransparency||(r=o.color.opaque(r)),r}_getForegroundColor(t,e,i,r,a,n,L,h,l,d){const c=this._getMinimumContrastColor(t,e,i,r,a,n,L,l,h,d);if(c)return c;let _;switch(a){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&l&&n<8&&(n+=8),_=this._getColorFromAnsiIndex(n);break;case 50331648:const t=T.AttributeData.toColorRGB(n);_=o.channels.toColor(t[0],t[1],t[2]);break;default:_=L?this._config.colors.background:this._config.colors.foreground}return this._config.allowTransparency&&(_=o.color.opaque(_)),h&&(_=o.color.multiplyOpacity(_,s.DIM_OPACITY)),_}_resolveBackgroundRgba(t,e,i){switch(t){case 16777216:case 33554432:return this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(t,e,i,s){switch(t){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&e<8&&(e+=8),this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(t,e,i,s,r,a,n,L,T,h){if(1===this._config.minimumContrastRatio||h)return;const l=this._getContrastCache(T),d=l.getColor(t,s);if(void 0!==d)return d??void 0;const c=this._resolveBackgroundRgba(e,i,n),_=this._resolveForegroundRgba(r,a,n,L),Q=o.rgba.ensureContrastRatio(c,_,this._config.minimumContrastRatio/(T?2:1));if(!Q)return void l.setColor(t,s,null);const u=o.channels.toColor(Q>>24&255,Q>>16&255,Q>>8&255);return l.setColor(t,s,u),u}_getContrastCache(t){return t?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(t,e,i,o,n,L){const h="number"==typeof t?String.fromCharCode(t):t;L&&this._tmpCanvas.parentElement!==L&&(this._tmpCanvas.style.display="none",L.append(this._tmpCanvas));const l=Math.min(this._config.deviceCellWidth*Math.max(h.length,2)+4,this._config.deviceMaxTextureSize);this._tmpCanvas.width=t?2*t-o:t-o;!1==!(o>=t)||0===_?(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(T+_,s),this._tmpCtx.lineTo(h,s)):(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(T+_,s),this._tmpCtx.moveTo(T+_+t,s),this._tmpCtx.lineTo(h,s)),o=(0,a.computeNextVariantOffset)(h-T,t,o);break;case 5:const Q=.6,u=.3,p=h-T,g=Math.floor(Q*p),M=Math.floor(u*p),y=p-g-M;this._tmpCtx.setLineDash([g,M,y]),this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(h,s);break;default:this._tmpCtx.moveTo(T,s),this._tmpCtx.lineTo(h,s)}n||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!O&&this._config.fontSize>=12&&!this._config.allowTransparency&&" "!==h){this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";const e=this._tmpCtx.measureText(h);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in e&&e.actualBoundingBoxDescent>0){this._tmpCtx.save();const e=new Path2D;e.rect(i,L-Math.ceil(t/2),this._config.deviceCellWidth*I,l-L+Math.ceil(t/2)),this._tmpCtx.clip(e),this._tmpCtx.lineWidth=3*this._config.devicePixelRatio,this._tmpCtx.strokeStyle=E.css,this._tmpCtx.strokeText(h,D,D+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(C){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),e=t%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(D,D+e),this._tmpCtx.lineTo(D+this._config.deviceCharWidth*I,D+e),this._tmpCtx.stroke()}if(O||this._tmpCtx.fillText(h,D,D+this._config.deviceCharHeight),"_"===h&&!this._config.allowTransparency){let t=u(this._tmpCtx.getImageData(D,D,this._config.deviceCellWidth,this._config.deviceCellHeight),E,S,W);if(t)for(let e=1;e<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=E.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(h,D,D+this._config.deviceCharHeight-e),t=u(this._tmpCtx.getImageData(D,D,this._config.deviceCellWidth,this._config.deviceCellHeight),E,S,W),t);e++);}if(f){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),e=this._tmpCtx.lineWidth%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(D,D+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.lineTo(D+this._config.deviceCharWidth*I,D+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.stroke()}this._tmpCtx.restore();const N=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height);let P;if(P=this._config.allowTransparency?function(t){for(let e=0;e0)return!1;return!0}(N):u(N,E,S,W),P)return d;const B=this._findGlyphBoundingBox(N,this._workBoundingBox,l,Z,O,D);let $,F;for(;;){if(0===this._activePages.length){const t=this._createNewPage();$=t,F=t.currentRow,F.height=B.size.y;break}$=this._activePages[this._activePages.length-1],F=$.currentRow;for(const t of this._activePages)B.size.y<=t.currentRow.height&&($=t,F=t.currentRow);for(let t=this._activePages.length-1;t>=0;t--)for(const e of this._activePages[t].fixedRows)e.height<=F.height&&B.size.y<=e.height&&($=this._activePages[t],F=e);if(B.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new Q(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),$=this._overflowSizePage,F=this._overflowSizePage.currentRow,F.x+B.size.x>=$.canvas.width&&(F.x=0,F.y+=F.height,F.height=0);break}if(F.y+B.size.y>=$.canvas.height||F.height>B.size.y+2){let t=!1;if($.currentRow.y+$.currentRow.height+B.size.y>=$.canvas.height){let e;for(const t of this._activePages)if(t.currentRow.y+t.currentRow.height+B.size.y=_.maxAtlasPages&&F.y+B.size.y<=$.canvas.height&&F.height>=B.size.y&&F.x+B.size.x<=$.canvas.width)t=!0;else{const e=this._createNewPage();$=e,F=e.currentRow,F.height=B.size.y,t=!0}}t||($.currentRow.height>0&&$.fixedRows.push($.currentRow),F={x:0,y:$.currentRow.y+$.currentRow.height,height:B.size.y},$.fixedRows.push(F),$.currentRow={x:0,y:F.y+F.height,height:0})}if(F.x+B.size.x<=$.canvas.width)break;F===$.currentRow?(F.x=0,F.y+=F.height,F.height=0):$.fixedRows.splice($.fixedRows.indexOf(F),1)}return B.texturePage=this._pages.indexOf($),B.texturePosition.x=F.x,B.texturePosition.y=F.y,B.texturePositionClipSpace.x=F.x/$.canvas.width,B.texturePositionClipSpace.y=F.y/$.canvas.height,B.sizeClipSpace.x/=$.canvas.width,B.sizeClipSpace.y/=$.canvas.height,F.height=Math.max(F.height,B.size.y),F.x+=B.size.x,$.ctx.putImageData(N,B.texturePosition.x-this._workBoundingBox.left,B.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,B.size.x,B.size.y),$.addGlyph(B),$.version=++Q.nextVersion,B}_findGlyphBoundingBox(t,e,i,s,r,a){e.top=0;const o=s?this._config.deviceCellHeight:this._tmpCanvas.height,n=s?this._config.deviceCellWidth:i;let L=!1;for(let i=0;i=a;i--){for(let s=0;s=0;i--){for(let s=0;s>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=i.rgba>>>24,L=i.rgba>>>16&255,T=i.rgba>>>8&255,h=Math.floor((Math.abs(r-n)+Math.abs(a-L)+Math.abs(o-T))/12);let l=!0;for(let e=0;e{this._refreshCharAtlas(t,e),this.reset(t)})),this._register((0,r.toDisposable)(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=(0,a.throwIfFalsy)(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,e,i){}handleSelectionChanged(t,e,i,s=!1){}_setTransparency(t,e){if(e===this._alpha)return;const i=this._canvas;this._alpha=e,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,i),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,e){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=(0,s.acquireTextureAtlas)(t,this._optionsService.rawOptions,e,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,e){this._deviceCellWidth=e.device.cell.width,this._deviceCellHeight=e.device.cell.height,this._deviceCharWidth=e.device.char.width,this._deviceCharHeight=e.device.char.height,this._deviceCharLeft=e.device.char.left,this._deviceCharTop=e.device.char.top,this._canvas.width=e.device.canvas.width,this._canvas.height=e.device.canvas.height,this._canvas.style.width=`${e.css.canvas.width}px`,this._canvas.style.height=`${e.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,e,i=1){this._ctx.fillRect(t*this._deviceCellWidth,(e+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,i*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,e,i,s){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight))}_fillCharTrueColor(t,e,i,s){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=o.TEXT_BASELINE,this._clipCell(i,s,e.getWidth()),this._ctx.fillText(e.getChars(),i*this._deviceCellWidth+this._deviceCharLeft,s*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,e,i){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,e,i){return`${i?"italic":""} ${e?t.options.fontWeightBold:t.options.fontWeight} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}}e.BaseRenderLayer=n},183(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.tryDrawCustomGlyph=function(t,e,i,s,o,n,L,T,h,l,d,c=0){const _=r.customGlyphDefinitions[e];if(_){const e=Array.isArray(_)?_:[_];for(const r of e)a(t,r,i,s,o,n,L,T,h,l,d,c);return!0}return!1};const s=i(959),r=i(915);function a(t,e,i,r,a,T,d,c,_,Q,u,p=0){let g=a,M=T,y=i,v=r;switch(1===e.scaleType&&(g=d,M=c,y=i+(a-d)/2,v=r+(T-c)/2),e.clipPath&&(t.save(),function(t,e,i,s,r,a){t.beginPath();for(const o of e.split(" ")){const e=o[0];if("Z"===e){t.closePath();continue}const n=o.substring(1).split(",");if(!n[0]||!n[1])continue;const L=i+parseFloat(n[0])*r,T=s+parseFloat(n[1])*a;"M"===e?t.moveTo(L,T):"L"===e&&t.lineTo(L,T)}t.clip()}(t,e.clipPath,y,v,g,M)),e.type){case 0:!function(t,e,i,s,r,a){for(let o=0;o7&&parseInt(h.slice(7,9),16)||1;else{if(!h.startsWith("rgba"))throw new Error(`Unexpected fillStyle color format "${h}" when drawing pattern glyph`);[L,d,c,_]=h.substring(5,h.length-1).split(",").map(t=>parseFloat(t))}for(let t=0;t>1&1,c=1&n;0!==d||0!==c?l.setTransform((new DOMMatrix).translateSelf(-d,-c)):l.setTransform(new DOMMatrix),t.fillStyle=l,t.fillRect(i,r,a,o)}(t,e.data,y,v,g,M,p);break;case 2:!function(t,e,i,s,r,a,o,n){let L;t.save(),t.beginPath(),t.rect(i,s,r,a),t.clip(),t.beginPath(),L="function"==typeof e?e(.15,.15/a*r):e;const T={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const e of L.split(" ")){const n=e[0];if("Z"===n){t.closePath(),T.lastCommand=n;continue}const L=h[n];if(!L){console.error(`Could not find drawing instructions for "${n}"`);continue}const d=e.substring(1).split(",");d[0]&&d[1]&&(L(t,l(d,r,a,i,s,!0,o,0,0,!1),T),T.lastCommand=n)}void 0!==n?(t.strokeStyle=t.fillStyle,t.lineWidth=o*n,t.stroke()):t.fill(),t.closePath(),t.restore()}(t,e.data,y,v,g,M,Q,e.strokeWidth);break;case 3:!function(t,e,i,s,r,a,o,L){const T="string"==typeof e?e:e(0,0);t.beginPath();let h=0,l=0,d=0,c=0,_="";for(const e of T.split(" ")){const o=e[0],L=e.substring(1).split(",");if("Z"===o){t.closePath(),_=o;continue}if("V"===o){const e=s+parseFloat(L[0])*a;t.lineTo(h,e),l=e,d=h,c=l,_=o;continue}if("H"===o){const e=i+parseFloat(L[0])*r;t.lineTo(e,l),h=e,d=h,c=l,_=o;continue}if(!L[0]||!L[1])continue;if("A"===o){const e=parseFloat(L[0])*r,o=parseFloat(L[1])*a,T=parseFloat(L[2])*Math.PI/180,d=parseInt(L[3]),c=parseInt(L[4]),_=i+parseFloat(L[5])*r,Q=s+parseFloat(L[6])*a;n(t,h,l,e,o,T,d,c,_,Q),h=_,l=Q;continue}const T=L.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});if("M"===o)t.moveTo(T[0],T[1]),h=T[0],l=T[1],d=h,c=l;else if("L"===o)t.lineTo(T[0],T[1]),h=T[0],l=T[1],d=h,c=l;else if("Q"===o)t.quadraticCurveTo(T[0],T[1],T[2],T[3]),d=T[0],c=T[1],h=T[2],l=T[3];else if("T"===o){let e,i;"Q"===_||"T"===_?(e=2*h-d,i=2*l-c):(e=h,i=l),t.quadraticCurveTo(e,i,T[0],T[1]),d=e,c=i,h=T[0],l=T[1]}else"C"===o&&(t.bezierCurveTo(T[0],T[1],T[2],T[3],T[4],T[5]),d=T[2],c=T[3],h=T[4],l=T[5]);_=o}void 0!==L?(t.strokeStyle=t.fillStyle,t.lineWidth=o*L,t.stroke()):t.fill()}(t,e.data,y,v,g,M,Q,e.strokeWidth);break;case 4:!function(t,e,i,s,r,a,o,n){t.save(),t.fillRect(i,s,r,a),n&&(t.fillStyle=n,t.strokeStyle=n),t.lineWidth=o,t.lineCap="square",t.beginPath();for(const o of e.d.split(" ")){const e=o[0],n=o.substring(1).split(",");if(!n[0]||!n[1]){"Z"===e&&t.closePath();continue}const L=n.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});"M"===e?t.moveTo(L[0],L[1]):"L"===e&&t.lineTo(L[0],L[1])}1===e.type?t.stroke():t.fill(),t.restore()}(t,e.data,y,v,g,M,Q,u);break;case 5:!function(t,e,i,s,r,a,o,n){const L=new Path2D;L.rect(i,s,r,a),t.clip(L),t.beginPath();const T=o/12;t.lineWidth=n*T;const d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const o of e.d.split(" ")){const L=o[0];if("Z"===L){t.closePath(),d.lastCommand=L;continue}const c=h[L];if(!c){console.error(`Could not find drawing instructions for "${L}"`);continue}const _=o.substring(1).split(",");_[0]&&_[1]&&(c(t,l(_,r,a,i,s,!1,n,(e.leftPadding??0)*(T/2),(e.rightPadding??0)*(T/2)),d),d.lastCommand=L)}1===e.type?(t.strokeStyle=t.fillStyle,t.stroke()):t.fill(),t.closePath()}(t,e.data,y,v,g,M,_,Q);break;case 6:!function(t,e,i,s,r,a){const n=r/8,L=.1*a,T=.8*a/8,h=Math.min(n,T);for(let r=0;r<8;r++)if(e&1<1){const t=Math.sqrt(y);u=(s*=t)*s,p=(r*=t)*r}let v=(u*p-u*M-p*g)/(u*M+p*g);v<0&&(v=0);const f=(o===n?-1:1)*Math.sqrt(v),C=f*(s*Q/r),m=f*(-r*_/s),w=h*C-l*m+(e+L)/2,x=l*C+h*m+(i+T)/2,b=(_-C)/s,E=(Q-m)/r,R=(-_-C)/s,k=(-Q-m)/r,A=Math.atan2(E,b);let Z=Math.atan2(k,R)-A;0===n&&Z>0?Z-=2*Math.PI:1===n&&Z<0&&(Z+=2*Math.PI);const S=A+Z;t.ellipse(w,x,s,r,a,A,S,0===n)}const L=new Map;function T(t,e,i=0){return Math.max(Math.min(t,e),i)}const h={C:(t,e,i)=>{t.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),i.lastControlX=e[2],i.lastControlY=e[3],i.currentX=e[4],i.currentY=e[5]},L:(t,e,i)=>{t.lineTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},M:(t,e,i)=>{t.moveTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},Q:(t,e,i)=>{t.quadraticCurveTo(e[0],e[1],e[2],e[3]),i.lastControlX=e[0],i.lastControlY=e[1],i.currentX=e[2],i.currentY=e[3]},T:(t,e,i)=>{let s,r;"Q"===i.lastCommand||"T"===i.lastCommand?(s=2*i.currentX-i.lastControlX,r=2*i.currentY-i.lastControlY):(s=i.currentX,r=i.currentY),t.quadraticCurveTo(s,r,e[0],e[1]),i.lastControlX=s,i.lastControlY=r,i.currentX=e[0],i.currentY=e[1]}};function l(t,e,i,s,r,a,o,n=0,L=0,h=!0){const l=t.map(t=>parseFloat(t)||parseInt(t));if(l.length<2)throw new Error("Too few arguments for instruction");for(let t=0;tL.deleteProgram(this._program))),this._projectionLocation=(0,a.throwIfFalsy)(L.getUniformLocation(this._program,"u_projection")),this._vertexArrayObject=L.createVertexArray(),L.bindVertexArray(this._vertexArrayObject);const T=new Float32Array([0,0,1,0,0,1,1,1]),h=L.createBuffer();this._register((0,s.toDisposable)(()=>L.deleteBuffer(h))),L.bindBuffer(L.ARRAY_BUFFER,h),L.bufferData(L.ARRAY_BUFFER,T,L.STATIC_DRAW),L.enableVertexAttribArray(3),L.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);const l=new Uint8Array([0,1,2,3]),d=L.createBuffer();this._register((0,s.toDisposable)(()=>L.deleteBuffer(d))),L.bindBuffer(L.ELEMENT_ARRAY_BUFFER,d),L.bufferData(L.ELEMENT_ARRAY_BUFFER,l,L.STATIC_DRAW),this._attributesBuffer=(0,a.throwIfFalsy)(L.createBuffer()),this._register((0,s.toDisposable)(()=>L.deleteBuffer(this._attributesBuffer))),L.bindBuffer(L.ARRAY_BUFFER,this._attributesBuffer),L.enableVertexAttribArray(0),L.vertexAttribPointer(0,2,L.FLOAT,!1,o,0),L.vertexAttribDivisor(0,1),L.enableVertexAttribArray(1),L.vertexAttribPointer(1,2,L.FLOAT,!1,o,2*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(1,1),L.enableVertexAttribArray(2),L.vertexAttribPointer(2,4,L.FLOAT,!1,o,4*Float32Array.BYTES_PER_ELEMENT),L.vertexAttribDivisor(2,1),this._updateCachedColors(n.colors),this._register(this._themeService.onChangeColors(t=>{this._updateCachedColors(t),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){const e=this._gl;e.useProgram(this._program),e.bindVertexArray(this._vertexArrayObject),e.uniformMatrix4fv(this._projectionLocation,!1,r.PROJECTION_MATRIX),e.bindBuffer(e.ARRAY_BUFFER,this._attributesBuffer),e.bufferData(e.ARRAY_BUFFER,t.attributes,e.DYNAMIC_DRAW),e.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,e.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){const e=this._terminal,i=this._vertices;let s,r,a,o,n,L,T,h,l,d,c,_=1;for(s=0;s>24&255)/255,d=(L>>16&255)/255,c=(L>>8&255)/255,_=1,this._addRectangle(t.attributes,e,T,h,(o-a)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,l,d,c,_)}_addRectangle(t,e,i,s,r,a,o,n,L,T){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o,t[e+5]=n,t[e+6]=L,t[e+7]=T}_addRectangleFloat(t,e,i,s,r,a,o){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o[0],t[e+5]=o[1],t[e+6]=o[2],t[e+7]=o[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(255&t.rgba)/255])}}e.RectangleRenderer=Q},208(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.RenderModel=e.COMBINED_CHAR_BIT_MASK=void 0;const s=i(948);e.COMBINED_CHAR_BIT_MASK=2147483648,e.RenderModel=class{cells;lineLengths;selection;cursor;constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=(0,s.createSelectionRenderModel)()}resize(t,e){const i=t*e*4;i!==this.cells.length&&(this.cells=new Uint32Array(i),this.lineLengths=new Uint32Array(e))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}}},215(t,e){Object.defineProperty(e,"__esModule",{value:!0})},267(t,e){Object.defineProperty(e,"__esModule",{value:!0})},306(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.LinkRenderLayer=void 0;const s=i(657),r=i(814),a=i(133);class o extends a.BaseRenderLayer{_state;constructor(t,e,i,s,r,a,o){super(i,t,"link",e,!0,r,a,o),this._register(s.onShowLinkUnderline(t=>this._handleShowLinkUnderline(t))),this._register(s.onHideLinkUnderline(t=>this._handleHideLinkUnderline(t)))}resize(t,e){super.resize(t,e),this._state=void 0}reset(t){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);const t=this._state.y2-this._state.y1-1;t>0&&this._clearCells(0,this._state.y1+1,this._state.cols,t),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(t){if(t.fg===r.INVERTED_DEFAULT_COLOR?this._ctx.fillStyle=this._themeService.colors.background.css:void 0!==t.fg&&(0,s.is256Color)(t.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[t.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,t.y1===t.y2)this._fillBottomLineAtCells(t.x1,t.y1,t.x2-t.x1);else{this._fillBottomLineAtCells(t.x1,t.y1,t.cols-t.x1);for(let e=t.y1+1;e65535?(t-=65536,String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):String.fromCharCode(t)},e.utf32ToString=function(t,e=0,i=t.length){let s="";for(let r=e;r65535?(e-=65536,s+=String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):s+=String.fromCharCode(e)}return s},e.StringToUtf32=class{_interim=0;clear(){this._interim=0}decode(t,e){const i=t.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=t.charCodeAt(r++);56320<=i&&i<=57343?e[s++]=1024*(this._interim-55296)+i-56320+65536:(e[s++]=this._interim,e[s++]=i),this._interim=0}for(let a=r;a=i)return this._interim=r,s;const o=t.charCodeAt(a);56320<=o&&o<=57343?e[s++]=1024*(r-55296)+o-56320+65536:(e[s++]=r,e[s++]=o);continue}65279!==r&&(e[s++]=r)}return s}},e.Utf8ToUtf32=class{interim=new Uint8Array(3);clear(){this.interim.fill(0)}decode(t,e){const i=t.length;if(!i)return 0;let s,r,a,o,n=0,L=0,T=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let a,o=0;for(;(a=63&this.interim[++o])&&o<4;)r<<=6,r|=a;const L=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,h=L-o;for(;T=i)return 0;if(a=t[T++],128!=(192&a)){T--,s=!0;break}this.interim[o++]=a,r<<=6,r|=63&a}s||(2===L?r<128?T--:e[n++]=r:3===L?r<2048||r>=55296&&r<=57343||65279===r||(e[n++]=r):r<65536||r>1114111||(e[n++]=r)),this.interim.fill(0)}const h=i-4;let l=T;for(;l=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(L=(31&s)<<6|63&r,L<128){l--;continue}e[n++]=L}else if(224==(240&s)){if(l>=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,n;if(a=t[l++],128!=(192&a)){l--;continue}if(L=(15&s)<<12|(63&r)<<6|63&a,L<2048||L>=55296&&L<=57343||65279===L)continue;e[n++]=L}else if(240==(248&s)){if(l>=i)return this.interim[0]=s,n;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,n;if(a=t[l++],128!=(192&a)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=a,n;if(o=t[l++],128!=(192&o)){l--;continue}if(L=(7&s)<<18|(63&r)<<12|(63&a)<<6|63&o,L<65536||L>1114111)continue;e[n++]=L}}return n}}},399(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.JoinedCellData=e.WebglRenderer=void 0;const s=i(864),r=i(670),a=i(773),o=i(697),n=i(917),L=i(721),T=i(564),h=i(536),l=i(28),d=i(203),c=i(208);i(816);const _=i(306),Q=i(414),u=i(105),p=i(426),g=i(959);class M extends p.Disposable{_terminal;_characterJoinerService;_charSizeService;_coreBrowserService;_coreService;_decorationService;_optionsService;_themeService;_customGlyphs;_renderLayers;_cursorBlinkStateManager=this._register(new p.MutableDisposable);_textBlinkStateManager;_charAtlasDisposable=this._register(new p.MutableDisposable);_charAtlas;_devicePixelRatio;_deviceMaxTextureSize;_observerDisposable=this._register(new p.MutableDisposable);_model=new c.RenderModel;_rowHasBlinkingCells=[];_rowHasBlinkingCellsCount=0;_workCell=new L.CellData;_cellColorResolver;_canvas;_gl;_rectangleRenderer=this._register(new p.MutableDisposable);_glyphRenderer=this._register(new p.MutableDisposable);dimensions;_core;_isAttached;_contextRestorationTimeout;_onChangeTextureAtlas=this._register(new Q.Emitter);onChangeTextureAtlas=this._onChangeTextureAtlas.event;_onAddTextureAtlasCanvas=this._register(new Q.Emitter);onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=this._register(new Q.Emitter);onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;_onRequestRedraw=this._register(new Q.Emitter);onRequestRedraw=this._onRequestRedraw.event;_onContextLoss=this._register(new Q.Emitter);onContextLoss=this._onContextLoss.event;constructor(t,e,i,a,n,L,T,l,d=!0,c){super(),this._terminal=t,this._characterJoinerService=e,this._charSizeService=i,this._coreBrowserService=a,this._coreService=n,this._decorationService=L,this._optionsService=T,this._themeService=l,this._customGlyphs=d,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas");const Q={antialias:!1,depth:!1,preserveDrawingBuffer:c};if(this._gl=this._canvas.getContext("webgl2",Q),!this._gl)throw new Error("WebGL2 not supported "+this._gl);this._register(this._themeService.onChangeColors(()=>this._handleColorChange())),this._cellColorResolver=new s.CellColorResolver(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new _.LinkRenderLayer(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,T,this._themeService)],this.dimensions=(0,g.createRenderDimensions)(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(T.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new h.TextBlinkStateManager(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextlost",t=>{console.log("webglcontextlost event received"),t.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,console.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(t)},3e3)})),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextrestored",t=>{console.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,(0,r.removeTerminalFromCache)(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,this._coreBrowserService.window,(t,e)=>this._setCanvasDevicePixelDimensions(t,e)),this._register(this._coreBrowserService.onWindowChange(t=>{this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,t,(t,e)=>this._setCanvasDevicePixelDimensions(t,e))})),this._register((0,u.addDisposableListener)(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register((0,p.toDisposable)(()=>{for(const t of this._renderLayers)t.dispose();this._canvas.parentElement?.removeChild(this._canvas),(0,r.removeTerminalFromCache)(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,e){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(const t of this._renderLayers)t.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(const t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(const t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,e,i){for(const s of this._renderLayers)s.handleSelectionChanged(this._terminal,t,e,i);this._model.selection.update(this._core,t,e,i),this._requestRedrawViewport()}handleCursorMove(){for(const t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new d.RectangleRenderer(this._terminal,this._gl,this.dimensions,this._themeService),this._glyphRenderer.value=new l.GlyphRenderer(this._terminal,this._gl,this.dimensions,this._optionsService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0)return void(this._isAttached=!1);const t=(0,r.acquireTextureAtlas)(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=(0,p.combinedDisposable)(Q.EventUtils.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),Q.EventUtils.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(const t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,e){if(!this._isAttached){if(!(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height))return;this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0}for(const i of this._renderLayers)i.handleGridChanged(this._terminal,t,e);if(!this._glyphRenderer.value||!this._rectangleRenderer.value)return;this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,e);let i=!1,s=0;for(;this._charAtlas&&this._glyphRenderer.value.beginFrame()&&s++<32;)i=!0,this._clearModel(!0),this._updateModel(0,this._terminal.rows-1);i&&this._glyphRenderer.value.invalidateAtlasTextures(),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),this._cursorBlinkStateManager.value&&!this._cursorBlinkStateManager.value.isCursorVisible||this._rectangleRenderer.value.renderCursor()}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new a.CursorBlinkStateManager(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,e){const i=this._core;let s,r,a,o,n,L,h,l,d,_,Q,u,p,g,M,f=this._workCell,C=0,m=!0;t=v(t,i.rows-1,0),e=v(e,i.rows-1,0);const w=this._coreService.decPrivateModes.cursorStyle??i.options.cursorStyle??"block",x=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,b=x-i.buffer.ydisp,E=Math.min(this._terminal.buffer.active.cursorX,i.cols-1);let R=-1;const k=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let A=!1;for(r=t;r<=e;r++){a=r+i.buffer.ydisp,o=i.buffer.lines.get(a);let t=!1;for(this._model.lineLengths[r]=0,d=x===a,C=0,n=this._characterJoinerService.getJoinedCharacters(a),g=0;g=C,h=g,n.length>0&&g===n[0][0]&&m){l=n.shift();const t=this._model.selection.isCellSelected(this._terminal,l[0],a);for(p=l[0]+1;p=l[1],m?(L=!0,f=new y(f,o.translateToString(!0,l[0],l[1]),l[1]-l[0]),h=l[1]-1):C=l[1]}if(_=f.getChars(),Q=f.getCode(),p=4*(r*i.cols+g),!t&&f.isBlink()&&(t=!0),this._cellColorResolver.resolve(f,g,a,this.dimensions.device.cell.width,this.dimensions.device.cell.height),k&&a===x&&(g===E&&(this._model.cursor={x:E,y:b,width:f.getWidth(),style:this._coreBrowserService.isFocused?w:i.options.cursorInactiveStyle,cursorWidth:i.options.cursorWidth,dpr:this._devicePixelRatio},R=E+f.getWidth()-1),g>=E&&g<=R&&(this._coreBrowserService.isFocused&&"block"===w||!1===this._coreBrowserService.isFocused&&"block"===i.options.cursorInactiveStyle)&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&f.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),Q!==T.NULL_CELL_CODE&&(this._model.lineLengths[r]=g+1),(this._model.cells[p]!==Q||this._model.cells[p+1]!==this._cellColorResolver.result.bg||this._model.cells[p+2]!==this._cellColorResolver.result.fg||this._model.cells[p+3]!==this._cellColorResolver.result.ext)&&(A=!0,_.length>1&&(Q|=c.COMBINED_CHAR_BIT_MASK),this._model.cells[p]=Q,this._model.cells[p+1]=this._cellColorResolver.result.bg,this._model.cells[p+2]=this._cellColorResolver.result.fg,this._model.cells[p+3]=this._cellColorResolver.result.ext,u=f.getWidth(),this._glyphRenderer.value.updateCell(g,r,Q,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,_,u,s),L)){for(f=this._workCell,g++;g<=h;g++)M=4*(r*i.cols+g),this._glyphRenderer.value.updateCell(g,r,T.NULL_CELL_CODE,0,0,0,T.NULL_CELL_CHAR,0,0),this._model.cells[M]=T.NULL_CELL_CODE,this._model.cells[M+1]=this._cellColorResolver.result.bg,this._model.cells[M+2]=this._cellColorResolver.result.fg,this._model.cells[M+3]=this._cellColorResolver.result.ext;g--}}this._setRowBlinkState(r,t)}A&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,e){this._rowHasBlinkingCells[t]!==e&&(this._rowHasBlinkingCells[t]=e,this._rowHasBlinkingCellsCount+=e?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){this._charSizeService.width&&this._charSizeService.height&&(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=1===this._optionsService.rawOptions.lineHeight?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,e){this._canvas.width===t&&this._canvas.height===e||(this._canvas.width=t,this._canvas.height=e,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){const t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}}e.WebglRenderer=M;class y extends n.AttributeData{_width;content=0;fg;bg;combinedData="";constructor(t,e,i){super(),this.fg=t.fg,this.bg=t.bg,this.combinedData=e,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}function v(t,e,i=0){return Math.max(Math.min(t,e),i)}e.JoinedCellData=y},414(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.EventUtils=e.Emitter=void 0;const s=i(426);var r;e.Emitter=class{_listeners=[];_disposed=!1;_event;get event(){return this._event||(this._event=(t,e,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:t,thisArgs:e};this._listeners.push(r);const a=(0,s.toDisposable)(()=>{const t=this._listeners.indexOf(r);-1!==t&&this._listeners.splice(t,1)});return i&&(Array.isArray(i)?i.push(a):i.add(a)),a}),this._event}fire(t){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:e,thisArgs:i}=this._listeners[0];return void e.call(i,t)}default:{const e=this._listeners.slice();for(const{fn:i,thisArgs:s}of e)i.call(s,t)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(t){t.forward=function(t,e){return t(t=>e.fire(t))},t.map=function(t,e){return(i,s,r)=>t(t=>i.call(s,e(t)),void 0,r)},t.any=function(...t){return(e,i,r)=>{const a=new s.DisposableStore;for(const s of t)a.add(s(t=>e.call(i,t)));return r&&(Array.isArray(r)?r.push(a):r.add(a)),a}},t.runAndSubscribe=function(t,e,i){return e(i),t(t=>e(t))}}(r||(e.EventUtils=r={}))},426(t,e){function i(t){return{dispose:t}}function s(t){if(!t)return t;if(Array.isArray(t)){for(const e of t)e.dispose();return[]}return t.dispose(),t}Object.defineProperty(e,"__esModule",{value:!0}),e.MutableDisposable=e.Disposable=e.DisposableStore=void 0,e.toDisposable=i,e.dispose=s,e.combinedDisposable=function(...t){return i(()=>s(t))};class r{_disposables=new Set;_isDisposed=!1;get isDisposed(){return this._isDisposed}add(t){return this._isDisposed?t.dispose():this._disposables.add(t),t}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const t of this._disposables)t.dispose();this._disposables.clear()}}clear(){for(const t of this._disposables)t.dispose();this._disposables.clear()}}e.DisposableStore=r;class a{static None=Object.freeze({dispose(){}});_store=new r;dispose(){this._store.dispose()}_register(t){return this._store.add(t)}}e.Disposable=a,e.MutableDisposable=class{_value;_isDisposed=!1;get value(){return this._isDisposed?void 0:this._value}set value(t){this._isDisposed||t===this._value||(this._value?.dispose(),this._value=t)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},536(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextBlinkStateManager=void 0;const s=i(426);class r extends s.Disposable{_renderCallback;_coreBrowserService;_optionsService;_intervalDuration=0;_interval;_blinkOn=!0;_needsBlinkInViewport=!1;_isViewportVisible=!0;constructor(t,e,i){super(),this._renderCallback=t,this._coreBrowserService=e,this._optionsService=i,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",t=>{this.setIntervalDuration(t)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const t=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(t||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}e.TextBlinkStateManager=r},564(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.WHITESPACE_CELL_CODE=e.WHITESPACE_CELL_WIDTH=e.WHITESPACE_CELL_CHAR=e.NULL_CELL_CODE=e.NULL_CELL_WIDTH=e.NULL_CELL_CHAR=e.CHAR_DATA_CODE_INDEX=e.CHAR_DATA_WIDTH_INDEX=e.CHAR_DATA_CHAR_INDEX=e.CHAR_DATA_ATTR_INDEX=e.DEFAULT_EXT=e.DEFAULT_ATTR=e.DEFAULT_COLOR=void 0,e.DEFAULT_COLOR=0,e.DEFAULT_ATTR=e.DEFAULT_COLOR<<9|256,e.DEFAULT_EXT=0,e.CHAR_DATA_ATTR_INDEX=0,e.CHAR_DATA_CHAR_INDEX=1,e.CHAR_DATA_WIDTH_INDEX=2,e.CHAR_DATA_CODE_INDEX=3,e.NULL_CELL_CHAR="",e.NULL_CELL_WIDTH=1,e.NULL_CELL_CODE=0,e.WHITESPACE_CELL_CHAR=" ",e.WHITESPACE_CELL_WIDTH=1,e.WHITESPACE_CELL_CODE=32},578(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.IntervalTimer=e.TimeoutTimer=void 0,e.timeout=function(t){return new Promise(e=>setTimeout(e,t))},e.disposableTimeout=function(t,e=0,i){const r=setTimeout(()=>{t(),i&&a.dispose()},e),a=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(a),a};const s=i(426);e.TimeoutTimer=class{_token=-1;_isDisposed=!1;dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(t,e){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,t()},e)}setIfNotSet(t,e){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,t()},e))}},e.IntervalTimer=class{_disposable;_isDisposed=!1;cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(t,e,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{t()},e);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},657(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.generateConfig=function(t,e,i,r,a,o,n,L,T=!0){const h={foreground:o.foreground,background:o.background,cursor:s.NULL_COLOR,cursorAccent:s.NULL_COLOR,selectionForeground:s.NULL_COLOR,selectionBackgroundTransparent:s.NULL_COLOR,selectionBackgroundOpaque:s.NULL_COLOR,selectionInactiveBackgroundTransparent:s.NULL_COLOR,selectionInactiveBackgroundOpaque:s.NULL_COLOR,overviewRulerBorder:s.NULL_COLOR,scrollbarSliderBackground:s.NULL_COLOR,scrollbarSliderHoverBackground:s.NULL_COLOR,scrollbarSliderActiveBackground:s.NULL_COLOR,ansi:o.ansi.slice(),contrastCache:o.contrastCache,halfContrastCache:o.halfContrastCache};return{customGlyphs:T,devicePixelRatio:n,deviceMaxTextureSize:L,letterSpacing:a.letterSpacing,lineHeight:a.lineHeight,deviceCellWidth:t,deviceCellHeight:e,deviceCharWidth:i,deviceCharHeight:r,fontFamily:a.fontFamily,fontSize:a.fontSize,fontWeight:a.fontWeight,fontWeightBold:a.fontWeightBold,allowTransparency:a.allowTransparency,drawBoldTextInBrightColors:a.drawBoldTextInBrightColors,minimumContrastRatio:a.minimumContrastRatio,colors:h}},e.configEquals=function(t,e){for(let i=0;i=0){if((0,r.configEquals)(i.config,c))return i.atlas;1===i.ownedBy.length?(i.atlas.dispose(),a.splice(e,1)):i.ownedBy.splice(s,1);break}}for(let e=0;e{const s=e.find(e=>e.target===t);if(!s)return;if(!("devicePixelContentBoxSize"in s))return r?.disconnect(),void(r=void 0);const a=s.devicePixelContentBoxSize[0].inlineSize,o=s.devicePixelContentBoxSize[0].blockSize;a>0&&o>0&&i(a,o)});try{r.observe(t,{box:["device-pixel-content-box"]})}catch{r.disconnect(),r=void 0}return(0,s.toDisposable)(()=>r?.disconnect())};const s=i(426)},719(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GLTexture=e.PROJECTION_MATRIX=void 0,e.createProgram=function(t,e,i){const a=(0,s.throwIfFalsy)(t.createProgram());if(t.attachShader(a,(0,s.throwIfFalsy)(r(t,t.VERTEX_SHADER,e))),t.attachShader(a,(0,s.throwIfFalsy)(r(t,t.FRAGMENT_SHADER,i))),t.linkProgram(a),t.getProgramParameter(a,t.LINK_STATUS))return a;console.error(t.getProgramInfoLog(a)),t.deleteProgram(a)},e.createShader=r,e.expandFloat32Array=function(t,e){const i=Math.min(2*t.length,e),s=new Float32Array(i);for(let e=0;e>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(t){this.fg=t[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let e=!1;if(t[r.CHAR_DATA_CHAR_INDEX].length>2)e=!0;else if(2===t[r.CHAR_DATA_CHAR_INDEX].length){const i=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|t[r.CHAR_DATA_WIDTH_INDEX]<<22:e=!0}else e=!0}else this.content=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[r.CHAR_DATA_WIDTH_INDEX]<<22;e&&(this.combinedData=t[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|t[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor())return!1;if(this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor())return!1;if(this.isInverse()!==t.isInverse())return!1;if(this.isBold()!==t.isBold())return!1;if(this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;const e=this.isUnderlineColorDefault(),i=t.isUnderlineColorDefault();if(!e||!i){if(e!==i)return!1;if(this.getUnderlineColor()!==t.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==t.getUnderlineColorMode())return!1}}return this.isOverline()===t.isOverline()&&this.isBlink()===t.isBlink()&&this.isInvisible()===t.isInvisible()&&this.isItalic()===t.isItalic()&&this.isDim()===t.isDim()&&this.isStrikethrough()===t.isStrikethrough()}}e.CellData=o},773(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.CursorBlinkStateManager=void 0,e.CursorBlinkStateManager=class{_renderCallback;_coreBrowserService;isCursorVisible;_animationFrame;_blinkStartTimeout;_blinkInterval;_idleTimeout;_isIdlePaused=!1;_animationTimeRestarted;constructor(t,e){this._renderCallback=t,this._coreBrowserService=e,this.isCursorVisible=!0,this._coreBrowserService.isFocused&&(this._restartInterval(),this._resetIdleTimer())}get isPaused(){return!(this._blinkStartTimeout||this._blinkInterval)}dispose(){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}restartBlinkAnimation(){this._isIdlePaused&&this._resetIdleTimer(),this.isPaused||(this._animationTimeRestarted=Date.now(),this.isCursorVisible=!0,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(t=600){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0)return void this._restartInterval(t)}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);return this._animationTimeRestarted=void 0,void this._restartInterval(t)}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},600)},t)}pause(){this.isCursorVisible=!0,this._isIdlePaused=!1,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){this._isIdlePaused=!1,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){this.isCursorVisible=!0,this._isIdlePaused=!0,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}}},814(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.INVERTED_DEFAULT_COLOR=void 0,e.INVERTED_DEFAULT_COLOR=257},816(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),i(267)},836(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.FourKeyMap=e.TwoKeyMap=void 0;class i{_data={};set(t,e,i){this._data[t]||(this._data[t]={}),this._data[t][e]=i}get(t,e){return this._data[t]?this._data[t][e]:void 0}clear(){this._data={}}}e.TwoKeyMap=i,e.FourKeyMap=class{_data=new i;set(t,e,s,r,a){this._data.get(t,e)||this._data.set(t,e,new i),this._data.get(t,e).set(s,r,a)}get(t,e,i,s){return this._data.get(t,e)?.get(i,s)}clear(){this._data.clear()}}},864(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.CellColorResolver=void 0;const s=i(564),r=i(993),a=i(959),o=i(915);let n,L=0,T=0,h=!1,l=!1,d=!1,c=0;e.CellColorResolver=class{_terminal;_optionService;_selectionRenderModel;_decorationService;_coreBrowserService;_themeService;result={fg:0,bg:0,ext:0};constructor(t,e,i,s,r,a){this._terminal=t,this._optionService=e,this._selectionRenderModel=i,this._decorationService=s,this._coreBrowserService=r,this._themeService=a}resolve(t,e,i,_,Q){this.result.bg=t.bg,this.result.fg=t.fg,this.result.ext=268435456&t.bg?t.extended.ext:0,T=0,L=0,l=!1,h=!1,d=!1,n=this._themeService.colors,c=0;const u=t.getCode();if(u!==s.NULL_CELL_CODE&&4===t.extended.underlineStyle){const t=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));c=e*_%(2*Math.round(t))}if(0===c&&o.blockPatternCodepoints.has(u)&&(c=e*_%2*2+i*Q%2),this._decorationService.forEachDecorationAtCell(e,i,"bottom",t=>{t.backgroundColorRGB&&(T=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,h=!0)}),d=this._selectionRenderModel.isCellSelected(this._terminal,e,i),d){if(67108864&this.result.fg||50331648&this.result.bg){if(67108864&this.result.fg)switch(50331648&this.result.fg){case 16777216:case 33554432:T=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:T=(16777215&this.result.fg)<<8|255;break;default:T=this._themeService.colors.foreground.rgba}else switch(50331648&this.result.bg){case 16777216:case 33554432:T=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:T=(16777215&this.result.bg)<<8|255}T=r.rgba.blend(T,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}else T=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(l=!0,n.selectionForeground&&(L=n.selectionForeground.rgba>>8&16777215,h=!0),(0,a.treatGlyphAsBackgroundColor)(t.getCode())){if(67108864&this.result.fg&&!(50331648&this.result.bg))L=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(67108864&this.result.fg)switch(50331648&this.result.bg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:L=(16777215&this.result.bg)<<8|255}else switch(50331648&this.result.fg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:L=(16777215&this.result.fg)<<8|255;break;default:L=this._themeService.colors.foreground.rgba}L=r.rgba.blend(L,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}h=!0}}this._decorationService.forEachDecorationAtCell(e,i,"top",t=>{t.backgroundColorRGB&&(T=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,h=!0)}),l&&(T=d?-16777216&t.bg&-134217729|T|50331648:-16777216&t.bg|T|50331648),h&&(L=-16777216&t.fg&-67108865|L|50331648),67108864&this.result.fg&&(l&&!h&&(L=50331648&this.result.bg?-134217728&this.result.fg|67108863&this.result.bg:-134217728&this.result.fg|16777215&n.background.rgba>>8|50331648,h=!0),!l&&h&&(T=50331648&this.result.fg?-67108864&this.result.bg|67108863&this.result.fg:-67108864&this.result.bg|16777215&n.foreground.rgba>>8|50331648,l=!0)),n=void 0,this.result.bg=l?T:this.result.bg,this.result.fg=h?L:this.result.fg,this.result.ext&=536870911,this.result.ext|=c<<29&3758096384}}},915(t,e,i){var s;function r(t){return{type:2,data:()=>{const e=[],i=[3/8,2/8,3/8],s=[0,3/8,5/8];for(let r=0;r<3;r++){const a=t>>2*r&1,o=t>>2*r+1&1;a&&o?e.push(`M0,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):a?e.push(`M0,${s[r]} L0.5,${s[r]} L0.5,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):o&&e.push(`M0.5,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0.5,${s[r]+i[r]} Z`)}return e.join(" ")}}}function a(t){const e=[],i=.15,s=.015,r=.075,a=.0375,o=.95;if(64&t){const t=.175,a=.2125,n=.25,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}if(32&t){const t=o-i,s=.875,r=o,n=.265,L=.485;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(16&t){const t=o-i,s=.875,r=o,n=.515,L=.735;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(8&t){const t=.75,a=.7875,n=.825,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}if(4&t){const t=.05,i=.125,s=.2,r=.515,o=.735;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(2&t){const t=.05,i=.125,s=.2,r=.265,o=.485;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(1&t){const t=.4625,a=.5,n=.5375,L=.2+s,T=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${T-r},${t} L${T},${a} L${T-r},${n} L${L+r},${n} Z`)}return e.join(" ")}Object.defineProperty(e,"__esModule",{value:!0}),e.blockPatternCodepoints=e.customGlyphDefinitions=void 0,i(215),function(t){t.LINE_H=Object.freeze({type:2,data:"M0,.5 L1,.5",strokeWidth:1}),t.LINE_V=Object.freeze({type:2,data:"M.5,0 L.5,1",strokeWidth:1}),t.FADE_RIGHT=Object.freeze({type:2,data:"M0,.5 L.28,.5 M.32,.5 L.52,.5 M.60,.5 L.72,.5 M.84,.5 L.90,.5",strokeWidth:1}),t.FADE_LEFT=Object.freeze({type:2,data:"M.10,.5 L.16,.5 M.28,.5 L.40,.5 M.48,.5 L.68,.5 M.72,.5 L1,.5",strokeWidth:1}),t.FADE_DOWN=Object.freeze({type:2,data:"M.5,0 L.5,.28 M.5,.32 L.5,.52 M.5,.60 L.5,.72 M.5,.84 L.5,.90",strokeWidth:1}),t.FADE_UP=Object.freeze({type:2,data:"M.5,.10 L.5,.16 M.5,.28 L.5,.40 M.5,.48 L.5,.68 M.5,.72 L.5,1",strokeWidth:1}),t.CURVE_DOWN_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_DOWN_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.CURVE_UP_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_UP_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.NODE_FILL=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5"}),t.NODE_STROKE=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5",strokeWidth:1}),t.NODE_LINE_RIGHT=Object.freeze({type:3,data:"M1,.5 L.85,.5",strokeWidth:1}),t.NODE_LINE_LEFT=Object.freeze({type:3,data:"M0,.5 L.15,.5",strokeWidth:1}),t.NODE_LINE_DOWN=Object.freeze({type:3,data:"M.5,1 L.5,.7",strokeWidth:1}),t.NODE_LINE_UP=Object.freeze({type:3,data:"M.5,0 L.5,.3",strokeWidth:1})}(s||(s={})),e.customGlyphDefinitions={"─":{type:2,data:"M0,.5 L1,.5",strokeWidth:1},"━":{type:2,data:"M0,.5 L1,.5",strokeWidth:3},"│":{type:2,data:"M.5,0 L.5,1",strokeWidth:1},"┃":{type:2,data:"M.5,0 L.5,1",strokeWidth:3},"┄":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:1},"┅":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:3},"┆":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:1},"┇":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:3},"┈":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:1},"┉":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:3},"┊":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:1},"┋":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:3},"┌":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},"┍":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┎":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┏":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3},"┐":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},"┑":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┒":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┓":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3},"└":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},"┕":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┖":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┗":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3},"┘":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},"┙":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┚":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┛":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3},"├":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},"┝":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┞":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┟":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┠":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┡":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┢":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┣":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3},"┤":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},"┥":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┦":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┧":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┨":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┩":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┪":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┫":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3},"┬":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},"┭":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┮":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┯":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┰":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┱":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┲":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┳":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3},"┴":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:1},"┵":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┶":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┷":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┸":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┹":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┺":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┻":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:3},"┼":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:1},"┽":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┾":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┿":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"╀":[{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"╁":[{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╂":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"╃":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"╄":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"╅":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"╆":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"╇":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:3}],"╈":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3}],"╉":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3}],"╊":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3}],"╋":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:3},"╌":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:1},"╍":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:3},"╎":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:1},"╏":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:3},"═":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"║":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╒":{type:2,data:(t,e)=>`M.5,1 L.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╓":{type:2,data:(t,e)=>`M${.5-t},1 L${.5-t},.5 L1,.5 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╔":{type:2,data:(t,e)=>`M1,${.5-e} L${.5-t},${.5-e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╕":{type:2,data:(t,e)=>`M0,${.5-e} L.5,${.5-e} L.5,1 M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╖":{type:2,data:(t,e)=>`M${.5+t},1 L${.5+t},.5 L0,.5 M${.5-t},.5 L${.5-t},1`,strokeWidth:1},"╗":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5+t},${.5-e} L${.5+t},1`,strokeWidth:1},"╘":{type:2,data:(t,e)=>`M.5,0 L.5,${.5+e} L1,${.5+e} M.5,${.5-e} L1,${.5-e}`,strokeWidth:1},"╙":{type:2,data:(t,e)=>`M1,.5 L${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╚":{type:2,data:(t,e)=>`M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0 M1,${.5+e} L${.5-t},${.5+e} L${.5-t},0`,strokeWidth:1},"╛":{type:2,data:(t,e)=>`M0,${.5+e} L.5,${.5+e} L.5,0 M0,${.5-e} L.5,${.5-e}`,strokeWidth:1},"╜":{type:2,data:(t,e)=>`M0,.5 L${.5+t},.5 L${.5+t},0 M${.5-t},.5 L${.5-t},0`,strokeWidth:1},"╝":{type:2,data:(t,e)=>`M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M0,${.5+e} L${.5+t},${.5+e} L${.5+t},0`,strokeWidth:1},"╞":{type:2,data:(t,e)=>`M.5,0 L.5,1 M.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╟":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1 M${.5+t},.5 L1,.5`,strokeWidth:1},"╠":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╡":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L.5,${.5-e} M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╢":{type:2,data:(t,e)=>`M0,.5 L${.5-t},.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╣":{type:2,data:(t,e)=>`M${.5+t},0 L${.5+t},1 M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0`,strokeWidth:1},"╤":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e} M.5,${.5+e} L.5,1`,strokeWidth:1},"╥":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},1 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╦":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╧":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e} M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╨":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╩":{type:2,data:(t,e)=>`M0,${.5+e} L1,${.5+e} M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╪":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╫":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╬":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╭":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╮":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╯":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╰":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╱":{type:2,data:"M1,0 L0,1",strokeWidth:1},"╲":{type:2,data:"M0,0 L1,1",strokeWidth:1},"╳":{type:2,data:"M1,0 L0,1 M0,0 L1,1",strokeWidth:1},"╴":{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},"╵":{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},"╶":{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},"╷":{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},"╸":{type:2,data:"M.5,.5 L0,.5",strokeWidth:3},"╹":{type:2,data:"M.5,.5 L.5,0",strokeWidth:3},"╺":{type:2,data:"M.5,.5 L1,.5",strokeWidth:3},"╻":{type:2,data:"M.5,.5 L.5,1",strokeWidth:3},"╼":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"╽":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╾":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"╿":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"▀":{type:0,data:[{x:0,y:0,w:8,h:4}]},"▁":{type:0,data:[{x:0,y:7,w:8,h:1}]},"▂":{type:0,data:[{x:0,y:6,w:8,h:2}]},"▃":{type:0,data:[{x:0,y:5,w:8,h:3}]},"▄":{type:0,data:[{x:0,y:4,w:8,h:4}]},"▅":{type:0,data:[{x:0,y:3,w:8,h:5}]},"▆":{type:0,data:[{x:0,y:2,w:8,h:6}]},"▇":{type:0,data:[{x:0,y:1,w:8,h:7}]},"█":{type:0,data:[{x:0,y:0,w:8,h:8}]},"▉":{type:0,data:[{x:0,y:0,w:7,h:8}]},"▊":{type:0,data:[{x:0,y:0,w:6,h:8}]},"▋":{type:0,data:[{x:0,y:0,w:5,h:8}]},"▌":{type:0,data:[{x:0,y:0,w:4,h:8}]},"▍":{type:0,data:[{x:0,y:0,w:3,h:8}]},"▎":{type:0,data:[{x:0,y:0,w:2,h:8}]},"▏":{type:0,data:[{x:0,y:0,w:1,h:8}]},"▐":{type:0,data:[{x:4,y:0,w:4,h:8}]},"░":{type:1,data:[[1,0],[0,0]]},"▒":{type:1,data:[[1,0],[0,1]]},"▓":{type:1,data:[[1,1],[1,0]]},"▔":{type:0,data:[{x:0,y:0,w:8,h:1}]},"▕":{type:0,data:[{x:7,y:0,w:1,h:8}]},"▖":{type:0,data:[{x:0,y:4,w:4,h:4}]},"▗":{type:0,data:[{x:4,y:4,w:4,h:4}]},"▘":{type:0,data:[{x:0,y:0,w:4,h:4}]},"▙":{type:0,data:[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"▚":{type:0,data:[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}]},"▛":{type:0,data:[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}]},"▜":{type:0,data:[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}]},"▝":{type:0,data:[{x:4,y:0,w:4,h:4}]},"▞":{type:0,data:[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}]},"▟":{type:0,data:[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"":{type:5,data:{d:"M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.7,.03 L.7,.1 L.355,.1 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M0,0 L1,.5 L0,1",type:0,rightPadding:2}},"":{type:5,data:{d:"M-1,-.5 L1,.5 L-1,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L0,.5 L1,1",type:0,leftPadding:2}},"":{type:5,data:{d:"M2,-.5 L0,.5 L2,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0",type:0,rightPadding:1}},"":{type:5,data:{d:"M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0",type:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0",type:0,leftPadding:1}},"":{type:5,data:{d:"M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0",type:1,leftPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L-.5,1.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L1.5,1.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L-.5,-.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L1.5,-.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0.8069,0.6075 Q0.7991,0.612,0.7815,0.6218 T0.7542,0.6369 T0.7282,0.6508 T0.7016,0.6644 T0.6778,0.6758 T0.6551,0.6854 T0.637,0.6911 T0.622,0.6936 Q0.6186,0.6936,0.6128,0.6942 T0.6018,0.6952 T0.5894,0.6956 T0.5757,0.6952 T0.561,0.693 T0.5451,0.6879 Q0.5342,0.6834,0.5193,0.6756 T0.4925,0.6614 T0.4673,0.6485 T0.4436,0.6389 T0.4239,0.6363 T0.4089,0.6422 Q0.4045,0.6467,0.3958,0.6554 T0.3843,0.6669 T0.3748,0.6746 T0.3613,0.683 T0.3442,0.6903 L0.3499,0.6956 Q0.3584,0.7042,0.3679,0.7177 T0.3836,0.7403 T0.4019,0.7583 T0.4295,0.7703 Q0.4688,0.7785,0.5203,0.7605 Q0.4902,0.7817,0.449,0.7911 T0.3696,0.8001 Q0.3567,0.7997,0.341,0.7942 T0.3111,0.7813 T0.2805,0.766 T0.2493,0.753 T0.218,0.7472 T0.187,0.754 Q0.1762,0.7589,0.1653,0.7681 T0.1453,0.7895 T0.1308,0.818 T0.1264,0.8507 L0.1264,0.858 Q0.1453,0.8462,0.1607,0.8433 T0.1899,0.8452 T0.2226,0.8625 Q0.2344,0.8707,0.2485,0.8752 T0.2813,0.8831 T0.3089,0.889 Q0.2981,0.8915,0.2846,0.8956 T0.2603,0.9037 T0.2368,0.9115 T0.2129,0.9174 T0.1894,0.9194 T0.1653,0.9155 Q0.1504,0.9115,0.1328,0.9196 T0.0986,0.9421 T0.0655,0.9708 T0.0317,0.9943 T0,1 Q0.001,0.94,0.0017,0.6508 T0.0024,0.2322 Q0.002,0.2109,0.0007,0.1379 T0,0.0249 Q0.0058,0.0253,0.009,0.0257 T0.0156,0.0288 T0.0203,0.0333 T0.0264,0.0422 T0.0349,0.0539 Q0.044,0.0657,0.0542,0.072 T0.0752,0.0789 T0.096,0.0775 T0.1175,0.0694 T0.1379,0.0573 T0.1579,0.0432 Q0.2141,0,0.2673,0.0318 Q0.2588,0.0318,0.2517,0.033 T0.239,0.0361 T0.2276,0.0424 T0.218,0.0504 T0.2085,0.0612 T0.1997,0.0732 T0.1899,0.0875 T0.1795,0.1028 Q0.1748,0.1093,0.1507,0.1234 T0.1186,0.1448 Q0.0999,0.1612,0.1108,0.1783 Q0.1162,0.1877,0.1238,0.1928 T0.1404,0.1979 T0.158,0.1967 T0.1775,0.1895 T0.1963,0.1791 T0.2151,0.1663 T0.2314,0.1534 T0.2458,0.141 T0.2561,0.1322 Q0.2815,0.1106,0.3017,0.101 T0.344,0.0926 T0.3919,0.1077 Q0.4099,0.1175,0.4265,0.1206 T0.4588,0.1202 T0.488,0.1098 T0.5176,0.0914 Q0.5071,0.1032,0.4963,0.1122 T0.4722,0.1285 T0.4497,0.1401 T0.4223,0.1518 T0.3943,0.1636 Q0.374,0.1726,0.3623,0.1863 T0.3482,0.2162 Q0.3445,0.2403,0.3608,0.2605 T0.4072,0.2876 Q0.4194,0.2909,0.4326,0.2899 T0.457,0.2856 T0.4805,0.2764 T0.5032,0.264 T0.5256,0.2497 T0.5478,0.2352 T0.5698,0.2222 T0.5921,0.2118 Q0.6982,0.173,0.81,0.2436 Q0.833,0.2583,0.8496,0.2674 T0.887,0.2846 T0.925,0.294 T0.9617,0.2911 T1,0.2746 Q0.9817,0.2946,0.96,0.3056 T0.9175,0.3191 T0.8731,0.3209 T0.8277,0.3164 T0.7818,0.3109 T0.7359,0.3101 T0.6907,0.3197 T0.647,0.3448 Q0.6443,0.3472,0.6333,0.3562 T0.6194,0.3674 T0.6076,0.3766 T0.594,0.386 T0.5815,0.3931 T0.5661,0.4007 T0.5501,0.4064 L0.5556,0.4121 Q0.5627,0.4198,0.5722,0.4251 T0.5926,0.4337 T0.6131,0.439 T0.6343,0.4431 T0.6521,0.4468 Q0.6762,0.4529,0.7056,0.4463 Q0.6772,0.4623,0.6543,0.471 T0.604,0.4814 Q0.5962,0.4818,0.5815,0.4825 T0.5591,0.4835 T0.5393,0.4857 T0.519,0.4908 T0.5007,0.4996 T0.4812,0.5141 T0.4627,0.5353 L0.4549,0.5451 L0.4661,0.543 Q0.4759,0.541,0.4909,0.55 T0.5315,0.5796 T0.5684,0.6083 Q0.5799,0.6161,0.5915,0.6212 T0.6181,0.6308 L0.6331,0.6353 Q0.6514,0.6418,0.7004,0.633 T0.8069,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.011,0.0469 Q0.0152,0.0412,0.02,0.0361 T0.0292,0.0281 T0.0365,0.0253 Q0.0426,0.0257,0.0461,0.0263 T0.0534,0.03 T0.0582,0.0345 T0.0645,0.0434 T0.0723,0.0546 Q0.0807,0.0653,0.0897,0.0712 T0.1081,0.0775 T0.1255,0.0765 T0.1438,0.0695 T0.1605,0.0593 T0.1772,0.0473 Q0.2388,0,0.2927,0.0326 L0.2614,0.0767 L0.2581,0.0767 Q0.2494,0.0767,0.2444,0.0773 T0.2359,0.0789 T0.2291,0.0842 T0.2233,0.0922 T0.2146,0.1054 T0.2026,0.1236 Q0.1943,0.1354,0.1843,0.1444 T0.1675,0.1574 T0.1526,0.1654 T0.1429,0.1701 Q0.1436,0.1717,0.1446,0.1733 Q0.1504,0.1823,0.1581,0.1866 T0.1733,0.1915 T0.1909,0.1884 T0.2085,0.1809 T0.2265,0.169 T0.2427,0.1564 T0.2575,0.1436 T0.2688,0.1338 Q0.2969,0.1101,0.3195,0.0995 T0.3643,0.0901 T0.4114,0.1048 Q0.4279,0.1138,0.4426,0.1168 T0.4705,0.117 T0.4945,0.1089 T0.5186,0.0942 L0.5211,0.0926 L0.5289,0.0922 Q0.5411,0.095,0.5189,0.1203 Q0.505,0.1358,0.4913,0.1474 T0.4634,0.167 T0.4389,0.1796 T0.4121,0.1909 T0.3866,0.2011 Q0.3782,0.2047,0.3727,0.2092 Q0.3717,0.2117,0.3714,0.2141 Q0.3695,0.2263,0.3751,0.2388 T0.394,0.2616 T0.4253,0.2761 Q0.4398,0.2785,0.4513,0.2779 T0.4756,0.2724 T0.4987,0.2616 T0.5216,0.2476 T0.5453,0.2321 T0.5703,0.217 T0.5976,0.2043 Q0.7015,0.166,0.8099,0.2345 Q0.8361,0.2512,0.8572,0.2616 T0.9006,0.2779 T0.9432,0.281 T0.9819,0.2663 L0.9887,0.2671 Q1,0.272,0.9768,0.2969 Q0.9558,0.3193,0.9319,0.333 T0.8858,0.3513 T0.8391,0.3569 T0.793,0.3546 T0.7485,0.3497 T0.7065,0.3473 T0.6681,0.3522 T0.6344,0.3699 Q0.5963,0.4005,0.5847,0.4082 Q0.595,0.4139,0.6079,0.4174 T0.6367,0.4237 T0.6586,0.4278 Q0.6793,0.4331,0.7044,0.4278 Q0.7057,0.4278,0.707,0.4274 Q0.7067,0.429,0.7062,0.4313 T0.7036,0.4398 T0.6993,0.4513 T0.6931,0.4619 T0.6851,0.4698 Q0.6573,0.4857,0.6336,0.4947 T0.5831,0.5053 Q0.577,0.5057,0.5615,0.5065 T0.5394,0.508 T0.5221,0.51 T0.5042,0.5139 T0.4905,0.5204 Q0.5008,0.5232,0.515,0.5328 T0.549,0.5585 T0.5799,0.5824 Q0.5905,0.5897,0.6013,0.5946 T0.6262,0.6036 T0.6409,0.6081 Q0.6567,0.6134,0.7035,0.605 T0.8028,0.5816 L0.7974,0.5922 T0.7867,0.6134 L0.7812,0.624 Q0.7799,0.6248,0.7786,0.6252 Q0.7709,0.6297,0.7573,0.6372 T0.7346,0.6499 T0.7123,0.6621 T0.6897,0.6741 T0.6686,0.6847 T0.648,0.6941 T0.6297,0.7013 T0.6129,0.7062 T0.5995,0.7076 Q0.5992,0.7076,0.5831,0.7092 T0.5529,0.7092 T0.525,0.7019 Q0.5105,0.6958,0.4824,0.6811 T0.4356,0.6586 T0.4079,0.6533 Q0.3872,0.6741,0.3827,0.6782 Q0.3872,0.6835,0.3934,0.6929 T0.4037,0.7078 T0.4142,0.7198 T0.4284,0.7304 T0.4466,0.7365 Q0.4824,0.7443,0.5292,0.728 L0.5292,0.7294 T0.529,0.7333 T0.5284,0.7392 T0.5266,0.7463 T0.5236,0.7539 T0.5186,0.7612 T0.5111,0.7679 Q0.5086,0.77,0.5056,0.7716 Q0.4466,0.8104,0.3591,0.8091 Q0.344,0.8091,0.3246,0.802 T0.2893,0.7863 T0.2543,0.77 T0.2206,0.76 T0.1894,0.7643 Q0.1804,0.7684,0.1733,0.7753 Q0.1591,0.7892,0.1601,0.8169 Q0.1784,0.8067,0.1938,0.8053 T0.2212,0.8087 T0.2507,0.825 Q0.2614,0.8324,0.2744,0.8365 T0.3053,0.844 T0.332,0.8499 L0.303,0.894 L0.2998,0.8948 Q0.2904,0.8968,0.278,0.9009 T0.2554,0.9086 T0.233,0.9158 T0.2099,0.9213 T0.1868,0.9229 T0.1633,0.9192 Q0.1517,0.916,0.1367,0.9241 T0.1065,0.9462 T0.0753,0.9737 T0.041,0.9955 T0.0061,1 Q0,0.9984,0.0068,0.9845 Q0.009,0.9804,0.0123,0.9755 Q0.0187,0.9666,0.0261,0.9606 T0.0374,0.9555 Q0.0481,0.958,0.0629,0.949 T0.0931,0.9262 T0.1247,0.8987 T0.1597,0.8777 T0.1949,0.8752 Q0.213,0.8805,0.2343,0.8772 Q0.2249,0.8736,0.2172,0.8683 Q0.1914,0.8511,0.1738,0.8497 T0.1349,0.8617 Q0.1304,0.865,0.1273,0.8646 T0.1242,0.8605 L0.1239,0.8536 Q0.1226,0.8295,0.1354,0.8016 T0.1723,0.7496 Q0.1914,0.7308,0.2104,0.7219 Q0.2259,0.7145,0.2425,0.7141 T0.273,0.7182 T0.3022,0.7296 T0.3301,0.7439 T0.3569,0.7571 T0.3821,0.7643 Q0.373,0.7561,0.3598,0.7367 T0.3382,0.7088 L0.3327,0.7035 Q0.3282,0.699,0.3353,0.6884 T0.3517,0.6692 L0.3611,0.6607 Q0.3711,0.657,0.3751,0.6554 T0.3837,0.6507 T0.3898,0.6458 T0.3979,0.637 T0.4111,0.6236 Q0.4172,0.6175,0.4234,0.6138 Q0.4327,0.6081,0.4439,0.6077 T0.4655,0.6105 T0.4884,0.6201 T0.5113,0.633 T0.5347,0.6468 T0.5573,0.6578 Q0.5705,0.6635,0.5833,0.665 T0.6112,0.665 T0.6286,0.6635 Q0.6421,0.6631,0.6718,0.6501 Q0.626,0.6578,0.6089,0.6517 Q0.6063,0.6509,0.6002,0.6491 T0.5884,0.6454 T0.5757,0.6409 T0.561,0.6344 T0.5466,0.6256 Q0.536,0.6183,0.5108,0.5989 T0.4722,0.5712 T0.4527,0.564 L0.4418,0.5661 Q0.4308,0.5681,0.4366,0.5563 Q0.4401,0.5485,0.4476,0.5392 L0.455,0.5298 Q0.4631,0.5188,0.4714,0.51 T0.4866,0.4947 T0.5021,0.4831 T0.516,0.4749 T0.53,0.4694 T0.5428,0.4657 T0.5558,0.4637 T0.5674,0.4629 T0.5795,0.4625 Q0.5489,0.4543,0.534,0.4384 L0.5286,0.4327 Q0.5244,0.4282,0.5318,0.4174 T0.5486,0.398 L0.5579,0.3899 Q0.567,0.387,0.5749,0.3834 T0.5878,0.3772 T0.6012,0.3687 T0.6126,0.3603 T0.6268,0.3487 T0.6421,0.3361 Q0.6567,0.3242,0.6712,0.3159 T0.7009,0.3034 T0.7277,0.2973 T0.7556,0.2961 T0.7809,0.2977 T0.8074,0.301 Q0.8096,0.3014,0.8109,0.3016 T0.8143,0.302 T0.818,0.3026 Q0.8012,0.2936,0.7767,0.2781 Q0.6718,0.2117,0.5744,0.2476 Q0.5637,0.2516,0.5492,0.2604 T0.5218,0.2779 T0.4923,0.2965 T0.461,0.3124 T0.4282,0.3216 T0.394,0.3206 Q0.3637,0.3136,0.3474,0.2936 T0.3353,0.2459 Q0.3385,0.2247,0.355,0.2023 Q0.3646,0.1892,0.3763,0.179 Q0.3898,0.1672,0.4063,0.1595 Q0.3927,0.1562,0.3785,0.1485 Q0.3604,0.1387,0.3453,0.1354 T0.3161,0.1354 T0.2899,0.1458 T0.2627,0.166 Q0.262,0.1664,0.254,0.1733 T0.2414,0.1841 T0.2267,0.196 T0.2094,0.2086 T0.1915,0.2196 T0.1725,0.2288 T0.1546,0.2337 T0.1373,0.2343 T0.1223,0.228 T0.1097,0.2145 Q0.0962,0.1929,0.1258,0.1582 Q0.131,0.1517,0.1371,0.1464 Q0.1462,0.1387,0.156,0.1321 T0.1752,0.1205 T0.1878,0.1134 Q0.1891,0.1117,0.1947,0.103 T0.2035,0.0899 T0.2125,0.0781 T0.2243,0.064 Q0.1949,0.0616,0.1672,0.0828 Q0.153,0.0934,0.1412,0.1009 T0.1146,0.1146 T0.0873,0.1213 T0.0616,0.116 T0.0374,0.0962 Q0.0339,0.0909,0.0295,0.0846 T0.024,0.0767 T0.0202,0.0728 T0.0148,0.0704 T0.0068,0.0697 Q0.0003,0.0693,0.0058,0.0567 Q0.0077,0.0522,0.011,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.1934,0.6075 Q0.2012,0.612,0.2188,0.6218 T0.2459,0.6369 T0.2718,0.6508 T0.2983,0.6644 T0.322,0.6758 T0.3447,0.6854 T0.363,0.6911 T0.3783,0.6936 Q0.3796,0.6936,0.396,0.6952 T0.4265,0.6952 T0.4548,0.6879 Q0.4656,0.6834,0.4805,0.6756 T0.5073,0.6614 T0.5327,0.6485 T0.5564,0.6389 T0.5759,0.6363 T0.5909,0.6422 Q0.5953,0.6467,0.604,0.6554 T0.6155,0.6669 T0.6251,0.6746 T0.6387,0.683 T0.6556,0.6903 L0.6498,0.6956 Q0.6414,0.7042,0.6319,0.7177 T0.6162,0.7403 T0.5979,0.7583 T0.5703,0.7703 Q0.5313,0.7785,0.4799,0.7605 Q0.5097,0.7817,0.5508,0.7911 T0.6302,0.8001 Q0.6431,0.7997,0.659,0.7942 T0.6888,0.7813 T0.7194,0.766 T0.7506,0.753 T0.7819,0.7472 T0.8131,0.754 Q0.8239,0.7589,0.8346,0.7681 T0.8544,0.7895 T0.8689,0.818 T0.8737,0.8507 L0.8733,0.858 Q0.8591,0.849,0.8474,0.845 T0.8242,0.8421 T0.8021,0.8482 T0.7772,0.8625 Q0.7653,0.8707,0.7513,0.8752 T0.7184,0.8831 T0.6908,0.889 Q0.7017,0.8915,0.7152,0.8956 T0.7396,0.9037 T0.7633,0.9115 T0.7872,0.9174 T0.8107,0.9194 T0.8347,0.9155 Q0.8493,0.9115,0.8671,0.9196 T0.9015,0.9421 T0.9345,0.9708 T0.9682,0.9943 T0.9997,1 Q0.999,0.94,0.9983,0.6508 T0.9976,0.2322 Q0.9976,0.2109,0.9992,0.1379 T1,0.0249 Q0.9949,0.0249,0.9917,0.0257 T0.9863,0.0273 T0.9817,0.0308 T0.9776,0.0357 T0.9721,0.0437 T0.9651,0.0539 Q0.956,0.0657,0.9456,0.072 T0.9247,0.0789 T0.904,0.0775 T0.8823,0.0694 T0.862,0.0573 T0.8422,0.0432 Q0.7856,0,0.7328,0.0318 Q0.7392,0.0318,0.7452,0.0324 T0.756,0.0345 T0.7657,0.0386 T0.7741,0.0437 T0.7821,0.0506 T0.7894,0.0588 T0.7966,0.0683 T0.8039,0.0787 T0.8119,0.0904 T0.8202,0.1028 Q0.8249,0.1093,0.849,0.1234 T0.8811,0.1448 Q0.8998,0.1612,0.8893,0.1783 Q0.8835,0.1877,0.8759,0.1928 T0.8593,0.1979 T0.8417,0.1967 T0.8222,0.1895 T0.8034,0.1791 T0.7848,0.1663 T0.7687,0.1534 T0.7543,0.141 T0.744,0.1322 Q0.7186,0.1106,0.6984,0.101 T0.6559,0.0926 T0.6082,0.1077 Q0.5899,0.1175,0.5733,0.1206 T0.5411,0.1202 T0.5122,0.1098 T0.4822,0.0914 Q0.4927,0.1032,0.5036,0.1122 T0.5276,0.1285 T0.5503,0.1401 T0.5777,0.1518 T0.6055,0.1636 Q0.6258,0.1726,0.6375,0.1863 T0.6519,0.2162 Q0.6556,0.2403,0.6392,0.2605 T0.5926,0.2876 Q0.5804,0.2909,0.5672,0.2899 T0.5428,0.2856 T0.5193,0.2764 T0.4966,0.264 T0.4744,0.2497 T0.4523,0.2352 T0.4301,0.2222 T0.4077,0.2118 Q0.3021,0.173,0.1903,0.2436 Q0.1669,0.2583,0.1504,0.2674 T0.1129,0.2846 T0.075,0.294 T0.0384,0.2911 T0,0.2746 Q0.021,0.297,0.0459,0.3082 T0.0953,0.3207 T0.146,0.3193 T0.1988,0.3131 T0.2508,0.3095 T0.3029,0.3176 T0.3529,0.3448 Q0.3566,0.348,0.3652,0.355 T0.3771,0.3645 T0.3877,0.3731 T0.3993,0.3815 T0.4103,0.3884 T0.4226,0.3953 T0.4352,0.4009 T0.4497,0.4064 L0.4443,0.4121 Q0.4372,0.4198,0.4277,0.4251 T0.4072,0.4337 T0.3867,0.439 T0.3656,0.4431 T0.3481,0.4468 Q0.3241,0.4529,0.2943,0.4463 Q0.3227,0.4623,0.3458,0.471 T0.3959,0.4814 Q0.404,0.4818,0.4187,0.4825 T0.4409,0.4835 T0.4605,0.4857 T0.4809,0.4908 T0.4992,0.4996 T0.5186,0.5141 T0.5371,0.5353 L0.5449,0.5451 L0.5337,0.543 Q0.5242,0.541,0.5091,0.55 T0.4683,0.5796 T0.4314,0.6083 Q0.4203,0.6161,0.4086,0.6212 T0.382,0.6308 T0.3667,0.6353 Q0.3485,0.6418,0.2995,0.633 T0.1934,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.989,0.0469 Q0.9829,0.0379,0.9753,0.0316 T0.9635,0.0253 Q0.9574,0.0257,0.9539,0.0263 T0.9466,0.03 T0.9418,0.0345 T0.9355,0.0434 T0.9277,0.0546 Q0.9193,0.0653,0.9103,0.0712 T0.8919,0.0775 T0.8745,0.0765 T0.8564,0.0695 T0.8396,0.0593 T0.8228,0.0473 Q0.7612,0,0.7073,0.0326 L0.7386,0.0767 L0.7419,0.0767 Q0.7506,0.0767,0.7556,0.0773 T0.7641,0.0789 T0.7709,0.0842 T0.7767,0.0922 T0.7854,0.1054 T0.7974,0.1236 Q0.8057,0.1354,0.8157,0.1444 T0.8325,0.1574 T0.8474,0.1654 T0.8571,0.1701 Q0.8564,0.1717,0.8554,0.1733 Q0.8503,0.1815,0.844,0.186 T0.8301,0.1909 T0.8153,0.1903 T0.7993,0.1845 T0.7836,0.1758 T0.768,0.1648 T0.754,0.1535 T0.741,0.1425 T0.7312,0.1338 Q0.7031,0.1101,0.6805,0.0995 T0.6357,0.0901 T0.5886,0.1048 Q0.5721,0.1138,0.5574,0.1168 T0.5295,0.117 T0.5055,0.1089 T0.4814,0.0942 L0.4789,0.0926 L0.4711,0.0922 Q0.4589,0.095,0.4811,0.1203 Q0.495,0.1358,0.5087,0.1474 T0.5366,0.167 T0.5611,0.1796 T0.5879,0.1909 T0.6134,0.2011 Q0.6218,0.2047,0.6273,0.2092 Q0.6283,0.2117,0.6286,0.2141 Q0.6305,0.2263,0.6249,0.2388 T0.606,0.2616 T0.5747,0.2761 Q0.5602,0.2785,0.5487,0.2779 T0.5245,0.2724 T0.5015,0.2616 T0.4784,0.2476 T0.4547,0.2321 T0.4297,0.217 T0.4024,0.2043 Q0.2985,0.166,0.1901,0.2345 Q0.1694,0.248,0.1523,0.2569 T0.1165,0.2726 T0.0818,0.2812 T0.0494,0.2796 T0.0181,0.2663 L0.0116,0.2671 Q0,0.272,0.0232,0.2969 Q0.0468,0.3222,0.0742,0.3365 T0.1278,0.3542 T0.1807,0.3564 T0.233,0.352 T0.2815,0.3473 T0.3269,0.3509 T0.3656,0.3699 Q0.4037,0.4005,0.4153,0.4082 Q0.405,0.4139,0.3921,0.4174 T0.3633,0.4237 T0.3414,0.4278 Q0.3207,0.4331,0.2956,0.4278 Q0.2943,0.4278,0.293,0.4274 Q0.2933,0.429,0.2938,0.4313 T0.2964,0.4398 T0.3007,0.4513 T0.3069,0.4619 T0.3149,0.4698 Q0.3427,0.4857,0.3664,0.4947 T0.4169,0.5053 Q0.423,0.5057,0.4385,0.5065 T0.4606,0.508 T0.4779,0.51 T0.4958,0.5139 T0.5095,0.5204 Q0.4992,0.5232,0.485,0.5328 T0.451,0.5585 T0.4201,0.5824 Q0.4095,0.5897,0.3987,0.5946 T0.3738,0.6036 T0.3591,0.6081 Q0.3433,0.6134,0.2965,0.605 T0.1972,0.5816 L0.2026,0.5922 T0.2133,0.6134 L0.2188,0.624 Q0.2201,0.6248,0.2214,0.6252 Q0.2291,0.6297,0.2486,0.6405 T0.2786,0.657 T0.3066,0.6721 T0.3353,0.6866 T0.3598,0.6972 T0.383,0.7051 T0.4005,0.7076 Q0.4008,0.7076,0.4169,0.7092 T0.4471,0.7092 T0.475,0.7019 Q0.4895,0.6958,0.5176,0.6811 T0.5644,0.6586 T0.5921,0.6533 Q0.6128,0.6741,0.6173,0.6782 Q0.6131,0.6835,0.6068,0.6929 T0.5963,0.7078 T0.5858,0.7198 T0.5716,0.7304 T0.5534,0.7365 Q0.5179,0.7443,0.4708,0.728 L0.4708,0.7294 T0.471,0.7333 T0.4716,0.7392 T0.4734,0.7463 T0.4764,0.7539 T0.4814,0.7612 T0.4889,0.7679 Q0.4914,0.77,0.4944,0.7716 Q0.5534,0.8104,0.6409,0.8091 Q0.6521,0.8091,0.6654,0.8053 T0.6907,0.7959 T0.7157,0.7838 T0.741,0.772 T0.7652,0.7631 T0.7888,0.7594 T0.8106,0.7643 Q0.8196,0.7684,0.8267,0.7753 Q0.8409,0.7892,0.8399,0.8169 Q0.8216,0.8067,0.8062,0.8053 T0.7788,0.8087 T0.7493,0.825 Q0.7386,0.8324,0.7256,0.8365 T0.6947,0.844 T0.668,0.8499 L0.697,0.894 L0.7002,0.8948 Q0.7099,0.8968,0.7222,0.9009 T0.7446,0.9086 T0.767,0.9158 T0.7901,0.9213 T0.8132,0.9229 T0.8367,0.9192 Q0.8483,0.916,0.8633,0.9241 T0.8937,0.9462 T0.9248,0.9737 T0.959,0.9955 T0.9939,1 Q1,0.9984,0.9932,0.9845 Q0.991,0.9804,0.9877,0.9755 Q0.9813,0.9666,0.9739,0.9606 T0.9626,0.9555 Q0.9519,0.958,0.9372,0.949 T0.9071,0.9262 T0.8753,0.8987 T0.8403,0.8777 T0.8051,0.8752 Q0.787,0.8805,0.7657,0.8772 Q0.7751,0.8736,0.7828,0.8683 Q0.8086,0.8511,0.8262,0.8497 T0.8651,0.8617 Q0.8696,0.865,0.8727,0.8646 T0.8758,0.8605 L0.8761,0.8536 Q0.8774,0.8295,0.8646,0.8016 T0.8277,0.7496 Q0.8086,0.7308,0.7896,0.7219 Q0.7741,0.7145,0.7575,0.7141 T0.727,0.7182 T0.6978,0.7296 T0.6699,0.7439 T0.6433,0.7571 T0.6179,0.7643 Q0.627,0.7561,0.6402,0.7367 T0.6618,0.7088 L0.6673,0.7035 Q0.6718,0.699,0.6647,0.6884 T0.6483,0.6692 L0.6389,0.6607 Q0.6289,0.657,0.6249,0.6554 T0.6163,0.6507 T0.6102,0.6458 T0.6021,0.637 T0.5889,0.6236 Q0.5828,0.6175,0.5766,0.6138 Q0.5673,0.6081,0.5561,0.6077 T0.5345,0.6105 T0.5116,0.6201 T0.4887,0.633 T0.4653,0.6468 T0.4427,0.6578 Q0.4295,0.6635,0.4167,0.665 T0.3888,0.665 T0.3714,0.6635 Q0.3579,0.6631,0.3282,0.6501 Q0.374,0.6578,0.3911,0.6517 Q0.3937,0.6509,0.4024,0.6482 T0.4179,0.6434 T0.4348,0.6364 T0.4534,0.6256 Q0.464,0.6183,0.4892,0.5989 T0.5278,0.5712 T0.5476,0.564 L0.5582,0.5661 Q0.5692,0.5681,0.5634,0.5563 Q0.5599,0.5485,0.5524,0.5392 L0.545,0.5298 Q0.5369,0.5188,0.5286,0.51 T0.5134,0.4947 T0.4979,0.4831 T0.484,0.4749 T0.47,0.4694 T0.4572,0.4657 T0.4442,0.4637 T0.4326,0.4629 T0.4205,0.4625 Q0.4511,0.4543,0.466,0.4384 L0.4714,0.4327 Q0.4795,0.4237,0.4469,0.394 L0.4421,0.3899 Q0.433,0.387,0.4251,0.3834 T0.4122,0.3772 T0.3988,0.3687 T0.3874,0.3603 T0.3732,0.3487 T0.3579,0.3361 Q0.3433,0.3242,0.3288,0.3159 T0.2993,0.3034 T0.2725,0.2973 T0.2446,0.2961 T0.2193,0.2977 T0.1926,0.301 Q0.1855,0.3022,0.182,0.3026 Q0.1991,0.2936,0.2233,0.2781 Q0.3282,0.2117,0.4256,0.2476 Q0.4363,0.2516,0.451,0.2604 T0.4784,0.2779 T0.5077,0.2965 T0.539,0.3124 T0.5718,0.3216 T0.606,0.3206 Q0.6363,0.3136,0.6526,0.2936 T0.6647,0.2459 Q0.6615,0.2247,0.645,0.2023 Q0.6354,0.1892,0.6237,0.179 Q0.6102,0.1672,0.5937,0.1595 Q0.6073,0.1562,0.6215,0.1485 Q0.6396,0.1387,0.6547,0.1354 T0.6839,0.1354 T0.7101,0.1458 T0.7373,0.166 Q0.738,0.1664,0.746,0.1733 T0.7586,0.1841 T0.7733,0.196 T0.7907,0.2086 T0.8086,0.2196 T0.8275,0.2288 T0.8454,0.2337 T0.8627,0.2343 T0.8777,0.228 T0.8903,0.2145 Q0.9038,0.1929,0.8742,0.1582 Q0.869,0.1517,0.8629,0.1464 Q0.8538,0.1387,0.844,0.1321 T0.8248,0.1205 T0.8122,0.1134 Q0.8109,0.1117,0.8053,0.103 T0.7965,0.0899 T0.7875,0.0781 T0.7757,0.064 Q0.8051,0.0616,0.8328,0.0828 Q0.847,0.0934,0.8588,0.1009 T0.8854,0.1146 T0.9127,0.1213 T0.9384,0.116 T0.9626,0.0962 Q0.9664,0.0909,0.9706,0.0846 T0.976,0.0767 T0.9798,0.0728 T0.9852,0.0704 T0.9932,0.0697 Q0.9997,0.0693,0.9942,0.0567 Q0.9923,0.0522,0.989,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0.9099,0.1158 L0.9099,0.1888 L0.9951,0.1888 L0.9951,0.1158 L0.9099,0.1158 Z M0.9075,0.1116 L0.9976,0.1116 Q1,0.1116,1,0.1137 L1,0.1909 Q1,0.193,0.9976,0.193 L0.9075,0.193 Q0.905,0.193,0.905,0.1909 L0.905,0.1137 Q0.905,0.1116,0.9075,0.1116 Z M0.7852,0.6086 L0.7852,0.682 L0.8704,0.682 L0.8704,0.6086 L0.7852,0.6086 Z M0.7828,0.6044 L0.8729,0.6044 Q0.8753,0.6044,0.8753,0.6065 L0.8753,0.6841 Q0.8753,0.6862,0.8729,0.6862 L0.7828,0.6862 Q0.7803,0.6862,0.7803,0.6841 L0.7803,0.6065 Q0.7803,0.6044,0.7828,0.6044 Z M0.66,0.1137 L0.7501,0.1137 L0.7501,0.1909 L0.66,0.1909 L0.66,0.1137 Z M0.6624,0.4102 L0.6624,0.4836 L0.7477,0.4836 L0.7477,0.4102 L0.6624,0.4102 Z M0.66,0.406 L0.7501,0.406 Q0.7526,0.406,0.7526,0.4081 L0.7526,0.4857 Q0.7526,0.4878,0.7501,0.4878 L0.66,0.4878 Q0.6576,0.4878,0.6576,0.4857 L0.6576,0.4081 Q0.6576,0.406,0.66,0.406 Z M0.6624,0.7131 L0.6624,0.7865 L0.7477,0.7865 L0.7477,0.7131 L0.6624,0.7131 Z M0.66,0.7089 L0.7501,0.7089 Q0.7526,0.7089,0.7526,0.711 L0.7526,0.7886 Q0.7526,0.7907,0.7501,0.7907 L0.66,0.7907 Q0.6576,0.7907,0.6576,0.7886 L0.6576,0.711 Q0.6576,0.7089,0.66,0.7089 Z M0.5407,0.2227 L0.5407,0.2836 L0.6113,0.2836 L0.6113,0.2227 L0.5407,0.2227 Z M0.5309,0.206 L0.621,0.206 Q0.623,0.206,0.6249,0.2068 T0.6281,0.2089 T0.6301,0.2116 T0.6308,0.2143 L0.6308,0.2919 Q0.6308,0.2957,0.6276,0.298 T0.621,0.3003 L0.5309,0.3003 Q0.5265,0.3003,0.5239,0.2976 T0.5212,0.2919 L0.5212,0.2143 Q0.5212,0.211,0.5244,0.2085 T0.5309,0.206 Z M0.5407,0.3184 L0.5407,0.3792 L0.6113,0.3792 L0.6113,0.3184 L0.5407,0.3184 Z M0.5309,0.3016 L0.621,0.3016 Q0.6254,0.3016,0.6281,0.3043 T0.6308,0.31 L0.6308,0.3876 Q0.6308,0.3914,0.6276,0.3937 T0.621,0.396 L0.5309,0.396 Q0.5265,0.396,0.5239,0.3932 T0.5212,0.3876 L0.5212,0.31 Q0.5212,0.3062,0.5244,0.3039 T0.5309,0.3016 Z M0.5309,0.4081 L0.621,0.4081 L0.621,0.4857 L0.5309,0.4857 L0.5309,0.4081 Z M0.5309,0.5059 L0.621,0.5059 L0.621,0.5835 L0.5309,0.5835 L0.5309,0.5059 Z M0.5407,0.6149 L0.5407,0.6758 L0.6113,0.6758 L0.6113,0.6149 L0.5407,0.6149 Z M0.5309,0.5982 L0.621,0.5982 Q0.6254,0.5982,0.6281,0.6009 T0.6308,0.6065 L0.6308,0.6841 Q0.6308,0.6879,0.6276,0.6902 T0.621,0.6925 L0.5309,0.6925 Q0.5265,0.6925,0.5239,0.6898 T0.5212,0.6841 L0.5212,0.6065 Q0.5212,0.6028,0.5244,0.6005 T0.5309,0.5982 Z M0.4062,0.1137 L0.4963,0.1137 L0.4963,0.1909 L0.4062,0.1909 L0.4062,0.1137 Z M0.4062,0.2143 L0.4963,0.2143 L0.4963,0.2919 L0.4062,0.2919 L0.4062,0.2143 Z M0.4184,0.4186 L0.4184,0.4753 L0.4842,0.4753 L0.4842,0.4186 L0.4184,0.4186 Z M0.4062,0.3977 L0.4963,0.3977 Q0.5017,0.3977,0.5051,0.401 T0.5085,0.4081 L0.5085,0.4857 Q0.5085,0.4904,0.5046,0.4933 T0.4963,0.4962 L0.4062,0.4962 Q0.4009,0.4962,0.3975,0.4929 T0.3941,0.4857 L0.3941,0.4081 Q0.3941,0.4035,0.398,0.4006 T0.4062,0.3977 Z M0.4062,0.5059 L0.4963,0.5059 L0.4963,0.5835 L0.4062,0.5835 L0.4062,0.5059 Z M0.4087,0.7131 L0.4087,0.7865 L0.4939,0.7865 L0.4939,0.7131 L0.4087,0.7131 Z M0.4062,0.7089 L0.4963,0.7089 Q0.4988,0.7089,0.4988,0.711 L0.4988,0.7886 Q0.4988,0.7907,0.4963,0.7907 L0.4062,0.7907 Q0.4038,0.7907,0.4038,0.7886 L0.4038,0.711 Q0.4038,0.7089,0.4062,0.7089 Z M0.2689,0.1137 L0.359,0.1137 L0.359,0.1909 L0.2689,0.1909 L0.2689,0.1137 Z M0.2689,0.2143 L0.359,0.2143 L0.359,0.2919 L0.2689,0.2919 L0.2689,0.2143 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.31 L0.359,0.31 L0.359,0.3876 L0.2689,0.3876 L0.2689,0.31 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.4081 L0.359,0.4081 L0.359,0.4857 L0.2689,0.4857 L0.2689,0.4081 Z M0.2689,0.5059 L0.359,0.5059 L0.359,0.5835 L0.2689,0.5835 L0.2689,0.5059 Z M0.2689,0.711 L0.359,0.711 L0.359,0.7886 L0.2689,0.7886 L0.2689,0.711 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.1374,0.1137 L0.2275,0.1137 L0.2275,0.1909 L0.1374,0.1909 L0.1374,0.1137 Z M0.1374,0.2143 L0.2275,0.2143 L0.2275,0.2919 L0.1374,0.2919 L0.1374,0.2143 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.31 L0.2275,0.31 L0.2275,0.3876 L0.1374,0.3876 L0.1374,0.31 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.4081 L0.2275,0.4081 L0.2275,0.4857 L0.1374,0.4857 L0.1374,0.4081 Z M0.1374,0.5059 L0.2275,0.5059 L0.2275,0.5835 L0.1374,0.5835 L0.1374,0.5059 Z M0.1374,0.6065 L0.2275,0.6065 L0.2275,0.6841 L0.1374,0.6841 L0.1374,0.6065 Z M0.1374,0.711 L0.2275,0.711 L0.2275,0.7886 L0.1374,0.7886 L0.1374,0.711 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0,0.1137 L0.0901,0.1137 L0.0901,0.1909 L0,0.1909 L0,0.1137 Z M0,0.2143 L0.0901,0.2143 L0.0901,0.2919 L0,0.2919 L0,0.2143 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.31 L0.0901,0.31 L0.0901,0.3876 L0,0.3876 L0,0.31 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.4081 L0.0901,0.4081 L0.0901,0.4857 L0,0.4857 L0,0.4081 Z M0,0.5059 L0.0901,0.5059 L0.0901,0.5835 L0,0.5835 L0,0.5059 Z M0,0.6065 L0.0901,0.6065 L0.0901,0.6841 L0,0.6841 L0,0.6065 Z M0,0.711 L0.0901,0.711 L0.0901,0.7886 L0,0.7886 L0,0.711 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M0.0901,0.1158 L0.0901,0.1888 L0.0049,0.1888 L0.0049,0.1158 L0.0901,0.1158 Z M0.0925,0.1116 L0.0024,0.1116 Q0,0.1116,0,0.1137 L0,0.1909 Q0,0.193,0.0024,0.193 L0.0925,0.193 Q0.095,0.193,0.095,0.1909 L0.095,0.1137 Q0.095,0.1116,0.0925,0.1116 Z M0.2148,0.6086 L0.2148,0.682 L0.1296,0.682 L0.1296,0.6086 L0.2148,0.6086 Z M0.2172,0.6044 L0.1271,0.6044 Q0.1247,0.6044,0.1247,0.6065 L0.1247,0.6841 Q0.1247,0.6862,0.1271,0.6862 L0.2172,0.6862 Q0.2197,0.6862,0.2197,0.6841 L0.2197,0.6065 Q0.2197,0.6044,0.2172,0.6044 Z M0.34,0.1137 L0.2499,0.1137 L0.2499,0.1909 L0.34,0.1909 L0.34,0.1137 Z M0.3376,0.4102 L0.3376,0.4836 L0.2523,0.4836 L0.2523,0.4102 L0.3376,0.4102 Z M0.34,0.406 L0.2499,0.406 Q0.2474,0.406,0.2474,0.4081 L0.2474,0.4857 Q0.2474,0.4878,0.2499,0.4878 L0.34,0.4878 Q0.3424,0.4878,0.3424,0.4857 L0.3424,0.4081 Q0.3424,0.406,0.34,0.406 Z M0.3376,0.7131 L0.3376,0.7865 L0.2523,0.7865 L0.2523,0.7131 L0.3376,0.7131 Z M0.34,0.7089 L0.2499,0.7089 Q0.2474,0.7089,0.2474,0.711 L0.2474,0.7886 Q0.2474,0.7907,0.2499,0.7907 L0.34,0.7907 Q0.3424,0.7907,0.3424,0.7886 L0.3424,0.711 Q0.3424,0.7089,0.34,0.7089 Z M0.4593,0.2227 L0.4593,0.2836 L0.3887,0.2836 L0.3887,0.2227 L0.4593,0.2227 Z M0.4691,0.206 L0.379,0.206 Q0.3746,0.206,0.3719,0.2087 T0.3692,0.2143 L0.3692,0.2919 Q0.3692,0.2957,0.3724,0.298 T0.379,0.3003 L0.4691,0.3003 Q0.4735,0.3003,0.4761,0.2976 T0.4788,0.2919 L0.4788,0.2143 Q0.4788,0.211,0.4756,0.2085 T0.4691,0.206 Z M0.4593,0.3184 L0.4593,0.3792 L0.3887,0.3792 L0.3887,0.3184 L0.4593,0.3184 Z M0.4691,0.3016 L0.379,0.3016 Q0.3746,0.3016,0.3719,0.3043 T0.3692,0.31 L0.3692,0.3876 Q0.3692,0.3914,0.3724,0.3937 T0.379,0.396 L0.4691,0.396 Q0.4735,0.396,0.4761,0.3932 T0.4788,0.3876 L0.4788,0.31 Q0.4788,0.3062,0.4756,0.3039 T0.4691,0.3016 Z M0.4691,0.4081 L0.379,0.4081 L0.379,0.4857 L0.4691,0.4857 L0.4691,0.4081 Z M0.4691,0.5059 L0.379,0.5059 L0.379,0.5835 L0.4691,0.5835 L0.4691,0.5059 Z M0.4593,0.6149 L0.4593,0.6758 L0.3887,0.6758 L0.3887,0.6149 L0.4593,0.6149 Z M0.4691,0.5982 L0.379,0.5982 Q0.3746,0.5982,0.3719,0.6009 T0.3692,0.6065 L0.3692,0.6841 Q0.3692,0.6879,0.3724,0.6902 T0.379,0.6925 L0.4691,0.6925 Q0.4735,0.6925,0.4761,0.6898 T0.4788,0.6841 L0.4788,0.6065 Q0.4788,0.6028,0.4756,0.6005 T0.4691,0.5982 Z M0.5938,0.1137 L0.5037,0.1137 L0.5037,0.1909 L0.5938,0.1909 L0.5938,0.1137 Z M0.5938,0.2143 L0.5037,0.2143 L0.5037,0.2919 L0.5938,0.2919 L0.5938,0.2143 Z M0.5816,0.4186 L0.5816,0.4753 L0.5158,0.4753 L0.5158,0.4186 L0.5816,0.4186 Z M0.5938,0.3977 L0.5037,0.3977 Q0.4983,0.3977,0.4949,0.401 T0.4915,0.4081 L0.4915,0.4857 Q0.4915,0.4904,0.4954,0.4933 T0.5037,0.4962 L0.5938,0.4962 Q0.5991,0.4962,0.6025,0.4929 T0.6059,0.4857 L0.6059,0.4081 Q0.6059,0.4035,0.602,0.4006 T0.5938,0.3977 Z M0.5938,0.5059 L0.5037,0.5059 L0.5037,0.5835 L0.5938,0.5835 L0.5938,0.5059 Z M0.5913,0.7131 L0.5913,0.7865 L0.5061,0.7865 L0.5061,0.7131 L0.5913,0.7131 Z M0.5938,0.7089 L0.5037,0.7089 Q0.5012,0.7089,0.5012,0.711 L0.5012,0.7886 Q0.5012,0.7907,0.5037,0.7907 L0.5938,0.7907 Q0.5962,0.7907,0.5962,0.7886 L0.5962,0.711 Q0.5962,0.7089,0.5938,0.7089 Z M0.7311,0.1137 L0.641,0.1137 L0.641,0.1909 L0.7311,0.1909 L0.7311,0.1137 Z M0.7311,0.2143 L0.641,0.2143 L0.641,0.2919 L0.7311,0.2919 L0.7311,0.2143 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.31 L0.641,0.31 L0.641,0.3876 L0.7311,0.3876 L0.7311,0.31 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.4081 L0.641,0.4081 L0.641,0.4857 L0.7311,0.4857 L0.7311,0.4081 Z M0.7311,0.5059 L0.641,0.5059 L0.641,0.5835 L0.7311,0.5835 L0.7311,0.5059 Z M0.7311,0.711 L0.641,0.711 L0.641,0.7886 L0.7311,0.7886 L0.7311,0.711 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.8626,0.1137 L0.7725,0.1137 L0.7725,0.1909 L0.8626,0.1909 L0.8626,0.1137 Z M0.8626,0.2143 L0.7725,0.2143 L0.7725,0.2919 L0.8626,0.2919 L0.8626,0.2143 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.31 L0.7725,0.31 L0.7725,0.3876 L0.8626,0.3876 L0.8626,0.31 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.4081 L0.7725,0.4081 L0.7725,0.4857 L0.8626,0.4857 L0.8626,0.4081 Z M0.8626,0.5059 L0.7725,0.5059 L0.7725,0.5835 L0.8626,0.5835 L0.8626,0.5059 Z M0.8626,0.6065 L0.7725,0.6065 L0.7725,0.6841 L0.8626,0.6841 L0.8626,0.6065 Z M0.8626,0.711 L0.7725,0.711 L0.7725,0.7886 L0.8626,0.7886 L0.8626,0.711 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M1,0.1137 L0.9099,0.1137 L0.9099,0.1909 L1,0.1909 L1,0.1137 Z M1,0.2143 L0.9099,0.2143 L0.9099,0.2919 L1,0.2919 L1,0.2143 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.31 L0.9099,0.31 L0.9099,0.3876 L1,0.3876 L1,0.31 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.4081 L0.9099,0.4081 L0.9099,0.4857 L1,0.4857 L1,0.4081 Z M1,0.5059 L0.9099,0.5059 L0.9099,0.5835 L1,0.5835 L1,0.5059 Z M1,0.6065 L0.9099,0.6065 L0.9099,0.6841 L1,0.6841 L1,0.6065 Z M1,0.711 L0.9099,0.711 L0.9099,0.7886 L1,0.7886 L1,0.711 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0.8574,0.3217 L0.8574,0.4098 L0.9691,0.4098 L0.9691,0.3217 L0.8574,0.3217 Z M0.8419,0.2978 L0.9846,0.2978 Q0.9892,0.2978,0.9928,0.2997 T0.9982,0.3043 T1,0.31 L1,0.4216 Q1,0.427,0.9951,0.4304 T0.9846,0.4337 L0.8419,0.4337 Q0.8352,0.4337,0.8308,0.4299 T0.8265,0.4216 L0.8265,0.31 Q0.8265,0.3062,0.829,0.3035 T0.835,0.2993 T0.8419,0.2978 Z M0.8574,0.888 L0.8574,0.9757 L0.9691,0.9757 L0.9691,0.888 L0.8574,0.888 Z M0.8419,0.8637 L0.9846,0.8637 Q0.9892,0.8637,0.9928,0.8658 T0.9982,0.8706 T1,0.8758 L1,0.9878 Q1,0.9929,0.9951,0.9964 T0.9846,1 L0.8419,1 Q0.8352,1,0.8308,0.996 T0.8265,0.9878 L0.8265,0.8758 Q0.8265,0.8708,0.8314,0.8672 T0.8419,0.8637 Z M0.6509,0.169 L0.6509,0.2706 L0.7806,0.2706 L0.7806,0.169 L0.6509,0.169 Z M0.6442,0.159 L0.7868,0.159 Q0.7899,0.159,0.7917,0.1604 T0.7935,0.164 L0.7935,0.2756 Q0.7935,0.2781,0.7915,0.2796 T0.7868,0.281 L0.6442,0.281 Q0.6416,0.281,0.6395,0.2794 T0.6375,0.2756 L0.6375,0.164 Q0.6375,0.1619,0.6398,0.1604 T0.6442,0.159 Z M0.6632,0.6044 L0.6632,0.6858 L0.7678,0.6858 L0.7678,0.6044 L0.6632,0.6044 Z M0.6442,0.5742 L0.7868,0.5742 Q0.7951,0.5742,0.8007,0.5791 T0.8064,0.5893 L0.8064,0.7013 Q0.8064,0.7076,0.8002,0.712 T0.7868,0.7164 L0.6442,0.7164 Q0.6359,0.7164,0.6305,0.7114 T0.6251,0.7013 L0.6251,0.5893 Q0.6251,0.5847,0.6282,0.5812 T0.6357,0.5759 T0.6442,0.5742 Z M0.4264,0.164 L0.5695,0.164 L0.5695,0.2756 L0.4264,0.2756 L0.4264,0.164 Z M0.4325,0.315 L0.4325,0.4165 L0.5628,0.4165 L0.5628,0.315 L0.4325,0.315 Z M0.4264,0.3049 L0.5695,0.3049 Q0.5721,0.3049,0.5739,0.3064 T0.5757,0.31 L0.5757,0.4216 Q0.5757,0.4237,0.5736,0.4251 T0.5695,0.4266 L0.4264,0.4266 Q0.4238,0.4266,0.422,0.4251 T0.4202,0.4216 L0.4202,0.31 Q0.4202,0.3087,0.4207,0.3077 T0.4222,0.306 T0.4243,0.3052 T0.4264,0.3049 Z M0.4264,0.4476 L0.5695,0.4476 L0.5695,0.5596 L0.4264,0.5596 L0.4264,0.4476 Z M0.4392,0.7408 L0.4392,0.8326 L0.5561,0.8326 L0.5561,0.7408 L0.4392,0.7408 Z M0.4264,0.7206 L0.5695,0.7206 Q0.5747,0.7206,0.5783,0.724 T0.5819,0.7307 L0.5819,0.8427 Q0.5819,0.8469,0.5778,0.8498 T0.5695,0.8528 L0.4264,0.8528 Q0.4207,0.8528,0.4171,0.8496 T0.4135,0.8427 L0.4135,0.7307 Q0.4135,0.7265,0.4176,0.7236 T0.4264,0.7206 Z M0.2178,0.164 L0.3605,0.164 L0.3605,0.2756 L0.2178,0.2756 L0.2178,0.164 Z M0.2178,0.31 L0.3605,0.31 L0.3605,0.4216 L0.2178,0.4216 L0.2178,0.31 Z M0.2178,0.5893 L0.3605,0.5893 L0.3605,0.7013 L0.2178,0.7013 L0.2178,0.5893 Z M0.2178,0.7307 L0.3605,0.7307 L0.3605,0.8427 L0.2178,0.8427 L0.2178,0.7307 Z M0.2178,0.8758 L0.3605,0.8758 L0.3605,0.9878 L0.2178,0.9878 L0.2178,0.8758 Z M0,0.164 L0.1432,0.164 L0.1432,0.2756 L0,0.2756 L0,0.164 Z M0,0.31 L0.1432,0.31 L0.1432,0.4216 L0,0.4216 L0,0.31 Z M0,0.4476 L0.1432,0.4476 L0.1432,0.5596 L0,0.5596 L0,0.4476 Z M0,0.5893 L0.1432,0.5893 L0.1432,0.7013 L0,0.7013 L0,0.5893 Z M0,0.7307 L0.1432,0.7307 L0.1432,0.8427 L0,0.8427 L0,0.7307 Z M0,0.8758 L0.1432,0.8758 L0.1432,0.9878 L0,0.9878 L0,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M0.1432,0.3217 L0.1432,0.4098 L0.0309,0.4098 L0.0309,0.3217 L0.1432,0.3217 Z M0.1586,0.2978 L0.0154,0.2978 Q0.0088,0.2978,0.0044,0.3016 T0,0.31 L0,0.4216 Q0,0.427,0.0049,0.4304 T0.0154,0.4337 L0.1586,0.4337 Q0.1648,0.4337,0.1694,0.4299 T0.174,0.4216 L0.174,0.31 Q0.174,0.3062,0.1715,0.3035 T0.1653,0.2993 T0.1586,0.2978 Z M0.1432,0.888 L0.1432,0.9757 L0.0309,0.9757 L0.0309,0.888 L0.1432,0.888 Z M0.1586,0.8637 L0.0154,0.8637 Q0.0108,0.8637,0.0072,0.8658 T0.0018,0.8706 T0,0.8758 L0,0.9878 Q0,0.9929,0.0049,0.9964 T0.0154,1 L0.1586,1 Q0.1648,1,0.1694,0.996 T0.174,0.9878 L0.174,0.8758 Q0.174,0.8725,0.1715,0.8695 T0.1653,0.8651 T0.1586,0.8637 Z M0.3496,0.169 L0.3496,0.2706 L0.2194,0.2706 L0.2194,0.169 L0.3496,0.169 Z M0.3563,0.159 L0.2132,0.159 Q0.2106,0.159,0.2088,0.1604 T0.207,0.164 L0.207,0.2756 Q0.207,0.2781,0.2091,0.2796 T0.2132,0.281 L0.3563,0.281 Q0.3589,0.281,0.3607,0.2794 T0.3625,0.2756 L0.3625,0.164 Q0.3625,0.1619,0.3605,0.1604 T0.3563,0.159 Z M0.3368,0.6044 L0.3368,0.6858 L0.2327,0.6858 L0.2327,0.6044 L0.3368,0.6044 Z M0.3563,0.5742 L0.2132,0.5742 Q0.2049,0.5742,0.1993,0.5791 T0.1936,0.5893 L0.1936,0.7013 Q0.1936,0.7076,0.2001,0.712 T0.2132,0.7164 L0.3563,0.7164 Q0.3641,0.7164,0.3697,0.7114 T0.3754,0.7013 L0.3754,0.5893 Q0.3754,0.5847,0.3723,0.5812 T0.3648,0.5759 T0.3563,0.5742 Z M0.5736,0.164 L0.431,0.164 L0.431,0.2756 L0.5736,0.2756 L0.5736,0.164 Z M0.5675,0.315 L0.5675,0.4165 L0.4377,0.4165 L0.4377,0.315 L0.5675,0.315 Z M0.5736,0.3049 L0.431,0.3049 Q0.4284,0.3049,0.4264,0.3064 T0.4243,0.31 L0.4243,0.4216 Q0.4243,0.4237,0.4266,0.4251 T0.431,0.4266 L0.5736,0.4266 Q0.5767,0.4266,0.5785,0.4251 T0.5803,0.4216 L0.5803,0.31 Q0.5803,0.3087,0.5798,0.3077 T0.5783,0.306 T0.5762,0.3052 T0.5736,0.3049 Z M0.5736,0.4476 L0.431,0.4476 L0.431,0.5596 L0.5736,0.5596 L0.5736,0.4476 Z M0.5613,0.7408 L0.5613,0.8326 L0.4439,0.8326 L0.4439,0.7408 L0.5613,0.7408 Z M0.5736,0.7206 L0.431,0.7206 Q0.4253,0.7206,0.4217,0.724 T0.4181,0.7307 L0.4181,0.8427 Q0.4181,0.8469,0.4222,0.8498 T0.431,0.8528 L0.5736,0.8528 Q0.5778,0.8528,0.5808,0.8511 T0.5855,0.8471 T0.587,0.8427 L0.587,0.7307 Q0.587,0.7265,0.5826,0.7236 T0.5736,0.7206 Z M0.7827,0.164 L0.6395,0.164 L0.6395,0.2756 L0.7827,0.2756 L0.7827,0.164 Z M0.7827,0.31 L0.6395,0.31 L0.6395,0.4216 L0.7827,0.4216 L0.7827,0.31 Z M0.7827,0.5893 L0.6395,0.5893 L0.6395,0.7013 L0.7827,0.7013 L0.7827,0.5893 Z M0.7827,0.7307 L0.6395,0.7307 L0.6395,0.8427 L0.7827,0.8427 L0.7827,0.7307 Z M0.7827,0.8758 L0.6395,0.8758 L0.6395,0.9878 L0.7827,0.9878 L0.7827,0.8758 Z M1,0.164 L0.8574,0.164 L0.8574,0.2756 L1,0.2756 L1,0.164 Z M1,0.31 L0.8574,0.31 L0.8574,0.4216 L1,0.4216 L1,0.31 Z M1,0.4476 L0.8574,0.4476 L0.8574,0.5596 L1,0.5596 L1,0.4476 Z M1,0.5893 L0.8574,0.5893 L0.8574,0.7013 L1,0.7013 L1,0.5893 Z M1,0.7307 L0.8574,0.7307 L0.8574,0.8427 L1,0.8427 L1,0.7307 Z M1,0.8758 L0.8574,0.8758 L0.8574,0.9878 L1,0.9878 L1,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.0008,0.9979 Q0.0008,1,0.0032,1 Q0.0482,1,0.0602,0.9975 Q0.0667,0.9963,0.0847,0.9917 T0.1084,0.9859 Q0.1124,0.9851,0.1221,0.9851 L0.1253,0.9851 L0.1285,0.9851 Q0.1518,0.9851,0.1655,0.9809 Q0.1695,0.9801,0.1731,0.9788 T0.1791,0.9766 T0.1839,0.9747 T0.1888,0.9732 L0.1936,0.972 T0.1988,0.9712 T0.2048,0.971 L0.2096,0.971 Q0.2201,0.9714,0.2233,0.9714 T0.2345,0.9693 Q0.2386,0.9685,0.2502,0.9674 T0.2715,0.966 L0.2811,0.9656 Q0.2916,0.9647,0.2916,0.9573 Q0.2916,0.954,0.2892,0.9515 T0.2819,0.9486 Q0.2755,0.9482,0.2683,0.9482 Q0.2643,0.9482,0.2582,0.9484 T0.2498,0.9486 Q0.2426,0.9486,0.2402,0.9473 Q0.2386,0.9469,0.2317,0.943 T0.2161,0.937 Q0.2104,0.9357,0.1992,0.933 T0.1831,0.9291 Q0.1807,0.9287,0.1783,0.9285 T0.1739,0.928 T0.1703,0.9276 T0.1675,0.9272 T0.1655,0.9266 T0.1639,0.9253 Q0.1598,0.9216,0.1462,0.9216 L0.143,0.9216 L0.1398,0.9216 L0.1365,0.9216 Q0.1197,0.9195,0.1197,0.9154 L0.1197,0.9154 Q0.1205,0.9121,0.1373,0.9088 T0.1703,0.9054 Q0.1791,0.9054,0.1863,0.904 T0.1984,0.9011 T0.2064,0.8992 Q0.2402,0.8967,0.2506,0.8934 Q0.2538,0.8926,0.2602,0.8899 T0.2707,0.8872 Q0.2715,0.8872,0.2731,0.8876 Q0.2795,0.8884,0.2884,0.8884 Q0.3068,0.8884,0.3092,0.8826 L0.3092,0.8797 Q0.3092,0.8772,0.3076,0.8747 T0.3044,0.8706 T0.3028,0.8681 Q0.3076,0.8656,0.3237,0.8656 Q0.3317,0.8656,0.3622,0.8623 Q0.3655,0.8623,0.3867,0.8617 T0.4217,0.8602 Q0.4241,0.8598,0.4289,0.8598 L0.4345,0.8598 L0.441,0.8598 Q0.4578,0.8598,0.4667,0.8586 Q0.4739,0.8573,0.4851,0.8567 T0.5004,0.8552 Q0.5012,0.8552,0.5108,0.855 T0.5269,0.853 T0.5333,0.8461 Q0.5333,0.8445,0.5325,0.8432 T0.5309,0.8409 T0.5289,0.8391 T0.5261,0.8378 L0.5229,0.837 T0.5185,0.8362 T0.5133,0.8355 T0.5076,0.8349 T0.502,0.8341 Q0.4932,0.8324,0.4859,0.8324 Q0.4827,0.8324,0.4787,0.8328 T0.4731,0.8333 Q0.4707,0.8333,0.4683,0.8324 Q0.4482,0.825,0.4394,0.825 Q0.4369,0.825,0.4345,0.8254 Q0.4225,0.827,0.4153,0.827 Q0.4088,0.827,0.4064,0.8258 Q0.3976,0.8204,0.3751,0.8204 Q0.3703,0.8204,0.3667,0.8214 T0.3622,0.8225 Q0.3598,0.8225,0.3558,0.82 Q0.351,0.8175,0.3402,0.8167 T0.3177,0.8156 T0.3028,0.8146 L0.2956,0.8121 T0.2871,0.8094 T0.2771,0.8073 T0.2635,0.8063 Q0.261,0.8063,0.2586,0.8067 Q0.2378,0.8071,0.2273,0.8071 Q0.2072,0.8071,0.2024,0.8051 Q0.1976,0.8034,0.1932,0.8026 T0.1867,0.8013 T0.1847,0.8001 L0.1847,0.7993 Q0.1855,0.7984,0.1855,0.7978 T0.1859,0.7966 T0.1867,0.7959 T0.188,0.7955 T0.1896,0.7951 T0.1916,0.7949 T0.1944,0.7945 T0.1984,0.7941 T0.2032,0.7939 Q0.212,0.7926,0.2325,0.7918 T0.2562,0.7905 Q0.2691,0.7893,0.2787,0.7847 Q0.2835,0.7827,0.2968,0.7791 T0.3149,0.7756 Q0.3189,0.7756,0.3305,0.7733 T0.3446,0.771 L0.3454,0.771 Q0.3462,0.771,0.3498,0.7721 T0.3574,0.774 T0.3655,0.7748 Q0.3703,0.7748,0.3743,0.7731 T0.3823,0.7708 T0.3916,0.77 T0.3992,0.7694 Q0.4008,0.7694,0.4076,0.7688 T0.4205,0.7675 L0.4305,0.7665 Q0.4345,0.7657,0.4402,0.7617 T0.4458,0.7545 Q0.4458,0.7503,0.4353,0.7487 Q0.4297,0.7478,0.4209,0.7462 T0.4044,0.7433 T0.3904,0.7416 L0.3847,0.7416 Q0.3807,0.7416,0.3747,0.7422 T0.3663,0.7428 T0.3606,0.7424 Q0.3582,0.742,0.3562,0.7418 T0.3522,0.7412 T0.3482,0.7406 T0.3446,0.7401 T0.341,0.7397 T0.3373,0.7393 T0.3341,0.7391 L0.3293,0.7391 Q0.3277,0.7395,0.3253,0.7395 Q0.3205,0.7395,0.3052,0.7387 T0.2835,0.7379 Q0.2755,0.7379,0.253,0.7325 Q0.2506,0.7316,0.2462,0.731 T0.2382,0.7296 T0.2305,0.7271 T0.2181,0.7234 T0.2048,0.7196 T0.2,0.7155 Q0.2,0.7142,0.2016,0.713 Q0.208,0.7059,0.2285,0.6978 T0.2618,0.6898 Q0.2739,0.6898,0.2831,0.6848 T0.2956,0.6794 Q0.3028,0.6786,0.3289,0.6765 T0.3566,0.674 Q0.3582,0.674,0.3855,0.6723 T0.4406,0.6688 T0.4755,0.6653 Q0.5004,0.6607,0.5124,0.6599 L0.5165,0.6599 Q0.5213,0.6599,0.5277,0.6605 T0.5373,0.6611 Q0.543,0.6611,0.5494,0.6599 Q0.592,0.6524,0.6,0.6524 Q0.6064,0.6524,0.61,0.6499 T0.6161,0.6474 Q0.6177,0.6474,0.6261,0.6495 T0.6418,0.6516 L0.6482,0.6516 Q0.653,0.6512,0.6651,0.6506 T0.6876,0.6493 T0.7084,0.6477 T0.7229,0.645 Q0.7269,0.6437,0.7333,0.6437 Q0.7373,0.6437,0.7458,0.6443 T0.7574,0.645 Q0.7614,0.645,0.7631,0.6445 T0.7655,0.6441 Q0.7671,0.6441,0.7723,0.6448 T0.7823,0.6454 Q0.788,0.6454,0.7952,0.6445 Q0.8233,0.6408,0.8723,0.6367 Q0.9012,0.6342,0.9092,0.6342 L0.9165,0.6342 L0.9205,0.6342 Q0.9277,0.6342,0.9382,0.6323 T0.9526,0.6304 Q0.9534,0.6304,0.9594,0.6307 T0.9695,0.6309 Q0.9976,0.6309,0.9992,0.6234 Q1,0.6201,1,0.618 Q1,0.6089,0.9775,0.608 Q0.9703,0.6076,0.9582,0.6076 Q0.9518,0.6076,0.9394,0.6078 T0.9213,0.608 Q0.9036,0.608,0.8972,0.6072 Q0.894,0.6068,0.8871,0.6058 T0.8751,0.6039 T0.8627,0.6024 T0.8506,0.6018 L0.8474,0.6018 Q0.841,0.6022,0.8281,0.6041 T0.8112,0.606 Q0.8096,0.606,0.808,0.6056 Q0.7783,0.6002,0.7647,0.6002 Q0.7598,0.6002,0.7454,0.6006 T0.7261,0.601 Q0.7221,0.601,0.7145,0.6022 T0.7036,0.6035 Q0.6996,0.6035,0.6964,0.6022 Q0.6723,0.5902,0.6442,0.5902 Q0.6426,0.5902,0.6386,0.5904 T0.6337,0.5906 Q0.6273,0.5906,0.6249,0.5894 L0.6177,0.5856 T0.6116,0.5838 T0.6016,0.5825 T0.5855,0.5823 Q0.5719,0.5823,0.5574,0.5798 T0.5373,0.5774 L0.5341,0.5774 Q0.5293,0.5778,0.5213,0.5786 T0.51,0.5794 Q0.5036,0.5794,0.4988,0.5769 Q0.4932,0.574,0.4771,0.574 Q0.4723,0.574,0.4631,0.5745 T0.4498,0.5749 Q0.4418,0.5749,0.4394,0.5736 Q0.4353,0.5724,0.4305,0.5715 T0.4225,0.5707 T0.4145,0.5705 T0.4056,0.5699 Q0.4008,0.5691,0.398,0.5689 T0.394,0.5689 T0.3908,0.5686 T0.3855,0.5674 Q0.3831,0.567,0.3811,0.5662 T0.3779,0.5645 T0.3743,0.563 T0.3695,0.5624 Q0.3655,0.5624,0.3594,0.5635 T0.3494,0.5645 T0.3406,0.5635 T0.3325,0.5624 Q0.3309,0.5624,0.3301,0.5628 L0.3141,0.5628 Q0.2635,0.5628,0.2506,0.5612 Q0.2474,0.5608,0.2434,0.5608 Q0.2402,0.5608,0.2349,0.561 T0.2273,0.5612 Q0.2209,0.5612,0.2177,0.5595 Q0.2145,0.557,0.2145,0.5558 Q0.2169,0.5537,0.2345,0.5521 Q0.2876,0.5475,0.3028,0.5425 Q0.3068,0.5413,0.3149,0.5396 T0.3309,0.5367 T0.3478,0.5338 T0.3606,0.5309 Q0.3631,0.5305,0.3755,0.5259 T0.3968,0.5214 Q0.3992,0.5214,0.4024,0.5218 T0.4096,0.5222 Q0.4161,0.5222,0.4213,0.5209 T0.4313,0.5185 T0.4402,0.5172 Q0.4418,0.5168,0.447,0.5168 L0.4558,0.5168 T0.4639,0.5162 T0.4707,0.5141 T0.4731,0.5102 Q0.4731,0.5089,0.4723,0.5081 Q0.4699,0.5019,0.4627,0.4994 T0.4369,0.4965 Q0.4273,0.4961,0.4193,0.4946 T0.4048,0.4915 T0.3952,0.4894 Q0.392,0.4886,0.388,0.4886 Q0.3847,0.4886,0.3775,0.4892 T0.3647,0.4898 Q0.3598,0.4898,0.355,0.4894 Q0.3165,0.4861,0.3141,0.4853 Q0.2779,0.4774,0.2715,0.4774 Q0.2699,0.4774,0.2683,0.4776 T0.2627,0.4778 Q0.2498,0.4778,0.2281,0.4762 Q0.2241,0.4749,0.2233,0.4737 Q0.2249,0.472,0.2321,0.4708 Q0.2402,0.4695,0.2554,0.4695 L0.2675,0.4695 L0.2795,0.4695 Q0.294,0.4695,0.2996,0.4679 Q0.3141,0.4629,0.3373,0.4616 Q0.3414,0.4612,0.3466,0.4606 T0.3582,0.4589 T0.3687,0.4562 T0.3727,0.4525 Q0.3727,0.4509,0.3703,0.4492 Q0.3639,0.4455,0.3474,0.4419 T0.3213,0.4384 L0.3157,0.4384 L0.31,0.4384 Q0.294,0.4384,0.2859,0.4363 Q0.2795,0.4347,0.2691,0.4347 L0.2618,0.4347 L0.2546,0.4347 Q0.2458,0.4347,0.2394,0.4338 Q0.2257,0.4318,0.2193,0.4303 T0.2092,0.4266 T0.2052,0.4224 T0.2024,0.4154 T0.196,0.4052 Q0.1912,0.3994,0.1867,0.3949 T0.1807,0.3884 T0.1791,0.3849 Q0.1791,0.3824,0.1839,0.3745 Q0.1871,0.3687,0.1944,0.3637 T0.2072,0.3561 T0.2221,0.3501 T0.2321,0.3463 Q0.2353,0.3455,0.241,0.3445 T0.2522,0.3428 T0.2639,0.3411 T0.2731,0.3384 T0.2956,0.3351 T0.3197,0.3326 Q0.3213,0.3322,0.3237,0.3322 T0.3325,0.3331 T0.3414,0.3339 Q0.3478,0.3339,0.3518,0.3314 Q0.3606,0.3264,0.3703,0.3264 Q0.3775,0.3264,0.3847,0.3293 Q0.3888,0.331,0.396,0.331 Q0.4024,0.331,0.4116,0.3295 T0.4265,0.3266 T0.4414,0.3227 T0.4514,0.3202 Q0.4578,0.3181,0.4839,0.3152 T0.5112,0.3123 T0.5177,0.3119 T0.5285,0.3111 T0.5398,0.3096 T0.549,0.3065 T0.5526,0.3015 Q0.5526,0.2999,0.5518,0.2982 Q0.547,0.2903,0.5386,0.2883 T0.5165,0.2862 Q0.5116,0.2862,0.5056,0.2866 T0.4972,0.287 Q0.4884,0.287,0.4787,0.2833 Q0.4594,0.2758,0.4265,0.2758 Q0.4169,0.2758,0.4129,0.2766 Q0.4112,0.2771,0.4104,0.2771 Q0.4088,0.2771,0.4056,0.2748 T0.3968,0.2721 Q0.3695,0.2696,0.359,0.2659 Q0.355,0.2646,0.351,0.2646 T0.3426,0.2661 T0.3365,0.2675 T0.3333,0.2659 Q0.3277,0.2605,0.3116,0.2553 T0.2859,0.2501 L0.2843,0.2501 L0.2819,0.2501 Q0.2779,0.2501,0.2723,0.2493 T0.2586,0.2472 T0.2474,0.2455 Q0.1799,0.236,0.1719,0.236 Q0.1663,0.236,0.1606,0.235 T0.1502,0.2316 T0.1454,0.2265 Q0.1454,0.2223,0.1558,0.2173 Q0.1679,0.2115,0.1859,0.2092 T0.2185,0.207 T0.2353,0.2057 Q0.2369,0.2049,0.2502,0.2041 T0.2799,0.2022 T0.302,0.2007 Q0.3084,0.1999,0.3245,0.1954 T0.347,0.1908 Q0.3502,0.1908,0.3534,0.1916 Q0.3663,0.197,0.388,0.197 Q0.404,0.197,0.4088,0.1929 Q0.4096,0.192,0.4108,0.192 T0.4177,0.1945 T0.4277,0.197 T0.4369,0.1949 Q0.4442,0.1908,0.4843,0.1875 Q0.4892,0.1871,0.4968,0.1839 T0.5084,0.1808 L0.5108,0.1808 Q0.5213,0.1825,0.5454,0.1825 Q0.5823,0.1825,0.5992,0.1779 Q0.6096,0.1746,0.6217,0.1746 Q0.6249,0.1746,0.6309,0.1748 T0.6394,0.175 Q0.6434,0.175,0.6466,0.1746 Q0.6474,0.1746,0.6594,0.1738 T0.6787,0.1713 T0.6892,0.1659 Q0.6924,0.1634,0.6924,0.1593 Q0.6924,0.1547,0.6888,0.151 T0.6795,0.1472 Q0.6779,0.1472,0.6763,0.1477 Q0.6699,0.1493,0.6667,0.1493 Q0.661,0.1493,0.6506,0.1456 Q0.6329,0.1394,0.6129,0.1394 Q0.6048,0.1394,0.5992,0.1377 T0.5888,0.136 T0.5803,0.1377 T0.5751,0.1394 Q0.5711,0.1394,0.5639,0.1369 Q0.5365,0.1273,0.5116,0.1273 Q0.4876,0.1273,0.4667,0.1253 Q0.4627,0.1248,0.4542,0.1211 T0.4337,0.1174 L0.4305,0.1174 Q0.4217,0.1178,0.4153,0.119 T0.408,0.1203 Q0.4056,0.1203,0.4,0.117 Q0.3976,0.1153,0.396,0.1136 T0.3932,0.1112 T0.39,0.1099 T0.3847,0.1095 Q0.3799,0.1095,0.3687,0.1103 Q0.355,0.1112,0.3434,0.1141 T0.3301,0.117 Q0.3261,0.117,0.3229,0.1145 Q0.3181,0.1116,0.3052,0.1087 T0.2787,0.1043 T0.2546,0.1029 Q0.2506,0.1029,0.2474,0.1033 Q0.2418,0.1037,0.2349,0.1054 T0.2249,0.107 Q0.2225,0.107,0.2193,0.1066 Q0.2024,0.1024,0.1863,0.1024 Q0.1823,0.1024,0.1562,0.0971 T0.1269,0.0917 T0.1161,0.0896 T0.106,0.0854 Q0.1076,0.0846,0.11,0.0842 Q0.1173,0.0821,0.1261,0.0821 Q0.1301,0.0821,0.1369,0.0825 T0.1462,0.083 Q0.1494,0.083,0.151,0.0825 T0.1831,0.0813 T0.2201,0.0784 T0.243,0.074 T0.2667,0.0718 Q0.2707,0.0718,0.2755,0.0724 T0.2843,0.073 T0.2936,0.0724 T0.3024,0.0705 T0.3072,0.068 T0.3092,0.0657 T0.3108,0.0647 Q0.3116,0.0643,0.3133,0.0643 L0.3157,0.0643 L0.3181,0.0643 Q0.3446,0.0643,0.3446,0.0556 T0.3205,0.0469 L0.3189,0.0469 Q0.3157,0.0469,0.3157,0.0465 Q0.3084,0.0427,0.2835,0.0386 Q0.2739,0.0369,0.2675,0.0342 T0.2538,0.0299 Q0.249,0.029,0.245,0.029 T0.2378,0.0303 T0.2321,0.0315 Q0.2305,0.0315,0.2273,0.0307 Q0.2201,0.0286,0.2004,0.0272 T0.1775,0.0245 Q0.1735,0.0228,0.1663,0.0226 T0.1522,0.0197 T0.1345,0.0145 T0.1124,0.0079 Q0.0996,0.0033,0.0671,0.0044 T0.0297,0.0041 Q0.0169,0,0.0024,0.0017 Q0,0.0021,0,0.0037 Z M0.0064,0.9954 L0.0056,0.0058 Q0.0177,0.005,0.0281,0.0083 Q0.0345,0.01,0.0675,0.0089 T0.1108,0.012 Q0.1221,0.0162,0.1329,0.0187 T0.1498,0.0236 T0.1606,0.0268 T0.1691,0.0274 T0.1759,0.0286 Q0.1807,0.0303,0.2,0.0315 T0.2257,0.0348 Q0.2305,0.0361,0.2337,0.0361 Q0.2361,0.0361,0.2398,0.0348 T0.2466,0.0336 Q0.249,0.0336,0.2522,0.034 Q0.2586,0.0353,0.2655,0.0382 T0.2827,0.0427 Q0.3068,0.0469,0.3124,0.0502 Q0.3157,0.0523,0.3237,0.0523 L0.3277,0.0523 L0.3309,0.0523 Q0.3398,0.0523,0.3398,0.0556 Q0.3398,0.0581,0.3341,0.0587 T0.3205,0.0593 T0.3092,0.0603 T0.3036,0.0643 T0.2932,0.068 Q0.2884,0.0684,0.2843,0.0684 Q0.2811,0.0684,0.2763,0.0678 T0.2667,0.0672 Q0.2586,0.0672,0.2422,0.0697 T0.2177,0.0747 Q0.2137,0.0759,0.1831,0.0767 T0.1494,0.0784 L0.1474,0.0784 T0.1398,0.078 T0.1285,0.0776 Q0.1173,0.0776,0.1084,0.08 Q0.1004,0.0817,0.1004,0.0846 Q0.1004,0.0867,0.104,0.0888 T0.1124,0.0923 T0.1213,0.0948 T0.1269,0.0958 T0.155,0.1014 T0.1863,0.107 Q0.2016,0.107,0.2185,0.1107 Q0.2217,0.1116,0.2257,0.1116 Q0.2289,0.1116,0.2365,0.1099 T0.2482,0.1074 L0.2538,0.1074 Q0.2683,0.1074,0.2912,0.1107 T0.3197,0.1182 Q0.3245,0.1215,0.3301,0.1215 Q0.3333,0.1215,0.3454,0.1184 T0.3687,0.1145 Q0.3735,0.1141,0.3779,0.1139 T0.3843,0.1134 T0.3871,0.1132 Q0.3896,0.1132,0.39,0.1136 T0.392,0.1161 T0.3968,0.1203 Q0.404,0.1248,0.408,0.1248 Q0.4096,0.1248,0.4165,0.1234 T0.4305,0.1215 L0.4337,0.1215 Q0.4442,0.1215,0.4518,0.1251 T0.4659,0.1294 Q0.4876,0.1319,0.5116,0.1319 T0.5622,0.141 Q0.5703,0.1439,0.5751,0.1439 Q0.5791,0.1439,0.5831,0.1423 T0.5888,0.1406 Q0.592,0.1406,0.5944,0.1412 T0.6012,0.1427 T0.6129,0.1435 Q0.6313,0.1435,0.649,0.1497 Q0.6602,0.1535,0.6667,0.1535 Q0.6715,0.1535,0.6779,0.1522 Q0.6787,0.1518,0.6795,0.1518 Q0.6827,0.1518,0.6847,0.1541 T0.6867,0.1593 Q0.6867,0.1618,0.6851,0.1634 Q0.6835,0.1655,0.6775,0.1667 T0.6598,0.169 T0.6458,0.1705 T0.6402,0.1709 Q0.6378,0.1709,0.6321,0.1705 T0.6225,0.1701 Q0.6096,0.1701,0.5968,0.1738 Q0.5823,0.1779,0.5446,0.1779 Q0.5205,0.1779,0.5116,0.1767 Q0.51,0.1763,0.5084,0.1763 Q0.5028,0.1763,0.4944,0.1796 T0.4835,0.1829 L0.4771,0.1833 T0.4659,0.1844 L0.4546,0.1858 T0.443,0.1881 T0.4337,0.1912 Q0.4321,0.1916,0.4305,0.1916 Q0.4273,0.1916,0.4209,0.1895 T0.412,0.1875 Q0.408,0.1875,0.4056,0.19 Q0.4024,0.1925,0.388,0.1925 Q0.3663,0.1925,0.3558,0.1879 Q0.3518,0.1862,0.3462,0.1862 Q0.3373,0.1862,0.3213,0.191 T0.3012,0.1962 Q0.2964,0.1966,0.2799,0.1976 T0.2494,0.1997 T0.2313,0.2024 L0.2313,0.2024 L0.2293,0.2024 L0.2261,0.2024 L0.2217,0.2024 Q0.204,0.2024,0.1851,0.2047 T0.1526,0.2136 Q0.1406,0.2198,0.1406,0.226 Q0.1406,0.2319,0.1518,0.2362 T0.1719,0.2406 Q0.1791,0.2406,0.2466,0.2497 Q0.2498,0.2501,0.2574,0.2516 T0.2711,0.2538 T0.2819,0.2547 L0.2851,0.2547 Q0.2851,0.2543,0.2859,0.2543 Q0.294,0.2543,0.3096,0.2592 T0.3285,0.2684 Q0.3317,0.2721,0.3373,0.2721 Q0.3406,0.2721,0.3462,0.2708 T0.3542,0.2696 Q0.3558,0.2696,0.3574,0.27 Q0.3679,0.2737,0.3968,0.2762 Q0.3984,0.2766,0.4004,0.2779 L0.4044,0.2804 T0.4096,0.2816 Q0.412,0.2816,0.4145,0.2808 Q0.4177,0.2804,0.4249,0.2804 Q0.4586,0.2804,0.4763,0.287 Q0.4867,0.2912,0.4972,0.2912 Q0.5004,0.2912,0.5064,0.2908 T0.5165,0.2903 Q0.5293,0.2903,0.5361,0.2922 T0.547,0.3003 L0.547,0.3011 Q0.547,0.3036,0.5414,0.3049 T0.5249,0.3067 T0.51,0.3082 Q0.5092,0.3082,0.4831,0.3111 T0.4498,0.3161 Q0.408,0.3264,0.3952,0.3264 Q0.3896,0.3264,0.3863,0.3252 Q0.3783,0.3223,0.3703,0.3223 Q0.359,0.3223,0.3494,0.3277 Q0.347,0.3293,0.343,0.3293 Q0.3414,0.3293,0.3353,0.3285 T0.3253,0.3277 T0.3181,0.3285 Q0.3149,0.3289,0.2952,0.3306 T0.2699,0.3347 Q0.2675,0.3364,0.2598,0.3372 T0.2434,0.3395 T0.2297,0.3426 L0.2205,0.3461 T0.2048,0.3523 T0.1908,0.3606 T0.1791,0.3725 Q0.1743,0.3803,0.1743,0.3845 Q0.1743,0.3874,0.1763,0.3901 T0.1827,0.3976 T0.1912,0.4073 T0.198,0.4189 T0.2032,0.4276 T0.2137,0.4326 T0.2386,0.438 Q0.2458,0.4392,0.2562,0.4392 L0.2639,0.4392 L0.2699,0.4392 Q0.2795,0.4392,0.2843,0.4405 Q0.2932,0.443,0.3092,0.443 L0.3157,0.443 L0.3213,0.443 Q0.3293,0.443,0.3458,0.4463 T0.3671,0.4525 Q0.3598,0.4558,0.3373,0.4571 Q0.3124,0.4587,0.298,0.4637 Q0.294,0.465,0.2811,0.465 L0.2703,0.465 L0.2586,0.465 Q0.241,0.465,0.2305,0.4666 Q0.2185,0.4687,0.2185,0.4728 Q0.2185,0.4745,0.2205,0.4764 T0.2241,0.4791 L0.2265,0.4803 L0.2273,0.4803 Q0.2506,0.4824,0.261,0.4824 Q0.2675,0.4824,0.2699,0.482 Q0.2699,0.4815,0.2707,0.4815 Q0.2771,0.4815,0.3124,0.4898 Q0.3173,0.4907,0.3229,0.4913 T0.3386,0.4925 T0.3542,0.4936 T0.3663,0.494 Q0.3711,0.494,0.3787,0.4936 T0.3888,0.4932 Q0.392,0.4932,0.3944,0.4936 T0.4032,0.4959 T0.4181,0.499 T0.4369,0.5006 Q0.449,0.501,0.455,0.5021 T0.4639,0.5048 T0.4675,0.5093 L0.4675,0.5097 Q0.4675,0.5106,0.4663,0.511 T0.4622,0.5116 L0.4566,0.512 T0.4486,0.5124 T0.4402,0.5127 Q0.4345,0.5131,0.4257,0.5153 T0.4096,0.5176 Q0.4064,0.5176,0.4024,0.5172 L0.3968,0.5172 Q0.3863,0.5172,0.3731,0.522 T0.359,0.5268 Q0.355,0.528,0.3317,0.5321 T0.3004,0.5388 Q0.2867,0.5433,0.2337,0.5479 Q0.2096,0.55,0.2096,0.5562 Q0.2096,0.5591,0.2145,0.5628 Q0.2193,0.5662,0.2289,0.5662 Q0.2313,0.5662,0.2373,0.5657 T0.2458,0.5653 L0.2498,0.5653 Q0.2635,0.5674,0.3124,0.5674 Q0.3261,0.5674,0.3301,0.567 L0.3317,0.567 Q0.3341,0.567,0.3394,0.568 T0.3486,0.5691 Q0.3542,0.5691,0.3606,0.5678 T0.3699,0.5666 T0.3759,0.5686 T0.3839,0.5718 T0.3908,0.573 T0.3964,0.5734 T0.4048,0.5742 T0.4165,0.5751 T0.4277,0.5757 T0.4369,0.5778 Q0.4426,0.5794,0.4538,0.5794 Q0.4586,0.5794,0.4679,0.5792 T0.4811,0.579 Q0.4924,0.579,0.4956,0.5807 Q0.502,0.584,0.5108,0.584 L0.5137,0.584 T0.5173,0.5838 L0.5205,0.5834 L0.5237,0.583 L0.5269,0.5825 T0.5305,0.5821 T0.5341,0.5819 L0.5365,0.5819 Q0.5414,0.5819,0.5562,0.5842 T0.5855,0.5865 Q0.5912,0.5865,0.5948,0.5867 T0.6012,0.5869 T0.6064,0.5873 T0.61,0.5877 T0.6133,0.5886 T0.6157,0.5896 L0.6185,0.591 T0.6217,0.5931 Q0.6265,0.5952,0.6369,0.5952 L0.6418,0.5952 L0.6466,0.5952 Q0.6723,0.5952,0.6932,0.606 Q0.698,0.608,0.7044,0.608 Q0.7084,0.608,0.7161,0.6068 T0.7261,0.6056 Q0.7309,0.6056,0.7454,0.6051 T0.7647,0.6047 Q0.7775,0.6047,0.8072,0.6101 Q0.8096,0.6105,0.812,0.6105 Q0.8177,0.6105,0.8297,0.6085 T0.8474,0.606 L0.8506,0.606 Q0.8562,0.606,0.8647,0.607 T0.8831,0.6095 T0.8964,0.6118 Q0.9036,0.6122,0.9221,0.6122 L0.9406,0.6122 L0.959,0.6122 L0.9767,0.6122 Q0.9952,0.613,0.9952,0.6172 Q0.9952,0.6176,0.9944,0.6221 Q0.9936,0.6259,0.9574,0.6259 L0.955,0.6259 L0.9526,0.6259 Q0.9478,0.6259,0.9373,0.628 T0.9205,0.63 L0.9173,0.63 Q0.9133,0.6296,0.9084,0.6296 Q0.9004,0.6296,0.8715,0.6321 Q0.8233,0.6367,0.7944,0.64 Q0.7888,0.6408,0.7839,0.6408 T0.7739,0.6402 T0.7671,0.6396 Q0.7639,0.6396,0.7622,0.64 Q0.7614,0.6404,0.759,0.6404 Q0.7574,0.6404,0.749,0.6398 T0.7357,0.6392 Q0.7261,0.6392,0.7213,0.6412 Q0.7173,0.6425,0.7032,0.6437 T0.6711,0.6458 T0.6474,0.647 Q0.645,0.6474,0.6418,0.6474 Q0.6353,0.6474,0.6277,0.6454 T0.6161,0.6433 Q0.6104,0.6433,0.6072,0.6456 T0.6,0.6479 Q0.5912,0.6479,0.5486,0.6557 Q0.5422,0.657,0.5373,0.657 Q0.5349,0.657,0.5289,0.6564 T0.5173,0.6557 L0.5124,0.6557 Q0.4996,0.6562,0.4739,0.6611 Q0.4675,0.6624,0.4398,0.6645 T0.3851,0.668 T0.3558,0.6698 Q0.355,0.6698,0.3285,0.6721 T0.2948,0.6752 Q0.2892,0.6761,0.2843,0.6786 T0.2747,0.6831 T0.2618,0.6852 Q0.253,0.6852,0.2394,0.6893 T0.2141,0.6993 T0.1968,0.7105 Q0.1944,0.713,0.1944,0.7155 Q0.1944,0.7196,0.2012,0.7229 T0.2177,0.7285 T0.2281,0.7308 Q0.2321,0.7329,0.2365,0.7339 T0.245,0.7356 T0.2514,0.7366 Q0.2522,0.737,0.2554,0.7379 T0.2614,0.7393 T0.2679,0.7406 T0.2755,0.7416 T0.2835,0.742 Q0.29,0.742,0.3048,0.7428 T0.3253,0.7437 L0.3301,0.7437 L0.3333,0.7437 Q0.3341,0.7437,0.3598,0.7466 Q0.3639,0.7474,0.3671,0.7474 T0.3759,0.7466 T0.3855,0.7457 Q0.388,0.7457,0.3904,0.7462 Q0.3984,0.7466,0.4137,0.7495 T0.4349,0.7532 T0.441,0.7557 Q0.441,0.7599,0.4289,0.7619 Q0.4257,0.7628,0.4197,0.7634 T0.4072,0.7644 T0.3984,0.7652 Q0.3968,0.7652,0.3912,0.7655 T0.3811,0.7665 T0.3719,0.7694 Q0.3695,0.7702,0.3655,0.7702 T0.3546,0.7686 T0.3462,0.7665 L0.3446,0.7665 Q0.3406,0.7665,0.3293,0.769 T0.3149,0.7715 Q0.3084,0.7715,0.2952,0.7748 T0.2763,0.781 Q0.2675,0.7847,0.2554,0.7864 Q0.253,0.7864,0.2325,0.7874 T0.2024,0.7893 Q0.2008,0.7893,0.1984,0.7897 T0.1948,0.7901 T0.1916,0.7903 T0.1888,0.7908 T0.1867,0.7914 T0.1847,0.7922 L0.1831,0.793 T0.1819,0.7943 L0.1811,0.7959 T0.1799,0.7976 Q0.1791,0.7993,0.1791,0.8001 Q0.1791,0.8034,0.1831,0.8051 T0.1932,0.8078 T0.2,0.8092 Q0.2072,0.8117,0.2257,0.8117 Q0.2337,0.8117,0.2586,0.8109 L0.2635,0.8109 Q0.2707,0.8109,0.2767,0.8117 T0.2859,0.8136 T0.2936,0.8163 T0.3012,0.8187 Q0.3068,0.82,0.3277,0.8206 T0.3526,0.8237 Q0.3582,0.8266,0.3622,0.8266 Q0.3647,0.8266,0.3687,0.8258 T0.3751,0.825 Q0.396,0.825,0.404,0.8291 Q0.408,0.8316,0.4161,0.8316 Q0.4217,0.8316,0.4353,0.8295 L0.4394,0.8295 Q0.4466,0.8295,0.4659,0.8366 Q0.4699,0.8378,0.4747,0.8378 Q0.4763,0.8378,0.4807,0.8374 T0.4884,0.837 Q0.494,0.837,0.5004,0.8382 Q0.5052,0.8391,0.5124,0.8399 Q0.5221,0.8411,0.5249,0.8422 T0.5277,0.8461 Q0.5277,0.8482,0.5233,0.849 T0.5112,0.8501 T0.4996,0.8511 Q0.4964,0.8519,0.4851,0.8523 T0.4659,0.8544 Q0.4578,0.8557,0.4402,0.8557 L0.4349,0.8557 L0.4297,0.8557 L0.4209,0.8557 Q0.408,0.8565,0.3867,0.8571 T0.3614,0.8582 Q0.3317,0.8611,0.3237,0.8611 L0.3225,0.8611 L0.3213,0.8611 Q0.2972,0.8611,0.2972,0.8681 Q0.2972,0.8714,0.3008,0.8747 T0.3044,0.8805 Q0.3044,0.881,0.3036,0.8814 Q0.3012,0.8839,0.2851,0.8839 Q0.2779,0.8839,0.2739,0.883 Q0.2723,0.8826,0.2699,0.8826 T0.2643,0.8835 T0.259,0.8853 T0.2542,0.8876 T0.249,0.8893 Q0.2386,0.8922,0.2056,0.8946 Q0.2016,0.8951,0.1916,0.898 T0.1703,0.9009 Q0.1518,0.9009,0.1337,0.9044 T0.1149,0.9146 L0.1149,0.9158 Q0.1149,0.9175,0.1157,0.9187 T0.1173,0.921 T0.1201,0.9226 T0.1233,0.9237 T0.1269,0.9245 L0.1305,0.9251 T0.1337,0.9255 L0.1357,0.9258 Q0.139,0.9262,0.1446,0.9262 T0.1542,0.9266 T0.1598,0.9282 Q0.1614,0.9303,0.1647,0.9314 T0.1739,0.9328 T0.1815,0.9336 Q0.1863,0.9345,0.1976,0.9372 T0.2145,0.9415 Q0.2225,0.9432,0.2285,0.9467 T0.2378,0.9515 Q0.2426,0.9531,0.2514,0.9531 Q0.2538,0.9531,0.2602,0.9529 T0.2707,0.9527 Q0.2763,0.9527,0.2815,0.9531 T0.2867,0.9569 T0.2811,0.9614 Q0.2434,0.9627,0.2329,0.9652 Q0.2249,0.9672,0.2241,0.9672 L0.2096,0.9668 L0.2056,0.9668 Q0.2,0.9668,0.196,0.9672 T0.1884,0.9687 T0.1815,0.9708 T0.1731,0.9737 T0.1639,0.9772 Q0.1518,0.9805,0.1285,0.9805 L0.1253,0.9805 Q0.1124,0.9805,0.1076,0.9818 Q0.1012,0.983,0.0831,0.9876 T0.0594,0.9934 Q0.049,0.9954,0.0064,0.9954 Z M0.0032,0.9979 L0.0024,0.0037 Q0.0161,0.0021,0.0289,0.0062 Q0.0337,0.0075,0.0671,0.0066 T0.1116,0.01 T0.1337,0.0166 T0.151,0.022 Q0.1582,0.0249,0.1659,0.0251 T0.1767,0.0265 Q0.1807,0.0278,0.2,0.0292 T0.2265,0.0328 Q0.2305,0.034,0.2329,0.034 T0.239,0.0326 T0.2458,0.0311 T0.253,0.0319 Q0.2578,0.0332,0.2614,0.0342 T0.2667,0.0363 T0.2723,0.0384 T0.2827,0.0406 Q0.3084,0.0448,0.3141,0.0485 Q0.3149,0.0494,0.3225,0.0494 T0.3361,0.0504 T0.3422,0.0554 T0.3357,0.0606 T0.3205,0.0618 T0.31,0.0626 Q0.3092,0.0626,0.3056,0.0659 T0.2932,0.0701 Q0.2884,0.0709,0.2843,0.0709 Q0.2811,0.0709,0.2759,0.0701 T0.2667,0.0693 Q0.2586,0.0693,0.2422,0.0718 T0.2193,0.0763 Q0.2137,0.0784,0.1831,0.079 T0.1502,0.0805 Q0.1494,0.0809,0.147,0.0809 Q0.1454,0.0809,0.1386,0.0803 T0.1277,0.0796 Q0.1173,0.0796,0.1092,0.0821 Q0.1036,0.0834,0.1036,0.0854 Q0.1036,0.0879,0.1129,0.0908 T0.1269,0.0937 Q0.1293,0.0937,0.1558,0.0991 T0.1863,0.1045 Q0.2016,0.1045,0.2193,0.1087 Q0.2225,0.1091,0.2249,0.1091 Q0.2289,0.1091,0.2357,0.1076 T0.2482,0.1054 Q0.2506,0.1049,0.2546,0.1049 Q0.2699,0.1049,0.2924,0.1085 T0.3213,0.1165 Q0.3253,0.119,0.3301,0.119 Q0.3325,0.119,0.3446,0.1161 T0.3687,0.1124 Q0.3831,0.1112,0.3863,0.1112 T0.3908,0.1118 T0.3936,0.1145 T0.3984,0.1186 Q0.4048,0.1228,0.408,0.1228 Q0.4096,0.1228,0.4161,0.1213 T0.4305,0.1195 L0.4337,0.1195 Q0.4426,0.1195,0.4474,0.1211 T0.4566,0.1246 T0.4667,0.1273 Q0.4876,0.1298,0.5116,0.1298 Q0.5365,0.1298,0.5631,0.1389 Q0.5703,0.1414,0.5751,0.1414 Q0.5783,0.1414,0.5819,0.1398 T0.5888,0.1381 Q0.5928,0.1381,0.5984,0.1398 T0.6129,0.1414 Q0.6321,0.1414,0.6498,0.1477 Q0.661,0.1514,0.6667,0.1514 Q0.6707,0.1514,0.6771,0.1501 Q0.6787,0.1497,0.6795,0.1497 Q0.6835,0.1497,0.6863,0.1526 T0.6892,0.1593 Q0.6892,0.1626,0.6876,0.1647 Q0.6851,0.1672,0.6799,0.1686 T0.6695,0.1705 T0.6566,0.1715 T0.6458,0.1725 Q0.6434,0.173,0.6402,0.173 Q0.6378,0.173,0.6317,0.1727 T0.6225,0.1725 Q0.6096,0.1725,0.5984,0.1759 Q0.5823,0.1804,0.5454,0.1804 Q0.5205,0.1804,0.5116,0.1788 L0.5084,0.1788 Q0.5036,0.1788,0.4956,0.1819 T0.4843,0.185 Q0.4434,0.1887,0.4353,0.1929 Q0.4321,0.1945,0.4289,0.1945 T0.4197,0.1922 T0.4112,0.19 T0.4072,0.1912 Q0.4032,0.1949,0.388,0.1949 Q0.3663,0.1949,0.3542,0.19 Q0.351,0.1883,0.3462,0.1883 Q0.3406,0.1883,0.3321,0.1906 T0.3153,0.1954 T0.302,0.1983 Q0.2972,0.1991,0.2859,0.1997 T0.2643,0.201 T0.245,0.2024 T0.2337,0.2041 Q0.2321,0.2045,0.2181,0.2045 T0.1851,0.207 T0.1542,0.2157 Q0.143,0.2211,0.143,0.226 Q0.143,0.2314,0.153,0.2348 T0.1719,0.2381 Q0.1791,0.2381,0.2474,0.2476 Q0.2506,0.248,0.2558,0.2489 T0.2651,0.2505 T0.2739,0.252 T0.2819,0.2526 Q0.2835,0.2526,0.2851,0.2522 L0.2859,0.2522 Q0.2948,0.2522,0.3104,0.2574 T0.3309,0.2671 Q0.3333,0.2696,0.3365,0.2696 Q0.339,0.2696,0.3442,0.2684 T0.3526,0.2671 T0.3582,0.2679 Q0.3687,0.2717,0.3968,0.2742 Q0.4,0.2746,0.4036,0.2771 T0.4096,0.2796 Q0.4112,0.2796,0.4137,0.2787 Q0.4169,0.2779,0.4257,0.2779 Q0.4594,0.2779,0.4779,0.2849 Q0.4876,0.2891,0.4972,0.2891 Q0.4996,0.2891,0.506,0.2887 T0.5165,0.2883 Q0.5253,0.2883,0.5305,0.2889 T0.541,0.292 T0.5494,0.299 L0.5494,0.3015 Q0.5494,0.3036,0.5466,0.3051 T0.5386,0.3073 T0.5285,0.3086 T0.5181,0.3094 T0.5108,0.3102 Q0.5068,0.3107,0.494,0.3121 L0.4683,0.315 T0.4506,0.3181 Q0.449,0.3181,0.4426,0.32 T0.4313,0.3229 T0.4197,0.3256 T0.4064,0.3281 T0.3952,0.3289 Q0.3888,0.3289,0.3855,0.3273 Q0.3783,0.3243,0.3703,0.3243 Q0.3598,0.3243,0.351,0.3297 Q0.3478,0.3314,0.3422,0.3314 Q0.3398,0.3314,0.3337,0.3306 T0.3245,0.3297 T0.3189,0.3306 Q0.3149,0.3314,0.2956,0.3328 T0.2715,0.3368 Q0.2691,0.338,0.2635,0.3391 T0.2522,0.3407 T0.2406,0.3424 T0.2313,0.3447 Q0.2305,0.3447,0.2213,0.348 T0.206,0.354 T0.1924,0.3621 T0.1815,0.3733 Q0.1767,0.3816,0.1767,0.3849 Q0.1767,0.387,0.1783,0.3893 T0.1847,0.3961 T0.1936,0.4063 T0.2004,0.4181 T0.2052,0.4264 T0.2145,0.4309 T0.2386,0.4359 Q0.2458,0.4372,0.2554,0.4372 Q0.2578,0.4372,0.2627,0.437 T0.2699,0.4367 Q0.2795,0.4367,0.2851,0.4384 Q0.294,0.4409,0.3092,0.4409 Q0.3116,0.4409,0.3157,0.4407 T0.3213,0.4405 Q0.3301,0.4405,0.3466,0.444 T0.3687,0.4509 Q0.3703,0.4517,0.3703,0.4525 Q0.3703,0.4575,0.3373,0.4596 Q0.3133,0.4608,0.2988,0.4658 Q0.294,0.4674,0.2803,0.4674 Q0.2763,0.4674,0.2687,0.4672 T0.257,0.467 Q0.2402,0.467,0.2313,0.4687 Q0.2209,0.4708,0.2209,0.4737 Q0.2209,0.4741,0.2213,0.4749 T0.2229,0.4764 L0.2249,0.4774 T0.2265,0.4778 L0.2273,0.4782 Q0.2498,0.4803,0.2618,0.4803 Q0.2667,0.4803,0.2691,0.4799 Q0.2699,0.4795,0.2715,0.4795 Q0.2779,0.4795,0.3133,0.4873 Q0.3181,0.4886,0.3233,0.4892 T0.3386,0.4905 T0.3542,0.4915 T0.3655,0.4919 Q0.3703,0.4919,0.3779,0.4915 T0.3888,0.4911 T0.3952,0.4915 Q0.3976,0.4919,0.4008,0.4927 L0.4072,0.4944 T0.4149,0.4961 T0.4249,0.4975 T0.4369,0.4985 Q0.449,0.499,0.4554,0.5 T0.4651,0.5031 T0.4699,0.5085 Q0.4707,0.5093,0.4707,0.5097 Q0.4707,0.5114,0.4687,0.5124 T0.4631,0.5139 T0.4562,0.5145 T0.4478,0.5147 L0.4402,0.5147 Q0.4353,0.5151,0.4265,0.5174 T0.4096,0.5197 L0.4024,0.5197 Q0.4,0.5193,0.3968,0.5193 Q0.3896,0.5193,0.3831,0.5209 T0.3703,0.5253 T0.3598,0.5288 Q0.3558,0.5301,0.3321,0.5342 T0.302,0.5409 Q0.2876,0.5454,0.2337,0.55 Q0.2112,0.5521,0.2112,0.5558 Q0.2112,0.5574,0.2161,0.5612 Q0.2201,0.5637,0.2281,0.5637 Q0.2305,0.5637,0.2361,0.5633 T0.2442,0.5628 Q0.2474,0.5628,0.2498,0.5633 Q0.2635,0.5653,0.3133,0.5653 Q0.3261,0.5653,0.3301,0.5649 L0.3325,0.5649 Q0.3349,0.5649,0.3402,0.5657 T0.3494,0.5666 Q0.3542,0.5666,0.3598,0.5657 Q0.3663,0.5645,0.3695,0.5645 Q0.3735,0.5645,0.3775,0.5666 T0.3847,0.5695 Q0.3888,0.5707,0.3912,0.5709 T0.3968,0.5711 T0.4048,0.572 Q0.4096,0.5724,0.4165,0.5726 T0.4281,0.5734 T0.4378,0.5757 Q0.4418,0.5774,0.4522,0.5774 Q0.4562,0.5774,0.4655,0.5769 T0.4795,0.5765 Q0.4932,0.5765,0.4972,0.579 Q0.5028,0.5819,0.51,0.5819 Q0.5141,0.5819,0.5221,0.5809 T0.5341,0.5798 L0.5373,0.5798 Q0.5422,0.5798,0.557,0.5821 T0.5855,0.5844 L0.5936,0.5844 L0.5992,0.5844 T0.604,0.5848 T0.6076,0.5852 T0.6108,0.5859 T0.6137,0.5865 T0.6161,0.5873 T0.6181,0.5881 T0.6209,0.5896 L0.6233,0.591 Q0.6265,0.5927,0.6353,0.5927 L0.6402,0.5927 L0.645,0.5927 Q0.6723,0.5927,0.6948,0.6039 Q0.6988,0.606,0.7036,0.606 Q0.7076,0.606,0.7153,0.6045 T0.7261,0.6031 Q0.7309,0.6031,0.7454,0.6027 T0.7647,0.6022 Q0.7783,0.6022,0.808,0.608 L0.812,0.608 Q0.8169,0.608,0.8293,0.6062 T0.8474,0.6039 L0.8506,0.6039 Q0.857,0.6039,0.8655,0.6049 T0.8835,0.6074 T0.8972,0.6093 Q0.9036,0.6101,0.9213,0.6101 L0.9398,0.6101 L0.959,0.6101 L0.9767,0.6101 Q0.9976,0.6109,0.9976,0.6176 Q0.9976,0.6188,0.9968,0.6226 Q0.9952,0.6284,0.9671,0.6284 L0.959,0.6284 L0.9526,0.6284 Q0.9486,0.6284,0.9382,0.6302 T0.9205,0.6321 L0.9173,0.6321 Q0.9133,0.6317,0.9092,0.6317 Q0.9012,0.6317,0.8715,0.6346 Q0.8233,0.6387,0.7944,0.6421 Q0.7888,0.6429,0.7831,0.6429 Q0.7783,0.6429,0.7731,0.6425 T0.7663,0.6421 L0.7622,0.6421 Q0.7614,0.6425,0.7586,0.6425 T0.7474,0.6421 T0.7349,0.6416 Q0.7261,0.6416,0.7221,0.6429 Q0.7173,0.645,0.7032,0.646 T0.6711,0.6479 T0.6482,0.6491 Q0.645,0.6495,0.6418,0.6495 Q0.6353,0.6495,0.6273,0.6474 T0.6161,0.6454 Q0.612,0.6454,0.6088,0.6477 T0.6,0.6499 Q0.5912,0.6499,0.5494,0.6578 Q0.5422,0.6591,0.5373,0.6591 Q0.5341,0.6591,0.5281,0.6584 T0.5173,0.6578 L0.5124,0.6578 Q0.5004,0.6582,0.4747,0.6632 Q0.4675,0.6649,0.4398,0.6667 T0.3851,0.6703 T0.3566,0.6719 Q0.3534,0.6723,0.3406,0.6734 T0.3141,0.6757 T0.2948,0.6773 Q0.2908,0.6781,0.2819,0.6829 T0.2618,0.6877 Q0.2482,0.6877,0.2273,0.6958 T0.1992,0.7117 Q0.1976,0.7138,0.1976,0.7155 Q0.1976,0.7188,0.2032,0.7213 T0.2181,0.7258 T0.2289,0.7292 Q0.2337,0.7308,0.2378,0.7316 L0.2458,0.7333 T0.2522,0.7345 Q0.2747,0.7399,0.2835,0.7399 Q0.29,0.7399,0.3048,0.7408 T0.3253,0.7416 Q0.3277,0.7416,0.3297,0.7414 T0.3341,0.7412 L0.3598,0.7445 Q0.3639,0.7449,0.3663,0.7449 Q0.3695,0.7449,0.3751,0.7443 T0.3855,0.7437 L0.3904,0.7437 Q0.3984,0.7441,0.4141,0.7472 T0.4353,0.7507 Q0.4434,0.752,0.4434,0.7553 Q0.4434,0.7578,0.439,0.7605 T0.4297,0.764 Q0.4257,0.7648,0.4201,0.7655 T0.4076,0.7665 T0.3984,0.7673 Q0.3968,0.7673,0.3912,0.7677 T0.3815,0.7688 T0.3735,0.771 T0.3655,0.7727 Q0.3606,0.7727,0.3534,0.7708 T0.3458,0.769 L0.3446,0.769 Q0.3414,0.769,0.3297,0.7713 T0.3149,0.7735 Q0.3092,0.7735,0.296,0.7771 T0.2771,0.7831 Q0.2683,0.7872,0.2554,0.7885 Q0.253,0.7889,0.2325,0.7897 T0.2032,0.7914 Q0.2008,0.7918,0.198,0.792 T0.194,0.7924 T0.1912,0.7928 T0.1888,0.793 T0.1867,0.7934 T0.1851,0.7943 T0.1843,0.7953 T0.1835,0.7968 T0.1823,0.7984 T0.1815,0.8001 Q0.1815,0.8022,0.1847,0.8032 T0.1932,0.8053 T0.2008,0.8071 Q0.2072,0.8092,0.2265,0.8092 Q0.2361,0.8092,0.2586,0.8088 L0.2635,0.8088 Q0.2731,0.8088,0.2795,0.8098 T0.2928,0.8134 T0.302,0.8167 Q0.306,0.8179,0.3277,0.8183 T0.3542,0.8221 Q0.359,0.8246,0.3622,0.8246 Q0.3639,0.8246,0.3675,0.8237 T0.3751,0.8229 Q0.3968,0.8229,0.4048,0.8275 Q0.4088,0.8291,0.4161,0.8291 Q0.4225,0.8291,0.4353,0.8275 Q0.4369,0.827,0.4394,0.827 Q0.4474,0.827,0.4675,0.8345 Q0.4707,0.8358,0.4739,0.8358 Q0.4755,0.8358,0.4799,0.8353 T0.4876,0.8349 Q0.494,0.8349,0.5012,0.8362 L0.5076,0.837 T0.5133,0.8376 T0.5181,0.8382 T0.5221,0.8391 L0.5253,0.8399 T0.5277,0.8409 L0.5293,0.8422 T0.5301,0.8438 L0.5301,0.8461 Q0.5301,0.8499,0.5249,0.8511 T0.5112,0.8526 T0.5004,0.8532 Q0.4964,0.854,0.4851,0.8546 T0.4667,0.8565 Q0.4578,0.8577,0.441,0.8577 L0.4345,0.8577 L0.4289,0.8577 L0.4209,0.8577 Q0.4153,0.8582,0.4088,0.8586 T0.3968,0.8592 T0.3855,0.8594 T0.3755,0.8596 T0.3675,0.86 T0.3622,0.8602 Q0.3317,0.8635,0.3237,0.8635 Q0.2996,0.8635,0.2996,0.8681 Q0.2996,0.8698,0.3032,0.8733 T0.3068,0.8797 L0.3068,0.8822 Q0.3052,0.8859,0.2859,0.8859 Q0.2787,0.8859,0.2739,0.8851 L0.2699,0.8851 Q0.2659,0.8851,0.259,0.888 T0.2498,0.8913 Q0.2394,0.8946,0.2056,0.8971 Q0.2032,0.8971,0.1928,0.9002 T0.1703,0.9034 Q0.1526,0.9034,0.1353,0.9067 T0.1173,0.915 L0.1173,0.9158 Q0.1173,0.9166,0.1177,0.9175 T0.1189,0.9191 T0.1205,0.9204 T0.1229,0.9212 T0.1257,0.922 T0.1285,0.9226 T0.1309,0.9231 T0.1337,0.9235 T0.1365,0.9237 L0.1446,0.9237 T0.1554,0.9243 T0.1614,0.9268 T0.1659,0.9293 T0.1743,0.9303 T0.1823,0.9316 Q0.1863,0.932,0.1912,0.9332 T0.2028,0.9361 T0.2153,0.939 Q0.2233,0.9411,0.2297,0.9448 T0.2386,0.9494 Q0.2426,0.9506,0.2506,0.9506 Q0.253,0.9506,0.2594,0.9504 T0.2699,0.9502 Q0.2763,0.9502,0.2819,0.9511 Q0.2851,0.9511,0.2871,0.9529 T0.2892,0.9573 Q0.2892,0.9627,0.2811,0.9635 Q0.2434,0.9652,0.2337,0.9672 Q0.2257,0.9693,0.2241,0.9693 Q0.2217,0.9693,0.2096,0.9689 L0.2056,0.9689 Q0.1976,0.9689,0.1924,0.9699 T0.1787,0.9741 L0.1647,0.9793 Q0.1518,0.983,0.1285,0.983 L0.1261,0.983 L0.1237,0.983 Q0.1124,0.983,0.1076,0.9838 Q0.102,0.9851,0.0839,0.9896 T0.0602,0.9954 T0.0406,0.9971 T0.0153,0.9979 L0.0032,0.9979 Z",type:0}},"":{type:5,data:{d:"M0.9992,0.998 Q0.9992,1,0.9968,1 Q0.9521,1,0.9392,0.998 Q0.9335,0.997,0.9152,0.9921 T0.8913,0.9862 Q0.8873,0.9852,0.8783,0.9852 L0.8747,0.9852 L0.871,0.9852 Q0.8483,0.9852,0.8345,0.9812 Q0.8289,0.9792,0.8228,0.9773 T0.8135,0.9743 T0.8054,0.9723 T0.7948,0.9713 L0.7908,0.9713 L0.777,0.9713 T0.7656,0.9693 Q0.7616,0.9683,0.7498,0.9674 T0.7283,0.9664 L0.7186,0.9654 Q0.708,0.9644,0.708,0.9575 Q0.708,0.9545,0.7109,0.952 T0.7178,0.9486 L0.7315,0.9486 L0.7417,0.9486 L0.7502,0.9486 Q0.7575,0.9486,0.7599,0.9476 Q0.7616,0.9466,0.7685,0.9426 T0.7843,0.9377 Q0.7899,0.9357,0.8009,0.9332 T0.8175,0.9298 Q0.82,0.9288,0.8228,0.9283 T0.8277,0.9278 T0.8313,0.9273 T0.8341,0.9263 T0.8362,0.9258 Q0.8402,0.9219,0.854,0.9219 L0.8569,0.9219 L0.8597,0.9219 L0.8637,0.9219 Q0.88,0.9199,0.88,0.9159 L0.88,0.9149 Q0.88,0.913,0.8719,0.911 T0.852,0.9075 T0.8297,0.906 Q0.8224,0.906,0.8167,0.905 T0.8074,0.9031 T0.8001,0.9006 T0.794,0.8991 Q0.7599,0.8971,0.7494,0.8942 Q0.7461,0.8932,0.7397,0.8902 T0.7299,0.8872 L0.7267,0.8872 Q0.721,0.8882,0.7121,0.8882 Q0.6926,0.8882,0.691,0.8833 L0.691,0.8803 Q0.691,0.8773,0.6922,0.8749 T0.6955,0.8704 L0.6975,0.8684 Q0.6926,0.8655,0.6764,0.8655 Q0.6683,0.8655,0.6375,0.8625 Q0.6358,0.8625,0.629,0.862 T0.6148,0.8615 T0.5973,0.861 T0.5791,0.8605 L0.5718,0.8605 L0.5661,0.8605 L0.5596,0.8605 Q0.5426,0.8605,0.5337,0.8586 Q0.5264,0.8576,0.515,0.8571 T0.4996,0.8556 Q0.498,0.8556,0.4947,0.8551 T0.4882,0.8546 T0.4809,0.8541 T0.474,0.8531 T0.4692,0.8506 T0.4672,0.8467 Q0.4672,0.8437,0.468,0.8422 T0.4704,0.8398 T0.4736,0.8383 T0.4781,0.8368 T0.4838,0.8358 T0.4911,0.8353 T0.4988,0.8338 Q0.5069,0.8328,0.5142,0.8328 Q0.5174,0.8328,0.5215,0.8333 T0.5264,0.8338 Q0.5296,0.8338,0.532,0.8328 Q0.5523,0.8249,0.5604,0.8249 L0.5653,0.8249 Q0.5775,0.8269,0.5848,0.8269 Q0.5912,0.8269,0.5937,0.8259 Q0.6026,0.821,0.6253,0.821 Q0.6294,0.821,0.6334,0.822 T0.6383,0.8229 Q0.6399,0.8229,0.644,0.82 Q0.6504,0.817,0.6723,0.816 T0.6975,0.814 Q0.6999,0.814,0.7048,0.8121 T0.7129,0.8091 T0.7226,0.8071 T0.7372,0.8061 L0.7421,0.8061 Q0.7624,0.8071,0.7721,0.8071 Q0.7924,0.8071,0.7981,0.8051 Q0.8021,0.8032,0.807,0.8027 T0.8135,0.8017 T0.8151,0.8002 L0.8151,0.7992 Q0.8143,0.7962,0.8127,0.7957 T0.8045,0.7943 Q0.7997,0.7943,0.7964,0.7933 Q0.7883,0.7933,0.7676,0.7923 T0.7437,0.7903 Q0.7307,0.7893,0.7218,0.7844 Q0.7194,0.7834,0.7141,0.7819 L0.7036,0.7789 T0.693,0.7765 T0.6853,0.7755 Q0.6805,0.7755,0.6691,0.773 T0.6553,0.7705 Q0.6545,0.7705,0.6545,0.7715 Q0.6537,0.7715,0.65,0.7725 T0.6423,0.774 T0.635,0.7745 Q0.6294,0.7745,0.6257,0.773 T0.618,0.771 T0.6083,0.77 T0.601,0.7695 Q0.5985,0.7695,0.5921,0.769 T0.5795,0.7676 T0.5702,0.7666 Q0.5653,0.7656,0.5596,0.7616 T0.5539,0.7547 Q0.5539,0.7498,0.5645,0.7488 Q0.5693,0.7478,0.5852,0.7448 T0.6099,0.7418 L0.6148,0.7418 Q0.6196,0.7418,0.6253,0.7423 T0.6338,0.7428 L0.6399,0.7428 Q0.6431,0.7418,0.6488,0.7409 T0.6586,0.7394 T0.6659,0.7389 L0.6707,0.7389 Q0.6723,0.7399,0.6748,0.7399 Q0.6796,0.7399,0.6946,0.7389 T0.7161,0.7379 Q0.7251,0.7379,0.7478,0.7319 Q0.7494,0.7319,0.7539,0.7315 T0.762,0.73 T0.7697,0.727 T0.7818,0.723 T0.7948,0.7196 T0.7997,0.7151 Q0.7997,0.7141,0.7989,0.7132 Q0.7924,0.7062,0.7717,0.6978 T0.738,0.6894 Q0.7267,0.6894,0.717,0.6845 T0.7048,0.6795 Q0.6975,0.6785,0.6711,0.6766 T0.6431,0.6746 Q0.6415,0.6736,0.6144,0.6721 T0.5596,0.6686 T0.5247,0.6657 Q0.4996,0.6607,0.4874,0.6597 L0.4834,0.6597 Q0.4785,0.6597,0.4724,0.6607 T0.4631,0.6617 Q0.4574,0.6617,0.4501,0.6597 Q0.4088,0.6518,0.4006,0.6518 Q0.3933,0.6518,0.3901,0.6499 T0.3844,0.6479 T0.3739,0.6499 T0.3585,0.6518 L0.352,0.6518 Q0.3471,0.6508,0.335,0.6503 T0.3122,0.6494 T0.2912,0.6479 T0.2766,0.6449 Q0.2733,0.6439,0.2668,0.6439 Q0.2628,0.6439,0.2543,0.6444 T0.2425,0.6449 T0.2368,0.6439 L0.2344,0.6439 Q0.2336,0.6439,0.2283,0.6444 T0.2182,0.6449 Q0.2117,0.6449,0.2052,0.6439 Q0.1768,0.6409,0.1281,0.637 Q0.0989,0.634,0.0916,0.634 L0.0835,0.634 L0.0795,0.634 Q0.0722,0.634,0.0616,0.632 T0.0479,0.6301 Q0.0462,0.6301,0.0406,0.6306 T0.0308,0.6311 Q0.0032,0.6311,0.0008,0.6231 Q0,0.6202,0,0.6182 Q0,0.6083,0.0227,0.6083 Q0.03,0.6073,0.0422,0.6073 Q0.0487,0.6073,0.0608,0.6078 T0.0787,0.6083 Q0.0973,0.6083,0.103,0.6073 Q0.1062,0.6063,0.1131,0.6053 T0.1253,0.6039 T0.1375,0.6024 T0.1492,0.6014 L0.1533,0.6014 Q0.1598,0.6024,0.1723,0.6044 T0.189,0.6063 Q0.1906,0.6063,0.1922,0.6053 Q0.2214,0.6004,0.236,0.6004 Q0.2409,0.6004,0.2551,0.6009 T0.2741,0.6014 Q0.2782,0.6014,0.2859,0.6024 T0.2968,0.6034 Q0.3009,0.6034,0.3041,0.6024 Q0.3277,0.5905,0.356,0.5905 L0.3613,0.5905 L0.3666,0.5905 Q0.3731,0.5905,0.3755,0.5895 Q0.382,0.5856,0.3844,0.5846 T0.3938,0.5831 T0.4144,0.5826 T0.4428,0.5801 T0.4631,0.5776 L0.4663,0.5776 Q0.4704,0.5776,0.4785,0.5786 T0.4899,0.5796 Q0.4964,0.5796,0.5012,0.5767 Q0.5069,0.5737,0.5223,0.5737 Q0.5272,0.5737,0.5369,0.5742 T0.5499,0.5747 Q0.558,0.5747,0.5612,0.5737 Q0.5661,0.5717,0.5714,0.5712 T0.5835,0.5702 T0.5945,0.5692 T0.6018,0.5687 L0.6062,0.5687 T0.6095,0.5682 T0.6148,0.5678 Q0.6172,0.5668,0.6196,0.5653 T0.6245,0.5633 T0.631,0.5628 T0.6407,0.5638 T0.6504,0.5648 T0.6594,0.5638 T0.6675,0.5628 L0.6707,0.5628 L0.6861,0.5628 Q0.7364,0.5628,0.7494,0.5608 L0.7567,0.5608 L0.7652,0.5608 L0.7729,0.5608 Q0.7794,0.5608,0.7818,0.5589 Q0.7851,0.5569,0.7859,0.5559 Q0.7835,0.5539,0.7656,0.5519 Q0.7121,0.547,0.6975,0.543 Q0.6934,0.541,0.6853,0.5396 L0.6691,0.5366 T0.6525,0.5336 T0.6391,0.5312 Q0.6367,0.5302,0.6245,0.5257 T0.6034,0.5213 L0.5977,0.5213 Q0.5945,0.5223,0.5904,0.5223 Q0.5839,0.5223,0.5787,0.5208 T0.5685,0.5183 T0.5596,0.5173 Q0.558,0.5173,0.5552,0.5168 T0.5495,0.5163 L0.5434,0.5163 T0.5373,0.5158 T0.532,0.5148 T0.5284,0.5129 T0.5272,0.5104 L0.5272,0.5074 Q0.5304,0.5015,0.5377,0.499 T0.5629,0.4965 Q0.5734,0.4955,0.5811,0.4946 T0.5953,0.4916 T0.605,0.4896 Q0.6083,0.4886,0.6123,0.4886 Q0.6148,0.4886,0.6225,0.4891 T0.635,0.4896 L0.6456,0.4896 Q0.6837,0.4857,0.6861,0.4857 Q0.7226,0.4768,0.7283,0.4768 Q0.7307,0.4768,0.732,0.4773 T0.7372,0.4777 Q0.7502,0.4777,0.7721,0.4758 Q0.7753,0.4748,0.777,0.4738 Q0.7745,0.4718,0.768,0.4708 Q0.7599,0.4688,0.7445,0.4688 Q0.7405,0.4688,0.7324,0.4693 T0.721,0.4698 Q0.7056,0.4698,0.6999,0.4679 Q0.6861,0.4629,0.6626,0.4619 Q0.6586,0.4609,0.6533,0.4604 T0.6419,0.459 T0.6314,0.456 T0.6269,0.452 Q0.6269,0.451,0.6302,0.4491 Q0.6358,0.4451,0.6525,0.4416 T0.6788,0.4382 L0.6845,0.4382 L0.6902,0.4382 Q0.7064,0.4382,0.7145,0.4362 Q0.7202,0.4342,0.7307,0.4342 L0.7356,0.4342 L0.7409,0.4342 L0.7453,0.4342 Q0.7543,0.4342,0.7607,0.4332 Q0.7778,0.4313,0.7847,0.4288 T0.7932,0.4248 T0.7972,0.4169 T0.8045,0.4045 L0.8094,0.3986 T0.8135,0.3942 L0.8167,0.3912 T0.8191,0.3887 T0.8204,0.3867 T0.8208,0.3848 Q0.8208,0.3818,0.8167,0.3739 Q0.8127,0.3689,0.8054,0.3635 T0.7924,0.3556 T0.7778,0.3497 T0.768,0.3462 Q0.764,0.3442,0.7478,0.3427 T0.7267,0.3383 Q0.7234,0.3363,0.7044,0.3348 T0.6805,0.3323 L0.6764,0.3323 T0.6679,0.3328 T0.6586,0.3333 Q0.6521,0.3333,0.648,0.3314 Q0.6391,0.3264,0.6294,0.3264 Q0.6229,0.3264,0.6156,0.3294 Q0.6115,0.3304,0.6042,0.3304 Q0.5977,0.3304,0.5888,0.3289 T0.5738,0.3264 T0.5584,0.3225 L0.5491,0.3195 Q0.5418,0.3185,0.5162,0.3155 T0.4895,0.3121 T0.4826,0.3116 T0.4716,0.3111 T0.4607,0.3096 T0.4513,0.3066 T0.4477,0.3017 Q0.4477,0.2997,0.4485,0.2977 Q0.4526,0.2898,0.4611,0.2878 T0.4842,0.2859 Q0.4882,0.2859,0.4943,0.2864 T0.5028,0.2868 Q0.5118,0.2868,0.5215,0.2829 Q0.5401,0.276,0.5734,0.276 Q0.5831,0.276,0.5872,0.277 L0.5904,0.277 T0.5949,0.2745 T0.6034,0.272 Q0.631,0.269,0.6407,0.2661 Q0.6448,0.2641,0.6488,0.2641 T0.6573,0.2656 T0.6634,0.2671 Q0.6659,0.2671,0.6675,0.2651 Q0.6723,0.2601,0.6886,0.2552 T0.7137,0.2502 L0.7153,0.2502 L0.7178,0.2502 Q0.7218,0.2502,0.7279,0.2493 T0.7417,0.2468 T0.7526,0.2453 Q0.8208,0.2354,0.8281,0.2354 Q0.8337,0.2354,0.8394,0.2344 T0.8496,0.2315 T0.854,0.2265 Q0.854,0.2216,0.8443,0.2176 Q0.8354,0.2127,0.8216,0.2102 T0.7972,0.2072 T0.777,0.2067 T0.7648,0.2057 Q0.7632,0.2047,0.7498,0.2038 T0.7202,0.2018 T0.6983,0.1998 Q0.6918,0.1998,0.676,0.1949 T0.6537,0.1899 Q0.6496,0.1899,0.6472,0.1919 Q0.6342,0.1968,0.6123,0.1968 Q0.5961,0.1968,0.5912,0.1929 Q0.5904,0.1919,0.5892,0.1919 T0.5823,0.1944 T0.5722,0.1968 T0.5637,0.1949 Q0.5556,0.1909,0.5158,0.1869 Q0.5109,0.1869,0.5032,0.1835 T0.4915,0.18 Q0.4907,0.18,0.4891,0.181 Q0.4793,0.182,0.455,0.182 Q0.4177,0.182,0.4015,0.1771 Q0.3901,0.1741,0.3779,0.1741 Q0.3747,0.1741,0.369,0.1746 T0.3609,0.1751 Q0.3569,0.1751,0.3536,0.1741 Q0.3512,0.1741,0.3459,0.1736 T0.3358,0.1726 T0.3256,0.1716 T0.3167,0.1696 T0.3106,0.1662 Q0.3082,0.1632,0.3082,0.1592 Q0.3082,0.1543,0.3114,0.1508 T0.3204,0.1474 L0.3236,0.1474 Q0.3301,0.1494,0.3333,0.1494 Q0.339,0.1494,0.3496,0.1454 Q0.3674,0.1385,0.3869,0.1385 Q0.395,0.1385,0.4011,0.137 T0.4116,0.1355 T0.4197,0.1375 T0.425,0.1395 Q0.429,0.1395,0.4355,0.1365 Q0.4631,0.1266,0.4882,0.1266 Q0.5126,0.1266,0.5337,0.1246 Q0.5369,0.1246,0.5458,0.1207 T0.5661,0.1167 L0.5693,0.1167 Q0.5791,0.1177,0.5852,0.1187 T0.5921,0.1197 Q0.5945,0.1197,0.6002,0.1167 Q0.6026,0.1147,0.605,0.1123 T0.6091,0.1093 T0.6156,0.1088 T0.6318,0.1098 Q0.6456,0.1108,0.6569,0.1137 T0.6707,0.1167 Q0.674,0.1167,0.6772,0.1147 Q0.6821,0.1108,0.6946,0.1083 T0.721,0.1044 T0.7453,0.1029 L0.7526,0.1029 Q0.7583,0.1039,0.7652,0.1053 T0.7753,0.1068 Q0.7778,0.1068,0.7802,0.1058 Q0.7981,0.1019,0.8143,0.1019 Q0.8175,0.1019,0.8439,0.0964 T0.8735,0.091 Q0.8759,0.091,0.8836,0.089 T0.8938,0.0851 Q0.8929,0.0841,0.8905,0.0841 Q0.8832,0.0821,0.8735,0.0821 Q0.8694,0.0821,0.8629,0.0826 T0.854,0.0831 Q0.8508,0.0831,0.8491,0.0821 Q0.8475,0.0821,0.8171,0.0811 T0.7802,0.0781 T0.7571,0.0737 T0.7332,0.0712 Q0.7299,0.0712,0.7247,0.0717 T0.7161,0.0722 L0.7064,0.0722 Q0.7032,0.0712,0.7003,0.0707 T0.6959,0.0692 T0.693,0.0678 T0.6914,0.0663 T0.6902,0.0648 T0.689,0.0643 L0.6869,0.0643 L0.6845,0.0643 L0.6821,0.0643 Q0.6553,0.0643,0.6553,0.0554 T0.6796,0.0465 L0.6813,0.0465 L0.6845,0.0465 Q0.6918,0.0425,0.717,0.0376 Q0.7226,0.0366,0.7267,0.0356 T0.7324,0.0336 T0.7376,0.0317 T0.747,0.0297 Q0.7518,0.0287,0.7551,0.0287 T0.762,0.0297 T0.768,0.0307 L0.7729,0.0307 Q0.7802,0.0277,0.7997,0.0267 T0.8224,0.0237 Q0.8273,0.0227,0.8341,0.0223 T0.8475,0.0193 T0.8654,0.0143 T0.8873,0.0079 Q0.9002,0.003,0.9331,0.004 T0.97,0.004 Q0.9838,0,0.9976,0.001 Q1,0.001,1,0.003 Z M0.9935,0.996 L0.9951,0.0049 Q0.9822,0.0049,0.9716,0.0079 Q0.9659,0.0099,0.9327,0.0089 T0.8897,0.0119 Q0.8775,0.0158,0.8666,0.0183 T0.85,0.0237 Q0.8418,0.0267,0.8341,0.0272 T0.8248,0.0277 Q0.82,0.0297,0.8001,0.0312 T0.7745,0.0346 Q0.7697,0.0356,0.7664,0.0356 Q0.764,0.0356,0.7603,0.0341 T0.7534,0.0326 Q0.751,0.0326,0.7478,0.0336 Q0.7429,0.0346,0.7397,0.0356 T0.7344,0.0376 T0.7283,0.0401 T0.7178,0.0425 Q0.6926,0.0465,0.6878,0.0495 Q0.6845,0.0514,0.6764,0.0514 L0.6723,0.0514 L0.6691,0.0514 Q0.6602,0.0514,0.6602,0.0554 Q0.6602,0.0574,0.6659,0.0579 T0.6796,0.0589 T0.691,0.0603 T0.6967,0.0643 T0.7072,0.0673 Q0.7121,0.0682,0.7153,0.0682 T0.7238,0.0678 T0.7332,0.0673 Q0.7413,0.0673,0.7579,0.0692 T0.7818,0.0742 Q0.7867,0.0752,0.8171,0.0762 T0.8508,0.0781 L0.852,0.0781 T0.8601,0.0776 T0.8719,0.0772 Q0.8832,0.0772,0.8921,0.0791 Q0.8994,0.0811,0.8994,0.0841 T0.8933,0.0895 T0.8812,0.094 T0.8735,0.0959 T0.8451,0.1014 T0.8143,0.1068 Q0.7989,0.1068,0.7818,0.1098 Q0.7778,0.1108,0.7745,0.1108 T0.768,0.1103 T0.7603,0.1088 T0.7518,0.1068 L0.7461,0.1068 Q0.7315,0.1068,0.7088,0.1103 T0.6805,0.1177 Q0.6756,0.1207,0.6699,0.1207 Q0.6667,0.1207,0.6545,0.1177 T0.631,0.1147 Q0.6269,0.1137,0.6225,0.1133 T0.616,0.1128 L0.6123,0.1128 T0.6103,0.1133 L0.6083,0.1157 T0.6034,0.1197 Q0.5961,0.1246,0.5921,0.1246 Q0.5904,0.1246,0.5835,0.1231 T0.5693,0.1217 L0.5661,0.1217 Q0.558,0.1217,0.5535,0.1231 T0.5446,0.1266 T0.5345,0.1296 Q0.5126,0.1316,0.4882,0.1316 T0.438,0.1405 Q0.4298,0.1434,0.425,0.1434 Q0.4209,0.1434,0.4173,0.1419 T0.4112,0.1405 T0.4027,0.1419 T0.3869,0.1434 Q0.3682,0.1434,0.3512,0.1494 Q0.3398,0.1533,0.3333,0.1533 Q0.3285,0.1533,0.3228,0.1513 L0.3212,0.1513 Q0.3179,0.1513,0.3155,0.1538 T0.3131,0.1592 Q0.3131,0.1612,0.3147,0.1632 Q0.3171,0.1652,0.3232,0.1667 T0.3406,0.1691 T0.3544,0.1701 L0.3601,0.1701 L0.3678,0.1701 L0.3771,0.1701 Q0.3909,0.1701,0.4031,0.1731 Q0.4177,0.178,0.4558,0.178 Q0.4793,0.178,0.4882,0.1761 L0.4915,0.1761 Q0.498,0.1761,0.5061,0.179 T0.5158,0.183 Q0.5166,0.183,0.5231,0.1835 T0.5341,0.1845 T0.545,0.1855 T0.5572,0.1874 T0.5661,0.1909 Q0.5677,0.1919,0.5702,0.1919 T0.5791,0.1894 T0.588,0.1869 Q0.5921,0.1869,0.5945,0.1899 Q0.5977,0.1919,0.6123,0.1919 Q0.6334,0.1919,0.6448,0.1879 Q0.6488,0.186,0.6537,0.186 Q0.6626,0.186,0.6784,0.1909 T0.6983,0.1958 Q0.704,0.1968,0.7206,0.1973 T0.7506,0.1993 T0.7689,0.2018 L0.7705,0.2018 L0.7741,0.2018 L0.7786,0.2018 Q0.8248,0.2018,0.8475,0.2136 Q0.8597,0.2196,0.8597,0.2255 T0.8483,0.2359 T0.8281,0.2404 Q0.8208,0.2404,0.7534,0.2493 Q0.7502,0.2502,0.7425,0.2512 T0.7287,0.2532 T0.7178,0.2542 L0.7153,0.2542 L0.7137,0.2542 Q0.7064,0.2542,0.6906,0.2591 T0.6715,0.2681 T0.6626,0.272 Q0.6594,0.272,0.6541,0.2705 T0.6464,0.269 T0.6431,0.27 Q0.6318,0.273,0.6034,0.276 Q0.6018,0.276,0.5998,0.2774 T0.5957,0.2799 T0.5904,0.2809 L0.5856,0.2809 Q0.5823,0.2799,0.575,0.2799 Q0.541,0.2799,0.5239,0.2868 Q0.5134,0.2908,0.5028,0.2908 Q0.4996,0.2908,0.4935,0.2903 T0.4842,0.2898 Q0.4704,0.2898,0.4635,0.2918 T0.4534,0.2997 L0.4534,0.3007 Q0.4534,0.3037,0.459,0.3046 T0.4753,0.3066 T0.4899,0.3076 Q0.4907,0.3076,0.5166,0.3106 T0.5507,0.3155 Q0.5921,0.3264,0.605,0.3264 Q0.6107,0.3264,0.6131,0.3254 Q0.6212,0.3225,0.6294,0.3225 Q0.6407,0.3225,0.6513,0.3274 Q0.6529,0.3294,0.6569,0.3294 Q0.6594,0.3294,0.665,0.3284 T0.6748,0.3274 T0.6821,0.3284 Q0.6861,0.3294,0.6967,0.3299 T0.7165,0.3314 T0.7299,0.3343 Q0.7324,0.3363,0.7486,0.3383 T0.7697,0.3422 Q0.7713,0.3432,0.7774,0.3452 T0.7887,0.3492 T0.8005,0.3546 T0.8127,0.3625 T0.8208,0.3719 Q0.8256,0.3798,0.8256,0.3848 Q0.8256,0.3877,0.8236,0.3902 T0.8171,0.3976 T0.809,0.4075 T0.8021,0.4189 T0.7968,0.4273 T0.7867,0.4322 T0.7616,0.4382 Q0.7543,0.4392,0.7437,0.4392 L0.7364,0.4392 L0.7299,0.4392 Q0.7202,0.4392,0.7161,0.4402 Q0.7072,0.4431,0.691,0.4431 L0.6845,0.4431 L0.6788,0.4431 Q0.6707,0.4431,0.6545,0.4461 T0.6334,0.452 Q0.6399,0.456,0.6626,0.457 Q0.6878,0.458,0.7024,0.4639 Q0.7064,0.4649,0.7186,0.4649 L0.7295,0.4649 L0.7413,0.4649 Q0.7591,0.4649,0.7697,0.4669 Q0.7818,0.4688,0.7818,0.4728 Q0.7818,0.4748,0.7798,0.4763 T0.7753,0.4787 L0.7737,0.4797 L0.7729,0.4797 Q0.7494,0.4827,0.7388,0.4827 Q0.7332,0.4827,0.7307,0.4817 L0.7291,0.4817 Q0.7226,0.4817,0.6878,0.4896 Q0.6829,0.4906,0.6772,0.4911 T0.6618,0.4921 T0.646,0.4931 T0.6342,0.4936 T0.6212,0.4931 T0.6115,0.4926 Q0.6083,0.4926,0.6058,0.4931 T0.5969,0.4955 T0.5819,0.499 T0.5629,0.5005 Q0.5572,0.5005,0.5527,0.501 T0.545,0.502 T0.5397,0.503 T0.5361,0.5045 T0.5337,0.5064 T0.5328,0.5094 L0.532,0.5094 Q0.532,0.5104,0.5337,0.5109 T0.5381,0.5114 T0.5438,0.5119 T0.5515,0.5124 L0.5604,0.5124 Q0.5661,0.5134,0.5746,0.5153 T0.5912,0.5173 L0.5977,0.5173 L0.6026,0.5173 Q0.6139,0.5173,0.6273,0.5218 T0.6407,0.5272 Q0.6448,0.5282,0.6683,0.5321 T0.6991,0.5381 Q0.7137,0.543,0.7664,0.548 Q0.7908,0.55,0.7908,0.5559 Q0.7908,0.5589,0.7859,0.5628 Q0.781,0.5658,0.7713,0.5658 Q0.7689,0.5658,0.7628,0.5653 T0.7543,0.5648 T0.7502,0.5658 Q0.7364,0.5678,0.6878,0.5678 Q0.674,0.5678,0.6699,0.5668 L0.6683,0.5668 Q0.6659,0.5668,0.6606,0.5678 T0.6513,0.5687 Q0.6464,0.5687,0.6391,0.5678 Q0.6334,0.5668,0.6302,0.5668 T0.6241,0.5687 T0.6164,0.5717 Q0.6123,0.5727,0.6099,0.5727 L0.6062,0.5727 T0.6022,0.5732 T0.5953,0.5737 Q0.5904,0.5747,0.5839,0.5747 T0.5726,0.5752 T0.5629,0.5776 T0.5458,0.5796 Q0.5418,0.5796,0.5324,0.5791 T0.5191,0.5786 Q0.5077,0.5786,0.5045,0.5806 Q0.498,0.5836,0.4899,0.5836 Q0.4858,0.5836,0.4777,0.5826 T0.4655,0.5816 L0.4631,0.5816 Q0.4582,0.5816,0.4436,0.5841 T0.4144,0.5865 L0.4051,0.5865 L0.3986,0.5865 T0.3933,0.587 T0.3897,0.5875 T0.3869,0.5885 T0.3844,0.5895 T0.3812,0.591 T0.3779,0.5925 Q0.3739,0.5955,0.3633,0.5955 L0.3585,0.5955 L0.3536,0.5955 Q0.3277,0.5955,0.3066,0.6053 Q0.3017,0.6083,0.296,0.6083 Q0.2912,0.6083,0.2839,0.6068 T0.2741,0.6053 Q0.2693,0.6053,0.2547,0.6048 T0.236,0.6044 Q0.2222,0.6044,0.193,0.6103 L0.1882,0.6103 Q0.1825,0.6103,0.1703,0.6083 T0.1525,0.6063 L0.15,0.6063 Q0.1436,0.6063,0.135,0.6073 T0.1168,0.6098 T0.1038,0.6113 Q0.0973,0.6123,0.0787,0.6123 L0.06,0.6123 L0.0414,0.6123 L0.0235,0.6123 Q0.0049,0.6133,0.0049,0.6172 L0.0057,0.6222 Q0.0073,0.6261,0.043,0.6261 L0.0454,0.6261 L0.0479,0.6261 Q0.0527,0.6261,0.0633,0.6281 T0.0803,0.6301 L0.0835,0.6301 L0.0916,0.6301 Q0.0998,0.6301,0.129,0.632 Q0.1768,0.636,0.206,0.64 Q0.2117,0.6409,0.2165,0.6409 Q0.2206,0.6409,0.2263,0.6405 T0.2336,0.64 L0.2384,0.64 L0.2409,0.64 T0.251,0.6395 T0.2644,0.639 Q0.2741,0.639,0.279,0.6409 Q0.283,0.6429,0.2972,0.6439 T0.3293,0.6459 T0.3528,0.6469 L0.3577,0.6469 Q0.3642,0.6469,0.3723,0.6449 T0.3844,0.6429 Q0.3901,0.6429,0.3933,0.6454 T0.4006,0.6479 Q0.4088,0.6479,0.4517,0.6558 Q0.4582,0.6568,0.4623,0.6568 Q0.4655,0.6568,0.4716,0.6563 T0.4826,0.6558 L0.4882,0.6558 Q0.5004,0.6558,0.5255,0.6607 Q0.5328,0.6627,0.5604,0.6647 T0.6152,0.6682 T0.644,0.6696 T0.6719,0.6721 T0.7056,0.6756 Q0.7113,0.6756,0.7198,0.6805 T0.738,0.6855 Q0.7453,0.6855,0.7551,0.6879 T0.7741,0.6939 T0.7912,0.7018 T0.8029,0.7102 Q0.8054,0.7132,0.8054,0.7151 Q0.8054,0.7191,0.8009,0.7216 T0.7908,0.7255 T0.779,0.7285 T0.7721,0.731 Q0.7672,0.7329,0.7632,0.7339 T0.7551,0.7354 T0.7486,0.7369 Q0.7478,0.7369,0.7445,0.7379 T0.7388,0.7394 T0.7324,0.7404 L0.7242,0.7413 T0.7161,0.7418 Q0.7105,0.7418,0.6955,0.7428 T0.6748,0.7438 L0.6699,0.7438 L0.6667,0.7438 Q0.6659,0.7438,0.6407,0.7468 Q0.6367,0.7478,0.6334,0.7478 T0.6245,0.7468 T0.6148,0.7458 L0.6099,0.7458 Q0.6018,0.7468,0.5864,0.7498 T0.5653,0.7532 T0.5596,0.7557 Q0.5596,0.7596,0.571,0.7616 Q0.5742,0.7626,0.5803,0.7631 T0.5929,0.7641 T0.6018,0.7656 L0.6087,0.7656 T0.6192,0.7666 T0.6277,0.7695 Q0.631,0.7705,0.6342,0.7705 Q0.6383,0.7705,0.6452,0.7685 T0.6537,0.7666 L0.6553,0.7666 Q0.6602,0.7666,0.6711,0.769 T0.6853,0.7715 Q0.691,0.7715,0.7048,0.775 T0.7242,0.7814 Q0.7324,0.7854,0.7445,0.7864 Q0.7478,0.7864,0.768,0.7873 T0.7972,0.7893 Q0.7989,0.7893,0.8017,0.7898 T0.8054,0.7903 L0.8082,0.7903 T0.811,0.7908 T0.8131,0.7913 T0.8151,0.7918 L0.8167,0.7928 T0.8179,0.7943 T0.8191,0.7957 T0.8204,0.7977 T0.8208,0.8002 Q0.8208,0.8032,0.8171,0.8051 T0.8074,0.8081 T0.7997,0.8091 Q0.7932,0.8121,0.7737,0.8121 Q0.7664,0.8121,0.7413,0.8111 L0.7364,0.8111 Q0.7275,0.8111,0.7214,0.8121 T0.7084,0.8155 T0.6991,0.819 Q0.6934,0.82,0.6723,0.8205 T0.6472,0.8239 Q0.6423,0.8269,0.6375,0.8269 Q0.6358,0.8269,0.6338,0.8264 T0.6294,0.8254 T0.6253,0.8249 Q0.6042,0.8249,0.5961,0.8289 Q0.5921,0.8318,0.5839,0.8318 Q0.5783,0.8318,0.5645,0.8299 L0.5604,0.8299 Q0.5531,0.8299,0.5337,0.8368 Q0.5296,0.8378,0.5255,0.8378 Q0.5239,0.8378,0.5195,0.8373 T0.5118,0.8368 Q0.5061,0.8368,0.4996,0.8388 Q0.4947,0.8388,0.4882,0.8398 Q0.4777,0.8417,0.4753,0.8427 T0.4728,0.8467 Q0.4728,0.8487,0.4769,0.8492 T0.4886,0.8501 T0.5004,0.8516 Q0.5036,0.8516,0.515,0.8521 T0.5345,0.8546 Q0.5426,0.8556,0.5596,0.8556 L0.5649,0.8556 L0.5702,0.8556 L0.5791,0.8556 Q0.5921,0.8566,0.6135,0.8571 T0.6383,0.8586 Q0.6683,0.8615,0.6764,0.8615 L0.6776,0.8615 L0.6788,0.8615 Q0.7032,0.8615,0.7032,0.8684 Q0.7032,0.8714,0.6995,0.8749 T0.6959,0.8803 L0.6959,0.8813 Q0.6991,0.8843,0.7153,0.8843 Q0.7218,0.8843,0.7259,0.8833 L0.7299,0.8833 Q0.7324,0.8833,0.7348,0.8838 T0.7393,0.8848 T0.7433,0.8863 L0.7474,0.8882 T0.751,0.8892 Q0.7616,0.8922,0.7948,0.8952 Q0.7981,0.8952,0.8082,0.8981 T0.8297,0.9011 T0.8528,0.9026 T0.8747,0.907 T0.8856,0.9149 L0.8856,0.9159 Q0.8856,0.9179,0.8848,0.9189 T0.8828,0.9209 T0.88,0.9228 T0.8767,0.9243 T0.8731,0.9248 T0.8698,0.9253 T0.8666,0.9258 L0.8646,0.9258 Q0.8621,0.9258,0.8577,0.9263 T0.85,0.9268 T0.8439,0.9273 T0.8402,0.9278 Q0.8386,0.9298,0.8358,0.9308 T0.8305,0.9322 T0.8244,0.9332 T0.8183,0.9337 Q0.8135,0.9347,0.8025,0.9372 T0.7859,0.9416 Q0.7778,0.9436,0.7717,0.9471 T0.7624,0.9515 Q0.7575,0.9535,0.7486,0.9535 Q0.7461,0.9535,0.7397,0.953 T0.7291,0.9525 Q0.7234,0.9525,0.7186,0.953 T0.7137,0.957 T0.7194,0.9614 Q0.7567,0.9634,0.7664,0.9654 Q0.7745,0.9674,0.7762,0.9674 L0.7908,0.9674 Q0.7924,0.9664,0.7948,0.9664 Q0.8029,0.9664,0.8082,0.9679 T0.8224,0.9723 T0.8362,0.9773 Q0.8483,0.9812,0.871,0.9812 L0.8751,0.9812 Q0.8873,0.9812,0.8929,0.9822 T0.9165,0.9876 T0.9408,0.9931 Q0.9513,0.996,0.9935,0.996 Z M0.9968,0.998 L0.9976,0.003 Q0.9838,0.002,0.9708,0.0059 Q0.9659,0.0069,0.9327,0.0059 T0.8881,0.0099 Q0.8767,0.0138,0.8658,0.0163 T0.8491,0.0218 Q0.8418,0.0247,0.8341,0.0247 T0.8232,0.0257 Q0.8191,0.0277,0.7997,0.0292 T0.7737,0.0326 Q0.7697,0.0336,0.7672,0.0336 T0.7612,0.0321 T0.7543,0.0307 T0.747,0.0317 Q0.7397,0.0326,0.7336,0.0356 T0.717,0.0406 Q0.6918,0.0445,0.6861,0.0475 Q0.6845,0.0485,0.6772,0.0485 T0.6638,0.05 T0.6577,0.0549 T0.6642,0.0598 T0.6796,0.0613 T0.6902,0.0623 Q0.691,0.0623,0.6946,0.0658 T0.7064,0.0702 L0.7161,0.0702 Q0.7194,0.0702,0.7242,0.0697 T0.7332,0.0692 Q0.7413,0.0692,0.7579,0.0717 T0.781,0.0762 Q0.7867,0.0781,0.8171,0.0786 T0.85,0.0801 L0.8532,0.0801 Q0.8548,0.0801,0.8613,0.0796 T0.8727,0.0791 Q0.8832,0.0791,0.8913,0.0811 Q0.897,0.0831,0.897,0.0851 Q0.897,0.088,0.8873,0.0905 T0.8735,0.093 Q0.871,0.093,0.8447,0.0984 T0.8143,0.1039 Q0.7981,0.1039,0.781,0.1078 Q0.7778,0.1088,0.7745,0.1088 T0.7644,0.1073 T0.7518,0.1048 L0.7453,0.1048 Q0.7299,0.1048,0.7076,0.1083 T0.6788,0.1157 Q0.6748,0.1187,0.6699,0.1187 Q0.6675,0.1187,0.6557,0.1157 T0.6318,0.1118 Q0.6164,0.1108,0.6135,0.1108 T0.6095,0.1113 T0.6067,0.1142 T0.6018,0.1187 Q0.5953,0.1227,0.5921,0.1227 Q0.5904,0.1227,0.5839,0.1212 T0.5693,0.1187 L0.5661,0.1187 Q0.558,0.1187,0.5527,0.1207 T0.5434,0.1246 T0.5337,0.1266 Q0.5126,0.1296,0.4882,0.1296 T0.4371,0.1385 Q0.4298,0.1414,0.425,0.1414 Q0.4225,0.1414,0.4189,0.1395 T0.4112,0.1375 Q0.4079,0.1375,0.4019,0.1395 T0.3869,0.1414 Q0.3682,0.1414,0.3504,0.1474 Q0.3398,0.1513,0.3333,0.1513 Q0.3293,0.1513,0.3228,0.1494 L0.3204,0.1494 Q0.3179,0.1494,0.3155,0.1508 T0.3118,0.1543 T0.3106,0.1592 T0.3127,0.1642 T0.32,0.1677 T0.3305,0.1701 T0.3435,0.1716 T0.3544,0.1721 Q0.3569,0.1731,0.3601,0.1731 Q0.3625,0.1731,0.3686,0.1726 T0.3779,0.1721 Q0.3901,0.1721,0.4023,0.1751 Q0.4177,0.18,0.455,0.18 Q0.4793,0.18,0.4882,0.178 L0.4915,0.178 Q0.4964,0.178,0.5045,0.1815 T0.5158,0.185 Q0.5564,0.1889,0.5645,0.1929 Q0.5677,0.1939,0.571,0.1939 Q0.575,0.1939,0.5807,0.1919 T0.5888,0.1899 T0.5929,0.1909 Q0.5969,0.1949,0.6123,0.1949 Q0.6334,0.1949,0.6456,0.1899 Q0.6488,0.1879,0.6537,0.1879 Q0.661,0.1879,0.6772,0.1929 T0.6983,0.1978 Q0.704,0.1988,0.7206,0.1998 T0.7502,0.2018 T0.7664,0.2038 Q0.7672,0.2038,0.7818,0.2043 T0.8151,0.2067 T0.8459,0.2156 Q0.8573,0.2206,0.8573,0.2255 Q0.8573,0.2295,0.8516,0.2324 T0.8394,0.2369 T0.8281,0.2384 Q0.8208,0.2384,0.7526,0.2473 Q0.7494,0.2483,0.7421,0.2493 T0.7287,0.2512 T0.7178,0.2522 L0.7153,0.2522 L0.7137,0.2522 Q0.7056,0.2522,0.6898,0.2572 T0.6691,0.2671 Q0.6667,0.269,0.6634,0.269 Q0.661,0.269,0.6557,0.2681 T0.6472,0.2671 Q0.6448,0.2671,0.6415,0.2681 Q0.631,0.271,0.6034,0.274 Q0.5994,0.274,0.5961,0.2765 T0.5904,0.2789 L0.5864,0.2789 Q0.5831,0.2779,0.5742,0.2779 Q0.541,0.2779,0.5223,0.2849 Q0.5126,0.2888,0.5028,0.2888 Q0.5004,0.2888,0.4943,0.2883 T0.4842,0.2878 Q0.4704,0.2878,0.4627,0.2898 T0.4509,0.2987 Q0.4501,0.2997,0.4501,0.3007 Q0.4501,0.3046,0.4574,0.3066 T0.4765,0.3091 T0.4899,0.3096 Q0.4931,0.3106,0.5061,0.3121 T0.5316,0.315 T0.5499,0.3175 Q0.5507,0.3175,0.5572,0.3195 T0.5685,0.3225 T0.5803,0.3254 T0.5933,0.3279 T0.605,0.3284 T0.6148,0.3274 Q0.6221,0.3244,0.6294,0.3244 Q0.6399,0.3244,0.6496,0.3294 Q0.6529,0.3314,0.6577,0.3314 Q0.6602,0.3314,0.6663,0.3304 T0.6756,0.3294 T0.6813,0.3304 Q0.6837,0.3304,0.6873,0.3309 L0.6946,0.3318 T0.7024,0.3323 T0.7105,0.3328 T0.7178,0.3338 T0.7238,0.3348 T0.7283,0.3363 Q0.7324,0.3383,0.7482,0.3403 T0.7689,0.3442 Q0.7697,0.3442,0.779,0.3477 T0.794,0.3536 T0.8074,0.3615 T0.8183,0.3729 Q0.8232,0.3808,0.8232,0.3848 Q0.8232,0.3867,0.8216,0.3892 T0.8151,0.3961 T0.8062,0.406 T0.7997,0.4179 T0.7952,0.4263 T0.7855,0.4308 T0.7616,0.4362 Q0.7543,0.4372,0.7445,0.4372 L0.7372,0.4372 L0.7307,0.4372 Q0.7202,0.4372,0.7153,0.4382 Q0.7064,0.4402,0.691,0.4402 L0.6845,0.4402 L0.6788,0.4402 Q0.6699,0.4402,0.6533,0.4436 T0.6318,0.451 Q0.6302,0.451,0.6302,0.452 Q0.6302,0.457,0.6626,0.459 Q0.6869,0.4609,0.7015,0.4659 Q0.7064,0.4669,0.7202,0.4669 L0.7311,0.4669 L0.7429,0.4669 Q0.7599,0.4669,0.7689,0.4688 Q0.7794,0.4708,0.7794,0.4738 L0.7786,0.4748 T0.777,0.4763 T0.7749,0.4773 L0.7737,0.4777 L0.7729,0.4777 Q0.7502,0.4797,0.738,0.4797 L0.7315,0.4797 L0.7291,0.4797 Q0.7226,0.4797,0.6869,0.4876 Q0.6821,0.4886,0.6768,0.4891 T0.6614,0.4901 T0.6456,0.4916 L0.6342,0.4916 Q0.6294,0.4916,0.6217,0.4911 T0.6115,0.4906 Q0.6083,0.4906,0.6054,0.4911 T0.5961,0.4936 T0.5815,0.4965 T0.5629,0.4985 Q0.5507,0.4985,0.5442,0.4995 T0.5345,0.5025 T0.5304,0.5084 L0.5296,0.5094 Q0.5296,0.5114,0.5316,0.5124 T0.5373,0.5138 T0.5442,0.5143 L0.5523,0.5143 L0.5604,0.5143 Q0.5637,0.5153,0.5677,0.5158 T0.5742,0.5173 T0.5811,0.5188 T0.5904,0.5193 L0.5977,0.5193 L0.6026,0.5193 Q0.6107,0.5193,0.6168,0.5208 T0.6294,0.5252 T0.6399,0.5292 Q0.6448,0.5302,0.6679,0.5341 T0.6983,0.541 Q0.7129,0.545,0.7664,0.55 Q0.7883,0.5519,0.7883,0.5559 Q0.7883,0.5579,0.7843,0.5608 T0.7721,0.5638 Q0.7697,0.5638,0.764,0.5633 T0.7559,0.5628 L0.7502,0.5628 Q0.7364,0.5648,0.6869,0.5648 L0.6699,0.5648 L0.6675,0.5648 Q0.665,0.5648,0.6602,0.5658 T0.6513,0.5668 Q0.6464,0.5668,0.6399,0.5658 Q0.6342,0.5648,0.6302,0.5648 T0.6225,0.5668 T0.6156,0.5697 Q0.6107,0.5707,0.6087,0.5707 T0.6034,0.5712 T0.5953,0.5717 Q0.5904,0.5727,0.5835,0.5727 T0.5718,0.5732 T0.562,0.5757 Q0.558,0.5767,0.5483,0.5767 L0.5349,0.5767 L0.5207,0.5767 Q0.5069,0.5767,0.5028,0.5786 Q0.4972,0.5816,0.4899,0.5816 Q0.4858,0.5816,0.4781,0.5806 T0.4663,0.5796 L0.4631,0.5796 Q0.4582,0.5796,0.4432,0.5821 T0.4144,0.5846 L0.4071,0.5846 L0.4011,0.5846 T0.3958,0.5851 T0.3921,0.5856 T0.3889,0.5861 T0.3865,0.5865 T0.384,0.5875 L0.382,0.5885 L0.3796,0.5895 L0.3771,0.5905 Q0.3731,0.5925,0.365,0.5925 L0.3601,0.5925 L0.3552,0.5925 Q0.3277,0.5925,0.3049,0.6044 Q0.3017,0.6053,0.296,0.6053 Q0.292,0.6053,0.2847,0.6044 T0.2741,0.6034 Q0.2693,0.6034,0.2547,0.6029 T0.236,0.6024 Q0.2222,0.6024,0.1922,0.6073 Q0.1906,0.6083,0.1882,0.6083 Q0.1833,0.6083,0.1711,0.6063 T0.1533,0.6044 Q0.1517,0.6034,0.1492,0.6034 Q0.1436,0.6034,0.135,0.6048 T0.1168,0.6078 T0.103,0.6093 Q0.0973,0.6103,0.0787,0.6103 L0.0604,0.6103 L0.0414,0.6103 L0.0235,0.6103 Q0.0024,0.6113,0.0024,0.6172 Q0.0024,0.6192,0.0032,0.6231 Q0.0049,0.6281,0.0333,0.6281 L0.0414,0.6281 L0.0479,0.6281 Q0.0519,0.6281,0.0624,0.6301 T0.0803,0.632 L0.0835,0.632 L0.0916,0.632 Q0.0989,0.632,0.1281,0.634 L0.206,0.6419 Q0.2117,0.6429,0.2174,0.6429 Q0.2214,0.6429,0.2271,0.6424 T0.2344,0.6419 L0.2376,0.6419 Q0.2393,0.6429,0.2417,0.6429 T0.2526,0.6419 T0.2652,0.6409 Q0.2741,0.6409,0.2782,0.6429 T0.2968,0.6459 T0.3293,0.6479 T0.352,0.6489 Q0.3552,0.6499,0.3585,0.6499 Q0.365,0.6499,0.3731,0.6474 T0.3844,0.6449 Q0.3885,0.6449,0.3917,0.6474 T0.4006,0.6499 Q0.4088,0.6499,0.4509,0.6578 Q0.4574,0.6588,0.4631,0.6588 Q0.4655,0.6588,0.4716,0.6583 T0.4834,0.6578 L0.4874,0.6578 Q0.5004,0.6588,0.5255,0.6637 Q0.532,0.6647,0.56,0.6667 T0.6148,0.6701 T0.644,0.6716 Q0.6464,0.6726,0.6594,0.6736 T0.6861,0.6756 T0.7048,0.6775 Q0.7097,0.6775,0.7186,0.6825 T0.738,0.6874 Q0.7518,0.6874,0.7729,0.6958 T0.8005,0.7122 Q0.8029,0.7141,0.8029,0.7151 Q0.8029,0.7191,0.7968,0.7216 T0.7818,0.726 T0.7705,0.729 L0.764,0.731 T0.7583,0.7324 T0.753,0.7334 T0.7486,0.7349 Q0.7251,0.7399,0.7161,0.7399 Q0.7097,0.7399,0.6951,0.7409 T0.6748,0.7418 L0.6703,0.7418 L0.6667,0.7418 Q0.6659,0.7418,0.6399,0.7448 L0.6334,0.7448 Q0.631,0.7448,0.6249,0.7443 T0.6148,0.7438 L0.6099,0.7438 Q0.601,0.7448,0.5856,0.7473 T0.5645,0.7507 Q0.5572,0.7517,0.5572,0.7547 T0.5616,0.7606 T0.5702,0.7646 Q0.5734,0.7646,0.5779,0.7651 T0.5864,0.7661 T0.5949,0.7671 T0.6018,0.7676 L0.6071,0.7676 T0.6135,0.7681 T0.62,0.769 T0.6269,0.771 T0.6342,0.7725 Q0.6391,0.7725,0.6464,0.7705 T0.6541,0.7685 L0.6553,0.7685 Q0.6586,0.7685,0.6699,0.771 T0.6853,0.7735 Q0.6902,0.7735,0.704,0.777 T0.7226,0.7834 Q0.7315,0.7873,0.7445,0.7883 Q0.747,0.7883,0.7676,0.7893 T0.7972,0.7913 Q0.7989,0.7913,0.8017,0.7918 T0.8058,0.7923 T0.809,0.7928 T0.8114,0.7933 T0.8131,0.7938 T0.8147,0.7943 T0.8159,0.7953 L0.8171,0.7967 T0.8175,0.7982 Q0.8183,0.7992,0.8183,0.8002 Q0.8183,0.8022,0.8155,0.8032 T0.807,0.8051 T0.7989,0.8071 Q0.7932,0.8091,0.7737,0.8091 L0.7413,0.8091 L0.7372,0.8091 Q0.7275,0.8091,0.721,0.8101 T0.7072,0.8136 T0.6983,0.817 Q0.6942,0.818,0.6825,0.818 T0.6602,0.819 T0.6456,0.822 Q0.6415,0.8249,0.6375,0.8249 Q0.6367,0.8249,0.6326,0.8239 T0.6253,0.8229 Q0.6034,0.8229,0.5953,0.8279 Q0.5912,0.8289,0.5848,0.8289 Q0.5775,0.8289,0.5645,0.8279 Q0.5629,0.8269,0.5604,0.8269 Q0.5523,0.8269,0.5328,0.8348 Q0.5296,0.8358,0.5264,0.8358 Q0.5247,0.8358,0.5203,0.8353 T0.5126,0.8348 Q0.5061,0.8348,0.4988,0.8358 Q0.4947,0.8368,0.4882,0.8378 Q0.4826,0.8388,0.4797,0.8388 T0.474,0.8398 T0.4704,0.8422 T0.4696,0.8462 T0.472,0.8501 T0.4781,0.8521 T0.4854,0.8526 L0.4935,0.8526 T0.4996,0.8536 Q0.5036,0.8536,0.515,0.8546 T0.5337,0.8566 Q0.5426,0.8576,0.5596,0.8576 L0.5657,0.8576 L0.571,0.8576 L0.5791,0.8576 Q0.5921,0.8586,0.6135,0.8591 T0.6383,0.8605 Q0.6683,0.8635,0.6764,0.8635 Q0.6999,0.8635,0.6999,0.8684 Q0.6999,0.8694,0.6967,0.8734 T0.6934,0.8803 L0.6934,0.8823 Q0.6951,0.8863,0.7137,0.8863 Q0.7218,0.8863,0.7267,0.8853 L0.7299,0.8853 Q0.734,0.8853,0.7409,0.8882 T0.7502,0.8912 Q0.7607,0.8942,0.794,0.8971 Q0.7972,0.8971,0.8078,0.9001 T0.8297,0.9031 Q0.8475,0.9031,0.8646,0.9065 T0.8824,0.9149 L0.8824,0.9159 Q0.8824,0.9169,0.882,0.9179 T0.8808,0.9194 L0.8792,0.9204 T0.8767,0.9214 T0.8743,0.9224 T0.8715,0.9228 L0.8686,0.9228 T0.8662,0.9233 T0.8637,0.9238 L0.8556,0.9238 T0.8447,0.9243 T0.8386,0.9268 Q0.837,0.9278,0.835,0.9288 T0.8305,0.9303 T0.8244,0.9308 T0.8175,0.9318 Q0.8127,0.9327,0.8017,0.9352 T0.7851,0.9397 Q0.777,0.9416,0.7705,0.9451 T0.7616,0.9496 Q0.7575,0.9505,0.7494,0.9505 L0.7405,0.9505 L0.7307,0.9505 Q0.7234,0.9505,0.7178,0.9515 Q0.7145,0.9515,0.7125,0.9535 T0.7105,0.9575 Q0.7105,0.9624,0.7194,0.9634 Q0.7567,0.9654,0.7664,0.9674 Q0.7745,0.9693,0.7762,0.9693 L0.7908,0.9693 L0.7948,0.9693 Q0.8005,0.9693,0.8058,0.9698 T0.8143,0.9718 T0.824,0.9753 T0.8354,0.9792 Q0.8483,0.9832,0.871,0.9832 L0.8739,0.9832 L0.8767,0.9832 Q0.8873,0.9832,0.8921,0.9842 Q0.8978,0.9852,0.9157,0.9896 T0.94,0.996 Q0.9424,0.996,0.9469,0.9965 T0.9566,0.997 T0.9676,0.9975 T0.9781,0.998 L0.9874,0.998 L0.9943,0.998 L0.9968,0.998 Z",type:0}},"":{type:5,data:{d:"M1,0.5071 L0.8623,0.7102 L0.5865,0.7102 L0.4489,0.5071 L0.5865,0.3045 L0.8623,0.3045 Z M0.4135,0.7974 L0.2758,1 L0,1 L0,0.5944 L0.2758,0.5944 Z M0.4135,0.2026 L0.2758,0.4056 L0,0.4056 L0,0 L0.2758,0 Z",type:0}},"":{type:5,data:{d:"M0.9726,0.5071 L0.8451,0.3184 L0.59,0.3184 L0.4624,0.5071 L0.59,0.6955 L0.8451,0.6955 Z M1,0.5071 L0.8588,0.716 L0.5768,0.716 L0.4355,0.5071 L0.5768,0.2982 L0.8588,0.2982 Z M0.401,0.7911 L0.2735,0.6028 L0.0236,0.6028 L0.0236,0.9799 L0.2735,0.9799 Z M0.4284,0.7911 L0.2872,1 L0,1 L0,0.5826 L0.2872,0.5826 Z M0.401,0.2089 L0.2735,0.0201 L0.0236,0.0201 L0.0236,0.3972 L0.2735,0.3972 Z M0.4284,0.2089 L0.2872,0.4174 L0,0.4174 L0,0 L0.2872,0 Z",type:0}},"":{type:5,data:{d:"M1,0.4996 Q1,0.5365,0.9924,0.568 T0.9718,0.6177 T0.9433,0.6363 L0.9425,0.6367 Q0.9355,0.6367,0.9285,0.6326 L0.9308,0.6326 Q0.9129,0.6195,0.9013,0.587 T0.8871,0.5164 T0.8943,0.4373 T0.9254,0.3687 L0.9254,0.3691 Q0.9339,0.3628,0.9425,0.3628 Q0.9584,0.3628,0.9716,0.3811 T0.9924,0.431 T1,0.4996 Z M0.9234,0.6321 L0.8776,0.7169 Q0.8791,0.732,0.8791,0.7441 Q0.8791,0.781,0.8716,0.8123 T0.8508,0.862 T0.8224,0.8809 L0.8216,0.8809 Q0.8154,0.8809,0.8092,0.8775 L0.8088,0.8775 Q0.7905,0.8633,0.7792,0.8297 T0.766,0.7571 T0.775,0.6768 T0.8088,0.6086 L0.7431,0.6065 Q0.719,0.6313,0.7072,0.6705 T0.6965,0.7487 T0.7097,0.823 T0.7423,0.8763 L0.7913,0.8775 L0.7314,0.9895 L0.4648,0.5038 L0.7299,0.0176 L0.7882,0.1229 L0.7404,0.1212 Q0.7244,0.138,0.7136,0.1617 T0.6982,0.2108 T0.6943,0.2632 T0.7003,0.3144 T0.7159,0.3586 T0.7396,0.3909 L0.8072,0.3926 Q0.7894,0.3796,0.7777,0.3479 T0.7635,0.2789 T0.7697,0.2011 T0.7987,0.1326 Q0.8092,0.1225,0.8204,0.1225 Q0.8321,0.1225,0.8426,0.1332 T0.8609,0.1623 T0.8731,0.2062 T0.8776,0.2592 Q0.8776,0.268,0.8768,0.2815 L0.9219,0.3628 L0.8644,0.3612 Q0.8403,0.3855,0.8284,0.4247 T0.8177,0.5029 T0.8309,0.5772 T0.8632,0.6305 Z M0.6759,0.6384 Q0.6576,0.625,0.6457,0.5914 T0.6319,0.5185 T0.6407,0.4375 T0.6751,0.3691 L0.609,0.367 Q0.5849,0.3918,0.5731,0.431 T0.5626,0.509 T0.5758,0.5833 T0.6082,0.6367 Z M0.6459,0.0025 L0.391,0.4551 L0.0035,0.4551 L0.246,0 Z M0.6553,1 L0.2421,1 L0,0.5449 L0.3875,0.5449 Z",type:0}},"":{type:5,data:{d:"M0.4996,0 Q0.5562,0,0.5961,0.0166 T0.6364,0.0566 L0.6368,0.0575 Q0.6368,0.0646,0.6324,0.0713 L0.6324,0.0692 Q0.6196,0.0868,0.5872,0.0986 T0.5166,0.1128 T0.4373,0.1057 T0.3689,0.0747 L0.3694,0.0747 Q0.3627,0.0663,0.3627,0.0575 Q0.3627,0.0336,0.4028,0.0168 T0.4996,0 Z M0.6324,0.0768 L0.717,0.1225 Q0.7321,0.1212,0.744,0.1212 Q0.8007,0.1212,0.8406,0.1378 T0.8809,0.1779 L0.8813,0.1783 Q0.8813,0.1846,0.8778,0.1909 Q0.8636,0.2093,0.8299,0.2208 T0.7573,0.2343 T0.6769,0.2253 T0.6089,0.1913 L0.6067,0.2567 Q0.6315,0.281,0.6707,0.2928 T0.7489,0.3035 T0.8231,0.2903 T0.8764,0.258 L0.8778,0.2085 L0.9898,0.2685 L0.504,0.5352 L0.0177,0.2701 L0.1227,0.2118 L0.1213,0.2596 Q0.1457,0.2836,0.1851,0.2955 T0.2635,0.3062 T0.3377,0.293 T0.3911,0.2605 L0.3928,0.193 Q0.38,0.2106,0.3483,0.2221 T0.2792,0.2364 T0.2013,0.2303 T0.1324,0.2013 Q0.1222,0.1909,0.1222,0.1795 Q0.1222,0.156,0.1623,0.1393 T0.2591,0.1225 Q0.2679,0.1225,0.2817,0.1233 L0.3627,0.078 L0.3609,0.1359 Q0.3857,0.1598,0.4249,0.1718 T0.5031,0.1825 T0.5773,0.1693 T0.6306,0.1367 Z M0.6386,0.3242 Q0.6253,0.3427,0.5917,0.3544 T0.5186,0.3683 T0.4376,0.3595 T0.3689,0.3251 L0.3671,0.3909 Q0.3915,0.4148,0.4309,0.4268 T0.5093,0.4375 T0.5835,0.4243 T0.6368,0.3918 Z M0.0027,0.354 L0.4553,0.6091 L0.4553,0.9962 L0,0.7542 Z M1,0.3448 L1,0.7576 L0.5447,1 L0.5447,0.6128 Z",type:0}},"":{type:5,data:{d:"M0,0.7699 Q0,0.7172,0.0277,0.6731 T0.103,0.6032 T0.2066,0.5774 Q0.2487,0.5774,0.2865,0.5922 T0.3526,0.6339 L0.3935,0.6715 Q0.4218,0.6984,0.438,0.7336 T0.4541,0.8075 Q0.4541,0.8602,0.4264,0.9046 T0.3511,0.9745 T0.2475,1 Q0.2054,1,0.1676,0.9852 T0.1016,0.9435 L0.0606,0.9059 Q0.0317,0.879,0.0159,0.8438 T0,0.7699 Z M0.5459,0.7699 Q0.5459,0.7172,0.5736,0.6731 T0.6489,0.6032 T0.7525,0.5774 Q0.7946,0.5774,0.8324,0.5922 T0.8984,0.6339 L0.9394,0.6715 Q0.9579,0.6892,0.9714,0.7105 T0.9925,0.7567 T1,0.8075 Q1,0.8602,0.9723,0.9046 T0.897,0.9745 T0.7934,1 Q0.7513,1,0.7135,0.9852 T0.6474,0.9435 L0.6065,0.9059 Q0.5776,0.879,0.5617,0.8438 T0.5459,0.7699 Z M0,0.1925 Q0,0.1398,0.0277,0.0957 T0.103,0.0258 T0.2066,0 Q0.2487,0,0.2865,0.0148 T0.3526,0.0565 L0.3935,0.0941 Q0.4218,0.121,0.438,0.1562 T0.4541,0.2301 Q0.4541,0.2828,0.4264,0.3269 T0.3511,0.3968 T0.2475,0.4226 Q0.2054,0.4226,0.1676,0.4078 T0.1016,0.3661 L0.0606,0.3285 Q0.0317,0.3016,0.0159,0.2664 T0,0.1925 Z M0.5459,0.1925 Q0.5459,0.1398,0.5736,0.0957 T0.6489,0.0258 T0.7525,0 Q0.7946,0,0.8324,0.0148 T0.8984,0.0565 L0.9394,0.0941 Q0.9677,0.121,0.9838,0.1562 T1,0.2301 Q1,0.2828,0.9723,0.3269 T0.897,0.3968 T0.7934,0.4226 Q0.7513,0.4226,0.7135,0.4078 T0.6474,0.3661 L0.6065,0.3285 Q0.5874,0.3108,0.5741,0.2895 T0.5534,0.2433 T0.5459,0.1925 Z M0.3301,0.6403 Q0.3306,0.6403,0.3312,0.6409 L0.3272,0.6371 L0.3272,0.6371 Z M0.8759,0.6403 Q0.8765,0.6403,0.8771,0.6409 Q0.8748,0.6387,0.8725,0.6371 L0.8731,0.6371 Z M0.3301,0.0629 Q0.3306,0.0629,0.3312,0.0634 L0.3272,0.0597 L0.3272,0.0602 Z M0.8759,0.0629 Q0.8765,0.0629,0.8771,0.0634 Q0.8748,0.0613,0.8725,0.0597 Q0.8731,0.0597,0.8731,0.0602 Z M0.779,0.4113 L0.7501,0.3849 Q0.7046,0.3844,0.6636,0.3661 Q0.7132,0.407,0.779,0.4113 Z M0.7957,0.4118 Q0.8477,0.4113,0.8918,0.3874 T0.9619,0.3223 T0.9885,0.2323 L0.9585,0.2048 Q0.9538,0.2769,0.8987,0.3282 T0.7657,0.3844 Z M0.9879,0.2167 Q0.9827,0.1554,0.9388,0.1097 Q0.9585,0.1478,0.959,0.1898 Z M0.2331,0.4113 L0.2043,0.3849 Q0.1587,0.3844,0.1177,0.3661 Q0.1673,0.407,0.2331,0.4113 Z M0.2499,0.4118 Q0.2885,0.4118,0.324,0.3973 T0.3852,0.3589 T0.4264,0.3019 T0.4426,0.2323 L0.4126,0.2048 Q0.408,0.2769,0.3529,0.3282 T0.2198,0.3844 Z M0.442,0.2167 Q0.4368,0.1554,0.393,0.1097 Q0.4126,0.1478,0.4132,0.1903 Z M0.779,0.9887 L0.7501,0.9624 Q0.7046,0.9618,0.6636,0.9435 Q0.7132,0.9844,0.779,0.9887 Z M0.7957,0.9892 Q0.8477,0.9887,0.8918,0.9648 T0.9619,0.8997 T0.9885,0.8097 L0.9585,0.7823 Q0.9538,0.8543,0.8987,0.9056 T0.7657,0.9618 Z M0.9879,0.7941 Q0.9827,0.7328,0.9388,0.6871 Q0.9585,0.7253,0.959,0.7672 Z M0.2331,0.9887 L0.2043,0.9624 Q0.1587,0.9618,0.1177,0.9435 Q0.1673,0.9844,0.2331,0.9887 Z M0.2499,0.9892 Q0.3018,0.9887,0.3459,0.9648 T0.416,0.8997 T0.4426,0.8102 L0.4126,0.7823 Q0.408,0.8543,0.3529,0.9056 T0.2198,0.9618 Z M0.442,0.7941 Q0.4368,0.7328,0.393,0.6871 Q0.4126,0.7253,0.4132,0.7672 Z M0.3191,0.6452 Q0.2943,0.622,0.2643,0.6091 T0.2066,0.5962 Q0.1795,0.5962,0.1497,0.6091 T0.0949,0.6441 T0.0537,0.6995 T0.0375,0.7699 Q0.0375,0.8419,0.0941,0.8941 Q0.1189,0.9172,0.1489,0.9304 T0.2066,0.9435 Q0.2337,0.9435,0.2634,0.9304 T0.3182,0.8952 T0.3595,0.8398 T0.3756,0.7699 Q0.3756,0.6978,0.3191,0.6452 Z M0.026,0.7731 L0.026,0.7699 Q0.026,0.6935,0.0854,0.6376 Q0.0658,0.6538,0.0514,0.6739 T0.0289,0.718 T0.0202,0.7677 Z M0.0271,0.7892 L0.0208,0.7833 Q0.0254,0.8419,0.0681,0.886 Q0.0664,0.8828,0.0646,0.879 Q0.0329,0.8382,0.0271,0.7892 Z M0.3866,0.7823 Q0.382,0.8511,0.3278,0.9016 Q0.3843,0.8565,0.3918,0.7871 Z M0.3872,0.7677 L0.393,0.7726 L0.393,0.7699 Q0.393,0.7349,0.3791,0.7038 Q0.3739,0.6973,0.3681,0.6914 Q0.3866,0.7274,0.3872,0.7677 Z M0.865,0.6452 Q0.8402,0.622,0.8102,0.6091 T0.7525,0.5962 Q0.7305,0.5962,0.7072,0.6043 T0.6619,0.628 T0.6223,0.6642 T0.5941,0.7124 T0.5834,0.7699 Q0.5834,0.8419,0.6399,0.8941 Q0.6561,0.9097,0.6757,0.9207 T0.7149,0.9376 T0.7525,0.9435 Q0.7796,0.9435,0.8093,0.9304 T0.8641,0.8952 T0.9054,0.8398 T0.9215,0.7699 Q0.9215,0.6978,0.865,0.6452 Z M0.5718,0.7731 L0.5718,0.7699 Q0.5718,0.6935,0.6313,0.6376 Q0.6013,0.6618,0.584,0.6954 T0.5661,0.7677 Z M0.573,0.7892 L0.5666,0.7833 Q0.5713,0.8419,0.614,0.886 Q0.6122,0.8828,0.6105,0.879 Q0.5788,0.8382,0.573,0.7892 Z M0.9325,0.7823 Q0.9279,0.8511,0.8736,0.9016 Q0.9308,0.8565,0.9377,0.7871 Z M0.9331,0.7672 L0.9388,0.7726 L0.9388,0.7699 Q0.9388,0.7349,0.925,0.7038 Q0.9198,0.6973,0.914,0.6914 Q0.9325,0.7274,0.9331,0.7672 Z M0.3191,0.0677 Q0.2943,0.0446,0.2643,0.0317 T0.2066,0.0188 Q0.1795,0.0188,0.1497,0.0317 T0.0949,0.0667 T0.0537,0.122 T0.0375,0.1925 Q0.0375,0.2645,0.0941,0.3167 Q0.1189,0.3398,0.1489,0.353 T0.2066,0.3661 Q0.2337,0.3661,0.2634,0.353 T0.3182,0.3177 T0.3595,0.2624 T0.3756,0.1925 Q0.3756,0.1204,0.3191,0.0677 Z M0.026,0.1957 L0.026,0.1925 Q0.026,0.1161,0.0854,0.0602 Q0.0554,0.0844,0.0381,0.118 T0.0202,0.1903 Z M0.0271,0.2118 L0.0208,0.2059 Q0.0254,0.2645,0.0681,0.3086 Q0.0664,0.3054,0.0646,0.3016 Q0.0329,0.2608,0.0271,0.2118 Z M0.3866,0.2048 Q0.382,0.2737,0.3278,0.3242 Q0.3849,0.279,0.3918,0.2097 Z M0.3872,0.1903 L0.393,0.1952 L0.393,0.1925 Q0.393,0.1581,0.3791,0.1263 Q0.3739,0.1199,0.3681,0.114 Q0.3866,0.15,0.3872,0.1903 Z M0.865,0.0677 Q0.8402,0.0446,0.8102,0.0317 T0.7525,0.0188 Q0.7253,0.0188,0.6956,0.0317 T0.6408,0.0667 T0.5995,0.122 T0.5834,0.1925 Q0.5834,0.2645,0.6399,0.3167 Q0.6647,0.3398,0.6947,0.353 T0.7525,0.3661 Q0.7796,0.3661,0.8093,0.353 T0.8641,0.3177 T0.9054,0.2624 T0.9215,0.1925 Q0.9215,0.1204,0.865,0.0677 Z M0.5718,0.1957 L0.5718,0.1925 Q0.5718,0.1161,0.6313,0.0602 Q0.6013,0.0844,0.584,0.118 T0.5661,0.1903 Z M0.573,0.2118 L0.5666,0.2059 Q0.5713,0.2645,0.614,0.3086 Q0.6122,0.3054,0.6105,0.3016 Q0.5788,0.2608,0.573,0.2118 Z M0.9325,0.2048 Q0.9279,0.2737,0.8736,0.3242 Q0.9308,0.279,0.9377,0.2097 Z M0.9331,0.1898 L0.9388,0.1952 L0.9388,0.1925 Q0.9388,0.1575,0.925,0.1263 Q0.9198,0.1199,0.914,0.114 Q0.9325,0.15,0.9331,0.1898 Z",type:0}},"":{type:5,data:{d:"M0.7639,0.1974 L0.9593,0.1974 L0.9593,0.1443 L0.7639,0.1443 L0.7639,0.1974 Z M0.7586,0.109 L0.9727,0.109 Q0.9839,0.109,0.992,0.1153 T1,0.1291 L1,0.382 Q1,0.3906,0.9912,0.3964 T0.9727,0.4021 L0.7586,0.4021 Q0.7468,0.4021,0.739,0.3956 T0.7313,0.382 L0.7313,0.1291 Q0.7313,0.1246,0.7339,0.1207 T0.7406,0.1143 T0.7495,0.1104 T0.7586,0.109 Z M0.7639,0.6575 L0.9593,0.6575 L0.9593,0.6044 L0.7639,0.6044 L0.7639,0.6575 Z M0.7313,0.8347 L0.7313,0.5843 Q0.7313,0.5757,0.7401,0.5699 T0.7586,0.5641 L0.9706,0.5641 Q0.9818,0.5641,0.9898,0.5705 T0.9979,0.5843 L0.9979,0.8347 Q0.9979,0.8433,0.989,0.8491 T0.9706,0.8549 L0.7586,0.8549 Q0.7468,0.8549,0.739,0.8483 T0.7313,0.8347 Z M0,1 L0,0 L0.7607,0 L0.7607,1 L0,1 Z",type:0}},"":{type:5,data:{d:"M0.9764,0.0029 L0.3179,0.4552 L0,0.4552 L0,0 Z M1,1 L0,1 L0,0.5448 L0.3091,0.5448 Z",type:0}},"":{type:5,data:{d:"M0.0236,0.0029 L0.6824,0.4552 L1,0.4552 L1,0 Z M0,1 L1,1 L1,0.5448 L0.6912,0.5448 Z",type:0}},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z M.25,.15 L1,.15 L1,.85 L.25,.85 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z M0,.15 L1,.15 L1,.85 L0,.85 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z M0,.15 L.75,.15 L.75,.85 L0,.85 Z"},"":{type:5,data:{d:"M0.6574,0.303 Q0.623,0.291,0.5852,0.285 T0.5082,0.279 T0.4311,0.285 T0.359,0.303 L0.3082,0.248 Q0.3525,0.232,0.4041,0.2235 T0.5082,0.215 T0.6123,0.2235 T0.7082,0.248 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.8557,0.582 L0.7656,0.551 Q0.7852,0.53,0.7951,0.507 T0.8049,0.46 Q0.8049,0.424,0.782,0.3905 T0.718,0.332 T0.6221,0.293 T0.5082,0.279 L0.5082,0.215 Q0.5607,0.215,0.6123,0.2235 T0.709,0.248 T0.7918,0.287 T0.8557,0.3375 T0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.582 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.482,0.705,0.4557,0.703 T0.4049,0.697 L0.4311,0.635 Q0.4508,0.638,0.4697,0.6395 T0.5082,0.641 Q0.5672,0.641,0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 Q0.8049,0.436,0.7951,0.413 T0.7656,0.369 L0.8557,0.338 Q0.882,0.365,0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 L0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 T0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 L0.9098,0.46 Q0.9098,0.492,0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.338 L0.2508,0.369 Q0.2311,0.39,0.2213,0.413 T0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 Q0.5279,0.641,0.5467,0.6395 T0.5852,0.635 L0.6115,0.697 Q0.5869,0.701,0.5607,0.703 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.1607,0.582 Q0.1344,0.555,0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.3375 T0.2246,0.287 T0.3074,0.248 T0.4041,0.2235 T0.5082,0.215 L0.5082,0.279 Q0.4492,0.279,0.3943,0.293 T0.2984,0.332 T0.2344,0.3905 T0.2115,0.46 Q0.2115,0.484,0.2213,0.507 T0.2508,0.551 Z",type:0},scaleType:1},"":s.LINE_H,"":s.LINE_V,"":s.FADE_RIGHT,"":s.FADE_LEFT,"":s.FADE_DOWN,"":s.FADE_UP,"":s.CURVE_DOWN_RIGHT,"":s.CURVE_DOWN_LEFT,"":s.CURVE_UP_RIGHT,"":s.CURVE_UP_LEFT,"":[s.LINE_V,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_RIGHT],"":[s.CURVE_DOWN_RIGHT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_UP_LEFT],"":[s.LINE_V,s.CURVE_DOWN_LEFT],"":[s.CURVE_DOWN_LEFT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_LEFT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT],"":[s.CURVE_DOWN_LEFT,s.CURVE_DOWN_RIGHT],"":[s.LINE_H,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_UP_RIGHT],"":[s.CURVE_UP_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_UP_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_LEFT,s.CURVE_DOWN_RIGHT],"":[s.LINE_H,s.CURVE_DOWN_LEFT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT,s.CURVE_UP_RIGHT],"":[s.LINE_V,s.CURVE_DOWN_RIGHT,s.CURVE_UP_LEFT],"":[s.LINE_V,s.CURVE_DOWN_LEFT,s.CURVE_UP_RIGHT],"":[s.LINE_H,s.CURVE_DOWN_RIGHT,s.CURVE_UP_LEFT],"":[s.LINE_H,s.CURVE_DOWN_LEFT,s.CURVE_UP_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE],"":s.NODE_STROKE,"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN],"":[s.NODE_STROKE,s.NODE_LINE_DOWN],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP],"":[s.NODE_STROKE,s.NODE_LINE_UP],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_UP],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_UP],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_FILL,s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"":[s.NODE_STROKE,s.NODE_LINE_UP,s.NODE_LINE_DOWN,s.NODE_LINE_LEFT,s.NODE_LINE_RIGHT],"🬀":r(1),"🬁":r(2),"🬂":r(3),"🬃":r(4),"🬄":r(5),"🬅":r(6),"🬆":r(7),"🬇":r(8),"🬈":r(9),"🬉":r(10),"🬊":r(11),"🬋":r(12),"🬌":r(13),"🬍":r(14),"🬎":r(15),"🬏":r(16),"🬐":r(17),"🬑":r(18),"🬒":r(19),"🬓":r(20),"🬔":r(22),"🬕":r(23),"🬖":r(24),"🬗":r(25),"🬘":r(26),"🬙":r(27),"🬚":r(28),"🬛":r(29),"🬜":r(30),"🬝":r(31),"🬞":r(32),"🬟":r(33),"🬠":r(34),"🬡":r(35),"🬢":r(36),"🬣":r(37),"🬤":r(38),"🬥":r(39),"🬦":r(40),"🬧":r(41),"🬨":r(43),"🬩":r(44),"🬪":r(45),"🬫":r(46),"🬬":r(47),"🬭":r(48),"🬮":r(49),"🬯":r(50),"🬰":r(51),"🬱":r(52),"🬲":r(53),"🬳":r(54),"🬴":r(55),"🬵":r(56),"🬶":r(57),"🬷":r(58),"🬸":r(59),"🬹":r(60),"🬺":r(61),"🬻":r(62),"🬼":{type:3,data:"M0,0.6667 L0,1 L0.5,1 Z"},"🬽":{type:3,data:"M0,0.6667 L0,1 L1,1 Z"},"🬾":{type:3,data:"M0,0.3333 L0,1 L0.5,1 Z"},"🬿":{type:3,data:"M0,0.3333 L0,1 L1,1 Z"},"🭀":{type:3,data:"M0,0 L0,1 L0.5,1 Z"},"🭁":{type:3,data:"M0,0.3333 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭂":{type:3,data:"M0,0.3333 L1,0 L1,1 L0,1 Z"},"🭃":{type:3,data:"M0,0.6667 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭄":{type:3,data:"M0,0.6667 L1,0 L1,1 L0,1 Z"},"🭅":{type:3,data:"M0,1 L0.5,0 L1,0 L1,1 Z"},"🭆":{type:3,data:"M0,0.6667 L1,0.3333 L1,1 L0,1 Z"},"🭇":{type:3,data:"M0.5,1 L1,0.6667 L1,1 Z"},"🭈":{type:3,data:"M0,1 L1,0.6667 L1,1 Z"},"🭉":{type:3,data:"M0.5,1 L1,0.3333 L1,1 Z"},"🭊":{type:3,data:"M0,1 L1,0.3333 L1,1 Z"},"🭋":{type:3,data:"M0.5,1 L1,0 L1,1 Z"},"🭌":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.3333 Z"},"🭍":{type:3,data:"M0,0 L0,1 L1,1 L1,0.3333 Z"},"🭎":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.6667 Z"},"🭏":{type:3,data:"M0,0 L0,1 L1,1 L1,0.6667 Z"},"🭐":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 Z"},"🭑":{type:3,data:"M0,0.3333 L0,1 L1,1 L1,0.6667 Z"},"🭒":{type:3,data:"M0,0.6667 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭓":{type:3,data:"M0,0.6667 L1,1 L1,0 L0,0 Z"},"🭔":{type:3,data:"M0,0.3333 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭕":{type:3,data:"M0,0.3333 L1,1 L1,0 L0,0 Z"},"🭖":{type:3,data:"M0,0 L0.5,1 L1,1 L1,0 Z"},"🭗":{type:3,data:"M0,0.3333 L0,0 L0.5,0 Z"},"🭘":{type:3,data:"M0,0.3333 L0,0 L1,0 Z"},"🭙":{type:3,data:"M0,0.6667 L0,0 L0.5,0 Z"},"🭚":{type:3,data:"M0,0.6667 L0,0 L1,0 Z"},"🭛":{type:3,data:"M0,1 L0,0 L0.5,0 Z"},"🭜":{type:3,data:"M0,0.6667 L0,0 L1,0 L1,0.3333 Z"},"🭝":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.6667 Z"},"🭞":{type:3,data:"M0,1 L0,0 L1,0 L1,0.6667 Z"},"🭟":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.3333 Z"},"🭠":{type:3,data:"M0,1 L0,0 L1,0 L1,0.3333 Z"},"🭡":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 Z"},"🭢":{type:3,data:"M0.5,0 L1,0 L1,0.3333 Z"},"🭣":{type:3,data:"M0,0 L1,0 L1,0.3333 Z"},"🭤":{type:3,data:"M0.5,0 L1,0 L1,0.6667 Z"},"🭥":{type:3,data:"M0,0 L1,0 L1,0.6667 Z"},"🭦":{type:3,data:"M0.5,0 L1,0 L1,1 Z"},"🭧":{type:3,data:"M0,0.3333 L1,0.6667 L1,0 L0,0 Z"},"🭨":{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0.5,0.5 Z"},"🭩":{type:3,data:"M0,0 L0.5,0.5 L1,0 L1,1 L0,1 Z"},"🭪":{type:3,data:"M0,0 L1,0 L0.5,0.5 L1,1 L0,1 Z"},"🭫":{type:3,data:"M0,0 L1,0 L1,1 L0.5,0.5 L0,1 Z"},"🭬":{type:3,data:"M0,0 L0.5,0.5 L0,1 Z"},"🭭":{type:3,data:"M0,0 L1,0 L0.5,0.5 Z"},"🭮":{type:3,data:"M1,0 L1,1 L0.5,0.5 Z"},"🭯":{type:3,data:"M0,1 L1,1 L0.5,0.5 Z"},"🭰":{type:0,data:[{x:1,y:0,w:1,h:8}]},"🭱":{type:0,data:[{x:2,y:0,w:1,h:8}]},"🭲":{type:0,data:[{x:3,y:0,w:1,h:8}]},"🭳":{type:0,data:[{x:4,y:0,w:1,h:8}]},"🭴":{type:0,data:[{x:5,y:0,w:1,h:8}]},"🭵":{type:0,data:[{x:6,y:0,w:1,h:8}]},"🭶":{type:0,data:[{x:0,y:1,w:8,h:1}]},"🭷":{type:0,data:[{x:0,y:2,w:8,h:1}]},"🭸":{type:0,data:[{x:0,y:3,w:8,h:1}]},"🭹":{type:0,data:[{x:0,y:4,w:8,h:1}]},"🭺":{type:0,data:[{x:0,y:5,w:8,h:1}]},"🭻":{type:0,data:[{x:0,y:6,w:8,h:1}]},"🭼":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🭽":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭾":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭿":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🮀":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮁":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮂":{type:0,data:[{x:0,y:0,w:8,h:2}]},"🮃":{type:0,data:[{x:0,y:0,w:8,h:3}]},"🮄":{type:0,data:[{x:0,y:0,w:8,h:5}]},"🮅":{type:0,data:[{x:0,y:0,w:8,h:6}]},"🮆":{type:0,data:[{x:0,y:0,w:8,h:7}]},"🮇":{type:0,data:[{x:6,y:0,w:2,h:8}]},"🮈":{type:0,data:[{x:5,y:0,w:3,h:8}]},"🮉":{type:0,data:[{x:3,y:0,w:5,h:8}]},"🮊":{type:0,data:[{x:2,y:0,w:6,h:8}]},"🮋":{type:0,data:[{x:1,y:0,w:7,h:8}]},"🮌":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},"🮍":{type:1,data:[[1,0],[0,1]],clipPath:"M0.5,0 L1,0 L1,1 L0.5,1 Z"},"🮎":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},"🮏":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},"🮐":{type:1,data:[[0,1],[1,0]]},"🮑":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},{type:0,data:[{x:0,y:0,w:8,h:4}]}],"🮒":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},{type:0,data:[{x:0,y:4,w:8,h:4}]}],"🮔":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},{type:0,data:[{x:4,y:0,w:4,h:8}]}],"🮕":{type:0,data:[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}]},"🮖":{type:0,data:[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}]},"🮗":{type:0,data:[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},"🮘":{type:2,data:"M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25",strokeWidth:1},"🮙":{type:2,data:"M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75",strokeWidth:1},"🮚":{type:5,data:{d:"M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0",type:0}},"🮛":{type:5,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1",type:0}},"🮜":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L0,1 Z"},"🮝":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,1 Z"},"🮞":{type:1,data:[[1,0],[0,1]],clipPath:"M1,0 L1,1 L0,1 Z"},"🮟":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,1 L0,1 Z"},"🮠":{type:2,data:"M.5,0 L0,.5",strokeWidth:1},"🮡":{type:2,data:"M.5,0 L1,.5",strokeWidth:1},"🮢":{type:2,data:"M0,.5 L.5,1",strokeWidth:1},"🮣":{type:2,data:"M1,.5 L.5,1",strokeWidth:1},"🮤":{type:2,data:"M.5,0 L0,.5 L.5,1",strokeWidth:1},"🮥":{type:2,data:"M.5,0 L1,.5 L.5,1",strokeWidth:1},"🮦":{type:2,data:"M0,.5 L.5,1 L1,.5",strokeWidth:1},"🮧":{type:2,data:"M0,.5 L.5,0 L1,.5",strokeWidth:1},"🮨":{type:2,data:"M.5,0 L0,.5 M1,.5 L.5,1",strokeWidth:1},"🮩":{type:2,data:"M.5,0 L1,.5 M0,.5 L.5,1",strokeWidth:1},"🮪":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5",strokeWidth:1},"🮫":{type:2,data:"M.5,0 L0,.5 L.5,1 L1,.5",strokeWidth:1},"🮬":{type:2,data:"M0,.5 L.5,0 L1,.5 L.5,1",strokeWidth:1},"🮭":{type:2,data:"M1,.5 L.5,0 L0,.5 L.5,1",strokeWidth:1},"🮮":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5 Z",strokeWidth:1},"🮯":{type:2,data:"M0,.5 L1,.5 M.5,.35 L.5,.65",strokeWidth:1},"🮰":{type:3,data:"M0.1,0.2 L0.1,.8 L.4,.6 L.9,0.6 Z",scaleType:1},"🮱":{type:4,data:{d:"M.1,.525 L.35,.675 L.9,.35",type:1},scaleType:1},"🮲":{type:3,data:"M.29,.27 L0.13,.56 L.22,.59 L.35,0.35 L.67,.35 L.57,.57 L.71,.76 L.22,.76 L.42,1 L.53,.98 L.43,.86 L.9,.86 L.71,.6 L1,.6 L1,.52 L.83,.52 L.92,.36 L1,.36 L1,.27Z M.99,.13 A.12,.12,0,1,1,.75,.13 A.12,.12,0,1,1,.99,.13"},"🮳":{type:3,data:"M0,.27 L.3,.27 L.55,.12 L.63,.18 L.33,.36 L0,.36 M0,.52 L.33,.52 L.59,.89 L.73,.89 L.73,.98 L.53,.98 L.28,.6 L0,.6"},"🮴":{type:4,data:{d:"M.15,.55 L.5,.4 L.5,.45 L.65,.45 L.65,.35 L.85,.35 L.85,.625 L.5,.625 L.5,.7 Z",type:0},scaleType:1},"🮵":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.15,.5 L.5,.35 L.5,.425 L.85,.425 L.85,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮶":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.85,.5 L.5,.35 L.5,.425 L.15,.425 L.15,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮷":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.675 L.2,.5 L.35,.5 L.35,.325 L.65,.325 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮸":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.325 L.2,.5 L.35,.5 L.35,.675 L.65,.675 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮹":{type:5,data:{d:"M1,.89 L.11,.89 L.11,.37 L.36,.12 L.74,.12 L.96,.34 L1,.34 L1,.405 L.92,.405 L.69,.185 L.41,.185 L.21,.42 L.21,.825 L1,.825 Z",type:0}},"🮺":{type:5,data:{d:"M0,.89 L0,.825 L.78,.825 L.78,.53 L.7,.415 L0,.415 L0,.35 L.75,.35 L.88,.48 L.88,.89 Z",type:0}},"🮻":{type:5,data:{d:"M.31,.275 L.44,.275 L.44,.47 L.05,.47 L.05,.405 L.31,.405 Z M.56,.275 L.69,.275 L.69,.405 L.95,.405 L.95,.47 L.56,.47 Z M.05,.53 L.44,.53 L.44,.725 L.31,.725 L.31,.595 L.05,.595 Z M.56,.53 L.95,.53 L.95,.595 L.69,.595 L.69,.725 L.56,.725 Z",type:0},scaleType:1},"🮼":[{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0,.935 L.885,.935 L.885,.065 L0,.065 Z"},{type:3,data:"M.67,.5 A.17,.085,0,1,1,.33,.5 A.17,.085,0,1,1,.67,.5",scaleType:1}],"🮽":{type:4,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1 Z",type:1}},"🮾":{type:4,data:{d:"M1,.5 L.5,1",type:1}},"🮿":{type:4,data:{d:"M.5,0 L1,.5 L.5,1 L0,.5 Z",type:1}},"🯀":{type:3,data:"M.16,.445 A.02,.01,0,0,1,.39,.33 L.5,.375 L.61,.33 A.02,.01,0,0,1,.84,.445 L.75,.5 L.84,.555 A.02,.01,0,0,1,.61,.67 L.5,.625 L.39,.67 A.02,.01,0,0,1,.16,.555 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",scaleType:1},"🯁":{type:3,data:"M0.142308,0.924 Q0.130770,0.924,0.120193,0.922 T0.101924,0.916 T0.089424,0.9065 T0.084616,0.895 V0.025 Q0.084616,0.019,0.089424,0.0135 T0.101924,0.004 T0.120193,-0.002 T0.142308,-0.004 Q0.150000,-0.004,0.157693,-0.003 T0.173078,0.000 L0.430770,0.085 Q0.451924,0.059,0.494232,0.041 T0.587501,0.013 T0.692309,-0.0005 T0.800001,-0.004 H1 V0.055 H0.800001 Q0.771155,0.055,0.742309,0.056 T0.685578,0.060 T0.630770,0.0685 T0.580770,0.0825 T0.543270,0.1045 T0.528847,0.133 V0.647 H0.530770 Q0.596155,0.645,0.659616,0.638 T0.782693,0.6165 T0.893270,0.5805 T1,0.531 V0.623 Q0.934616,0.644,0.880770,0.6595 T0.769232,0.685 T0.650963,0.700 T0.528847,0.707 V0.787 Q0.528847,0.802,0.543270,0.8155 T0.580770,0.8375 T0.630770,0.8515 T0.685578,0.860 T0.742309,0.864 T0.800001,0.865 H1 V0.924 H0.800001 Q0.746155,0.924,0.692309,0.9205 T0.587501,0.907 T0.494232,0.879 T0.430770,0.835 L0.173078,0.920 Q0.165386,0.922,0.157693,0.923 T0.142308,0.924 Z M0.2,0.841 L0.415385,0.770 V0.150 L0.2,0.079 V0.841 Z M1,0.698 Q0.973077,0.694,0.969231,0.6885 T0.965385,0.677 Q0.965385,0.672,0.969231,0.6665 T1,0.657 V0.698 Z"},"🯂":{type:3,data:"M0,0.623 V0.531 Q0.044231,0.512,0.0625,0.4905 T0.092308,0.4465 T0.108654,0.4005 T0.113462,0.354 V0.351 Q0.113462,0.345,0.117308,0.3395 T0.129808,0.3305 T0.148846,0.3245 T0.171154,0.323 H0.501923 Q0.532692,0.323,0.563462,0.3185 T0.617308,0.303 T0.651923,0.2765 T0.663462,0.2435 V0.241 Q0.663462,0.219,0.657692,0.197 T0.639423,0.1545 T0.604808,0.115 T0.549615,0.082 T0.475577,0.0615 T0.392308,0.055 H0 V-0.004 H1 V0.055 H0.667308 Q0.694231,0.071,0.713462,0.09 T0.746154,0.129 T0.766346,0.1705 T0.775,0.213 H1 V0.316 Q0.971154,0.305,0.955769,0.2965 T0.920192,0.2825 T0.876923,0.2745 T0.830769,0.272 H0.773077 Q0.767308,0.297,0.743269,0.319 T0.680769,0.355 T0.595192,0.375 T0.501923,0.381 H0.227308 Q0.223462,0.415,0.211,0.4485 T0.172692,0.5135 T0.108269,0.573 T0,0.623 Z M0.611538,0.924 H0 V0.865 H0.611538 Q0.642308,0.865,0.673077,0.8605 T0.727885,0.845 T0.762308,0.8185 T0.773077,0.787 V0.785 Q0.773077,0.769,0.762308,0.7535 T0.727885,0.727 T0.673077,0.7115 T0.611538,0.707 H0.059615 Q0.048077,0.707,0.037115,0.7045 T0,0.694 V0.653 Q0.026923,0.648,0.037115,0.646 T0.059615,0.644 H0.722692 Q0.753462,0.644,0.784231,0.6395 T0.839038,0.624 T0.873846,0.5975 T0.884615,0.566 V0.564 Q0.884615,0.548,0.873846,0.532 T0.839038,0.5055 T0.784231,0.49 T0.722692,0.4855 H0.392308 Q0.380769,0.4855,0.370192,0.483 T0.351923,0.4765 T0.339423,0.467 T0.334615,0.4555 T0.339423,0.444 T0.351923,0.4345 T0.370192,0.428 T0.392308,0.4255 H0.832692 Q0.855769,0.4255,0.878846,0.4235 T0.922115,0.416 T0.957692,0.4015 T1,0.3815 V0.4685 Q0.975,0.4705,0.966346,0.4725 T0.95,0.4765 Q0.961538,0.4835,0.969231,0.4915 T1,0.5075 V0.6205 Q0.965385,0.6455,0.926923,0.665 T0.84,0.6935 Q0.866923,0.7115,0.877596,0.7345 T0.894231,0.7855 V0.787 Q0.894231,0.815,0.876923,0.8425 T0.820192,0.8895 T0.726923,0.916 T0.611538,0.924 Z"},"🯃":{type:3,data:"M0,0.473 V0.386 Q0.025490,0.378,0.028431,0.3695 T0.031373,0.352 V0.35 Q0.031373,0.342,0.028431,0.333 T0,0.316 V0.213 H0.652941 Q0.684314,0.213,0.715686,0.2085 T0.770588,0.193 T0.804902,0.1665 T0.815686,0.135 V0.133 Q0.815686,0.117,0.804902,0.101 T0.770588,0.0745 T0.715686,0.0595 T0.652941,0.055 H0 V-0.004 H0.652941 Q0.707843,-0.004,0.761765,0.004 T0.856863,0.031 T0.915686,0.0775 T0.933333,0.133 V0.135 Q0.933333,0.163,0.915686,0.1905 T0.856863,0.237 T0.761765,0.264 T0.652941,0.272 H0.109804 Q0.131373,0.29,0.140196,0.31 T0.149020,0.35 V0.352 Q0.149020,0.37,0.142157,0.388 T0.118627,0.4225 T0.076471,0.452 T0,0.473 Z M0,0.625 V0.513 Q0.029412,0.526,0.032353,0.54 T0.035294,0.568 V0.57 Q0.035294,0.584,0.032353,0.598 T0,0.625 Z"},"🯄":{type:3,data:"M0.019231,1.085 V-0.165 H0.980769 V1.085 H0.019231 Z M0.446154,0.527 H0.553846 Q0.553846,0.511,0.5625,0.495 T0.591346,0.466 T0.631731,0.4405 T0.666346,0.4135 T0.688462,0.3825 T0.696154,0.35 Q0.696154,0.33,0.682692,0.311 T0.641346,0.2785 T0.575,0.259 T0.498077,0.253 T0.421154,0.259 T0.356731,0.279 T0.315385,0.3125 T0.301923,0.352 V0.357 H0.409615 V0.354 Q0.409615,0.345,0.414423,0.335 T0.431731,0.318 T0.4625,0.3075 T0.498077,0.304 Q0.517308,0.304,0.534615,0.307 T0.564423,0.3165 T0.582692,0.332 T0.588462,0.35 Q0.588462,0.366,0.572115,0.3805 T0.535577,0.4075 T0.496154,0.4335 T0.465385,0.4625 T0.45,0.4945 T0.446154,0.527 Z M0.5,0.667 Q0.519231,0.667,0.5375,0.664 T0.569231,0.654 T0.588462,0.637 T0.594231,0.617 T0.588462,0.5975 T0.569231,0.581 T0.5375,0.571 T0.5,0.568 T0.4625,0.571 T0.430769,0.581 T0.411538,0.5975 T0.405769,0.617 T0.411538,0.637 T0.430769,0.654 T0.4625,0.664 T0.5,0.667 Z",scaleType:1},"🯅":{type:5,data:{d:"M.44,.425 L.44,.51 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.51 L.56,.425 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯆":{type:5,data:{d:"M.44,.425 L.44,.46 L.23,.385 L.19,.43 L.44,.51 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.61,.735 L.7,.71 L.56,.56 L.56,.51 L.81,.43 L.77,.385 L.56,.46 L.56,.425 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯇":{type:5,data:{d:"M.44,.425 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.425 Z M.18,.53 L.23,.575 L.81,.43 L.77,.385 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯈":{type:5,data:{d:"M.44,.425 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.62,.735 L.71,.71 L.56,.56 L.56,.425 Z M.23,.385 L.18,.43 L.77,.575 L.81,.53 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯉":{type:5,data:{d:"M.44,.425 L.45,.495 L.15,.645 L.34,.645 L.34,.7 L.44,.7 L.44,.645 L.56,.645 L.56,.7 L.66,.7 L.66,.645 L.84,.645 L.54,.495 L.56,.425 Z M.39,.6 L.5,.55 L.60,.6 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯊":{type:5,data:{d:"M.26,.375 L.5,.255 L.74,.375 L.74,.665 L.5,.55 L.26,.665 Z M.37,.395 L.37,.54 L.5,.475 L.63,.54 L.63,.395 L.5,.33 Z",type:0},scaleType:1},"🯋":{type:5,data:{d:"M.09,.41 L.32,.295 L.5,.375 L.68,.295 L.91,.41 L.75,.5 L.91,.59 L.68,.705 L.5,.625 L.32,.705 L.09,.59 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",type:0},scaleType:1},"🯌":{type:5,data:{d:"M.55,.305 L.88,.305 L.88,.355 L.65,.355 L.65,.47 L.88,.47 L.88,.52 L.55,.52 Z",type:0},scaleType:1},"🯍":{type:5,data:{d:"M.38,.39 L.5,.33 L.62,.39 L.62,.53 L.5,.47 L.38,.53 Z",type:0},scaleType:1},"🯎":{type:3,data:"M0,0 L0.6667,0 L0.6667,1 L0,1 Z"},"🯏":{type:3,data:"M0,0 L0.3333,0 L0.3333,1 L0,1 Z"},"🯐":{type:2,data:"M1,.5 L0,1",strokeWidth:1},"🯑":{type:2,data:"M1,0 L0,.5",strokeWidth:1},"🯒":{type:2,data:"M0,0 L1,.5",strokeWidth:1},"🯓":{type:2,data:"M0,.5 L1,1",strokeWidth:1},"🯔":{type:2,data:"M0,0 L.5,1",strokeWidth:1},"🯕":{type:2,data:"M.5,0 L1,1",strokeWidth:1},"🯖":{type:2,data:"M1,0 L.5,1",strokeWidth:1},"🯗":{type:2,data:"M.5,0 L0,1",strokeWidth:1},"🯘":{type:2,data:"M0,0 L.5,.5 L1,0",strokeWidth:1},"🯙":{type:2,data:"M1,0 L.5,.5 L1,1",strokeWidth:1},"🯚":{type:2,data:"M0,1 L.5,.5 L1,1",strokeWidth:1},"🯛":{type:2,data:"M0,0 L.5,.5 L0,1",strokeWidth:1},"🯜":{type:2,data:"M0,0 L.5,1 L1,0",strokeWidth:1},"🯝":{type:2,data:"M1,0 L0,.5 L1,1",strokeWidth:1},"🯞":{type:2,data:"M0,1 L.5,0 L1,1",strokeWidth:1},"🯟":{type:2,data:"M0,0 L1,.5 L0,1",strokeWidth:1},"🯠":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0",type:1}},"🯡":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1",type:1}},"🯢":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1",type:1}},"🯣":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1",type:1}},"🯤":{type:0,data:[{x:2,y:0,w:4,h:4}]},"🯥":{type:0,data:[{x:2,y:4,w:4,h:4}]},"🯦":{type:0,data:[{x:0,y:2,w:4,h:4}]},"🯧":{type:0,data:[{x:4,y:2,w:4,h:4}]},"🯨":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0 Z",type:0}},"🯩":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1 Z",type:0}},"🯪":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1 Z",type:0}},"🯫":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1 Z",type:0}},"🯬":{type:5,data:{d:"M1,0 L.5,0 C.5,.276,.724,.5,1,.5 Z",type:0}},"🯭":{type:5,data:{d:"M0,1 L.5,1 C.5,.724,.276,.5,0,.5 Z",type:0}},"🯮":{type:5,data:{d:"M1,1 L1,.5 C.724,.5,.5,.724,.5,1 Z",type:0}},"🯯":{type:5,data:{d:"M0,0 L0,.5 C.276,.5,.5,.276,.5,0 Z",type:0}},"🯰":{type:5,data:{d:a(126),type:0},scaleType:1},"🯱":{type:5,data:{d:a(48),type:0},scaleType:1},"🯲":{type:5,data:{d:a(109),type:0},scaleType:1},"🯳":{type:5,data:{d:a(121),type:0},scaleType:1},"🯴":{type:5,data:{d:a(51),type:0},scaleType:1},"🯵":{type:5,data:{d:a(91),type:0},scaleType:1},"🯶":{type:5,data:{d:a(95),type:0},scaleType:1},"🯷":{type:5,data:{d:a(114),type:0},scaleType:1},"🯸":{type:5,data:{d:a(127),type:0},scaleType:1},"🯹":{type:5,data:{d:a(123),type:0},scaleType:1},"🯺":{type:5,data:{d:"M.5,.175 C.2,.175,.15,.305,.15,.435 L.05,.63 L.35,.63 C.35,.682,.42,.76,.5,.76 C.58,.76,.65,.682,.65,.63 L.95,.63 L.85,.435 C.85,.305,.8,.175,.5,.175 Z",type:0},scaleType:1},...Object.fromEntries(Array.from({length:256},(t,e)=>[String.fromCodePoint(10240+e),{type:6,data:e}]))},e.blockPatternCodepoints=new Set([9617,9618,9619,129932,129933,129934,129935,129936,129937,129938,129940,129948,129949,129950,129951])},917(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.ExtendedAttrs=e.AttributeData=void 0;class i{static toColorRGB(t){return[t>>>16&255,t>>>8&255,255&t]}static fromColorRGB(t){return(255&t[0])<<16|(255&t[1])<<8|255&t[2]}clone(){const t=new i;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}fg=0;bg=0;extended=new s;isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}e.AttributeData=i;class s{_ext=0;get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(t){this._ext=t}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(t){this._ext&=-469762049,this._ext|=t<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(t){this._ext&=-67108864,this._ext|=67108863&t}_urlId=0;get urlId(){return this._urlId}set urlId(t){this._urlId=t}get underlineVariantOffset(){const t=(3758096384&this._ext)>>29;return t<0?4294967288^t:t}set underlineVariantOffset(t){this._ext&=536870911,this._ext|=t<<29&3758096384}constructor(t=0,e=0){this._ext=t,this._urlId=e}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}e.ExtendedAttrs=s},930(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.DebouncedIdleTask=e.IdleTaskQueue=e.PriorityTaskQueue=void 0;class i{_tasks=[];_idleCallback;_i=0;_logService;constructor(t){this._logService=t}enqueue(t){this._tasks.push(t),this._start()}flush(){for(;this._ir)return s-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-e))}ms`),void this._start();s=r}this.clear()}}class s extends i{_requestCallback(t){return setTimeout(()=>t(this._createDeadline(16)))}_cancelCallback(t){clearTimeout(t)}_createDeadline(t){const e=performance.now()+t;return{timeRemaining:()=>Math.max(0,e-performance.now())}}}e.PriorityTaskQueue=s,e.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(t){return requestIdleCallback(t)}_cancelCallback(t){cancelIdleCallback(t)}}:s,e.DebouncedIdleTask=class{_queue;constructor(t){this._queue=new e.IdleTaskQueue(t)}set(t){this._queue.clear(),this._queue.enqueue(t)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},948(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.createSelectionRenderModel=function(){return new i};class i{hasSelection;columnSelectMode;viewportStartRow;viewportEndRow;viewportCappedStartRow;viewportCappedEndRow;startCol;endCol;selectionStart;selectionEnd;constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(t,e,i,s=!1){if(this.selectionStart=e,this.selectionEnd=i,!e||!i||e[0]===i[0]&&e[1]===i[1])return void this.clear();const r=t.buffers.active.ydisp,a=e[1]-r,o=i[1]-r,n=Math.max(a,0),L=Math.min(o,t.rows-1);n>=t.rows||L<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=a,this.viewportEndRow=o,this.viewportCappedStartRow=n,this.viewportCappedEndRow=L,this.startCol=e[0],this.endCol=i[0])}isCellSelected(t,e,i){return!!this.hasSelection&&(i-=t.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&i>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&e=this.startCol)}}},959(t,e){function i(t){return 57508<=t&&t<=57558}function s(t){return t>=128512&&t<=128591||t>=127744&&t<=128511||t>=128640&&t<=128767||t>=9728&&t<=9983||t>=9984&&t<=10175||t>=65024&&t<=65039||t>=129280&&t<=129535||t>=127462&&t<=127487}Object.defineProperty(e,"__esModule",{value:!0}),e.throwIfFalsy=function(t){if(!t)throw new Error("value must not be falsy");return t},e.isPowerlineGlyph=i,e.isRestrictedPowerlineGlyph=function(t){return 57520<=t&&t<=57527},e.isEmoji=s,e.allowRescaling=function(t,e,r,a){return 1===e&&r>Math.ceil(1.5*a)&&void 0!==t&&t>255&&!s(t)&&!i(t)&&!function(t){return 57344<=t&&t<=63743}(t)},e.treatGlyphAsBackgroundColor=function(t){return i(t)||function(t){return 9472<=t&&t<=9631}(t)},e.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},e.computeNextVariantOffset=function(t,e,i=0){return(t-(2*Math.round(e)-i))%(2*Math.round(e))}},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=l,e.contrastRatio=d;let i=0,s=0,r=0,a=0;var o,n,L,T,h;function l(t){const e=t.toString(16);return e.length<2?"0"+e:e}function d(t,e){return t>>0},t.toColor=function(e,i,s,r){return{css:t.toCss(e,i,s,r),rgba:t.toRgba(e,i,s,r)}}}(o||(e.channels=o={})),function(t){function e(t,e){return a=Math.round(255*e),[i,s,r]=h.toChannels(t.rgba),{css:o.toCss(i,s,r,a),rgba:o.toRgba(i,s,r,a)}}t.blend=function(t,e){if(a=(255&e.rgba)/255,1===a)return{css:e.css,rgba:e.rgba};const n=e.rgba>>24&255,L=e.rgba>>16&255,T=e.rgba>>8&255,h=t.rgba>>24&255,l=t.rgba>>16&255,d=t.rgba>>8&255;return i=h+Math.round((n-h)*a),s=l+Math.round((L-l)*a),r=d+Math.round((T-d)*a),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,i){const s=h.ensureContrastRatio(t.rgba,e.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[i,s,r]=h.toChannels(e),{css:o.toCss(i,s,r),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,i){return a=255&t.rgba,e(t,a*i/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(n||(e.color=n={})),function(t){let e,n;try{const t=document.createElement("canvas");t.width=1,t.height=1;const i=t.getContext("2d",{willReadFrequently:!0});i&&(e=i,e.globalCompositeOperation="copy",n=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),a=parseInt(t.slice(4,5).repeat(2),16),o.toColor(i,s,r,a);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const L=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(L)return i=parseInt(L[1]),s=parseInt(L[2]),r=parseInt(L[3]),a=Math.round(255*(void 0===L[5]?1:parseFloat(L[5]))),o.toColor(i,s,r,a);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!n)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=n,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[i,s,r,a]=e.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,a),css:t}}}(L||(e.css=L={})),function(t){function e(t,e,i){const s=t/255,r=e/255,a=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(T||(e.rgb=T={})),function(t){function e(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));for(;h0||n>0||L>0);)o-=Math.max(0,Math.ceil(.1*o)),n-=Math.max(0,Math.ceil(.1*n)),L-=Math.max(0,Math.ceil(.1*L)),h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));return(o<<24|n<<16|L<<8|255)>>>0}function n(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,h=d(T.relativeLuminance2(o,n,L),T.relativeLuminance2(s,r,a));for(;h>>0}t.blend=function(t,e){if(a=(255&e)/255,1===a)return e;const n=e>>24&255,L=e>>16&255,T=e>>8&255,h=t>>24&255,l=t>>16&255,d=t>>8&255;return i=h+Math.round((n-h)*a),s=l+Math.round((L-l)*a),r=d+Math.round((T-d)*a),o.toRgba(i,s,r)},t.ensureContrastRatio=function(t,i,s){const r=T.relativeLuminance(t>>8),a=T.relativeLuminance(i>>8);if(d(r,a)>8));if(od(r,T.relativeLuminance(e>>8))?a:e}return a}const o=n(t,i,s),L=d(r,T.relativeLuminance(o>>8));if(Ld(r,T.relativeLuminance(a>>8))?o:a}return o}},t.reduceLuminance=e,t.increaseLuminance=n,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function i(s){var r=e[s];if(void 0!==r)return r.exports;var a=e[s]={exports:{}};return t[s](a,a.exports,i),a.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.WebglAddon=void 0;const e=i(426),r=i(95),a=i(399),o=i(414);class n extends e.Disposable{_terminal;_renderer;_onChangeTextureAtlas=this._register(new o.Emitter);onChangeTextureAtlas=this._onChangeTextureAtlas.event;_onAddTextureAtlasCanvas=this._register(new o.Emitter);onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;_onRemoveTextureAtlasCanvas=this._register(new o.Emitter);onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;_onContextLoss=this._register(new o.Emitter);onContextLoss=this._onContextLoss.event;_customGlyphs;_preserveDrawingBuffer;constructor(t){if(r.isSafari&&(0,r.getSafariVersion)()<16){const t={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",t))throw new Error("Webgl2 is only supported on Safari 16 and above")}super(),this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){const i=t._core;if(!t.element)return void this._register(i.onWillOpen(()=>this.activate(t)));this._terminal=t;const s=i.coreService,r=i.optionsService,n=i,L=n._renderService,T=n._characterJoinerService,h=n._charSizeService,l=n._coreBrowserService,d=n._decorationService,c=n._themeService;this._renderer=this._register(new a.WebglRenderer(t,T,h,l,s,d,r,c,this._customGlyphs,this._preserveDrawingBuffer)),this._register(o.EventUtils.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(o.EventUtils.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(o.EventUtils.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(o.EventUtils.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),L.setRenderer(this._renderer),this._register((0,e.toDisposable)(()=>{if(this._terminal._core._store._isDisposed)return;const e=this._terminal._core._renderService;e.setRenderer(this._terminal._core._createRenderer()),e.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}}t.WebglAddon=n})(),s})()); + //# sourceMappingURL=addon-webgl.js.map +\ No newline at end of file +diff --git a/lib/addon-webgl.mjs b/lib/addon-webgl.mjs +index 97806fb..f8d4c0b 100644 +--- a/lib/addon-webgl.mjs ++++ b/lib/addon-webgl.mjs +@@ -48,7 +48,7 @@ void main() { + if (v_texpage == 0) { + outColor = texture(u_texture[0], v_texcoord); + } ${e} +-}`}var N=0,S,Ie=0,k0=0,re=class extends W{constructor(t,r,n,a){super();this._terminal=t;this._gl=r;this._dimensions=n;this._optionsService=a;this._activeBuffer=0;this._vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};let s=this._gl;j.maxAtlasPages===void 0&&(j.maxAtlasPages=Math.min(32,y(s.getParameter(s.MAX_TEXTURE_IMAGE_UNITS))),j.maxTextureSize=y(s.getParameter(s.MAX_TEXTURE_SIZE))),this._program=y(ie(s,Xt,Yt(j.maxAtlasPages))),this._register(g(()=>s.deleteProgram(this._program))),this._projectionLocation=y(s.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=y(s.getUniformLocation(this._program,"u_resolution")),this._textureLocation=y(s.getUniformLocation(this._program,"u_texture")),this._vertexArrayObject=s.createVertexArray(),s.bindVertexArray(this._vertexArrayObject);let o=new Float32Array([0,0,1,0,0,1,1,1]),l=s.createBuffer();this._register(g(()=>s.deleteBuffer(l))),s.bindBuffer(s.ARRAY_BUFFER,l),s.bufferData(s.ARRAY_BUFFER,o,s.STATIC_DRAW),s.enableVertexAttribArray(0),s.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);let T=new Uint8Array([0,1,2,3]),h=s.createBuffer();this._register(g(()=>s.deleteBuffer(h))),s.bindBuffer(s.ELEMENT_ARRAY_BUFFER,h),s.bufferData(s.ELEMENT_ARRAY_BUFFER,T,s.STATIC_DRAW),this._attributesBuffer=y(s.createBuffer()),this._register(g(()=>s.deleteBuffer(this._attributesBuffer))),s.bindBuffer(s.ARRAY_BUFFER,this._attributesBuffer),s.enableVertexAttribArray(2),s.vertexAttribPointer(2,2,s.FLOAT,!1,44,0),s.vertexAttribDivisor(2,1),s.enableVertexAttribArray(3),s.vertexAttribPointer(3,2,s.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(3,1),s.enableVertexAttribArray(4),s.vertexAttribPointer(4,1,s.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(4,1),s.enableVertexAttribArray(5),s.vertexAttribPointer(5,2,s.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(5,1),s.enableVertexAttribArray(6),s.vertexAttribPointer(6,2,s.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(6,1),s.enableVertexAttribArray(1),s.vertexAttribPointer(1,2,s.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(1,1),s.useProgram(this._program);let d=new Int32Array(j.maxAtlasPages);for(let _=0;_s.deleteTexture(c.texture))),s.activeTexture(s.TEXTURE0+_),s.bindTexture(s.TEXTURE_2D,c.texture),s.texParameteri(s.TEXTURE_2D,s.TEXTURE_WRAP_S,s.CLAMP_TO_EDGE),s.texParameteri(s.TEXTURE_2D,s.TEXTURE_WRAP_T,s.CLAMP_TO_EDGE),s.texImage2D(s.TEXTURE_2D,0,s.RGBA,1,1,0,s.RGBA,s.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[_]=c}s.enable(s.BLEND),s.blendFunc(s.SRC_ALPHA,s.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return this._atlas?this._atlas.beginFrame():!0}updateCell(t,r,n,a,s,o,l,T,h){this._updateCell(this._vertices.attributes,t,r,n,a,s,o,l,T,h)}_updateCell(t,r,n,a,s,o,l,T,h,d){if(N=(n*this._terminal.cols+r)*11,a===0||a===void 0){t.fill(0,N,N+11-1-2);return}this._atlas&&(T&&T.length>1?S=this._atlas.getRasterizedGlyphCombinedChar(T,s,o,l,!1,this._terminal.element):S=this._atlas.getRasterizedGlyph(a,s,o,l,!1,this._terminal.element),Ie=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),s!==d&&S.offset.x>Ie?(k0=S.offset.x-Ie,t[N]=-(S.offset.x-k0)+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=(S.size.x-k0)/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x+k0/this._atlas.pages[S.texturePage].canvas.width,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x-k0/this._atlas.pages[S.texturePage].canvas.width,t[N+8]=S.sizeClipSpace.y):(t[N]=-S.offset.x+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=S.size.x/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x,t[N+8]=S.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&Ze(a,h,S.size.x,this._dimensions.device.cell.width)&&(t[N+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width))}clear(){let t=this._terminal,r=t.cols*t.rows*11;this._vertices.count!==r?this._vertices.attributes=new Float32Array(r):this._vertices.attributes.fill(0);let n=0;for(;n=e.rows||T<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=n,this.viewportStartRow=s,this.viewportEndRow=o,this.viewportCappedStartRow=l,this.viewportCappedEndRow=T,this.startCol=t[0],this.endCol=r[0]}isCellSelected(e,t,r){return this.hasSelection?(r-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&r>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&r<=this.viewportCappedEndRow:r>this.viewportStartRow&&r=this.startCol&&t=this.startCol):!1}};function Tt(){return new Fe}var lt=2147483648,se=class{constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=Tt()}resize(e,t){let r=e*t*4;r!==this.cells.length&&(this.cells=new Uint32Array(r),this.lineLengths=new Uint32Array(t))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}};var jt=`#version 300 es ++}`}var N=0,S,Ie=0,k0=0,re=class extends W{constructor(t,r,n,a){super();this._terminal=t;this._gl=r;this._dimensions=n;this._optionsService=a;this._activeBuffer=0;this._vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};let s=this._gl;j.maxAtlasPages===void 0&&(j.maxAtlasPages=Math.min(32,y(s.getParameter(s.MAX_TEXTURE_IMAGE_UNITS))),j.maxTextureSize=y(s.getParameter(s.MAX_TEXTURE_SIZE))),this._program=y(ie(s,Xt,Yt(j.maxAtlasPages))),this._register(g(()=>s.deleteProgram(this._program))),this._projectionLocation=y(s.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=y(s.getUniformLocation(this._program,"u_resolution")),this._textureLocation=y(s.getUniformLocation(this._program,"u_texture")),this._vertexArrayObject=s.createVertexArray(),s.bindVertexArray(this._vertexArrayObject);let o=new Float32Array([0,0,1,0,0,1,1,1]),l=s.createBuffer();this._register(g(()=>s.deleteBuffer(l))),s.bindBuffer(s.ARRAY_BUFFER,l),s.bufferData(s.ARRAY_BUFFER,o,s.STATIC_DRAW),s.enableVertexAttribArray(0),s.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);let T=new Uint8Array([0,1,2,3]),h=s.createBuffer();this._register(g(()=>s.deleteBuffer(h))),s.bindBuffer(s.ELEMENT_ARRAY_BUFFER,h),s.bufferData(s.ELEMENT_ARRAY_BUFFER,T,s.STATIC_DRAW),this._attributesBuffer=y(s.createBuffer()),this._register(g(()=>s.deleteBuffer(this._attributesBuffer))),s.bindBuffer(s.ARRAY_BUFFER,this._attributesBuffer),s.enableVertexAttribArray(2),s.vertexAttribPointer(2,2,s.FLOAT,!1,44,0),s.vertexAttribDivisor(2,1),s.enableVertexAttribArray(3),s.vertexAttribPointer(3,2,s.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(3,1),s.enableVertexAttribArray(4),s.vertexAttribPointer(4,1,s.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(4,1),s.enableVertexAttribArray(5),s.vertexAttribPointer(5,2,s.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(5,1),s.enableVertexAttribArray(6),s.vertexAttribPointer(6,2,s.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(6,1),s.enableVertexAttribArray(1),s.vertexAttribPointer(1,2,s.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),s.vertexAttribDivisor(1,1),s.useProgram(this._program);let d=new Int32Array(j.maxAtlasPages);for(let _=0;_s.deleteTexture(c.texture))),s.activeTexture(s.TEXTURE0+_),s.bindTexture(s.TEXTURE_2D,c.texture),s.texParameteri(s.TEXTURE_2D,s.TEXTURE_WRAP_S,s.CLAMP_TO_EDGE),s.texParameteri(s.TEXTURE_2D,s.TEXTURE_WRAP_T,s.CLAMP_TO_EDGE),s.texImage2D(s.TEXTURE_2D,0,s.RGBA,1,1,0,s.RGBA,s.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[_]=c}s.enable(s.BLEND),s.blendFunc(s.SRC_ALPHA,s.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return this._atlas?this._atlas.beginFrame():!0}updateCell(t,r,n,a,s,o,l,T,h){this._updateCell(this._vertices.attributes,t,r,n,a,s,o,l,T,h)}_updateCell(t,r,n,a,s,o,l,T,h,d){if(N=(n*this._terminal.cols+r)*11,a===0||a===void 0){t.fill(0,N,N+11-1-2);return}this._atlas&&(T&&T.length>1?S=this._atlas.getRasterizedGlyphCombinedChar(T,s,o,l,!1,this._terminal.element):S=this._atlas.getRasterizedGlyph(a,s,o,l,!1,this._terminal.element),Ie=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),s!==d&&S.offset.x>Ie?(k0=S.offset.x-Ie,t[N]=-(S.offset.x-k0)+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=(S.size.x-k0)/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x+k0/this._atlas.pages[S.texturePage].canvas.width,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x-k0/this._atlas.pages[S.texturePage].canvas.width,t[N+8]=S.sizeClipSpace.y):(t[N]=-S.offset.x+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=S.size.x/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x,t[N+8]=S.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&Ze(a,h,S.size.x,this._dimensions.device.cell.width)&&(t[N+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width))}clear(){let t=this._terminal,r=t.cols*t.rows*11;this._vertices.count!==r?this._vertices.attributes=new Float32Array(r):this._vertices.attributes.fill(0);let n=0;for(;n=e.rows||T<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=n,this.viewportStartRow=s,this.viewportEndRow=o,this.viewportCappedStartRow=l,this.viewportCappedEndRow=T,this.startCol=t[0],this.endCol=r[0]}isCellSelected(e,t,r){return this.hasSelection?(r-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&r>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&r<=this.viewportCappedEndRow:r>this.viewportStartRow&&r=this.startCol&&t=this.startCol):!1}};function Tt(){return new Fe}var lt=2147483648,se=class{constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=Tt()}resize(e,t){let r=e*t*4;r!==this.cells.length&&(this.cells=new Uint32Array(r),this.lineLengths=new Uint32Array(t))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}};var jt=`#version 300 es + layout (location = 0) in vec2 a_position; + layout (location = 1) in vec2 a_size; + layout (location = 2) in vec4 a_color; +diff --git a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts +index 46b8efe..4b36a78 100644 +--- a/src/GlyphRenderer.ts ++++ b/src/GlyphRenderer.ts +@@ -387,8 +387,13 @@ export class GlyphRenderer extends Disposable { + gl.bindTexture(gl.TEXTURE_2D, this._atlasTextures[i].texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); ++ // Why: glyph atlas pages are sampled 1:1. ANGLE/OpenGL drivers on Linux ++ // Wayland can fail mipmap allocation for large atlas uploads, corrupting ++ // terminal glyph textures instead of cleanly losing the WebGL context. ++ // Separate from upstream atlas-merge fix: https://github.com/xtermjs/xterm.js/pull/5883 ++ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); ++ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.pages[i].canvas); +- gl.generateMipmap(gl.TEXTURE_2D); + this._atlasTextures[i].version = atlas.pages[i].version; + } diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index f52544bcf1c..e100def9346 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -73,6 +73,43 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b var DEFAULT_FILE = 'sh'; var DEFAULT_NAME = 'xterm'; var DESTROY_SOCKET_TIMEOUT_MS = 200; +diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js +index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644 +--- a/lib/conpty_console_list_agent.js ++++ b/lib/conpty_console_list_agent.js +@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true }); + var utils_1 = require("./utils"); + var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList; + var shellPid = parseInt(process.argv[2], 10); +-var consoleProcessList = getConsoleProcessList(shellPid); ++var consoleProcessList; ++try { ++ consoleProcessList = getConsoleProcessList(shellPid); ++} ++catch (_a) { ++ // Why: AttachConsole can fail after the shell exits; parent already has this fallback. ++ consoleProcessList = [shellPid]; ++} + process.send({ consoleProcessList: consoleProcessList }); + process.exit(0); + //# sourceMappingURL=conpty_console_list_agent.js.map +diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts +index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644 +--- a/src/conpty_console_list_agent.ts ++++ b/src/conpty_console_list_agent.ts +@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils'; + const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList; + const shellPid = parseInt(process.argv[2], 10); +-const consoleProcessList = getConsoleProcessList(shellPid); ++let consoleProcessList: number[]; ++try { ++ consoleProcessList = getConsoleProcessList(shellPid); ++} catch { ++ // Why: AttachConsole can fail after the shell exits; parent already has this fallback. ++ consoleProcessList = [shellPid]; ++} + process.send!({ consoleProcessList }); + process.exit(0); diff --git a/src/unix/pty.cc b/src/unix/pty.cc index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644 --- a/src/unix/pty.cc diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index c4502752152..6530ba75576 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -49,7 +49,7 @@ function readRuntimeArg() { } function ensureNodeRuntime() { - const initial = runCurrentProcessCheck() + const initial = runNodeCheck() if (initial.ok) { return } @@ -61,7 +61,7 @@ function ensureNodeRuntime() { printCheckError(initial) runPnpm(['rebuild', ...failedModules]) - const final = runCurrentProcessCheck() + const final = runNodeCheck() if (!final.ok) { console.error( `[native-runtime] Native modules still do not load for ${formatRuntimeLabel('node')}.` @@ -93,12 +93,16 @@ function ensureElectronRuntime() { } } -function runCurrentProcessCheck() { - const failures = collectNativeModuleFailures() - if (failures.length === 0) { - return { ok: true, failures } - } - return { ok: false, failures } +function runNodeCheck() { + // Why: a failed native addon load can poison the current process, so the + // post-rebuild verification must happen in a fresh Node process. + const result = spawnSync(process.execPath, [scriptPath, CHILD_CHECK_FLAG], { + cwd: projectDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + + return parseChildCheckResult(result) } function runElectronCheck() { @@ -119,15 +123,33 @@ function runElectronCheck() { stdio: ['ignore', 'pipe', 'pipe'] }) + return parseChildCheckResult(result) +} + +function parseChildCheckResult(result) { + const failures = parseCheckFailures(result.stderr) + return { ok: result.status === 0, status: result.status, stdout: result.stdout, stderr: result.stderr, - error: result.error + error: result.error, + failures } } +function parseCheckFailures(stderr) { + const failures = [] + for (const line of (stderr ?? '').split(/\r?\n/)) { + const match = /^([^:]+):\s*(.*)$/.exec(line) + if (match && NATIVE_MODULES.includes(match[1])) { + failures.push({ moduleName: match[1], message: match[2] }) + } + } + return failures +} + function collectNativeModuleFailures() { const failures = [] for (const moduleName of NATIVE_MODULES) { diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs new file mode 100644 index 00000000000..65ba4f0cd60 --- /dev/null +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -0,0 +1,135 @@ +import { spawnSync } from 'node:child_process' +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) + +describe('ensure-native-runtime', () => { + it('rechecks Node native modules in fresh child processes after rebuilding', () => { + const projectDir = mkTempProject() + + try { + const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') + const logPath = join(projectDir, 'native-runtime.log') + const markerPath = join(projectDir, 'rebuilt.marker') + const binDir = join(projectDir, 'bin') + copyFileSync(sourceScriptPath, scriptPath) + writeFakeNativeModules(projectDir) + writeFakePnpm(binDir) + + const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { + cwd: projectDir, + encoding: 'utf8', + env: envWithPrependedPath(binDir, { + ORCA_NATIVE_TEST_LOG: logPath, + ORCA_NATIVE_TEST_MARKER: markerPath + }) + }) + + expect(result.status, result.stderr).toBe(0) + const log = readFileSync(logPath, 'utf8') + expect(log).toContain('pnpm rebuild better-sqlite3\n') + expect(log.split('\n').filter((line) => line.startsWith('better-sqlite3 '))).toEqual([ + 'better-sqlite3 child marker=false', + 'better-sqlite3 child marker=true' + ]) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) +}) + +function mkTempProject() { + const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-')) + mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) + return projectDir +} + +function envWithPrependedPath(binDir, extraEnv) { + const pathKey = + process.platform === 'win32' + ? (Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path') + : 'PATH' + return { + ...process.env, + ...extraEnv, + [pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}` + } +} + +function writeFakeNativeModules(projectDir) { + const sqliteDir = join(projectDir, 'node_modules', 'better-sqlite3') + const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') + mkdirSync(sqliteDir, { recursive: true }) + mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) + + writeFileSync( + join(sqliteDir, 'index.js'), + ` +const { appendFileSync, existsSync } = require('node:fs') + +module.exports = class Database { + constructor() { + const markerExists = existsSync(process.env.ORCA_NATIVE_TEST_MARKER) + appendFileSync( + process.env.ORCA_NATIVE_TEST_LOG, + \`better-sqlite3 \${process.argv.includes('--check-only') ? 'child' : 'parent'} marker=\${markerExists}\\n\` + ) + if (!markerExists) { + throw new Error('ABI mismatch sentinel') + } + } + + close() {} +} +` + ) + + writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') + writeFileSync( + join(nodePtyDir, 'lib', 'utils.js'), + ` +const { appendFileSync } = require('node:fs') + +exports.loadNativeModule = function loadNativeModule(nativeName) { + appendFileSync( + process.env.ORCA_NATIVE_TEST_LOG, + \`node-pty \${process.argv.includes('--check-only') ? 'child' : 'parent'} \${nativeName}\\n\` + ) +} +` + ) +} + +function writeFakePnpm(binDir) { + mkdirSync(binDir, { recursive: true }) + const shimPath = join(binDir, 'pnpm-shim.cjs') + writeFileSync( + shimPath, + ` +const { appendFileSync, writeFileSync } = require('node:fs') + +appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`) +writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt') +` + ) + + const posixPnpmPath = join(binDir, 'pnpm') + writeFileSync(posixPnpmPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimPath)})\n`) + chmodSync(posixPnpmPath, 0o755) + writeFileSync( + join(binDir, 'pnpm.cmd'), + `@echo off\r\n"${process.execPath}" "%~dp0\\pnpm-shim.cjs" %*\r\n` + ) +} diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs new file mode 100644 index 00000000000..25662d10755 --- /dev/null +++ b/config/scripts/install-electron-package-binary.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node + +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { platform as osPlatform, tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const electronPackageDir = resolve(projectDir, 'node_modules/electron') +const electronRequire = createRequire(resolve(electronPackageDir, 'package.json')) +const { version: electronVersion } = electronRequire('./package.json') +const { downloadArtifact } = electronRequire('@electron/get') +const platformPath = getElectronPlatformPath() + +try { + // Why: Electron's own install.js can exit 0 while an async extract promise is + // still unsettled, leaving a partial dist/. Top-level await makes that fail. + await main() +} catch (error) { + console.error('[electron-package] Failed to install Electron package binary.') + console.error(error) + logElectronInstallDiagnostics() + process.exit(1) +} + +async function main() { + if (electronPackageIsUsable()) { + return + } + + // Why: PR tests run under system Node after native modules are rebuilt for + // Node. Install only Electron's npm package binary here; do not run the full + // Electron native-module rebuild path, which would undo the Node ABI rebuild. + console.log('[electron-package] Electron package binary is missing; running Electron install.') + resetPartialElectronInstall() + await installElectronPackageBinary() + + repairElectronPathFile() + + if (!electronPackageIsUsable()) { + logElectronInstallDiagnostics() + console.error('[electron-package] Electron package is still unavailable after install.') + process.exit(1) + } +} + +function electronPackageIsUsable() { + try { + const electronPath = resolveElectronPath() + return existsSync(electronPath) + } catch { + return false + } +} + +function resolveElectronPath() { + const electronModulePath = require.resolve('electron') + delete require.cache[electronModulePath] + return require('electron') +} + +function resetPartialElectronInstall() { + rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true }) + rmSync(resolve(electronPackageDir, 'path.txt'), { force: true }) +} + +function repairElectronPathFile() { + const electronExecutable = resolve(electronPackageDir, 'dist', platformPath) + if (!existsSync(electronExecutable)) { + return + } + + const pathFile = resolve(electronPackageDir, 'path.txt') + let currentPath = '' + try { + currentPath = readFileSync(pathFile, 'utf8') + } catch { + // Missing path.txt is the common CI failure this script repairs. + } + + if (currentPath !== platformPath) { + writeFileSync(pathFile, platformPath) + console.log(`[electron-package] Repaired Electron path.txt -> ${platformPath}`) + } +} + +async function installElectronPackageBinary() { + const electronDistDir = resolve(electronPackageDir, 'dist') + const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-')) + const cacheRoot = join(tempDir, 'cache') + const extractDir = join(tempDir, 'extract') + + try { + const zipPath = await downloadArtifact({ + version: electronVersion, + artifactName: 'electron', + platform: process.env.npm_config_platform || osPlatform(), + arch: process.env.npm_config_arch || process.arch, + cacheRoot, + force: true, + tempDirectory: tempDir, + ...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') }) + }) + + // Why: CI has observed partial extracts directly under node_modules/electron + // that leave only dist/locales. Verify in temp before replacing package dist. + extractElectronArchive(zipPath, extractDir) + const extractedExecutable = resolve(extractDir, platformPath) + if (!existsSync(extractedExecutable)) { + console.error('[electron-package] Electron archive extract did not contain executable.') + console.error(` platformPath=${platformPath}`) + console.error(` extractDir=${extractDir}`) + console.error(` extractEntries=${safeReaddir(extractDir).join(', ')}`) + process.exit(1) + } + + moveExtractedElectronDist(extractDir, electronDistDir) + + const srcTypeDefPath = resolve(electronDistDir, 'electron.d.ts') + if (existsSync(srcTypeDefPath)) { + renameSync(srcTypeDefPath, resolve(electronPackageDir, 'electron.d.ts')) + } + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +} + +function extractElectronArchive(zipPath, extractDir) { + mkdirSync(extractDir, { recursive: true }) + // Why: extract-zip/Electron install.js can leave Node 24 with an unsettled + // promise and no active handles on CI. Host unzip tools fail synchronously. + const command = getExtractorCommand(zipPath, extractDir) + const result = spawnSync(command.file, command.args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + + if (result.error) { + throw result.error + } + if (result.status !== 0) { + throw new Error(formatExtractorFailure(command, result)) + } +} + +function moveExtractedElectronDist(extractDir, electronDistDir) { + rmSync(electronDistDir, { recursive: true, force: true }) + try { + // Why: macOS Electron archives rely on framework symlinks. Moving the + // verified tree preserves them exactly; copying has broken them in CI. + renameSync(extractDir, electronDistDir) + } catch (/** @type {any} */ err) { + if (err?.code !== 'EXDEV') { + throw err + } + cpSync(extractDir, electronDistDir, { + recursive: true, + dereference: false, + verbatimSymlinks: true + }) + } +} + +function getExtractorCommand(zipPath, extractDir) { + if (process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR) { + return { + file: process.execPath, + args: [process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR, zipPath, extractDir], + label: `node ${process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR}` + } + } + + if (osPlatform() === 'win32') { + return { + file: process.env.ORCA_POWERSHELL_BIN || 'powershell', + args: [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + [ + "$ErrorActionPreference = 'Stop'", + `Expand-Archive -LiteralPath ${quotePowerShellLiteral(zipPath)} -DestinationPath ${quotePowerShellLiteral(extractDir)} -Force` + ].join('; ') + ], + label: 'powershell Expand-Archive' + } + } + + return { + file: process.env.ORCA_UNZIP_BIN || 'unzip', + args: ['-q', zipPath, '-d', extractDir], + label: 'unzip' + } +} + +function quotePowerShellLiteral(value) { + return `'${String(value).replaceAll("'", "''")}'` +} + +function formatExtractorFailure(command, result) { + return [ + `[electron-package] ${command.label} failed with status ${result.status}.`, + result.stdout ? `stdout:\n${result.stdout.trim()}` : '', + result.stderr ? `stderr:\n${result.stderr.trim()}` : '' + ] + .filter(Boolean) + .join('\n') +} + +function shouldUseRemoteChecksums() { + return Boolean( + process.env.electron_use_remote_checksums || + process.env.npm_config_electron_use_remote_checksums + ) +} + +function logElectronInstallDiagnostics() { + const electronDistDir = resolve(electronPackageDir, 'dist') + const pathFile = resolve(electronPackageDir, 'path.txt') + console.error('[electron-package] Electron install diagnostics:') + console.error(` packageDir=${electronPackageDir} exists=${existsSync(electronPackageDir)}`) + console.error(` distDir=${electronDistDir} exists=${existsSync(electronDistDir)}`) + console.error(` pathFile=${pathFile} exists=${existsSync(pathFile)}`) + console.error(` platformPath=${platformPath}`) + if (existsSync(electronDistDir)) { + console.error(` distEntries=${safeReaddir(electronDistDir).join(', ')}`) + } +} + +function safeReaddir(targetPath) { + try { + return readdirSync(targetPath).slice(0, 40) + } catch { + return [] + } +} + +function getElectronPlatformPath() { + const targetPlatform = process.env.npm_config_platform || osPlatform() + switch (targetPlatform) { + case 'mas': + case 'darwin': + return 'Electron.app/Contents/MacOS/Electron' + case 'freebsd': + case 'openbsd': + case 'linux': + return 'electron' + case 'win32': + return 'electron.exe' + default: + throw new Error(`Electron builds are not available on platform: ${targetPlatform}`) + } +} diff --git a/config/scripts/install-electron-package-binary.test.mjs b/config/scripts/install-electron-package-binary.test.mjs new file mode 100644 index 00000000000..95c1057eb91 --- /dev/null +++ b/config/scripts/install-electron-package-binary.test.mjs @@ -0,0 +1,177 @@ +import { + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const sourceScriptPath = fileURLToPath( + new URL('./install-electron-package-binary.mjs', import.meta.url) +) + +describe('install-electron-package-binary', () => { + it('installs Electron from an isolated cache and repairs path.txt', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir) + + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toMatch( + /cacheRoot=.*orca-electron-.*cache/ + ) + expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe( + 'electron' + ) + if (process.platform !== 'win32') { + expect( + lstatSync( + join(projectDir, 'node_modules', 'electron', 'dist', 'version-link') + ).isSymbolicLink() + ).toBe(true) + } + expect(result.stdout).toContain('Repaired Electron path.txt -> electron') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('fails instead of silently accepting a partial Electron extract', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir) + writeFakeExtractor(projectDir, { createExecutable: false }) + mkdirSync(join(projectDir, 'node_modules', 'electron', 'dist', 'locales'), { + recursive: true + }) + writeFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'stale-path') + + const result = runInstallScript(projectDir) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Electron archive extract did not contain executable') + expect(result.stderr).toContain('extractEntries=locales') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('does not exit successfully when Electron download never settles', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { downloadNeverSettles: true }) + writeFakeExtractor(projectDir, { createExecutable: false }) + + const result = runInstallScript(projectDir) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Detected unsettled top-level await') + expect(existsSync(join(projectDir, 'node_modules', 'electron', 'path.txt'))).toBe(false) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) +}) + +function mkTempProject() { + const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-')) + mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) + copyFileSync( + sourceScriptPath, + join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs') + ) + return projectDir +} + +function runInstallScript(projectDir) { + return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], { + cwd: projectDir, + encoding: 'utf8', + env: { + ...process.env, + npm_config_platform: 'linux', + npm_config_arch: 'x64', + ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs') + } + }) +} + +function writeFakeElectronPackage(projectDir) { + const electronDir = join(projectDir, 'node_modules', 'electron') + mkdirSync(electronDir, { recursive: true }) + writeFileSync( + join(electronDir, 'package.json'), + JSON.stringify({ name: 'electron', version: '41.5.0' }) + ) + writeFileSync(join(electronDir, 'checksums.json'), '{}') + writeFileSync( + join(electronDir, 'index.js'), + ` +const fs = require('node:fs') +const path = require('node:path') +const pathFile = path.join(__dirname, 'path.txt') +if (!fs.existsSync(pathFile)) { + throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again') +} +module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8')) +` + ) +} + +function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) { + const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get') + mkdirSync(getDir, { recursive: true }) + writeFileSync( + join(getDir, 'index.js'), + ` +const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs') +const { join } = require('node:path') +exports.downloadArtifact = async function downloadArtifact(details) { + appendFileSync('electron-get.log', 'cacheRoot=' + details.cacheRoot + '\\n') + if (${JSON.stringify(downloadNeverSettles)}) { + return new Promise(() => {}) + } + mkdirSync(details.cacheRoot, { recursive: true }) + const artifactPath = join(details.cacheRoot, 'electron.zip') + writeFileSync(artifactPath, 'fake zip') + return artifactPath +} +` + ) +} + +function writeFakeExtractor(projectDir, { createExecutable }) { + writeFileSync( + join(projectDir, 'fake-extractor.cjs'), + ` +const { mkdirSync, symlinkSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +const extractDir = process.argv[3] +mkdirSync(join(extractDir, 'locales'), { recursive: true }) +if (${JSON.stringify(createExecutable)}) { + writeFileSync(join(extractDir, 'electron'), '') + writeFileSync(join(extractDir, 'version'), 'v41.5.0') + if (process.platform !== 'win32') { + symlinkSync('version', join(extractDir, 'version-link')) + } +} +` + ) +} diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs new file mode 100644 index 00000000000..99275d6385f --- /dev/null +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -0,0 +1,87 @@ +import { readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) + +describe('Electron runtime package contract', () => { + it('keeps root postinstall as the single Electron binary install owner', () => { + expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs') + expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron') + }) + + it('guards package scripts that launch Electron tooling', () => { + const scripts = packageJson.scripts + const guardedScripts = [ + 'start', + 'dev', + 'dev-stable-name', + 'build:unpack', + 'build:win', + 'build:mac', + 'build:mac:release', + 'build:linux', + 'test:e2e', + 'test:e2e:headful' + ] + + for (const scriptName of guardedScripts) { + expect(scripts[scriptName], scriptName).toContain('pnpm run ensure:electron-runtime &&') + } + }) + + it('guards release publishing before electron-builder runs', () => { + const releaseWorkflow = readFileSync( + join(projectDir, '.github/workflows/release-cut.yml'), + 'utf8' + ) + const parsedWorkflow = parse(releaseWorkflow) + const releaseCommands = new Map( + parsedWorkflow.jobs.build.strategy.matrix.include.map(({ platform, release_command }) => [ + platform, + release_command + ]) + ) + + expect([...releaseCommands.keys()].sort()).toEqual(['linux', 'mac', 'win']) + for (const command of releaseCommands.values()) { + expect(command).toContain('node config/scripts/ensure-native-runtime.mjs --runtime=electron') + expect(command).toContain('electron-builder') + expect(command.indexOf('ensure-native-runtime')).toBeLessThan( + command.indexOf('electron-builder') + ) + } + expect(releaseCommands.get('mac')).toContain(' && ORCA_MAC_RELEASE=1 ') + expect(releaseCommands.get('linux')).toContain(' && pnpm exec electron-builder ') + expect(releaseCommands.get('win')).toContain( + '; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder ' + ) + }) + + it('lets release-cut tag a version that is already present on main', () => { + const releaseWorkflow = readFileSync( + join(projectDir, '.github/workflows/release-cut.yml'), + 'utf8' + ) + const parsedWorkflow = parse(releaseWorkflow) + const bumpStep = parsedWorkflow.jobs.cut.steps.find( + (step) => step.name === 'Bump package.json and tag' + ) + + expect(bumpStep.run).toContain('git diff --cached --quiet') + expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"') + }) + + it('installs the Electron package binary in PR checks without changing native module ABI', () => { + const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') + const parsedWorkflow = parse(prWorkflow) + const installStep = parsedWorkflow.jobs.verify.steps.find( + (step) => step.name === 'Install Electron package binary for tests' + ) + + expect(installStep.run).toBe('node config/scripts/install-electron-package-binary.mjs') + }) +}) diff --git a/config/scripts/pty-batch-flush-benchmark.mjs b/config/scripts/pty-batch-flush-benchmark.mjs new file mode 100644 index 00000000000..ba1ff39e41a --- /dev/null +++ b/config/scripts/pty-batch-flush-benchmark.mjs @@ -0,0 +1,481 @@ +/* eslint-disable no-control-regex -- Benchmarks mirror PTY ANSI/OSC parsing regexes. */ +import { performance } from 'node:perf_hooks' +import v8 from 'node:v8' + +const PTY_COUNT = Number.parseInt(process.env.ORCA_PTY_BENCH_PTY_COUNT ?? '24', 10) +const PAYLOAD_CHARS = Number.parseInt(process.env.ORCA_PTY_BENCH_PAYLOAD_CHARS ?? '262144', 10) +const RUNS = Number.parseInt(process.env.ORCA_PTY_BENCH_RUNS ?? '30', 10) +const MEASURE_TIMER_DELAYS = process.env.ORCA_PTY_BENCH_MEASURE_TIMER_DELAYS !== '0' +const INGRESS_CHUNKS = Number.parseInt(process.env.ORCA_PTY_BENCH_INGRESS_CHUNKS ?? '96', 10) +const INGRESS_CHUNK_CHARS = Number.parseInt( + process.env.ORCA_PTY_BENCH_INGRESS_CHARS ?? '65536', + 10 +) +const CHUNK_CHARS = 16 * 1024 +const MAX_WRITES_PER_SLICE = 2 +const RECENT_PTY_OUTPUT_LIMIT = 4096 +const MAX_TAIL_LINES = 2000 +const MAX_TAIL_CHARS = 256 * 1024 +const MAX_TAIL_PARTIAL_CHARS = 4000 +const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g +const URL_CANDIDATE_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/gi + +if (!Number.isInteger(PTY_COUNT) || PTY_COUNT <= 0) { + throw new Error(`ORCA_PTY_BENCH_PTY_COUNT must be positive, received ${PTY_COUNT}`) +} +if (!Number.isInteger(PAYLOAD_CHARS) || PAYLOAD_CHARS <= 0) { + throw new Error(`ORCA_PTY_BENCH_PAYLOAD_CHARS must be positive, received ${PAYLOAD_CHARS}`) +} +if (!Number.isInteger(RUNS) || RUNS <= 0) { + throw new Error(`ORCA_PTY_BENCH_RUNS must be positive, received ${RUNS}`) +} +if (!Number.isInteger(INGRESS_CHUNKS) || INGRESS_CHUNKS <= 0) { + throw new Error( + `ORCA_PTY_BENCH_INGRESS_CHUNKS must be positive, received ${INGRESS_CHUNKS}` + ) +} +if (!Number.isInteger(INGRESS_CHUNK_CHARS) || INGRESS_CHUNK_CHARS <= 0) { + throw new Error( + `ORCA_PTY_BENCH_INGRESS_CHARS must be positive, received ${INGRESS_CHUNK_CHARS}` + ) +} + +function makePendingData() { + const pending = new Map() + for (let index = 0; index < PTY_COUNT; index++) { + pending.set(`pty-${index}`, `${index}:`.padEnd(PAYLOAD_CHARS, 'x')) + } + return pending +} + +function simulateWebContentsSend(id, data) { + return v8.serialize({ channel: 'pty:data', payload: { id, data } }).byteLength +} + +function flushLegacy(pending) { + let bytes = 0 + const start = performance.now() + for (const [id, data] of pending) { + bytes += simulateWebContentsSend(id, data) + } + pending.clear() + return { bytes, durationMs: performance.now() - start } +} + +function flushBoundedSlice(pending) { + let bytes = 0 + let writes = 0 + const start = performance.now() + while (pending.size > 0 && writes < MAX_WRITES_PER_SLICE) { + const next = pending.entries().next().value + if (!next) { + break + } + const [id, data] = next + pending.delete(id) + const chunk = data.slice(0, CHUNK_CHARS) + const remaining = data.slice(CHUNK_CHARS) + if (remaining) { + pending.set(id, remaining) + } + bytes += simulateWebContentsSend(id, chunk) + writes++ + } + return { bytes, durationMs: performance.now() - start } +} + +function drainBounded(pending) { + let bytes = 0 + const sliceDurations = [] + while (pending.size > 0) { + const result = flushBoundedSlice(pending) + bytes += result.bytes + sliceDurations.push(result.durationMs) + } + return { bytes, sliceDurations } +} + +function makeIngressChunk() { + return `${'x'.repeat(INGRESS_CHUNK_CHARS - 1)}\n` +} + +function extractLastOscTitleLegacy(data) { + let last = null + for (const m of data.matchAll(OSC_TITLE_RE)) { + last = m[2] + } + return last +} + +function extractLastOscTitleCurrent(data) { + if (!data.includes('\x1b]')) { + return null + } + return extractLastOscTitleLegacy(data) +} + +function hasMeaningfulContentLegacy(chunk) { + return ( + chunk + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[@-_]/g, '') + .replace(/\u0008/g, '') + .replace(/[^\x09\x0a\x20-\x7e]/g, '') + .trim().length > 0 + ) +} + +function hasMeaningfulContentCurrent(chunk) { + for (let index = 0; index < chunk.length; index++) { + const code = chunk.charCodeAt(index) + if (code === 0x1b || code < 0x09 || (code > 0x0d && code < 0x20) || code > 0x7e) { + break + } + if (code > 0x20) { + return true + } + } + return hasMeaningfulContentLegacy(chunk) +} + +function normalizeTerminalChunkLegacy(chunk) { + return chunk + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[@-_]/g, '') + .replace(/\u0008/g, '') + .replace(/[^\x09\x0a\x20-\x7e]/g, '') +} + +function terminalChunkNeedsNormalization(chunk) { + for (let index = 0; index < chunk.length; index++) { + const code = chunk.charCodeAt(index) + if ( + code === 0x1b || + code === 0x0d || + code < 0x09 || + (code > 0x0a && code < 0x20) || + code > 0x7e + ) { + return true + } + } + return false +} + +function normalizeTerminalChunkCurrent(chunk) { + return terminalChunkNeedsNormalization(chunk) ? normalizeTerminalChunkLegacy(chunk) : chunk +} + +function appendNormalizedToTailBuffer(previousLines, previousPartialLine, normalizedChunk) { + if (normalizedChunk.length === 0) { + return { + lines: previousLines, + partialLine: previousPartialLine + } + } + + const boundedPreviousPartialLine = previousPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) + const pieces = `${boundedPreviousPartialLine}${normalizedChunk}`.split('\n') + const nextPartialLine = (pieces.pop() ?? '').replace(/[ \t]+$/g, '') + const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) + let nextLines = + pieces.length > 0 + ? [...previousLines, ...pieces.map((line) => line.replace(/[ \t]+$/g, ''))] + : previousLines + + while (nextLines.length > MAX_TAIL_LINES) { + nextLines.shift() + } + + if (pieces.length > 0 || retainedPartialLine.length > previousPartialLine.length) { + if (nextLines === previousLines) { + nextLines = [...previousLines] + } + let totalChars = + nextLines.reduce((sum, line) => sum + line.length, 0) + retainedPartialLine.length + while (nextLines.length > 0 && totalChars > MAX_TAIL_CHARS) { + totalChars -= nextLines.shift().length + } + } + + return { + lines: nextLines, + partialLine: retainedPartialLine + } +} + +function appendTailBufferLegacy(previousLines, previousPartialLine, chunk) { + return appendNormalizedToTailBuffer( + previousLines, + previousPartialLine, + normalizeTerminalChunkLegacy(chunk) + ) +} + +function tailStateMatches(lines, partialLine, snapshot) { + if ( + partialLine !== snapshot.partialLine || + lines.length !== snapshot.lines.length || + lines.length !== snapshot.linesTotal + ) { + return false + } + if (lines === snapshot.lines) { + return true + } + for (let index = 0; index < lines.length; index++) { + if (lines[index] !== snapshot.lines[index]) { + return false + } + } + return true +} + +class AdvertisedUrlPtyBuffer { + raw = '' + + ingest(chunk) { + this.raw += chunk + if (this.raw.length > 4096) { + this.raw = this.raw.slice(-4096) + } + const lastLineBreak = Math.max(this.raw.lastIndexOf('\n'), this.raw.lastIndexOf('\r')) + if (lastLineBreak === -1) { + return '' + } + const finalized = this.raw.slice(0, lastLineBreak + 1) + this.raw = this.raw.slice(lastLineBreak + 1) + return finalized + } +} + +function scanAdvertisedUrls(buffer, chunk) { + const finalized = buffer.ingest(chunk) + if (!finalized) { + return + } + for (const _match of finalized.matchAll(URL_CANDIDATE_PATTERN)) { + // Candidate extraction is enough for this benchmark; URL validation is rare + // and only happens after a URL-shaped match. + } +} + +function measureRuntimeIngressLegacy() { + const chunk = makeIngressChunk() + const advertisedUrlBuffer = new AdvertisedUrlPtyBuffer() + let recentOutput = '' + let ptyTail = { lines: [], partialLine: '' } + let leafTail = { lines: [], partialLine: '' } + const start = performance.now() + + for (let index = 0; index < INGRESS_CHUNKS; index++) { + recentOutput = `${recentOutput}${chunk}`.slice(-RECENT_PTY_OUTPUT_LIMIT) + hasMeaningfulContentLegacy(chunk) + extractLastOscTitleLegacy(chunk) + scanAdvertisedUrls(advertisedUrlBuffer, chunk) + extractLastOscTitleLegacy(chunk) + ptyTail = appendTailBufferLegacy(ptyTail.lines, ptyTail.partialLine, chunk) + leafTail = appendTailBufferLegacy(leafTail.lines, leafTail.partialLine, chunk) + } + + return performance.now() - start +} + +function measureRuntimeIngressCurrent() { + const chunk = makeIngressChunk() + const advertisedUrlBuffer = new AdvertisedUrlPtyBuffer() + let recentOutput = '' + let ptyTail = { lines: [], partialLine: '' } + let leafTail = { lines: [], partialLine: '' } + const start = performance.now() + + for (let index = 0; index < INGRESS_CHUNKS; index++) { + recentOutput = `${recentOutput}${chunk}`.slice(-RECENT_PTY_OUTPUT_LIMIT) + hasMeaningfulContentCurrent(chunk) + extractLastOscTitleCurrent(chunk) + scanAdvertisedUrls(advertisedUrlBuffer, chunk) + extractLastOscTitleCurrent(chunk) + const normalizedChunk = normalizeTerminalChunkCurrent(chunk) + const ptyTailBefore = { + lines: ptyTail.lines, + partialLine: ptyTail.partialLine, + linesTotal: ptyTail.lines.length + } + ptyTail = appendNormalizedToTailBuffer(ptyTail.lines, ptyTail.partialLine, normalizedChunk) + leafTail = tailStateMatches(leafTail.lines, leafTail.partialLine, ptyTailBefore) + ? { lines: ptyTail.lines, partialLine: ptyTail.partialLine } + : appendNormalizedToTailBuffer(leafTail.lines, leafTail.partialLine, normalizedChunk) + } + + return performance.now() - start +} + +function scheduleLegacyFlush(pending) { + return new Promise((resolve) => { + setTimeout(() => { + flushLegacy(pending) + resolve() + }, 0) + }) +} + +function scheduleBoundedFlush(pending) { + return new Promise((resolve) => { + const drain = () => { + flushBoundedSlice(pending) + if (pending.size > 0) { + setTimeout(drain, 1) + return + } + resolve() + } + setTimeout(drain, 0) + }) +} + +async function measureInputTimerDelay(flushKind) { + const pending = makePendingData() + const scheduledAt = performance.now() + const drainPromise = + flushKind === 'legacy' ? scheduleLegacyFlush(pending) : scheduleBoundedFlush(pending) + const inputDelay = await new Promise((resolve) => { + setTimeout(() => resolve(performance.now() - scheduledAt), 0) + }) + await drainPromise + return inputDelay +} + +function percentile(values, p) { + const sorted = [...values].sort((a, b) => a - b) + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)) + return sorted[index] ?? 0 +} + +function summarize(values) { + return { + median: percentile(values, 50), + p95: percentile(values, 95), + max: Math.max(...values) + } +} + +function formatMs(value) { + return `${value.toFixed(3)}ms` +} + +async function runBenchmark() { + const legacyDurations = [] + const boundedFirstSliceDurations = [] + const boundedMaxSliceDurations = [] + const boundedTotalDurations = [] + const legacyInputTimerDelays = [] + const boundedInputTimerDelays = [] + const legacyRuntimeIngressDurations = [] + const currentRuntimeIngressDurations = [] + let legacyBytes = 0 + let boundedBytes = 0 + + for (let run = 0; run < RUNS; run++) { + const legacy = flushLegacy(makePendingData()) + legacyDurations.push(legacy.durationMs) + legacyBytes = legacy.bytes + + const boundedStart = performance.now() + const bounded = drainBounded(makePendingData()) + boundedBytes = bounded.bytes + boundedFirstSliceDurations.push(bounded.sliceDurations[0] ?? 0) + boundedMaxSliceDurations.push(Math.max(...bounded.sliceDurations)) + boundedTotalDurations.push(performance.now() - boundedStart) + + if (MEASURE_TIMER_DELAYS) { + legacyInputTimerDelays.push(await measureInputTimerDelay('legacy')) + boundedInputTimerDelays.push(await measureInputTimerDelay('bounded')) + } + + legacyRuntimeIngressDurations.push(measureRuntimeIngressLegacy()) + currentRuntimeIngressDurations.push(measureRuntimeIngressCurrent()) + } + + const legacySummary = summarize(legacyDurations) + const boundedFirstSliceSummary = summarize(boundedFirstSliceDurations) + const boundedMaxSliceSummary = summarize(boundedMaxSliceDurations) + const boundedTotalSummary = summarize(boundedTotalDurations) + const legacyInputTimerSummary = MEASURE_TIMER_DELAYS ? summarize(legacyInputTimerDelays) : null + const boundedInputTimerSummary = MEASURE_TIMER_DELAYS ? summarize(boundedInputTimerDelays) : null + const legacyRuntimeIngressSummary = summarize(legacyRuntimeIngressDurations) + const currentRuntimeIngressSummary = summarize(currentRuntimeIngressDurations) + + console.log( + JSON.stringify( + { + scenario: { + ptyCount: PTY_COUNT, + payloadChars: PAYLOAD_CHARS, + runs: RUNS, + totalPayloadMiB: (PTY_COUNT * PAYLOAD_CHARS) / 1024 / 1024, + ingressChunks: INGRESS_CHUNKS, + ingressChunkChars: INGRESS_CHUNK_CHARS, + ingressPayloadMiB: (INGRESS_CHUNKS * INGRESS_CHUNK_CHARS) / 1024 / 1024 + }, + legacy: { + bytesPerRun: legacyBytes, + singleCallback: legacySummary, + inputTimerDelay: legacyInputTimerSummary + }, + bounded: { + bytesPerRun: boundedBytes, + firstSlice: boundedFirstSliceSummary, + maxSlice: boundedMaxSliceSummary, + totalDrain: boundedTotalSummary, + inputTimerDelay: boundedInputTimerSummary + }, + runtimeIngress: { + legacyRepeatedScans: legacyRuntimeIngressSummary, + currentFastPath: currentRuntimeIngressSummary, + estimatedReduction: + legacyRuntimeIngressSummary.max / + Math.max(currentRuntimeIngressSummary.max, Number.EPSILON) + }, + estimatedPtyWriteDelay: + legacyInputTimerSummary && boundedInputTimerSummary + ? { + before: legacyInputTimerSummary.max, + after: boundedInputTimerSummary.max, + reduction: + legacyInputTimerSummary.max / + Math.max(boundedInputTimerSummary.max, Number.EPSILON) + } + : null + }, + null, + 2 + ) + ) + + console.error( + [ + `legacy max single callback: ${formatMs(legacySummary.max)}`, + `bounded max first slice: ${formatMs(boundedFirstSliceSummary.max)}`, + `bounded max any slice: ${formatMs(boundedMaxSliceSummary.max)}`, + `bounded max total drain: ${formatMs(boundedTotalSummary.max)}`, + `runtime ingress legacy max: ${formatMs(legacyRuntimeIngressSummary.max)}`, + `runtime ingress current max: ${formatMs(currentRuntimeIngressSummary.max)}`, + legacyInputTimerSummary + ? `legacy max input timer delay: ${formatMs(legacyInputTimerSummary.max)}` + : null, + boundedInputTimerSummary + ? `bounded max input timer delay: ${formatMs(boundedInputTimerSummary.max)}` + : null + ] + .filter(Boolean) + .join('\n') + ) +} + +await runBenchmark() diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 3e73aafdd56..49ebc103da3 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -22,7 +22,8 @@ import { rebuild } from '@electron/rebuild' import { execFileSync, spawnSync } from 'node:child_process' import { createRequire } from 'node:module' -import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, globSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { platform as osPlatform } from 'node:os' import { resolve } from 'node:path' const require = createRequire(import.meta.url) @@ -45,6 +46,8 @@ const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features'] const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) const forceRebuild = process.env.ORCA_FORCE_NATIVE_REBUILD === '1' +ensureElectronPackageInstalled() + if (!forceRebuild) { // Why: Windows cannot unlink a loaded .node DLL, so avoid @electron/rebuild // when the current install already works with Electron's ABI. @@ -123,6 +126,159 @@ try { process.exit(1) } +function ensureElectronPackageInstalled() { + try { + require('electron') + return + } catch (/** @type {any} */ err) { + if (!isElectronPackageInstallError(err)) { + throw err + } + } + + // Why: CI has observed Electron's postinstall exiting cleanly without + // writing path.txt; use our strict installer instead of Electron's install.js + // so partial extracts and poisoned caches are rejected before native rebuild. + console.log('[rebuild] Electron package binary is missing; installing Electron package binary.') + resetPartialElectronInstall() + try { + runElectronPackageBinaryInstall() + } catch (/** @type {any} */ err) { + console.error('[rebuild] Electron install retry failed:', err?.message ?? err) + logElectronInstallDiagnostics() + if (continuePostinstallWithoutElectron()) { + process.exit(0) + } + process.exit(1) + } + + try { + require('electron') + } catch (/** @type {any} */ err) { + if (!repairElectronPathFile()) { + logElectronInstallDiagnostics() + if (continuePostinstallWithoutElectron()) { + process.exit(0) + } + console.error( + '[rebuild] Electron package is still unavailable after retry:', + err?.message ?? err + ) + process.exit(1) + } + try { + require('electron') + } catch (/** @type {any} */ retryErr) { + console.error( + '[rebuild] Electron package is still unavailable after repairing path.txt:', + retryErr?.message ?? retryErr + ) + process.exit(1) + } + } +} + +function runElectronPackageBinaryInstall() { + const env = { ...process.env } + delete env.ELECTRON_SKIP_BINARY_DOWNLOAD + delete env.npm_config_electron_skip_binary_download + + const result = spawnSync( + process.execPath, + ['config/scripts/install-electron-package-binary.mjs'], + { + cwd: projectDir, + env, + stdio: 'inherit' + } + ) + + if (result.error) { + throw result.error + } + if (result.status !== 0) { + throw new Error( + `config/scripts/install-electron-package-binary.mjs exited with status ${result.status}` + ) + } +} + +function resetPartialElectronInstall() { + const electronPackageDir = resolve(projectDir, 'node_modules/electron') + // Why: Electron's installer can leave a partial dist/ tree behind after + // skipped or interrupted postinstall runs; retry from a clean target. + rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true }) + rmSync(resolve(electronPackageDir, 'path.txt'), { force: true }) +} + +function continuePostinstallWithoutElectron() { + if (!isPostinstall() || process.env.ORCA_STRICT_ELECTRON_INSTALL === '1') { + return false + } + console.error( + '[rebuild] Continuing postinstall because Electron binary installation failed. ' + + 'Electron-consuming package scripts and release jobs run ' + + 'config/scripts/ensure-native-runtime.mjs --runtime=electron before launching Electron.' + ) + return true +} + +function repairElectronPathFile() { + const electronPackageDir = resolve(projectDir, 'node_modules/electron') + const platformPath = getElectronPlatformPath() + const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH + ? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath) + : resolve(electronPackageDir, 'dist', platformPath) + if (!existsSync(electronPath)) { + return false + } + + // Why: Electron's install script has exited successfully in CI after + // extraction without leaving path.txt. The package main only needs this file + // to point at the already-extracted executable. + writeFileSync(resolve(electronPackageDir, 'path.txt'), platformPath) + console.log(`[rebuild] Repaired Electron path.txt -> ${platformPath}`) + return true +} + +function logElectronInstallDiagnostics() { + const electronPackageDir = resolve(projectDir, 'node_modules/electron') + const electronDistDir = resolve(electronPackageDir, 'dist') + const pathFile = resolve(electronPackageDir, 'path.txt') + console.error('[rebuild] Electron install diagnostics:') + console.error(` packageDir=${electronPackageDir} exists=${existsSync(electronPackageDir)}`) + console.error(` distDir=${electronDistDir} exists=${existsSync(electronDistDir)}`) + console.error(` pathFile=${pathFile} exists=${existsSync(pathFile)}`) + if (existsSync(electronDistDir)) { + console.error(` distEntries=${safeReaddir(electronDistDir).join(', ')}`) + } +} + +function safeReaddir(targetPath) { + try { + return readdirSync(targetPath).slice(0, 20) + } catch { + return [] + } +} + +function getElectronPlatformPath() { + const targetPlatform = process.env.npm_config_platform || osPlatform() + switch (targetPlatform) { + case 'mas': + case 'darwin': + return 'Electron.app/Contents/MacOS/Electron' + case 'freebsd': + case 'openbsd': + case 'linux': + return 'electron' + case 'win32': + return 'electron.exe' + default: + throw new Error(`Electron builds are not available on platform: ${targetPlatform}`) + } +} + function probeElectronNativeModules(moduleNames) { let electronExecutable try { @@ -217,6 +373,10 @@ function isPostinstall() { return process.env.npm_lifecycle_event === 'postinstall' } +function isElectronPackageInstallError(error) { + return /Electron failed to install correctly/i.test(formatError(error)) +} + function formatError(error) { return error instanceof Error ? error.message : String(error) } diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs new file mode 100644 index 00000000000..55ff6c41fbf --- /dev/null +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -0,0 +1,233 @@ +import { spawnSync } from 'node:child_process' +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', import.meta.url)) +const sourceInstallScriptPath = fileURLToPath( + new URL('./install-electron-package-binary.mjs', import.meta.url) +) + +describe('rebuild-native-deps Electron install fallback', () => { + it('continues non-strict postinstall when Electron retry download fails', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { downloadRejects: true }) + writeFakeExtractZip(projectDir, { createExecutable: false }) + writeFakeElectronRebuild(projectDir) + + const result = runRebuildScript(projectDir, { + npm_lifecycle_event: 'postinstall', + ORCA_STRICT_ELECTRON_INSTALL: '' + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stderr).toContain('Electron install retry failed') + expect(result.stderr).toContain( + 'Continuing postinstall because Electron binary installation failed' + ) + expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toBe( + 'download attempted\n' + ) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('fails strict postinstall when Electron retry download fails', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { downloadRejects: true }) + writeFakeExtractZip(projectDir, { createExecutable: false }) + writeFakeElectronRebuild(projectDir) + + const result = runRebuildScript(projectDir, { + npm_lifecycle_event: 'postinstall', + ORCA_STRICT_ELECTRON_INSTALL: '1' + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Electron install retry failed') + expect(result.stderr).not.toContain( + 'Continuing postinstall because Electron binary installation failed' + ) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('fails non-postinstall rebuild commands when Electron retry download fails', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { downloadRejects: true }) + writeFakeExtractZip(projectDir, { createExecutable: false }) + writeFakeElectronRebuild(projectDir) + + const result = runRebuildScript(projectDir) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Electron install retry failed') + expect(result.stderr).not.toContain( + 'Continuing postinstall because Electron binary installation failed' + ) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('clears partial Electron package contents before retrying install', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { logPartialStateBeforeInstall: true }) + writeFakeExtractZip(projectDir, { createExecutable: false }) + writeFakeElectronRebuild(projectDir) + mkdirSync(join(projectDir, 'node_modules', 'electron', 'dist', 'locales'), { + recursive: true + }) + writeFileSync( + join(projectDir, 'node_modules', 'electron', 'dist', 'locales', 'stale.pak'), + '' + ) + writeFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'stale-path') + + const result = runRebuildScript(projectDir, { + ORCA_STRICT_ELECTRON_INSTALL: '1' + }) + + expect(result.status).toBe(1) + expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toBe( + 'partial cleared\ndownload attempted\n' + ) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) +}) + +function mkTempProject() { + const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-')) + mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) + copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs')) + copyFileSync( + sourceInstallScriptPath, + join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs') + ) + return projectDir +} + +function runRebuildScript(projectDir, extraEnv = {}) { + return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs'], { + cwd: projectDir, + encoding: 'utf8', + env: { + ...process.env, + npm_config_platform: 'linux', + npm_config_arch: 'x64', + ...extraEnv + } + }) +} + +function writeFakeElectronPackage(projectDir) { + const electronDir = join(projectDir, 'node_modules', 'electron') + mkdirSync(electronDir, { recursive: true }) + writeFileSync( + join(electronDir, 'package.json'), + JSON.stringify({ name: 'electron', version: '41.5.0' }) + ) + writeFileSync(join(electronDir, 'checksums.json'), '{}') + writeFileSync( + join(electronDir, 'index.js'), + ` +const fs = require('node:fs') +const path = require('node:path') +const pathFile = path.join(__dirname, 'path.txt') +if (!fs.existsSync(pathFile)) { + throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again') +} +const electronPath = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8')) +if (!fs.existsSync(electronPath)) { + throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again') +} +module.exports = electronPath +` + ) +} + +function writeFakeElectronGet( + projectDir, + { downloadRejects = false, logPartialStateBeforeInstall = false } = {} +) { + const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get') + mkdirSync(getDir, { recursive: true }) + writeFileSync( + join(getDir, 'index.js'), + ` +const { appendFileSync, existsSync, mkdirSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +exports.downloadArtifact = async function downloadArtifact(details) { + if (${JSON.stringify(logPartialStateBeforeInstall)}) { + appendFileSync( + 'electron-get.log', + existsSync('node_modules/electron/dist') || existsSync('node_modules/electron/path.txt') + ? 'partial still present\\n' + : 'partial cleared\\n' + ) + } + appendFileSync('electron-get.log', 'download attempted\\n') + if (${JSON.stringify(downloadRejects)}) { + throw new Error('download failed') + } + mkdirSync(details.cacheRoot, { recursive: true }) + const artifactPath = join(details.cacheRoot, 'electron.zip') + writeFileSync(artifactPath, 'fake zip') + return artifactPath +} +` + ) +} + +function writeFakeExtractZip(projectDir, { createExecutable }) { + const extractDir = join(projectDir, 'node_modules', 'electron', 'node_modules', 'extract-zip') + mkdirSync(extractDir, { recursive: true }) + writeFileSync( + join(extractDir, 'index.js'), + ` +const { mkdirSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +module.exports = async function extract(_zipPath, options) { + mkdirSync(join(options.dir, 'locales'), { recursive: true }) + if (${JSON.stringify(createExecutable)}) { + writeFileSync(join(options.dir, 'electron'), '') + writeFileSync(join(options.dir, 'version'), 'v41.5.0') + } +} +` + ) + chmodSync(join(extractDir, 'index.js'), 0o755) +} + +function writeFakeElectronRebuild(projectDir) { + const rebuildDir = join(projectDir, 'node_modules', '@electron', 'rebuild') + mkdirSync(rebuildDir, { recursive: true }) + writeFileSync(join(rebuildDir, 'package.json'), JSON.stringify({ type: 'module' })) + writeFileSync(join(rebuildDir, 'index.js'), 'export async function rebuild() {}\n') +} diff --git a/config/scripts/verify-release-required-assets.test.mjs b/config/scripts/verify-release-required-assets.test.mjs new file mode 100644 index 00000000000..06a853e11ac --- /dev/null +++ b/config/scripts/verify-release-required-assets.test.mjs @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + extractManifestAssetNames, + getRequiredReleaseAssetNames, + verifyRequiredReleaseAssets +} from './verify-release-required-assets.mjs' + +function jsonResponse(body) { + return { + ok: true, + status: 200, + statusText: 'OK', + json: vi.fn(async () => body), + text: vi.fn(async () => (typeof body === 'string' ? body : JSON.stringify(body))) + } +} + +function releaseWithAssets(tag, assetNames) { + return { + tag_name: tag, + draft: true, + prerelease: false, + assets: assetNames.map((name, index) => ({ + id: index + 1, + name, + state: 'uploaded', + size: 123 + })) + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('getRequiredReleaseAssetNames', () => { + it('includes both mac updater ZIP names for the tag version', () => { + expect(getRequiredReleaseAssetNames('v1.4.27')).toEqual( + expect.arrayContaining([ + 'Orca-1.4.27-mac.zip', + 'Orca-1.4.27-mac.zip.blockmap', + 'Orca-1.4.27-arm64-mac.zip', + 'Orca-1.4.27-arm64-mac.zip.blockmap' + ]) + ) + }) +}) + +describe('extractManifestAssetNames', () => { + it('extracts relative and absolute manifest asset names', () => { + expect( + extractManifestAssetNames( + [ + 'files:', + ' - url: Orca-1.4.27-arm64-mac.zip', + ' - url: https://example.com/downloads/orca-windows-setup.exe', + 'path: orca-linux.AppImage' + ].join('\n') + ) + ).toEqual(['Orca-1.4.27-arm64-mac.zip', 'orca-windows-setup.exe', 'orca-linux.AppImage']) + }) +}) + +describe('verifyRequiredReleaseAssets', () => { + it('fails when a manifest-referenced asset has not been uploaded', async () => { + const tag = 'v1.4.27' + const required = getRequiredReleaseAssetNames(tag) + const assets = required.filter((name) => name !== 'Orca-1.4.27-arm64-mac.zip') + const release = releaseWithAssets(tag, assets) + const latestMacAsset = release.assets.find((asset) => asset.name === 'latest-mac.yml') + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse([release])) + .mockResolvedValueOnce( + jsonResponse( + [ + 'version: 1.4.27', + 'files:', + ' - url: Orca-1.4.27-arm64-mac.zip', + ' sha512: test', + 'path: Orca-1.4.27-arm64-mac.zip' + ].join('\n') + ) + ) + .mockResolvedValue(jsonResponse('version: 1.4.27\n')) + vi.stubGlobal('fetch', fetchMock) + + await expect( + verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag, token: 'token' }) + ).rejects.toThrow('Missing: Orca-1.4.27-arm64-mac.zip') + expect(latestMacAsset).toBeTruthy() + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index dbcdaa729a4..111ac7a86bf 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -1,6 +1,29 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["../src/cli/**/*", "../src/shared/**/*", "../src/main/runtime/runtime-metadata.ts"], + "include": [ + "../src/cli/**/*", + "../src/shared/**/*", + "../src/main/agent-hooks/installer-utils.ts", + "../src/main/agent-hooks/installer-utils-remote.ts", + "../src/main/agent-hooks/managed-agent-hook-controls.ts", + "../src/main/antigravity/hook-service.ts", + "../src/main/claude/hook-settings.ts", + "../src/main/claude/hook-service.ts", + "../src/main/codex/codex-config-mirror.ts", + "../src/main/codex/codex-home-paths.ts", + "../src/main/codex/config-toml-trust.ts", + "../src/main/codex/hook-service.ts", + "../src/main/codex-accounts/fs-utils.ts", + "../src/main/command-code/hook-service.ts", + "../src/main/copilot/hook-service.ts", + "../src/main/cursor/hook-service.ts", + "../src/main/droid/hook-service.ts", + "../src/main/gemini/hook-service.ts", + "../src/main/grok/hook-service.ts", + "../src/main/hermes/hook-service.ts", + "../src/main/runtime/runtime-metadata.ts", + "../src/main/win32-utils.ts" + ], "compilerOptions": { "composite": true, "module": "CommonJS", diff --git a/docs/failed-automation-rerun-action.md b/docs/failed-automation-rerun-action.md new file mode 100644 index 00000000000..1074bf05c06 --- /dev/null +++ b/docs/failed-automation-rerun-action.md @@ -0,0 +1,50 @@ +# Failed Automation Rerun Action + +## Problem + +- Failed Orca automation run details show only the disabled/open-target action when no workspace launched, so a user who sees a `dispatch_failed` error has no local recovery action in the failed-run view: `src/renderer/src/components/automations/AutomationsPage.tsx:1858`. +- The reusable detail header already accepts actions, but the run detail action set is limited to `getAutomationRunViewState`/`openRunWorkspace`: `src/renderer/src/components/automations/AutomationRunPageFrame.tsx:9`, `src/renderer/src/components/automations/automation-run-view-state.ts:13`. +- Manual rerun behavior already exists as `runNow`, which creates a fresh manual run for the automation; the renderer wrapper currently refreshes the page after calling it: `src/renderer/src/components/automations/AutomationsPage.tsx:1033`, `src/main/automations/service.ts:66`. + +## Goal + +Add an easy rerun button to Orca automation run detail pages for failed launch/recovery statuses. The button creates a fresh manual run for the same automation through the existing `runNow` path. + +## Non-goals + +- Do not mutate or replay the failed run record. +- Do not add backend APIs or provider-specific rerun behavior. +- Do not add rerun support for external Hermes/OpenClaw run details in this change. +- Do not bypass existing SSH availability, worktree creation, or dispatch failure handling. + +## Design + +1. Add a small pure predicate near the run view state code, for example `canRerunAutomationRun({ automation, run })`. + - Return `true` only when an Orca automation still exists, `run.automationId === automation.id`, and the run status is one of `dispatch_failed`, `skipped_unavailable`, or `skipped_needs_interactive_auth`. + - Do not show rerun for `pending`, `dispatching`, `dispatched`, `completed`, or `skipped_missed`. `skipped_missed` is scheduler catch-up history, not a launch failure the detail page should recover. +2. In `AutomationsPage`, render a `Rerun` action in `AutomationRunPageFrame.actions` when that predicate is true for `selected` and `selectedAutomationRunPage`. +3. The action calls `window.api.automations.runNow({ id: selected.id })` through a small handler, then `refresh()`, then shows the existing queued toast. Wrap the call in `try/catch/finally`: if the automation was deleted or the IPC rejects, toast the error and refresh so the stale run detail can disappear. +4. Preserve dispatch behavior by reusing `runNow`; do not duplicate local/SSH dispatch logic in the renderer. `runNow` creates a new manual run and `AutomationService.requestDispatch` sends the existing `automations:dispatchRequested` payload to the renderer, where `useAutomationDispatchEvents` handles SSH reconnect, interactive-auth skips, worktree creation/reuse, and terminal launch. +5. Add a local in-flight state keyed by the failed run id. Disable only that rerun button while pending so repeated clicks in one window cannot queue duplicates before the first request settles. +6. Keep the existing `View run` / `Open workspace` action beside the new rerun action. Do not gate rerun on `selectedAutomationRunPageViewState.canOpen`; failed runs with no launch should still show the disabled view action plus enabled rerun. +7. Use existing shadcn `Button`, lucide icon sizing, and styleguide tokens. The button should be compact (`size="sm"`), outline-level emphasis, and fit the existing header action row. +8. Add focused tests for the pure predicate in `automation-run-view-state.test.ts`. Avoid component-level tests unless a harness already exists locally. + +## Edge cases + +- Failed run has no workspace because base ref refresh, SSH connection, missing project/workspace, or renderer availability failed: rerun remains available and lets the existing dispatch path try again. +- Failed run has a workspace but terminal is closed: rerun stays available, and open workspace behavior remains unchanged. +- Automation was deleted while the request is in flight or by another window: `runNow` can throw `Automation not found.`; catch it, clear pending state, and refresh. +- Selection changed while the request is in flight: capture `automation.id` and `run.id` before awaiting, clear that run id in `finally`, and avoid reading mutable `selected` after the await. +- SSH automation cannot reconnect or needs credentials: rerun still goes through `runNow`; existing dispatch code records `skipped_unavailable` or `skipped_needs_interactive_auth` as a new run. +- The user clicks rerun repeatedly in the same window: disable the rerun button while the request is pending. +- The user clicks rerun in two windows: the renderer guard will not prevent duplicate manual runs across windows. Do not claim backend idempotency unless a service-level dedupe key is added. +- Refresh after rerun may leave the failed run detail selected because `selectedAutomationRunPageId` still points at the old run. That is acceptable for this change, but the run list count and rows must refresh so the new manual run is visible after navigating back. +- `runNow` itself does not broadcast `orca:automations-changed`; the initiating page must call `refresh()` after the IPC resolves. Other windows update only when dispatch result handling fires the existing event or when focus/visibility refresh runs. + +## Rollout + +1. Add a small predicate/helper for rerun action visibility and unit tests if it can live near automation run view state without mixing responsibilities. +2. Add rerun pending state, error handling, and handler in `AutomationsPage`. +3. Render the new action in the selected Orca run detail header. +4. Run focused tests, then `pnpm typecheck` and `pnpm lint`. diff --git a/docs/new-worktree-sidebar-reveal.md b/docs/new-worktree-sidebar-reveal.md new file mode 100644 index 00000000000..75abb7bd647 --- /dev/null +++ b/docs/new-worktree-sidebar-reveal.md @@ -0,0 +1,79 @@ +# New Worktree Sidebar Reveal + +## Problem + +Issue https://github.com/stablyai/orca-internal/issues/350 asks that newly created worktrees jump into view in the left sidebar list with no scroll animation. + +Current behavior: + +- `activateAndRevealWorktree(...)` always calls `state.revealWorktreeInSidebar(worktreeId)` with no options. +- `revealWorktreeInSidebar` defaults `behavior` to `'smooth'` (`ui.ts`). +- `WorktreeList` forwards that behavior to `virtualizer.scrollToIndex(..., { behavior })`. + +Result: off-screen targets animate by default, including freshly created worktrees. + +## Root Cause + +`activateAndRevealWorktree` conflates two intents: + +1. activate existing worktree navigation (smooth reveal is fine); +2. activate a just-created worktree (must jump immediately). + +Created-worktree callers cannot currently express reveal intent, so they inherit `'smooth'`. + +## Scope and Non-goals + +- Add an opt-in reveal behavior at activation call sites. +- Apply `'auto'` only where the worktree is newly created/added in that flow. +- Preserve existing behavior for normal worktree navigation (clicks, keyboard, history nav, palette selection of existing worktrees, port/status-driven activation) unless a caller opts in. +- Do not change direct raw reveal paths in IPC handlers that intentionally call `store.revealWorktreeInSidebar(...)` outside `activateAndRevealWorktree` (terminal/editor/mobile focus paths remain smooth). +- Do not change sorting/grouping/filter UI, list virtualization strategy, or sidebar styling. + +## Design + +1. Extend `activateAndRevealWorktree` options: + - `sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']`. +2. In `activateAndRevealWorktree`, call: + - `state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior })` when provided; + - otherwise keep `state.revealWorktreeInSidebar(worktreeId)` so default behavior stays unchanged. +3. Pass `sidebarRevealBehavior: 'auto'` only from created/added-worktree flows: + - `useComposerState` full-create path; + - `useComposerState` quick-create path; + - `useIpcEvents` `onActivateWorktree` only when the event corresponds to a newly created worktree; + - `launch-work-item-direct`; + - folder add/create flows that activate a newly-added synthetic folder worktree (`AddRepoCreateStep` folder branch, `NonGitFolderDialog`, and `repos` slice `addNonGitFolder` path). +4. Keep existing navigation activations smooth, including: + - `AddRepoDialog` / `ProjectAddedDialog` “open primary worktree” actions (these can target pre-existing worktrees, not guaranteed newly created); + - all existing `activateAndRevealWorktree(...)` callers that do not opt in. +5. Keep `WorktreeList` reveal effect unchanged; it already honors `pendingRevealWorktree.behavior`. + +## Correctness Notes and Edge Cases + +- Repo-filter clearing remains unchanged: `activateAndRevealWorktree` only clears `filterRepoIds` when target repo is excluded. +- Other visibility constraints are not auto-cleared. If the target exists but is hidden by other sidebar state, `resolvePendingSidebarReveal(...)` keeps the reveal pending. +- The reveal effect uncollapses lineage/group containers before scroll; behavior changes only animation mode, not visibility resolution. +- Pending reveal is a single store slot (`pendingRevealWorktree`). Concurrent reveal requests are last-writer-wins; this change should not alter that behavior. +- If activation cannot resolve the worktree (`getKnownWorktreeById` miss), behavior remains unchanged (`false`, no reveal queued). +- `ui:activateWorktree` is an overloaded IPC used by both creation and non-creation activation paths. The renderer must choose `'auto'` only for create cases (for example, worktree absent before fetch and present after fetch), and keep default smooth reveal for existing-worktree activations. +- Multi-window consistency remains per renderer window store; each window applies its own reveal behavior locally. +- This change is renderer-only; it does not add main-process coordination and does not make reveal ordering transactional across concurrent async creators. + +## Tests + +Add/adjust focused tests in `worktree-activation` coverage: + +- explicit `sidebarRevealBehavior: 'auto'` is forwarded to `revealWorktreeInSidebar(worktreeId, { behavior: 'auto' })`; +- no option still calls `revealWorktreeInSidebar(worktreeId)` (store default remains smooth). + +Add call-site regression tests (recommended, small): + +- one composer create path passes `'auto'`; +- one non-created navigation path stays default (no behavior option). + +## Rollout + +1. Add `sidebarRevealBehavior` option in `activateAndRevealWorktree`. +2. Update created-worktree callers to pass `'auto'`. +3. Add tests above. +4. Run targeted Vitest tests, then `pnpm typecheck` and `pnpm lint`. +5. Validate in Electron: with sidebar overflow, create a worktree and verify the list jumps to it without smooth animation. diff --git a/docs/orchestration-reset-scope-validation.md b/docs/orchestration-reset-scope-validation.md new file mode 100644 index 00000000000..71c02abae0c --- /dev/null +++ b/docs/orchestration-reset-scope-validation.md @@ -0,0 +1,89 @@ +# Orchestration Reset Scope Validation + +## Problem + +`orchestration.reset` silently clears all orchestration state when no scope is provided. The RPC schema accepts every scope as optional at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:139), and the handler falls through to `db.resetAll()` at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:585). Existing reset tests only cover explicit single scopes at [src/main/runtime/rpc/methods/orchestration.test.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.test.ts:1024). + +## Root Cause + +`ResetParams` models `all`, `tasks`, and `messages` as independent optional booleans. The handler then chooses the first truthy scope and treats zero truthy scopes as `all`, so `{}` wipes everything and contradictory inputs such as `{ tasks: true, messages: true }` partially apply the first truthy branch. + +## Non-goals + +- Redesign orchestration storage or reset semantics. +- Add confirmation prompts to the RPC protocol. +- Change `resetAll`, `resetTasks`, or `resetMessages` database behavior. +- Change unrelated orchestration commands or provider-specific behavior. + +## Design + +1. Enforce exactly one truthy reset scope at the `ResetParams` schema using `superRefine`. +2. Remove the handler fallthrough to `db.resetAll()` so only validated explicit scopes can mutate state. +3. Preserve the existing CLI no-flag shortcut by having [src/cli/handlers/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/cli/handlers/orchestration.ts:440) pass `all: true` when no reset scope flag is present. This keeps CLI compatibility explicit while preventing ambiguous direct RPC calls. +4. Let explicit multi-flag CLI invocations reach RPC validation and fail with the shared invalid-argument path. Duplicating scope validation in the CLI is unnecessary because all CLI calls already pass through this RPC method. +5. Add RPC regression tests that seed one message and one task, call invalid reset params, assert rejection, and assert both seeded records remain. +6. Add CLI parser tests that `orca orchestration reset` calls RPC with `all: true`, and that explicit flags are passed through unchanged for RPC validation. + +## Data Flow + +- CLI no-flag path: `orca orchestration reset` -> CLI handler sends `{ all: true }` -> RPC schema validates -> handler calls `resetAll`. +- CLI explicit-flag path: CLI handler forwards the provided flags -> RPC schema validates exactly one truthy scope -> handler calls one database reset method or rejects before side effects. +- Direct RPC path: caller params -> RPC schema validates exactly one scope -> invalid params fail before handler side effects. + +## Edge Cases + +- `{}` rejects and leaves messages and tasks unchanged. +- `{ all: false }` rejects and leaves messages and tasks unchanged. +- `{ tasks: true, messages: true }` rejects and leaves messages and tasks unchanged. +- `{ all: true, tasks: true }` rejects and leaves messages and tasks unchanged. +- `{ all: false, tasks: true }` is valid and resets tasks only; false values are not selected scopes. +- Non-boolean values such as `{ all: "true" }` are transformed to `undefined` by `OptionalBoolean` and must reject unless exactly one real boolean `true` is present. +- Explicit `{ all: true }`, `{ tasks: true }`, and `{ messages: true }` continue to work. +- Remote and SSH callers are covered because the validation sits behind the shared RPC method, not local CLI process state. +- Unknown keys do not select a scope. If strict unknown-key rejection is desired, that is a separate RPC schema policy change and should not be bundled into this fix. + +## Test Plan + +- Unit/RPC: extend `src/main/runtime/rpc/methods/orchestration.test.ts` with invalid reset scope tests covering empty params and multiple scopes with preservation assertions. +- Unit/RPC: keep existing single-scope tests green to prove no regression to valid reset behavior. +- CLI parser: extend `src/cli/index.test.ts` to assert no-flag `orchestration reset` sends `all: true` explicitly. +- CLI parser: assert explicit `--tasks`, `--messages`, and multi-flag invocations are represented faithfully rather than silently normalized by the CLI. +- Type/lint: run `pnpm typecheck`, `pnpm lint`, and targeted Vitest tests for the touched RPC and CLI files. +- Electron/e2e: not required for golden behavior because this is non-UI RPC/CLI validation; Stage 6 should validate via tests and local CLI/RPC behavior instead of app screenshots. + +## UI Quality Bar + +Not UI-visible. + +## Review Screenshots + +No user-visible UI states. Screenshot artifacts are not required; the validation notes should state that UI screenshot review was skipped because the changed behavior is headless RPC/CLI behavior. + +## Rollout + +1. Tighten `ResetParams` validation. +2. Simplify the reset handler to trust validated single-scope params. +3. Make CLI no-flag reset pass `all: true` explicitly. +4. Add RPC regression tests for invalid params preserving state. +5. Add CLI parser coverage for the explicit no-flag shortcut and explicit/multi-flag passthrough. +6. Run targeted tests, typecheck, and lint. + +## Lightweight Eng Review + +- Scope: Kept to reset RPC validation, CLI argument shaping, and regression tests; no storage or UI changes. +- Architecture/data flow: Validation belongs at the shared RPC boundary so CLI, remote, SSH, and direct runtime callers get the same safety contract. CLI no-flag compatibility remains a CLI concern by passing `all: true`. +- Failure modes covered: + - Empty reset params cannot mutate state. + - Contradictory scopes cannot partially apply the first truthy branch. + - Failed validation happens before any database reset method runs. + - CLI shorthand cannot rely on a dangerous RPC fallback. + - CLI multi-flag input fails through the same RPC validation as any other caller. +- Test coverage required: + - `src/main/runtime/rpc/methods/orchestration.test.ts`: reject empty, false-only, and multi-scope params while preserving seeded message/task state. + - `src/main/runtime/rpc/methods/orchestration.test.ts`: existing valid single-scope tests continue passing. + - `src/cli/index.test.ts`: no-flag CLI reset passes `all: true`; explicit flags and multi-flag input remain directly represented. +- Performance/blast radius: No material concern. One tiny schema refinement and branch simplification run only when reset is invoked. +- UI quality bar: Not UI-visible. +- Required review screenshots: None; final validation notes should explain that screenshot capture is skipped for headless RPC/CLI behavior. +- Residual risks: CLI users may still accidentally clear all state with `orca orchestration reset` because the compatibility shortcut remains, but direct RPC callers can no longer do so accidentally and CLI behavior is now explicit in the caller. +- Concurrency/consistency: This change prevents invalid reset requests from entering the mutation path, but it does not make the existing multi-statement reset methods transactional or coordinate concurrent reset/create/send calls across windows. That is acceptable for the issue scope because valid resets are still destructive administrative operations; adding reset serialization would be a separate storage-level change. diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index df37010b377..108a8eacba2 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -37,6 +37,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_). Gemini   Antigravity   Pi   + oh-my-pi   Hermes Agent   OpenCode   Goose   @@ -46,6 +47,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_). Charm   Cline   Codebuff   + Command Code   Continue   Cursor   Droid   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index befbff6dd91..2bfe4360fef 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -37,6 +37,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス Gemini   Antigravity   Pi   + oh-my-pi   Hermes Agent   OpenCode   Goose   @@ -46,6 +47,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス Charm   Cline   Codebuff   + Command Code   Continue   Cursor   Droid   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 8372b07da84..7e25bb80829 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -37,6 +37,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정 Gemini   Antigravity   Pi   + oh-my-pi   Hermes Agent   OpenCode   Goose   @@ -46,6 +47,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정 Charm   Cline   Codebuff   + Command Code   Continue   Cursor   Droid   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 98bb98abc58..bf31b3bc0cd 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -37,6 +37,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。 Gemini   Antigravity   Pi   + oh-my-pi   Hermes Agent   OpenCode   Goose   @@ -46,6 +47,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。 Charm   Cline   Codebuff   + Command Code   Continue   Cursor   Droid   diff --git a/docs/reference/feature-discovery-interaction-tracking.md b/docs/reference/feature-discovery-interaction-tracking.md index 8cb5a4a745d..539730b1b04 100644 --- a/docs/reference/feature-discovery-interaction-tracking.md +++ b/docs/reference/feature-discovery-interaction-tracking.md @@ -1,6 +1,6 @@ # Feature Discovery Interaction Tracking -This document defines local feature-interaction state used to decide whether Orca should still teach a feature with a contextual tour or feature tip. +This document defines local feature-interaction state used to decide whether Orca should still teach a feature with an education surface such as a tour or feature tip. ## Decision @@ -53,9 +53,9 @@ Do not upload this state as broad analytics. Product analytics should continue t | Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. | | Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. | -## Existing Contextual Tour Features +## Surface-Level Features -The branch already records surface-level interactions for the current contextual tours: +Orca also records surface-level interactions for feature areas where opening the surface is itself a meaningful discovery signal: - `workspace-board`: workspace board opened - `browser`: non-blank browser page viewed diff --git a/docs/reference/keyboard-layout-shortcut-dispatch.md b/docs/reference/keyboard-layout-shortcut-dispatch.md new file mode 100644 index 00000000000..33a2a4d867e --- /dev/null +++ b/docs/reference/keyboard-layout-shortcut-dispatch.md @@ -0,0 +1,66 @@ +# Keyboard Layout Shortcut Dispatch + +## Problem + +Keyboard shortcuts must follow the user's active keyboard layout. A shortcut like `Cmd+W` +means "Command plus the key that produces `w`", not "Command plus the physical key labeled +W on a US keyboard." Physical-position matching breaks Dvorak, Colemak, AZERTY, JIS, and +other non-US layouts, and it also makes user keybinding overrides impossible to reason about. + +## Decision + +Orca app shortcuts dispatch by logical key by default. + +The shared keybinding registry in `src/shared/keybindings.ts` is the source of truth for +app commands, configurable commands, shortcut recording, labels, conflict detection, browser +guest forwarding, and terminal pane commands. Code handling a user-facing app command must +call `keybindingMatchesAction`, `keybindingMatchesInput`, or a policy function built on those +helpers. + +Physical `KeyboardEvent.code` may only decide a shortcut when the key is layout-invariant or +the platform cannot provide a real logical key. + +Allowed physical-code uses: + +- Modifier key release tracking, such as left/right Control release for held `Ctrl+Tab`. +- Layout-invariant keys, such as arrows, Tab, Enter, Escape, Backspace, Delete, Insert, + PageUp, PageDown, and explicit numpad bindings. +- Dead, unidentified, or missing logical keys where `KeyboardEvent.key` cannot describe the + produced key. +- Terminal byte encoding where the intent is a physical terminal escape sequence rather than + an Orca command. + +Disallowed physical-code uses: + +- Letter shortcuts for app actions. +- Punctuation shortcuts for app actions when `KeyboardEvent.key` reports the produced + punctuation. +- Clipboard shortcuts that are exposed as app or terminal UI commands. +- Hardcoded undo/redo/new/close/copy/paste handling outside the shared registry. + +## Terminal Boundary + +Terminal handling has two different jobs: + +1. Orca commands that act on terminal UI, such as copy selection, paste, search, clear, + pane focus, split, and close. These are app shortcuts and must be layout-aware. +2. Bytes sent to the shell, such as readline escapes and Option-as-Alt sequences. These may + use physical key positions when terminal compatibility requires it. + +This boundary is intentional. It lets non-US layouts use Orca commands naturally while +preserving shell behavior where users expect physical terminal-control sequences. + +## Regression Requirements + +Shortcut tests must cover both directions of a non-QWERTY swap: + +- The key that produces the configured logical character must match, even if its physical code + differs. +- The physical US key must not match when it produces a different logical character. + +Tests must also cover intentional exceptions: + +- Dead or missing key fallback. +- Shifted punctuation aliases. +- Numpad-specific bindings. +- Terminal byte-encoding paths that intentionally use physical codes. diff --git a/docs/reference/memory-leak-audit.md b/docs/reference/memory-leak-audit.md new file mode 100644 index 00000000000..6c3e6442eb2 --- /dev/null +++ b/docs/reference/memory-leak-audit.md @@ -0,0 +1,74 @@ +# Memory Leak Audit + +Started: 2026-05-26 18:36 PDT + +Objective: scan the entire Orca codebase for memory leaks, fix confirmed leaks, and keep durable coverage notes until the repository-wide scan is complete. + +## Codebase Inventory + +Tracked code files, from `git ls-files`: + +| Bucket | Files | Status | +| ----------------- | ----: | ------------------------------------------- | +| `src/renderer` | 1534 | Checked | +| `src/main` | 838 | Checked | +| `src/shared` | 220 | Checked | +| `mobile/src` | 94 | Checked | +| `tests/e2e` | 71 | Checked | +| `src/relay` | 65 | Checked | +| `src/cli` | 64 | Checked | +| `config/scripts` | 27 | Checked | +| `mobile/app` | 16 | Checked | +| `other-code` | 13 | Checked | +| `mobile/packages` | 12 | Checked | +| `native` | 6 | Checked | +| `src/preload` | 6 | Checked by IPC subscription heuristic | +| Total | 2966 | Repository-wide risk-pattern scan completed | + +## Scan Log + +- 2026-05-26: Loaded `$memory-leak-audit` checklist and built an initial inventory from `git ls-files`. +- 2026-05-26: Ran a broad listener/timer/observer search across `src`, `mobile`, `tests`, and `config`. +- 2026-05-26: Ran a React `useEffect` cleanup heuristic over 1845 TS/TSX files. Manual follow-up identified one confirmed issue in `src/renderer/src/components/editor/PdfViewer.tsx`: `EventBus.on('scalechanging', ...)` had no matching `off`. +- 2026-05-26: Ran a preload IPC heuristic. `src/preload/index.ts` and `src/preload/runtime-environment-subscriptions.ts` had apparent cleanup for their `ipcRenderer.on` subscriptions. +- 2026-05-26: Ran an `ipcMain.on` cleanup heuristic over `src/main`. The only unpaired hit was `src/main/ipc/session.ts`, which is a process-lifetime registration via `registerSessionHandlers`. +- 2026-05-26: Manually reviewed main-process interval/service candidates. Confirmed `src/main/speech/stt-service.ts` retained a timed-out, terminated speech worker in service state until idle teardown. +- 2026-05-26: Ran a Promise/EventEmitter heuristic over main, relay, and CLI code. Confirmed `src/main/ssh/ssh-connection.ts` left ssh2 startup listeners attached after a successful long-lived connection. +- 2026-05-26: Confirmed `src/main/browser/cdp-ws-proxy.ts` retained its startup HTTP server `error` listener after successful bind. +- 2026-05-26: Continued Promise/EventEmitter follow-up in daemon startup paths. Confirmed startup listeners were retained after successful bind/readiness in `src/main/daemon/daemon-server.ts`, `src/main/daemon/daemon-init.ts`, and `src/main/daemon/production-launcher.ts`. +- 2026-05-26: Reviewed BrowserWindow/session listeners. Confirmed `src/main/window/attach-main-window-services.ts` stacked a persistent browser-session `will-download` listener on every main-window services attach. +- 2026-05-26: Reviewed browser-session profile lifecycle. Confirmed deleted isolated/imported profiles left partition policy callbacks/listeners and configured-partition bookkeeping behind in `src/main/browser/browser-session-registry.ts`. +- 2026-05-26: Reviewed mobile RPC subscriptions. Confirmed `mobile/src/notifications/mobile-notifications.ts` retained a local streaming callback when notification subscription cleanup ran before the desktop returned `ready`. +- 2026-05-26: Reviewed speech model extraction timers. Confirmed `src/main/speech/model-manager.ts` left the abort polling interval alive when extraction abort/timeout rejected before the child emitted `close` or `error`. +- 2026-05-26: Manually followed up renderer high-risk surfaces from the broad scan: pane manager, browser webviews, terminal pane lifecycle, editor integrations, sidebar/global document listeners, observers, and shared clocks. Remaining hits had cleanup, were process-lifetime globals, or were element-owned listeners that are collected with their DOM nodes. +- 2026-05-26: Manually followed up main-process long-lived services from the broad scan: runtime RPC, SSH services, speech worker/model manager, rate-limit polling, filesystem watchers, BrowserWindow-scoped IPC/listeners, browser-session handlers, daemon startup, and relay-backed flows. +- 2026-05-26: Checked mobile React Native subscriptions, WebView injected listeners, RPC subscription teardown, repeated polling intervals, and app/session screens. Confirmed one notifications subscription cleanup issue and no additional unpaired repeated-mount listener leaks. +- 2026-05-26: Checked `src/shared`, `src/relay`, `src/cli`, `config/scripts`, `tests/e2e`, `mobile/packages`, and native computer-use runtimes for listener/timer/socket/watch patterns. Follow-up found existing cleanup or process/test-lifetime ownership. + +## Confirmed Fixes + +- `src/renderer/src/components/editor/PdfViewer.tsx`: unregister the pdf.js `scalechanging` listener on cleanup to avoid retaining stale component state after repeated PDF opens. +- `src/main/speech/stt-service.ts`: when dictation stop times out, remove worker listeners, terminate the worker, and clear the warm-worker slot so later dictation cannot retain or reuse a dead worker. +- `src/main/ssh/ssh-connection.ts`: remove ssh2 startup `ready`/`error` listeners once the connection resolves or rejects; steady-state disconnect listeners remain scoped to the live connection. +- `src/main/browser/cdp-ws-proxy.ts`: remove the startup server `error` listener after the proxy binds so the resolved start promise closure is not retained for the proxy lifetime. +- `src/main/daemon/daemon-server.ts`: remove the startup server `error` listener after the daemon socket binds. +- `src/main/daemon/daemon-init.ts`: remove detached daemon child-process startup listeners after IPC readiness or startup failure. +- `src/main/daemon/production-launcher.ts`: remove detached daemon child-process startup listeners after IPC readiness or startup failure. +- `src/main/window/attach-main-window-services.ts`: replace the named persistent browser-session `will-download` handler before registering it so window re-attach cannot stack handlers. +- `src/main/browser/browser-session-registry.ts`: clear deleted profile partition permission/display-media callbacks, remove the download listener, and drop configured-partition bookkeeping. +- `mobile/src/notifications/mobile-notifications.ts`: always drop the local notification stream on cleanup, even if the desktop has not returned a subscription id yet. +- `src/main/speech/model-manager.ts`: clear extraction timeout/abort pollers and remove child listeners directly on abort/timeout rejection, instead of depending on a later child `close`. + +## Validation + +- Passed: `pnpm vitest run --config config/vitest.config.ts src/main/speech/stt-service.test.ts src/main/speech/model-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/browser/cdp-ws-proxy.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/production-launcher.test.ts src/main/daemon/daemon-init.test.ts src/main/window/attach-main-window-services.test.ts src/main/browser/browser-session-registry.test.ts src/main/browser/browser-session-registry.persistence.test.ts` (10 files, 128 tests). +- Passed: `pnpm run typecheck:node`. +- Passed: `pnpm run typecheck:web`. +- Passed: focused `pnpm exec oxlint` over changed TypeScript files. +- Passed after final import cleanup: `pnpm vitest run --config config/vitest.config.ts src/main/daemon/daemon-server.test.ts src/main/daemon/production-launcher.test.ts`. +- Passed: `git diff --check`. +- Attempted: `./node_modules/.bin/vitest run mobile/src/notifications/mobile-notifications.test.ts`; blocked in this workspace because mobile dependencies are not installed (`expo/tsconfig.base` cannot be resolved). The mobile test file is included for the mobile package test environment. + +## Residual Risk + +- This was a repository-wide static/manual memory-leak audit of listener, timer, observer, worker, socket, watcher, subscription, and resource-pool patterns. It does not replace heap-snapshot profiling under a long interactive session, but no further confirmed leaks were found in the scanned patterns. diff --git a/docs/show-full-tab-title-tooltip.md b/docs/show-full-tab-title-tooltip.md new file mode 100644 index 00000000000..96ea7a0d39f --- /dev/null +++ b/docs/show-full-tab-title-tooltip.md @@ -0,0 +1,131 @@ +# Show Full Tab Title Tooltip + +## Problem + +Issue #2966 reports that long tab titles are truncated with an ellipsis but do not expose the full name on hover or focus. + +- Terminal tab labels render in `src/renderer/src/components/tab-bar/SortableTab.tsx:324` as `truncate max-w-[72px]` with no tooltip. +- Browser tab labels render in `src/renderer/src/components/tab-bar/BrowserTab.tsx:163` as `truncate max-w-[100px]` with no tooltip. +- Editor tab labels render in `src/renderer/src/components/tab-bar/EditorFileTab.tsx:289` as `truncate max-w-[80px]` with no tooltip. +- The app already has a Radix-backed shadcn tooltip wrapper in `src/renderer/src/components/ui/tooltip.tsx:23`. +- The app-level `TooltipProvider` is mounted in `src/renderer/src/App.tsx` with `delayDuration={400}`; this feature should use that provider, not add another one. + +## Root Cause + +The tab components render plain text spans for their ellipsized labels. The full title exists in component state, but the focusable tab root is not wired to the shared `Tooltip`/`TooltipTrigger`/`TooltipContent` primitive, so neither hover nor keyboard focus exposes it. + +## Non-Goals + +- Do not change tab width, scrolling, drag-and-drop behavior, context menus, title generation, or rename semantics. +- Do not add persistence, IPC, or main-process behavior. +- Do not replace the tab bar layout or introduce a new tooltip primitive. +- Do not add custom overflow measurement in this pass unless a reviewed implementation finds the always-available tooltip materially harmful. + +## Design + +1. Add full-title tooltips to non-editing tabs in the existing tab components. + - Terminal: display `tab.customTitle ?? tab.title`. + - Browser: display `getBrowserTabLabel(tab)`. + - Editor: display `getEditorDisplayLabel(file)`, preserving preview italic and external mutation/status adornments outside the label. +2. Use the existing shadcn tooltip primitive from `@/components/ui/tooltip`. + - Do not add a nested `TooltipProvider`; the app root already supplies the 400ms delay required by the style guide. + - In non-rename states, wrap the sortable tab root with `...` so both pointer hover and keyboard focus on the dnd-kit focusable tab (`role`, `tabIndex=0`) surface the tooltip. + - Keep the tooltip gated off while a context menu is open. Right-click opens a Radix menu from the same root hover area, so the full-title tooltip must not remain visible over the menu. + - Do not make the label span itself the only trigger: it is not focusable today, and adding `tabIndex` to the label would create a second keyboard stop inside each tab. + - Render `full label`. + - `side="bottom"` is intentional because the tab strip sits at the top edge; Radix collision handling can still flip/shift when needed. + - Keep tooltip content non-interactive; it is only a label. +3. Keep edit states out of the tooltip path. + - Terminal rename input and editor rename input should remain direct inputs with no tooltip wrapper. Editor rename is nested inside an otherwise sortable root, so `isRenaming` must also disable the root tooltip, not only the label span tooltip. + - Double-click handlers for rename/pin must keep their current event behavior. +4. Preserve drag, activation, and close controls. + - `TooltipTrigger asChild` must attach to the existing sortable root element without introducing a new DOM wrapper, so `setNodeRef`, dnd-kit attributes/listeners, activation, context-menu capture, middle-click close, and close/collapse buttons keep their current event paths. + - Because wrapping the root makes the entire tab chrome a tooltip trigger, Electron validation must check the tooltip does not obscure or compete with close/collapse/context-menu affordances. The shared `TooltipContent` is already `pointer-events-none`, but visual overlap and native `title` on the terminal collapse button still need checking. + - The outer tab remains the sortable/activation target. +5. Add focused regression coverage for the render contract. + - Unit tests should assert that terminal, browser, and editor rendered tab roots are passed through the tooltip primitive with the correct full label. + - Tests can mock tooltip components to avoid Radix portal timing and assert structural wiring. Because these components call hooks and the Vitest environment is Node-only, render them with `react-dom/server` or another existing local pattern, and mock `@dnd-kit/sortable` so the root exposes deterministic `role`/`tabIndex` attributes. + +## Data Flow + +- Store/runtime tab state updates title fields. +- `TabBar` maps current visible tabs to `SortableTab`, `BrowserTab`, and `EditorFileTab`. +- Each tab component computes the displayed label. +- The non-editing sortable root remains the trigger so pointer hover and keyboard focus share the same tooltip path. +- Hover/focus opens Radix tooltip content containing the same full label after the app's normal 400ms delay. +- Title changes from runtime/browser/editor state re-render the same component; do not cache tooltip text outside the render path. + +## Edge Cases + +- Terminal custom title overrides the runtime title in both visible label and tooltip. +- Terminal title updates from remote/SSH PTY state should update the tooltip through normal React props. +- Browser tab with missing/blank title should show the URL-derived fallback or `New Tab`, matching the visible label. +- Browser tooltip text must use `getBrowserTabLabel(tab)` from the same `tab` prop as the visible label, not `getLiveBrowserUrl` or the redacted context-menu URL; otherwise live URL drift can make the tooltip disagree with the tab. +- Editor preview, dirty, git-status, conflict-review, diff, markdown-preview, and external-mutation labels should keep existing styling and adjacent badges. +- Rename modes must not show stale title tooltips over the input. +- Keyboard focus on a terminal, browser, or editor tab must show the same tooltip as hover. The existing dnd-kit root provides the focus target; do not add focusable descendants for tooltip-only behavior. +- Context menu, middle-click close, drag start, and split drop indicators must still work because the sortable root and pointer handlers remain unchanged. +- Very long tooltip text should wrap within a bounded width and stay readable in light/dark themes. +- Context-menu open must force the corresponding tooltip closed for terminal, browser, and editor tabs. +- Hovering root-child controls such as close, terminal collapse, loading indicators, dirty dots, and status badges may enter the root trigger; the tooltip must remain non-blocking and should not create duplicate/conflicting hover copy that makes those controls harder to use. +- Multi-window and web/SSH remote-client paths should need no IPC or persistence work because this is renderer-only display of existing tab state. The implementation must still derive text from current props every render so external title/file mutations do not leave stale tooltip content while a tab remains mounted. + +## Test Plan + +- Unit: add or update tab-bar component tests that mock tooltip primitives and assert: + - terminal tab label tooltip content uses custom title when present; + - browser tab label tooltip content uses `getBrowserTabLabel`; + - editor tab label tooltip content uses `getEditorDisplayLabel`; + - the tooltip trigger is the sortable root in non-rename mode, preserving dnd-kit `role`/`tabIndex` focusability; + - rename/context-menu suppression is unit-tested only where the implementation exposes that state through a small testable render path; do not contort Node-only server-render tests to fake private interactive state. +- Integration/Electron: create long terminal/browser/editor tab labels, hover each tab, wait for the app's 400ms tooltip delay, and verify a tooltip with the full label appears while tab activation/close still works. +- Integration/Electron: keyboard-focus one terminal, browser, and editor tab and verify the tooltip appears without adding an extra tab stop inside the label. +- Integration/Electron: enter terminal/editor rename modes and open terminal/browser/editor context menus, then verify the full-title tooltip is absent or dismissed. +- Regression: run `pnpm typecheck`, `pnpm lint`, and the relevant tab-bar Vitest tests via `pnpm test -- ` if focused files are added. + +## UI Quality Bar + +- Tooltip appears near the hovered/focused truncated label without covering the close button unnecessarily. +- Tooltip uses existing Orca token-based styling through `TooltipContent`; no new colors, shadows, or typography. +- Long text wraps at a readable max width, including long path/URL segments, and does not clip off-screen in a normal desktop viewport. +- Tab dimensions, hover colors, active indicator, unread bell, dirty dot, git status, and close-button reveal behavior do not shift. +- Light and dark theme surfaces remain legible. + +## Review Screenshots + +1. Terminal tab with a long custom or runtime title hovered, showing the full title tooltip. +2. Browser tab with a long page title or URL hovered, showing the full title tooltip. +3. Editor tab with a long filename/path-derived label hovered, showing the full title tooltip while dirty/status adornments still look unchanged. +4. Adjacent smoke state: a keyboard-focused tab shows the tooltip, and close/context-menu affordances still work after tooltip wiring. + +## Rollout + +1. Add tooltip imports and wrap the non-editing sortable tab roots in `SortableTab`, `BrowserTab`, and `EditorFileTab`. +2. Add focused tests for tooltip label wiring. +3. Run relevant tests, typecheck, and lint. +4. Validate in Electron with hover screenshots for terminal, browser, editor, and adjacent tab controls. + +## Lightweight Eng Review + +- Scope: kept to renderer tab-label rendering only. No overflow observer, persistence, IPC, or new primitive is needed to satisfy the issue. +- Architecture/data flow: title data already reaches the tab components; tooltip content should be derived in the same component that renders each visible label. This keeps terminal, browser, and editor ownership unchanged and avoids cross-window/SSH special cases. The trigger must be the focusable sortable root, not the inner label span, because the issue requires focus behavior. +- Failure modes covered: + - Tooltip root/trigger wiring could interfere with drag/activation if it adds an extra DOM wrapper or drops the dnd-kit ref/listeners/attributes. + - Rename mode could show stale tooltip content over an input if the tooltip is not gated out. + - Context-menu open could leave a delayed tooltip rendered over menu items unless `menuOpen` suppresses it. + - Long tooltip text could overflow the viewport if content width is unbounded. + - Browser fallback labels could drift if tooltip recomputes a different label than the visible span. + - Editor badges/status text could shift if the tooltip adds a block wrapper inside the label row. + - Root-level hover could show the tooltip while the pointer is over close/collapse/context-menu surfaces; validation must prove it does not block those controls or create unusable duplicate copy. +- Test coverage required: + - Unit/component: tab-bar label tooltip wiring for terminal, browser, and editor components. + - Unit/component: terminal custom title precedence, browser fallback label parity, and non-rename root trigger focusability. + - Electron validation: hover, keyboard-focus, rename suppression, context-menu suppression, and adjacent close/context-menu smoke for terminal/browser/editor labels. +- Performance/blast radius: small but not free. This adds one Radix tooltip root per visible tab and a few event handlers on the existing tab root; that is acceptable because tab count is bounded by rendered tabs and there is no IPC, polling, file watching, persistence, startup work, or overflow measurement. +- UI quality bar: existing tooltip primitive only, bounded wrapping, no tab-size shift, no overlap with close/action affordances, legible in light/dark. +- Required review screenshots: + 1. Long terminal tab tooltip. + 2. Long browser tab tooltip. + 3. Long editor tab tooltip with existing adornments intact. + 4. Keyboard-focus tooltip plus adjacent close/context-menu smoke state. +- Residual risks: Radix `TooltipTrigger asChild` must merge props/ref with the dnd-kit sortable root without disrupting pointer or keyboard drag behavior; unit tests with mocked dnd-kit can only prove wiring, so Electron validation must explicitly cover drag/activation adjacency, context-menu suppression, and focus-triggered display. diff --git a/docs/ssh-handler-reregistration-port-forwards.md b/docs/ssh-handler-reregistration-port-forwards.md new file mode 100644 index 00000000000..f45e0120151 --- /dev/null +++ b/docs/ssh-handler-reregistration-port-forwards.md @@ -0,0 +1,243 @@ +# SSH Handler Re-registration Port Forwards + +## Problem + +Issue #2932 reported that macOS window reactivation can re-run +`attachMainWindowServices`, which calls `registerSshHandlers` again +([src/main/window/attach-main-window-services.ts:83](src/main/window/attach-main-window-services.ts:83)). + +Before this change, `registerSshHandlers` removed and re-added IPC handlers but +also replaced the module-level `connectionManager` and `portForwardManager` +([src/main/ipc/ssh.ts:438](src/main/ipc/ssh.ts:438), +[src/main/ipc/ssh.ts:439](src/main/ipc/ssh.ts:439)). +`activeSessions` remains module-global +([src/main/ipc/ssh.ts:63](src/main/ipc/ssh.ts:63)), +so live relay sessions kept references to the old port-forward manager while +new IPC handlers read a fresh empty one. + +The visible failure is: + +1. Connect an SSH target. +2. Add a local port forward. +3. Close all windows on macOS while the app process remains alive. +4. Reactivate Orca, causing SSH handlers to register again. +5. `ssh:listPortForwards` returns an empty list, `ssh:removePortForward` cannot + remove the old forward id, `ssh:addPortForward`/`ssh:updatePortForward` can + fail because the fresh connection manager has no live connection, and + `ssh:disconnect` does not close the old SSH connection or local listener. + Re-adding the same local port fails because the old server remains bound. + +## Root Cause + +SSH handler registration mixes two lifetimes: + +- Process-lifetime session state: active SSH connections, relay sessions, port + listeners, relay lost backoff, reset/connect in-flight maps. +- Window-lifetime callback state: `getMainWindow` and renderer IPC handlers. + +The previous re-registration path preserved `activeSessions` but replaced the +managers that sessions and IPC handlers must share. Port-forward IPC operations +use `portForwardManager` ([src/main/ipc/ssh.ts:992](src/main/ipc/ssh.ts:992), +[src/main/ipc/ssh.ts:1047](src/main/ipc/ssh.ts:1047), +[src/main/ipc/ssh.ts:1056](src/main/ipc/ssh.ts:1056)), +and disconnect/terminate cleanup also uses that variable +([src/main/ipc/ssh.ts:750](src/main/ipc/ssh.ts:750), +[src/main/ipc/ssh.ts:814](src/main/ipc/ssh.ts:814)). +After replacement, those operations no longer targeted the manager that owns the +live local servers. Replacing `connectionManager` also strands the live +`SshConnection` objects: existing relay sessions still hold their current +connection, but new IPC handlers and `getSshConnectionManager()` see an empty +manager. + +## Non-goals + +- Do not change relay protocol, remote deployment, or SSH transport behavior. +- Do not redesign port-forward persistence or enrichment. +- Do not change renderer UI. +- Do not introduce a second SSH service layer. +- Do not force-dispose live SSH sessions merely because a window was recreated. + +## Design + +1. Preserve process-lifetime managers across handler re-registration. + Instantiate `SshConnectionManager` and `SshPortForwardManager` only when + absent; later `registerSshHandlers` calls reuse the existing instances. + +2. Refresh every live callback owner on re-registration. This is required; a + plain `connectionManager ??= new SshConnectionManager(callbacks)` is not + enough. + - `SshConnectionManager` must update callbacks used by both future and + existing `SshConnection` objects, either via explicit `setCallbacks` methods + on manager/connection or via a stable callback proxy whose implementation + is mutable. + - Existing `SshRelaySession` objects must refresh `getMainWindow`, store, + runtime, and detected-port callback references. Event handlers must call + the current callback at event time; do not capture the old `getMainWindow` + in long-lived provider callbacks. + - The credential-request tracking set must not be per-registration if live + connections can switch callbacks during an in-flight `ssh:connect`. + +3. Re-register IPC handlers and dependent global listeners on every call. + `ipcMain` handlers, advertised URL refresh, credential IPC, browse handler, + and power-monitor listeners are window-registration concerns and should still + point at the latest window. + +4. Preserve existing explicit teardown behavior. + `ssh:disconnect`, `ssh:terminateSessions`, `ssh:removeTarget`, reset, and + double-connect cleanup must still remove forwards through the shared manager + before detaching or disposing sessions. + +5. Add regression tests in `src/main/ipc/ssh.test.ts`. + Connect a target, add a mocked port forward, call `registerSshHandlers` + again, then assert: + - `ssh:listPortForwards` still returns the original forward. + - `ssh:removePortForward` can remove the original id. + - `ssh:addPortForward`/`ssh:updatePortForward` still use the original live + connection. + - A second re-registration followed by `ssh:disconnect` still calls + `removeAllForwards` and `disconnect` on the original shared managers. + - State, credential, PTY, and detected-port callbacks from an existing live + session publish to the newest window after re-registration. + +## Data Flow + +- First registration: + - `registerSshHandlers(store, getWindowA)` creates store wrapper, connection + manager, port-forward manager, handlers, listeners, and current callback + environment. + - `ssh:connect` creates a relay session with the shared port-forward manager. + - `ssh:addPortForward` stores a local server in that same manager. + +- Window reactivation: + - `registerSshHandlers(store, getWindowB)` removes/re-adds IPC handlers. + - Existing managers are reused. + - Existing connection and relay-session callback owners are refreshed to the + latest store/runtime/window environment. + - New handlers close over `getWindowB` and call the same managers. + +- Cleanup: + - `ssh:removePortForward` and `ssh:disconnect` operate on the same manager + that owns the live forward, then broadcast through the latest window. + +## Edge Cases + +- Re-registration while a target is connected and has active port forwards. +- Re-registration while `ssh:connect`, `restorePortForwards`, reset, reconnect, + or disconnect is in flight. The operation must not split credential tracking + or create a session that holds stale callbacks. +- Re-registration while no targets are connected. +- Re-registration after the store object changes. Either update existing relay + sessions to use the new store/runtime or document and test the stronger + invariant that production re-registration always passes the same process + store/runtime. +- Re-registration after the window changes. All broadcasts, credential prompts, + PTY events, detected-port events, advertised URL refreshes, relay-loss state + changes, and terminal relay errors must use the newest `getMainWindow`. +- Disconnect after re-registration must release old local ports. +- `ssh:connect` after window reactivation must be idempotent when the existing + session is already ready and healthy: return the connected state without + tearing down forwards. Explicit reset/reconnect or non-ready replacement paths + must still await old port teardown before restoring forwards. +- `getSshConnectionManager()` consumers must continue to see live connections + after re-registration. +- Test isolation must not depend on module-singleton state leaking between + tests. Add explicit reset/teardown support if preserving managers makes + `beforeEach(registerSshHandlers)` insufficient. +- SSH and relay paths must keep working for remote targets; the fix must not + assume local filesystem or local-only execution. +- Windows/Linux remain unaffected: re-registration can still happen during + development or future window lifecycles, and the fix must avoid path or + platform assumptions. + +## Test Plan + +- Unit: `pnpm vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts` + - Add regression coverage for list/remove/disconnect after handler + re-registration. + - Add coverage that add/update after re-registration uses the still-live + connection manager connection, not a fresh empty manager. + - Add coverage that existing connection/session callbacks publish to a second + mock window after re-registration. + - Add an in-flight connect or credential-request test if callback refresh uses + mutable callback objects. + - Existing connect, disconnect, reset, relay-loss, and terminate tests cover + adjacent lifecycle behavior. +- Typecheck: `pnpm typecheck`. +- Lint: `pnpm lint`. +- Electron/SSH validation: use an existing SSH target such as `openclaw 2` if + available in the running app, add a disposable local port forward, trigger + window/service re-registration by closing and reopening the main window on + macOS, then verify the forward remains listed and removable. IPC/unit tests + are supporting evidence only; if the golden path cannot be exercised safely, + halt before PR and report the missing evidence. + +## UI Quality Bar + +Not UI-visible. No layout, copy, or visual styling changes are expected. The +only user-visible expectation is that existing SSH port-forward rows remain +present and actionable after window reactivation. + +## Review Screenshots + +1. SSH target connected with a port forward listed before re-registration. +2. Same SSH target after window reactivation, showing the same port forward + still listed. +3. Same SSH target after removing the port forward, showing it gone without an + error. + +## Rollout + +1. Add the focused regression test to prove the current lifecycle bug. +2. Change SSH handler registration to reuse process-lifetime managers. +3. Run the focused test, then typecheck and lint. +4. Validate in Electron against an SSH target if feasible; otherwise halt + before PR if the golden-path SSH UI cannot be exercised. + +## Lightweight Eng Review + +- Scope: reduced to SSH IPC lifecycle only. No relay, renderer, or persistence + redesign is needed because the broken boundary is manager replacement during + handler re-registration. +- Architecture/data flow: process-lifetime managers stay module-level and are + reused; window-lifetime IPC handlers/listeners are refreshed; existing + connection and relay-session callback owners must also be refreshed or proxied + so live events target the current BrowserWindow. +- Failure modes covered: + - Active forwards becoming invisible after re-registration. + - `ssh:removePortForward` missing the old forward id. + - `ssh:addPortForward`/`ssh:updatePortForward` failing against a fresh empty + connection manager. + - `ssh:disconnect` failing to close old SSH connections and local listeners + after re-registration. + - Store/window/runtime callback refresh after re-registration. + - Re-registration during in-flight connect/reset/reconnect. + - No-session re-registration continuing to work. +- Test coverage required: + - Unit in `src/main/ipc/ssh.test.ts` for connect/add/list/remove across + `registerSshHandlers` calls. + - Unit in `src/main/ipc/ssh.test.ts` for disconnect cleanup after + re-registration. + - Unit in `src/main/ipc/ssh.test.ts` for existing live callbacks reaching the + newest window after re-registration. + - Unit in `src/main/ipc/ssh.test.ts` for no-session re-registration and test + teardown/reset of module singletons. + - Existing lifecycle tests for reset, terminate, relay loss, and sleep remain + adjacent coverage. +- Performance/blast radius: no material startup or IPC cost. Reusing managers + avoids leaked runtime state and does not add polling, watchers, or + cross-process calls. Callback refresh is O(number of live SSH connections and + sessions) per registration, which should be tiny. +- UI quality bar: not UI-visible; preserve existing SSH port-forward UI state + rather than changing layout or copy. +- Required review screenshots: + 1. Connected SSH target with active port forward before re-registration. + 2. Connected SSH target with same port forward after re-registration. + 3. Connected SSH target after removing that forward. +- Feasibility: one-time manager creation is feasible only with callback refresh + for existing `SshConnection` and `SshRelaySession` instances. If that refresh + proves larger than expected, prefer a stable callback proxy over recreating + managers; do not dispose live sessions just to make callback ownership easier. +- Residual risks: Electron validation may be constrained by availability of an + existing SSH target and by avoiding live-user port collisions. If the golden + path cannot be exercised safely, stop before opening a PR and report the + missing manual evidence. diff --git a/docs/terminal-main-owned-state.md b/docs/terminal-main-owned-state.md new file mode 100644 index 00000000000..a9c3165e7cf --- /dev/null +++ b/docs/terminal-main-owned-state.md @@ -0,0 +1,88 @@ +# Terminal Main-Owned State + +## Problem + +Hidden and background terminal panes cannot rely on renderer memory as the only +place that terminal output exists. Chromium may throttle a hidden Electron +document while the PTY continues producing bytes. If the renderer retains every +hidden byte until xterm can parse it, a noisy terminal can pin large strings in +renderer memory and stall or crash the app. + +The renderer must keep a hard memory bound, but the user-visible terminal state +should still be recoverable when the pane becomes visible again. + +## Reference Pattern + +Use a host-owned terminal model as the recovery source and treat the renderer as +a view: + +- The host process receives PTY bytes first and appends them to a bounded + headless terminal model. +- The renderer writes visible output directly for low latency. +- Hidden renderer queues are bounded. When they overflow, the renderer marks its + xterm as stale and drops further hidden bytes instead of retaining them. +- On visibility resume, the renderer asks the host for a serialized snapshot, + clears its xterm, replays that snapshot, then resumes live writes. +- Live output racing with restore carries a monotonic sequence number, so bytes + already included in the snapshot are not written twice. + +This gives the foreground path the same latency profile as today, bounds hidden +renderer memory, and preserves terminal state from the host-owned model instead +of depending on an unbounded renderer backlog. + +## Requirements + +- Renderer hidden-output memory is capped per terminal. +- A hidden flood must not grow the renderer by retaining strings or chunk arrays + past the cap. +- Restoring a stale renderer must use main/runtime state for local, daemon, and + SSH PTYs. +- Remote runtime PTYs that do not have local main-owned state must keep the + existing warning fallback rather than pretending recovery is available. +- Restore must avoid xterm query auto-replies reaching the shell. +- Clear, resize, exit, and pane disposal must clean up recovery state. +- The SSH path must participate in the same sequencing and snapshot behavior as + local PTYs. + +## Chosen Design + +The existing runtime headless terminal is the main-owned model. Every PTY byte +already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for +local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated +and can serialize it. + +The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded: + +1. The scheduler replaces the queued backlog with a small warning fallback. +2. The terminal connection marks that pane as needing main-state recovery. +3. Further hidden bytes for that stale pane are not enqueued in the renderer. +4. When the pane/document becomes visible, the connection requests a main-owned + snapshot, clears xterm, replays the snapshot under the replay guard, and + sends the normal post-reattach reset. +5. Live foreground chunks that arrive while restore is in flight are retained in + a small bounded queue. After snapshot replay, sequence numbers decide which + chunks were already included and which still need to be written. + +If the main snapshot is unavailable, the small warning fallback remains the +visible behavior. That is the expected fallback for surfaces without local +main-owned terminal state. + +## Non-Goals + +- This does not add a durable full byte log. The host-owned model is bounded + terminal state, not an infinite transcript. +- This does not throttle the PTY producer. Producer backpressure can be added + later with ACKs if we need to reduce host-side work during extreme floods. +- This does not change foreground terminal write latency. + +## Verification + +- Unit-test scheduler overflow callback behavior and chunk-count bounding. +- Unit-test renderer recovery so hidden overflow drops renderer backlog, fetches + the main snapshot on visibility/foreground resume, and does not duplicate + sequenced live output. +- Unit-test main IPC snapshot sequencing. +- Run terminal scheduler and PTY connection tests. +- Run typecheck. +- Exercise the Electron hidden-flood repro and confirm renderer memory stays + bounded while the recovered terminal shows the host-owned terminal state. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index da5357bc7e2..c715965c40d 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -48,7 +48,12 @@ export default defineConfig({ index: resolve('src/main/index.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), - 'stt-worker': resolve('src/main/speech/stt-worker.ts') + 'stt-worker': resolve('src/main/speech/stt-worker.ts'), + // Why: electron-vite cleans out/main in dev. The dev CLI imports + // this path for `orca agent hooks ...`, so it must survive rebuilds. + 'agent-hooks/managed-agent-hook-controls': resolve( + 'src/main/agent-hooks/managed-agent-hook-controls.ts' + ) } } }, diff --git a/mobile/app.json b/mobile/app.json index e9aefbe0957..57e43ffdd01 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.9", + "version": "0.0.10", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -69,7 +69,7 @@ "expo-camera", { "cameraPermission": "Allow Orca to use the camera for QR code scanning.", - "microphonePermission": false, + "microphonePermission": "Allow Orca to use your microphone for voice dictation sent to your paired desktop for transcription.", "recordAudioAndroid": false } ], diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 01a9b609624..5bd76d65e03 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Animated, AppState, type AppStateStatus } from 'react-native' import * as Clipboard from 'expo-clipboard' import { BackHandler, + FlatList, View, Text, StyleSheet, @@ -12,7 +13,9 @@ import { Keyboard, Platform, ActivityIndicator, - type KeyboardEvent + type KeyboardEvent, + type ListRenderItem, + type TextStyle } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' @@ -28,6 +31,7 @@ import { FileText, GitBranch, Globe, + Keyboard as KeyboardIcon, Mic, Monitor, Plus, @@ -51,10 +55,15 @@ import { import { TerminalWebView, type TerminalKeyboardAvoidanceMetrics, + type MobileTerminalTheme, type TerminalModes, type TerminalWebViewHandle } from '../../../../src/terminal/TerminalWebView' import { TERMINAL_ACCESSORY_KEYS } from '../../../../src/terminal/terminal-accessory-keys' +import { + getTerminalLiveSpecialKeyBytes, + isTerminalLiveInputWithinByteLimit +} from '../../../../src/terminal/terminal-live-input' import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input' import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane' import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url' @@ -62,6 +71,7 @@ import { StatusDot } from '../../../../src/components/StatusDot' import { ActionSheetModal } from '../../../../src/components/ActionSheetModal' import { TextInputModal } from '../../../../src/components/TextInputModal' import { ConfirmModal } from '../../../../src/components/ConfirmModal' +import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor' import { CustomKeyModal, loadCustomKeys, @@ -72,13 +82,25 @@ import { buildMobileDiffLines, type MobileDiffLine } from '../../../../src/session/mobile-diff-lines' +import { + buildPlainMobileDiffSyntaxLines, + highlightMobileCode, + highlightMobileDiffLines, + resolveMobileSyntaxLanguage, + type MobileHighlightedDiffLine, + type MobileSyntaxSegment, + type MobileSyntaxTokenKind +} from '../../../../src/session/mobile-file-syntax' +import { + getTerminalRecordsFromSessionTabs, + mergeTerminalListWithKnownRecords, + mergeTerminalRecordsByCurrentOrder, + terminalRecordsEqual, + type TerminalRecord +} from '../../../../src/session/mobile-terminal-records' import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' -type Terminal = { - handle: string - title: string - isActive: boolean -} +type Terminal = TerminalRecord type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser' @@ -91,6 +113,7 @@ type MobileSessionTab = leafId?: string status?: 'pending-handle' | 'ready' terminal: string | null + terminalTheme?: MobileTerminalTheme isActive: boolean } | { @@ -152,39 +175,28 @@ type FileDocState = | { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean } | { status: 'error'; message: string } +type RenderableDiffLine = MobileHighlightedDiffLine + +type ReadyFileDocState = Extract + +type FileSyntaxState = { + doc: ReadyFileDocState + language: string + segments: MobileSyntaxSegment[] +} + +type DiffSyntaxState = { + doc: ReadyFileDocState + language: string + lines: RenderableDiffLine[] +} + type DirtyMarkdownDraft = { tabId: string title: string content: string } -function mergeTerminalRecordsByCurrentOrder( - terminalTabs: Terminal[], - currentTerminals: Terminal[] -): Terminal[] { - if (currentTerminals.length === 0) { - return terminalTabs - } - const terminalTabsByHandle = new Map(terminalTabs.map((tab) => [tab.handle, tab])) - const currentHandles = new Set(currentTerminals.map((terminal) => terminal.handle)) - return [ - ...currentTerminals.map((terminal) => terminalTabsByHandle.get(terminal.handle) ?? terminal), - ...terminalTabs.filter((terminal) => !currentHandles.has(terminal.handle)) - ] -} - -function terminalRecordsEqual(a: Terminal[], b: Terminal[]): boolean { - return ( - a.length === b.length && - a.every( - (terminal, index) => - terminal.handle === b[index]?.handle && - terminal.title === b[index]?.title && - terminal.isActive === b[index]?.isActive - ) - ) -} - function mobileSessionTabsEqual(a: MobileSessionTab[], b: MobileSessionTab[]): boolean { return a.length === b.length && a.every((tab, index) => mobileSessionTabEqual(tab, b[index])) } @@ -206,7 +218,8 @@ function mobileSessionTabEqual(a: MobileSessionTab, b: MobileSessionTab | undefi a.parentTabId === b.parentTabId && a.leafId === b.leafId && a.status === b.status && - a.terminal === b.terminal + a.terminal === b.terminal && + JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null) ) case 'markdown': return ( @@ -272,6 +285,11 @@ function getMobileSessionTabTitle(tab: MobileSessionTab): string { return tab.title || 'Terminal' } +function isFileExistsErrorMessage(message: string): boolean { + const normalized = message.toLowerCase() + return normalized.includes('eexist') || normalized.includes('already exists') +} + type TerminalCreateResult = { tab: Extract } @@ -313,6 +331,7 @@ function TerminalPaneView({ handle, active, keyboardLift, + terminalTheme, onRef, onWebReady, onSelectionMode, @@ -321,11 +340,13 @@ function TerminalPaneView({ onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, - onTerminalInput + onTerminalInput, + onTerminalTap }: { handle: string active: boolean keyboardLift: number + terminalTheme?: MobileTerminalTheme onRef: (handle: string, ref: TerminalWebViewHandle | null) => void onWebReady: (handle: string) => void onSelectionMode: (handle: string, active: boolean) => void @@ -335,6 +356,7 @@ function TerminalPaneView({ onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput: (handle: string, bytes: string) => void + onTerminalTap: (handle: string) => void }) { const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { @@ -355,6 +377,7 @@ function TerminalPaneView({ onWebReady(handle)} onSelectionMode={(a) => onSelectionMode(handle, a)} onSelectionCopy={(t) => onSelectionCopy(handle, t)} @@ -363,12 +386,14 @@ function TerminalPaneView({ onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)} onHaptic={onHaptic} onTerminalInput={(bytes) => onTerminalInput(handle, bytes)} + onTerminalTap={() => onTerminalTap(handle)} /> ) } function MarkdownReader({ + documentId, doc, onRefresh, onChange, @@ -376,6 +401,7 @@ function MarkdownReader({ onCopy, onDiscard }: { + documentId: string doc: MarkdownDocState | undefined onRefresh: () => void onChange: (content: string) => void @@ -416,16 +442,11 @@ function MarkdownReader({ return ( - {showFloatingActions ? ( @@ -478,7 +499,111 @@ function MarkdownReader({ ) } -function FileReader({ doc, title }: { doc: FileDocState | undefined; title: string }) { +function SyntaxSegments({ segments }: { segments: MobileSyntaxSegment[] }) { + return ( + <> + {segments.map((segment, index) => ( + + {segment.text} + + ))} + + ) +} + +function DiffLineRow({ + line, + title, + index +}: { + line: RenderableDiffLine + title: string + index: number +}) { + return ( + + {line.oldLineNumber ?? line.newLineNumber ?? ''} + + + {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} + + + + + ) +} + +function FileReader({ + doc, + title, + relativePath, + language +}: { + doc: FileDocState | undefined + title: string + relativePath: string + language?: string +}) { + const syntaxLanguage = useMemo( + () => resolveMobileSyntaxLanguage(relativePath || title, language), + [language, relativePath, title] + ) + const [fileSyntax, setFileSyntax] = useState(null) + const [diffSyntax, setDiffSyntax] = useState(null) + const plainDiffLines = useMemo( + () => + doc?.status === 'ready' && doc.kind === 'diff' + ? buildPlainMobileDiffSyntaxLines(doc.lines) + : [], + [doc] + ) + const renderDiffLine: ListRenderItem = useCallback( + ({ item, index }) => , + [title] + ) + + useEffect(() => { + if (doc?.status !== 'ready') { + return undefined + } + + // Why: highlighting can create many nested Text nodes; defer it one tick so + // large files show immediately as plain text before colors are applied. + const timer = setTimeout(() => { + if (doc.kind === 'file') { + setFileSyntax({ + doc, + language: syntaxLanguage, + segments: highlightMobileCode(doc.content, syntaxLanguage).segments + }) + return + } + setDiffSyntax({ + doc, + language: syntaxLanguage, + lines: highlightMobileDiffLines(doc.lines, syntaxLanguage) + }) + }, 0) + + return () => clearTimeout(timer) + }, [doc, syntaxLanguage]) + if (!doc || doc.status === 'loading') { return ( @@ -495,39 +620,23 @@ function FileReader({ doc, title }: { doc: FileDocState | undefined; title: stri } if (doc.kind === 'diff') { + const activeDiffSyntax = + diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null return ( - - {doc.lines.map((line, index) => ( - - - {line.oldLineNumber ?? line.newLineNumber ?? ''} - - - {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} - {line.text} - - - ))} - + keyExtractor={(line, index) => + `${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}` + } + renderItem={renderDiffLine} + initialNumToRender={32} + maxToRenderPerBatch={48} + windowSize={7} + removeClippedSubviews={Platform.OS !== 'web'} + /> ) } @@ -539,7 +648,13 @@ function FileReader({ doc, title }: { doc: FileDocState | undefined; title: stri contentContainerStyle={styles.filePreviewContent} > - {doc.content} + @@ -572,6 +687,10 @@ export default function SessionScreen() { const sessionTabsRef = useRef([]) const [terminalsLoaded, setTerminalsLoaded] = useState(false) const [input, setInput] = useState('') + const [liveInputCapture, setLiveInputCapture] = useState('') + const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( + () => new Set() + ) const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) @@ -580,6 +699,7 @@ export default function SessionScreen() { const [fileDocs, setFileDocs] = useState>(new Map()) const [creating, setCreating] = useState(false) const [creatingBrowser, setCreatingBrowser] = useState(false) + const [creatingMarkdown, setCreatingMarkdown] = useState(false) const [createError, setCreateError] = useState('') const [createWarning, setCreateWarning] = useState(initialCreateWarning) const [showCreateTabDrawer, setShowCreateTabDrawer] = useState(false) @@ -637,6 +757,7 @@ export default function SessionScreen() { const viewportRef = useRef<{ cols: number; rows: number } | null>(null) const viewportMeasuredRef = useRef(false) const terminalRefs = useRef>(new Map()) + const liveInputRef = useRef(null) const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) @@ -649,6 +770,7 @@ export default function SessionScreen() { const activeSessionTabTypeRef = useRef(null) const pendingActiveSessionTabIdRef = useRef(null) const pendingActiveTerminalHandleRef = useRef(null) + const initialEmptySessionAutoCreateRef = useRef(null) const markdownSaveSeqRef = useRef>(new Map()) const markdownSaveInFlightRef = useRef>(new Set()) const subscribeSeqRef = useRef>(new Map()) @@ -672,6 +794,7 @@ export default function SessionScreen() { activeSessionTab?.type !== 'markdown' && activeSessionTab?.type !== 'file' && activeSessionTab?.type !== 'browser' + const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) useEffect(() => { @@ -1079,8 +1202,15 @@ export default function SessionScreen() { return true }) - setTerminals(deduped) - terminalsRef.current = deduped + const mergedTerminals = mergeTerminalListWithKnownRecords( + deduped, + terminalsRef.current, + sessionTabsRef.current + ) + setTerminals((prev) => + terminalRecordsEqual(prev, mergedTerminals) ? prev : mergedTerminals + ) + terminalsRef.current = mergedTerminals // Session tabs are the UI authority. terminal.list only refreshes // per-handle metadata for existing ready terminal surfaces. @@ -1122,12 +1252,7 @@ export default function SessionScreen() { // Why: subscribe snapshots often repeat identical tab payloads. Avoid a // render loop where the subscription effect tears down and replays itself. setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs)) - const terminalTabs = nextTabs.flatMap((tab): Terminal[] => { - if (tab.type !== 'terminal' || typeof tab.terminal !== 'string') { - return [] - } - return [{ handle: tab.terminal, title: tab.title || 'Terminal', isActive: tab.isActive }] - }) + const terminalTabs = getTerminalRecordsFromSessionTabs(nextTabs) const mergedTerminalsForActive = mergeTerminalRecordsByCurrentOrder( terminalTabs, terminalsRef.current @@ -1696,6 +1821,7 @@ export default function SessionScreen() { activeSessionTabTypeRef.current = null pendingActiveSessionTabIdRef.current = null pendingActiveTerminalHandleRef.current = null + initialEmptySessionAutoCreateRef.current = null for (const queued of terminalGestureInputQueuesRef.current.values()) { if (queued.timer) clearTimeout(queued.timer) } @@ -1706,6 +1832,8 @@ export default function SessionScreen() { terminalsRef.current = [] setSessionTabs([]) setActiveSessionTabId(null) + setLiveInputCapture('') + setLiveInputTerminalHandles(new Set()) setMarkdownDocs(new Map()) setFileDocs(new Map()) }, [clearTerminalCache, worktreeId]) @@ -2034,6 +2162,117 @@ export default function SessionScreen() { } } + const sendLiveTerminalInput = useCallback( + (handle: string, bytes: string) => { + if (bytes.length === 0) return + if (!isTerminalLiveInputWithinByteLimit(bytes)) { + triggerError() + showToast('Input too large (max 256 KiB)', 1500) + return + } + const rpc = clientRef.current + if ( + !rpc || + connStateRef.current !== 'connected' || + handle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return + } + void rpc + .sendRequest('terminal.send', { + terminal: handle, + text: bytes, + enter: false, + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + .catch(() => { + // Transient failure + }) + }, + [showToast] + ) + + const focusLiveInput = useCallback(() => { + if (!canSend || !liveInputEnabled) return + liveInputRef.current?.focus() + }, [canSend, liveInputEnabled]) + + const handleTerminalTap = useCallback( + (handle: string) => { + if (handle !== activeHandleRef.current) return + focusLiveInput() + }, + [focusLiveInput] + ) + + const toggleLiveInput = useCallback(() => { + if (!activeHandle) return + const nextEnabled = !liveInputTerminalHandles.has(activeHandle) + setLiveInputTerminalHandles((prev) => { + const next = new Set(prev) + if (nextEnabled) { + next.add(activeHandle) + } else { + next.delete(activeHandle) + } + return next + }) + setLiveInputCapture('') + if (nextEnabled) { + setTimeout(() => liveInputRef.current?.focus(), 50) + } else { + liveInputRef.current?.blur() + } + }, [activeHandle, liveInputTerminalHandles]) + + const handleLiveInputChange = useCallback( + (text: string) => { + if (!activeHandle) { + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + return + } + if (!liveInputTerminalHandles.has(activeHandle)) { + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + return + } + if (text.length > 0) { + sendLiveTerminalInput(activeHandle, text) + } + setLiveInputCapture('') + // Why: the field is only a keyboard capture surface. Clearing the + // native value prevents subsequent phone-keyboard events from replaying + // already-sent characters when React state remains the empty string. + liveInputRef.current?.setNativeProps({ text: '' }) + }, + [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] + ) + + const handleLiveInputKeyPress = useCallback( + (event: { nativeEvent: { key: string } }) => { + if (!activeHandle) return + if (!liveInputTerminalHandles.has(activeHandle)) return + const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key) + if (!bytes) return + sendLiveTerminalInput(activeHandle, bytes) + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + }, + [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] + ) + + const handleLiveInputSubmit = useCallback(() => { + if (!activeHandle) return + if (!liveInputTerminalHandles.has(activeHandle)) return + sendLiveTerminalInput(activeHandle, '\r') + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + }, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]) + const allowTerminalGestureInput = useCallback( (handle: string, sequenceCount: number): boolean => { const now = Date.now() @@ -2407,14 +2646,21 @@ export default function SessionScreen() { activeHandleRef.current = createdHandle setActiveHandle(createdHandle) setTerminals((prev) => { - if (prev.some((t) => t.handle === createdHandle)) { - terminalsRef.current = prev - return prev + const existing = prev.find((terminal) => terminal.handle === createdHandle) + const createdTerminal: Terminal = { + handle: createdHandle, + title: created.title || existing?.title || 'Terminal', + terminalTheme: created.terminalTheme ?? existing?.terminalTheme, + isActive: true } - const next = [ - ...prev, - { handle: createdHandle, title: created.title || 'Terminal', isActive: true } - ] + if (existing) { + const next = prev.map((terminal) => + terminal.handle === createdHandle ? { ...terminal, ...createdTerminal } : terminal + ) + terminalsRef.current = next + return terminalRecordsEqual(prev, next) ? prev : next + } + const next = [...prev, createdTerminal] terminalsRef.current = next return next }) @@ -2434,6 +2680,50 @@ export default function SessionScreen() { } } + async function handleCreateMarkdownNote() { + if (!client || creatingMarkdown) return + + setCreatingMarkdown(true) + setCreateError('') + + try { + const worktree = `id:${worktreeId}` + for (let attempt = 1; attempt <= 100; attempt += 1) { + const relativePath = attempt === 1 ? 'untitled.md' : `untitled-${attempt}.md` + const createResponse = await client.sendRequest( + 'files.createFile', + { worktree, relativePath }, + { timeoutMs: 15_000 } + ) + if (!createResponse.ok) { + const message = (createResponse as RpcFailure).error.message + if (isFileExistsErrorMessage(message) && attempt < 100) { + continue + } + throw new Error(message || 'Failed to create markdown note') + } + + const openResponse = await client.sendRequest( + 'files.open', + { worktree, relativePath }, + { timeoutMs: 15_000 } + ) + if (!openResponse.ok) { + throw new Error((openResponse as RpcFailure).error.message) + } + setTimeout(() => void fetchSessionTabs(), 300) + return + } + throw new Error('Unable to create untitled markdown note') + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create markdown note' + setCreateError(message) + showToast(message, 1800) + } finally { + setCreatingMarkdown(false) + } + } + async function handleCreateBrowser(rawUrl = 'about:blank'): Promise { if (!client || creatingBrowser) return false if (browserScreencastSupported !== true) { @@ -2603,6 +2893,25 @@ export default function SessionScreen() { const showLoadingState = connState === 'connected' && !terminalsLoaded && visibleTabs.length === 0 const showEmptyState = connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle + + useEffect(() => { + if ( + !client || + !showEmptyState || + creating || + creatingBrowser || + creatingMarkdown || + initialEmptySessionAutoCreateRef.current === worktreeId + ) { + return + } + // Why: a sleeping/new workspace can hydrate with zero session tabs. Create + // the first terminal once on initial load instead of leaving mobile blank. + initialEmptySessionAutoCreateRef.current = worktreeId + setCreateError('') + void handleCreateTerminal() + }, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId]) + const terminalSummary = connState === 'connected' ? showLoadingState @@ -2756,10 +3065,15 @@ export default function SessionScreen() { style={({ pressed }) => [ styles.newTerminalButton, pressed && styles.newTerminalButtonPressed, - (creating || creatingBrowser || connState !== 'connected') && + (creating || + creatingBrowser || + creatingMarkdown || + connState !== 'connected') && styles.newTerminalButtonDisabled ]} - disabled={creating || creatingBrowser || connState !== 'connected'} + disabled={ + creating || creatingBrowser || creatingMarkdown || connState !== 'connected' + } onPress={() => { setCreateError('') setShowCreateTabDrawer(true) @@ -2800,17 +3114,19 @@ export default function SessionScreen() { { setCreateError('') setShowCreateTabDrawer(true) }} > - {creating || creatingBrowser ? 'Creating…' : 'Create Tab'} + {creating || creatingBrowser || creatingMarkdown ? 'Creating...' : 'Create Tab'} @@ -2818,6 +3134,7 @@ export default function SessionScreen() { ) : activeMarkdownTab ? ( void readMarkdownTab(activeMarkdownTab)} onChange={(content) => updateMarkdownLocalContent(activeMarkdownTab.id, content)} @@ -2836,6 +3153,8 @@ export default function SessionScreen() { {toastMessage && ( @@ -2883,6 +3202,7 @@ export default function SessionScreen() { handle={terminal.handle} active={terminal.handle === activeHandle} keyboardLift={terminal.handle === activeHandle ? activeTerminalKeyboardLift : 0} + terminalTheme={terminal.terminalTheme} onRef={setTerminalWebViewRef} onWebReady={handleTerminalWebReady} onSelectionMode={handleSelectionMode} @@ -2892,6 +3212,7 @@ export default function SessionScreen() { onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics} onHaptic={handleHaptic} onTerminalInput={handleTerminalInput} + onTerminalTap={handleTerminalTap} /> ))} {toastMessage && ( @@ -2943,6 +3264,31 @@ export default function SessionScreen() { /> )} + [ + styles.accessoryKey, + liveInputEnabled && styles.accessoryKeyActive, + pressed && styles.accessoryKeyPressed, + !canSend && styles.accessoryKeyDisabled + ]} + disabled={!canSend} + onPress={toggleLiveInput} + accessibilityLabel={ + liveInputEnabled + ? 'Switch to buffered command input' + : 'Switch to live terminal input' + } + > + + Live + + {canPaste && ( [ @@ -3030,72 +3376,107 @@ export default function SessionScreen() { {/* Input bar */} - - void handleSend()} - /> + {liveInputEnabled ? ( { - if (dictation.isProcessing) { - void dictation.cancel() - } else if (dictation.isStarting) { - return - } else if (dictation.isRecording) { - void dictation.stop() - } else { - void dictation.start().catch((err) => { - triggerError() - showToast(err instanceof Error ? err.message : String(err)) - }) - } - }} - onLongPress={() => { - if (dictation.isRecording || dictation.isProcessing) { - void dictation.cancel() - } - }} - accessibilityLabel={ - dictation.isRecording - ? 'Stop voice dictation' - : dictation.isProcessing - ? 'Cancel voice dictation' - : dictation.isStarting - ? 'Starting voice dictation' - : 'Start voice dictation' - } + onPress={focusLiveInput} + accessibilityLabel="Focus live terminal input" > - {dictation.isProcessing ? ( - - ) : dictation.isStarting || dictation.isRecording ? ( - - ) : ( - - )} + + + Live + + + Keyboard input goes to terminal + + - void handleSend()} - accessibilityLabel="Send command" - > - - - + ) : ( + + void handleSend()} + /> + { + if (dictation.isProcessing) { + void dictation.cancel() + } else if (dictation.isStarting) { + return + } else if (dictation.isRecording) { + void dictation.stop() + } else { + void dictation.start().catch((err) => { + triggerError() + showToast(err instanceof Error ? err.message : String(err)) + }) + } + }} + onLongPress={() => { + if (dictation.isRecording || dictation.isProcessing) { + void dictation.cancel() + } + }} + accessibilityLabel={ + dictation.isRecording + ? 'Stop voice dictation' + : dictation.isProcessing + ? 'Cancel voice dictation' + : dictation.isStarting + ? 'Starting voice dictation' + : 'Start voice dictation' + } + > + {dictation.isProcessing ? ( + + ) : dictation.isStarting || dictation.isRecording ? ( + + ) : ( + + )} + + void handleSend()} + accessibilityLabel="Send command" + > + + + + )} )} @@ -3123,6 +3504,14 @@ export default function SessionScreen() { } setShowCreateBrowserModal(true) } + }, + { + label: 'Markdown Note', + icon: FileText, + onPress: () => { + setShowCreateTabDrawer(false) + void handleCreateMarkdownNote() + } } ]} onClose={() => setShowCreateTabDrawer(false)} @@ -3581,7 +3970,7 @@ const styles = StyleSheet.create({ filePreviewScroll: { flex: 1, minHeight: 0, - backgroundColor: colors.bgBase + backgroundColor: colors.editorSurface }, filePreviewContent: { paddingHorizontal: spacing.lg, @@ -3597,16 +3986,16 @@ const styles = StyleSheet.create({ diffLine: { flexDirection: 'row', borderLeftWidth: 2, - borderLeftColor: colors.bgBase, + borderLeftColor: colors.editorSurface, paddingRight: spacing.sm }, diffLineAdded: { - backgroundColor: colors.bgPanel, - borderLeftColor: colors.statusGreen + backgroundColor: colors.diffAddedBg, + borderLeftColor: colors.gitDecorationAdded }, diffLineDeleted: { - backgroundColor: colors.bgPanel, - borderLeftColor: colors.statusRed + backgroundColor: colors.diffDeletedBg, + borderLeftColor: colors.gitDecorationDeleted }, diffGutter: { width: 42, @@ -3624,11 +4013,14 @@ const styles = StyleSheet.create({ lineHeight: 22, fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }) }, - diffTextAdded: { - color: colors.statusGreen + diffPrefix: { + color: colors.textMuted }, - diffTextDeleted: { - color: colors.statusRed + diffPrefixAdded: { + color: colors.gitDecorationAdded + }, + diffPrefixDeleted: { + color: colors.gitDecorationDeleted }, markdownRefreshButton: { alignSelf: 'flex-start', @@ -3654,7 +4046,7 @@ const styles = StyleSheet.create({ position: 'absolute', left: spacing.md, right: spacing.md, - top: spacing.md, + bottom: spacing.lg, alignItems: 'flex-end', gap: spacing.xs }, @@ -3801,6 +4193,9 @@ const styles = StyleSheet.create({ accessoryKeyPressed: { backgroundColor: colors.borderSubtle }, + accessoryKeyActive: { + backgroundColor: colors.accentBlue + }, customAccessoryKey: { borderWidth: 1, borderColor: colors.borderSubtle @@ -3813,6 +4208,10 @@ const styles = StyleSheet.create({ fontSize: 12, fontFamily: typography.monoFamily }, + accessoryKeyTextActive: { + color: colors.textPrimary, + fontWeight: '700' + }, accessoryKeyTextDisabled: { color: colors.textMuted }, @@ -3836,6 +4235,37 @@ const styles = StyleSheet.create({ fontFamily: typography.monoFamily, marginRight: spacing.sm }, + liveInputBar: { + gap: spacing.sm + }, + liveInputBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + backgroundColor: colors.accentBlue, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radii.button + }, + liveInputBadgeText: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '700', + fontFamily: typography.monoFamily + }, + liveInputHint: { + flex: 1, + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily + }, + liveInputCapture: { + position: 'absolute', + opacity: 0, + width: 1, + height: 1, + color: colors.textPrimary + }, sendButton: { backgroundColor: colors.bgRaised, width: 34, @@ -3863,3 +4293,33 @@ const styles = StyleSheet.create({ opacity: 0.35 } }) + +const syntaxTokenStyles: Record = StyleSheet.create({ + plain: { + color: colors.textPrimary + }, + comment: { + color: colors.syntaxComment + }, + keyword: { + color: colors.syntaxKeyword + }, + string: { + color: colors.syntaxString + }, + number: { + color: colors.syntaxNumber + }, + type: { + color: colors.syntaxType + }, + function: { + color: colors.syntaxFunction + }, + variable: { + color: colors.syntaxVariable + }, + meta: { + color: colors.syntaxMeta + } +}) diff --git a/mobile/package.json b/mobile/package.json index ea249de28b4..bae2b78012e 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -30,6 +30,7 @@ "expo-secure-store": "^55.0.13", "expo-splash-screen": "^55.0.20", "expo-status-bar": "^55.0.6", + "lowlight": "^3.3.0", "lucide-react-native": "^1.14.0", "react": "^19.2.6", "react-native": "^0.83.9", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index c8f59ab72f9..2fa0bd18806 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: expo-status-bar: specifier: ^55.0.6 version: 55.0.6(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + lowlight: + specifier: ^3.3.0 + version: 3.3.0 lucide-react-native: specifier: ^1.14.0 version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -2162,6 +2165,9 @@ packages: '@types/hammerjs@2.0.46': resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -2205,6 +2211,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2904,6 +2913,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2919,6 +2932,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3699,6 +3715,10 @@ packages: hermes-parser@0.35.0: resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -4310,6 +4330,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -8363,6 +8386,10 @@ snapshots: '@types/hammerjs@2.0.46': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -8421,6 +8448,8 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/unist@3.0.3': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.6.0 @@ -9257,6 +9286,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destroy@1.2.0: {} detect-libc@2.1.2: {} @@ -9265,6 +9296,10 @@ snapshots: detect-node-es@1.1.0: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + diff-sequences@29.6.3: {} dnssd-advertise@1.1.4: {} @@ -10281,6 +10316,8 @@ snapshots: dependencies: hermes-estree: 0.35.0 + highlight.js@11.11.1: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -11117,6 +11154,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@10.4.3: {} lru-cache@11.3.6: {} diff --git a/mobile/src/components/MobileAgentIcon.tsx b/mobile/src/components/MobileAgentIcon.tsx index cc6f439d1e6..2e6ecf28b11 100644 --- a/mobile/src/components/MobileAgentIcon.tsx +++ b/mobile/src/components/MobileAgentIcon.tsx @@ -1,6 +1,6 @@ import { Image, StyleSheet, Text, View } from 'react-native' import { Terminal } from 'lucide-react-native' -import Svg, { G, Path } from 'react-native-svg' +import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg' import { colors } from '../theme/mobile-theme' import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' import { ClaudeIcon, OpenAIIcon } from './AgentIcons' @@ -21,6 +21,23 @@ function PiIcon({ size = 16 }: { size?: number }) { ) } +function OmpIcon({ size = 16 }: { size?: number }) { + // SVG sourced from omp.sh's transparent homepage mark. Why: react-native-svg + // does not support the homepage's CSS oklch stops, so use its favicon hex stops. + return ( + + + + + + + + + + + ) +} + function AiderIcon({ size = 16 }: { size?: number }) { return ( @@ -65,6 +82,7 @@ export function MobileAgentIcon({ agentId, size = 16 }: { agentId: string; size? if (agentId === 'claude') return if (agentId === 'codex') return if (agentId === 'pi') return + if (agentId === 'omp') return if (agentId === 'aider') return if (agentId === '__blank__' || agentId === 'blank') { return diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx new file mode 100644 index 00000000000..35121cc5b67 --- /dev/null +++ b/mobile/src/components/MobileRichMarkdownEditor.tsx @@ -0,0 +1,287 @@ +import { memo, useCallback, useEffect, useMemo, useRef, type ComponentType } from 'react' +import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native' +import { + Bold, + Code2, + FileCode2, + Heading1, + Heading2, + Heading3, + ImageIcon, + Italic, + Link, + List, + ListOrdered, + ListTodo, + Pilcrow, + Quote, + Strikethrough +} from 'lucide-react-native' +import WebView, { type WebViewMessageEvent } from 'react-native-webview' +import { colors, radii, spacing } from '../theme/mobile-theme' +import { + buildMobileRichMarkdownEditorHtml, + escapeInjectedJavaScriptString +} from './mobile-rich-markdown-editor-html' + +const EDITOR_DOCUMENT_ORIGIN = 'https://orca-mobile-editor.invalid' +const EDITOR_DOCUMENT_URL = `${EDITOR_DOCUMENT_ORIGIN}/rich-markdown-editor` + +function normalizeExternalEditorUrl(value: string): string | null { + const url = value.trim() + if (!url) return null + for (let index = 0; index < url.length; index += 1) { + const code = url.charCodeAt(index) + if (code <= 32 || code === 127) return null + } + if (/^mailto:/i.test(url)) return url + if (!/^https?:\/\//i.test(url)) return null + try { + const parsed = new URL(url) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null + } catch { + return null + } +} + +type RichMarkdownCommand = + | 'paragraph' + | 'heading1' + | 'heading2' + | 'heading3' + | 'bold' + | 'italic' + | 'strike' + | 'bulletList' + | 'orderedList' + | 'taskList' + | 'quote' + | 'inlineCode' + | 'codeBlock' + | 'link' + | 'image' + +type Props = { + content: string + editable: boolean + onChange: (content: string) => void +} + +type EditorWebViewMessage = + | { type: 'ready' } + | { type: 'change'; markdown: string; generation: number } + | { type: 'openLink'; url: string } + +type ToolbarItem = { + command: RichMarkdownCommand + label: string + icon: ComponentType<{ size?: number; color?: string }> +} + +const TOOLBAR_ITEMS: ToolbarItem[] = [ + { command: 'paragraph', label: 'Body', icon: Pilcrow }, + { command: 'heading1', label: 'H1', icon: Heading1 }, + { command: 'heading2', label: 'H2', icon: Heading2 }, + { command: 'heading3', label: 'H3', icon: Heading3 }, + { command: 'bold', label: 'Bold', icon: Bold }, + { command: 'italic', label: 'Italic', icon: Italic }, + { command: 'strike', label: 'Strike', icon: Strikethrough }, + { command: 'bulletList', label: 'Bullet list', icon: List }, + { command: 'orderedList', label: 'Numbered list', icon: ListOrdered }, + { command: 'taskList', label: 'Checklist', icon: ListTodo }, + { command: 'quote', label: 'Quote', icon: Quote }, + { command: 'link', label: 'Link', icon: Link }, + { command: 'image', label: 'Image', icon: ImageIcon }, + { command: 'inlineCode', label: 'Inline code', icon: Code2 }, + { command: 'codeBlock', label: 'Code block', icon: FileCode2 } +] + +function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) { + const webViewRef = useRef(null) + const readyRef = useRef(false) + const documentGenerationRef = useRef(0) + const currentWebViewContentRef = useRef(null) + const html = useMemo(() => buildMobileRichMarkdownEditorHtml(), []) + + const inject = useCallback((script: string) => { + webViewRef.current?.injectJavaScript(`${script}\ntrue;`) + }, []) + + const applyContent = useCallback( + (nextContent: string) => { + documentGenerationRef.current += 1 + currentWebViewContentRef.current = nextContent + inject( + `window.__orcaRichMarkdown && window.__orcaRichMarkdown.setMarkdown(${escapeInjectedJavaScriptString(nextContent)}, ${documentGenerationRef.current});` + ) + }, + [inject] + ) + + const applyEditable = useCallback( + (nextEditable: boolean) => { + inject( + `window.__orcaRichMarkdown && window.__orcaRichMarkdown.setEditable(${nextEditable ? 'true' : 'false'});` + ) + }, + [inject] + ) + + useEffect(() => { + if (!readyRef.current) return + if (currentWebViewContentRef.current !== content) { + applyContent(content) + } + }, [applyContent, content]) + + useEffect(() => { + if (readyRef.current) { + applyEditable(editable) + } + }, [applyEditable, editable]) + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + let message: unknown + try { + message = JSON.parse(event.nativeEvent.data) + } catch { + return + } + if (!message || typeof message !== 'object') return + const editorMessage = message as Partial + if ('type' in message && message.type === 'ready') { + readyRef.current = true + applyContent(content) + applyEditable(editable) + return + } + if ( + editorMessage.type === 'change' && + typeof editorMessage.markdown === 'string' && + editorMessage.generation === documentGenerationRef.current + ) { + currentWebViewContentRef.current = editorMessage.markdown + onChange(editorMessage.markdown) + return + } + if (editorMessage.type === 'openLink' && typeof editorMessage.url === 'string') { + const url = normalizeExternalEditorUrl(editorMessage.url) + if (url) { + void Linking.openURL(url).catch(() => {}) + } + } + }, + [applyContent, applyEditable, content, editable, onChange] + ) + + const handleShouldStartLoadWithRequest = useCallback((request: { url?: string }) => { + const url = request.url ?? '' + const isEditorDocument = + url === 'about:blank' || + url === EDITOR_DOCUMENT_URL || + url.startsWith(`${EDITOR_DOCUMENT_URL}#`) + // Why: editor content is untrusted markdown; links must leave through openLink. + return isEditorDocument + }, []) + + const runCommand = useCallback( + (command: RichMarkdownCommand) => { + inject( + `window.__orcaRichMarkdown && window.__orcaRichMarkdown.runCommand(${escapeInjectedJavaScriptString(command)});` + ) + }, + [inject] + ) + + return ( + + + + {TOOLBAR_ITEMS.map((item) => { + const Icon = item.icon + return ( + runCommand(item.command)} + style={({ pressed }) => [ + styles.toolbarButton, + pressed && editable ? styles.toolbarButtonPressed : null, + !editable ? styles.toolbarButtonDisabled : null + ]} + > + + + ) + })} + + + + + ) +} + +export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner) + +const styles = StyleSheet.create({ + container: { + flex: 1, + minHeight: 0, + backgroundColor: colors.bgBase + }, + toolbar: { + minHeight: 42, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + toolbarContent: { + alignItems: 'center', + gap: 6, + paddingHorizontal: spacing.sm, + paddingVertical: 6 + }, + toolbarButton: { + minWidth: 30, + height: 30, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + paddingHorizontal: spacing.xs + }, + toolbarButtonPressed: { + backgroundColor: colors.bgRaised + }, + toolbarButtonDisabled: { + opacity: 0.55 + }, + webView: { + flex: 1, + minHeight: 0, + backgroundColor: colors.bgBase + } +}) diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.test.ts b/mobile/src/components/mobile-rich-markdown-editor-html.test.ts new file mode 100644 index 00000000000..810ef180eb9 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-html.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest' +import { + buildMobileRichMarkdownEditorHtml, + escapeInjectedJavaScriptString +} from './mobile-rich-markdown-editor-html' + +function editorScript(): string { + const html = buildMobileRichMarkdownEditorHtml() + const script = html.match(/') + + expect(escaped).not.toContain('') + expect(JSON.parse(escaped.replace(/<\\\/script/gi, '' + ) + }) + + it('renders and serializes nested bullet, ordered, and task lists with indentation intact', () => { + const markdown = [ + '- Parent', + ' 1. Ordered child', + ' - [x] Done task', + ' - [ ] Open task', + '- Sibling' + ].join('\n') + + const html = runtimeMarkdownToHtml(markdown, true) + + expect(html).toContain( + '
  • Parent

    1. Ordered child

      ' + ) + expect(html).toContain('
        ') + expect(html).toContain('
      • Sibling

      ') + + const listMarkdown = runtimeListMarkdown() + const fakeList = { + tagName: 'UL', + getAttribute: () => null, + children: [ + { + tagName: 'LI', + text: 'Parent', + querySelector: () => null, + nestedLists: [ + { + tagName: 'OL', + getAttribute: () => null, + children: [ + { + tagName: 'LI', + text: 'Ordered child', + querySelector: () => null, + nestedLists: [ + { + tagName: 'UL', + getAttribute: (name: string) => (name === 'data-type' ? 'taskList' : null), + children: [ + { + tagName: 'LI', + text: 'Done task', + querySelector: () => ({ checked: true }), + nestedLists: [] + }, + { + tagName: 'LI', + text: 'Open task', + querySelector: () => ({ checked: false }), + nestedLists: [] + } + ] + } + ] + } + ] + } + ] + }, + { tagName: 'LI', text: 'Sibling', querySelector: () => null, nestedLists: [] } + ] + } + + expect(listMarkdown(fakeList)).toBe(markdown) + }) + + it('renders markdown entities as characters without double-escaping them', () => { + const html = runtimeMarkdownToHtml('R&D & Sales and <tag>', true) + + expect(html).toContain('R&D & Sales and <tag>') + expect(html).not.toContain('&amp;') + }) + + it('preserves explicit ordered-list numbering during serialization', () => { + const markdown = ['3. Third step', '4. Fourth step'].join('\n') + const html = runtimeMarkdownToHtml(markdown, true) + + expect(html).toContain('
        ') + expect(html).toContain('data-list-number="3"') + + const listMarkdown = runtimeListMarkdown() + const fakeList = { + tagName: 'OL', + getAttribute: () => null, + children: [ + { + tagName: 'LI', + text: 'Third step', + getAttribute: (name: string) => (name === 'data-list-number' ? '3' : null), + querySelector: () => null, + nestedLists: [] + }, + { + tagName: 'LI', + text: 'Fourth step', + getAttribute: (name: string) => (name === 'data-list-number' ? '4' : null), + querySelector: () => null, + nestedLists: [] + } + ] + } + + expect(listMarkdown(fakeList)).toBe(markdown) + }) + + it('serializes ordered lists from parent start when item metadata is missing', () => { + const listMarkdown = runtimeListMarkdown() + const fakeList = { + tagName: 'OL', + getAttribute: (name: string) => (name === 'start' ? '8' : null), + children: [ + { + tagName: 'LI', + text: 'Pasted step', + getAttribute: () => null, + querySelector: () => null, + nestedLists: [] + }, + { + tagName: 'LI', + text: 'Inserted step', + getAttribute: () => null, + querySelector: () => null, + nestedLists: [] + } + ] + } + + expect(listMarkdown(fakeList)).toBe(['8. Pasted step', '9. Inserted step'].join('\n')) + }) + + it('renders task checkboxes as disabled while read-only and guards mutation emitters', () => { + const html = runtimeMarkdownToHtml('- [ ] Read-only task', false) + const script = editorScript() + + expect(html).toContain('type="checkbox" disabled') + expect(extractFunctionSource(script, 'emitChange')).toContain('suppressInput || !editable') + expect(extractFunctionSource(script, 'setEditable')).toContain('syncTaskCheckboxesDisabled') + }) + + it('carries document generations through content replacement and change messages', () => { + const script = editorScript() + const setMarkdown = extractFunctionSource(script, 'setMarkdown') + const emitChange = extractFunctionSource(script, 'emitChange') + + expect(setMarkdown).toContain('window.clearTimeout(inputTimer)') + expect(setMarkdown).toContain('documentGeneration = Number(generation) || 0') + expect(emitChange).toContain('var pendingGeneration = documentGeneration') + expect(emitChange).not.toContain('window.setTimeout') + expect(emitChange).toContain('generation: pendingGeneration') + }) +}) diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts new file mode 100644 index 00000000000..826bd1693d0 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -0,0 +1,684 @@ +import { colors } from '../theme/mobile-theme' + +export function escapeInjectedJavaScriptString(value: string): string { + return JSON.stringify(value).replace(/<\/script/gi, '<\\/script') +} + +export function buildMobileRichMarkdownEditorHtml(): string { + return ` + + + + + + + +
        + + +` +} diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index 2a8c16170f9..29963ecec05 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -1,3 +1,6 @@ +/* oxlint-disable max-lines -- Why: mobile dictation keeps permission, recording, + * chunk upload, completion, and cancellation in one hook so native audio state + * cannot drift from the runtime RPC lifecycle. */ import { useCallback, useEffect, useRef, useState } from 'react' import { Buffer } from 'buffer' import { diff --git a/mobile/src/session/mobile-file-language.ts b/mobile/src/session/mobile-file-language.ts new file mode 100644 index 00000000000..30e4656c0a2 --- /dev/null +++ b/mobile/src/session/mobile-file-language.ts @@ -0,0 +1,91 @@ +function extname(filePath: string): string { + const lastDot = filePath.lastIndexOf('.') + const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + if (lastDot <= lastSlash) { + return '' + } + return filePath.slice(lastDot) +} + +const EXT_TO_LANGUAGE: Record = { + '.ts': 'typescript', + '.tsx': 'typescript', + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.json': 'json', + '.jsonc': 'json', + '.md': 'markdown', + '.mdx': 'markdown', + '.css': 'css', + '.scss': 'scss', + '.less': 'less', + '.html': 'html', + '.htm': 'html', + '.xml': 'xml', + '.svg': 'xml', + '.py': 'python', + '.rs': 'rust', + '.go': 'go', + '.java': 'java', + '.kt': 'kotlin', + '.kts': 'kotlin', + '.c': 'c', + '.h': 'c', + '.cpp': 'cpp', + '.cc': 'cpp', + '.cxx': 'cpp', + '.hpp': 'cpp', + '.cs': 'csharp', + '.rb': 'ruby', + '.php': 'php', + '.swift': 'swift', + '.sh': 'shell', + '.bash': 'shell', + '.zsh': 'shell', + '.fish': 'shell', + '.ps1': 'powershell', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'ini', + '.ini': 'ini', + '.cfg': 'ini', + '.conf': 'ini', + '.sql': 'sql', + '.graphql': 'graphql', + '.gql': 'graphql', + '.lua': 'lua', + '.r': 'r', + '.R': 'r', + '.make': 'makefile' +} + +const FILENAME_TO_LANGUAGE: Record = { + Dockerfile: 'dockerfile', + Makefile: 'makefile', + 'CMakeLists.txt': 'cmake', + '.gitignore': 'ini', + '.gitattributes': 'ini', + '.editorconfig': 'ini', + '.env': 'ini', + '.env.local': 'ini', + '.env.development': 'ini', + '.env.production': 'ini' +} + +export function detectMobileFileLanguage(filePath: string, preferredLanguage?: string): string { + const normalizedPreferred = preferredLanguage?.trim().toLowerCase() + if (normalizedPreferred && normalizedPreferred !== 'plaintext') { + return normalizedPreferred + } + + const parts = filePath.split(/[\\/]/) + const filename = parts.at(-1) ?? filePath + const exact = FILENAME_TO_LANGUAGE[filename] + if (exact) { + return exact + } + + return EXT_TO_LANGUAGE[extname(filename).toLowerCase()] ?? 'plaintext' +} diff --git a/mobile/src/session/mobile-file-syntax.test.ts b/mobile/src/session/mobile-file-syntax.test.ts new file mode 100644 index 00000000000..9dca0073f34 --- /dev/null +++ b/mobile/src/session/mobile-file-syntax.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { detectMobileFileLanguage } from './mobile-file-language' +import { + buildPlainMobileDiffSyntaxLines, + highlightMobileCode, + highlightMobileDiffLines, + resolveMobileSyntaxLanguage +} from './mobile-file-syntax' +import type { MobileDiffLine } from './mobile-diff-lines' + +describe('mobile file syntax highlighting', () => { + it('detects common source languages from file paths', () => { + expect(detectMobileFileLanguage('src/App.tsx')).toBe('typescript') + expect(detectMobileFileLanguage('scripts/deploy.sh')).toBe('shell') + expect(detectMobileFileLanguage('Dockerfile')).toBe('dockerfile') + expect(resolveMobileSyntaxLanguage('src/App.tsx')).toBe('typescript') + expect(resolveMobileSyntaxLanguage('Dockerfile')).toBe('plaintext') + }) + + it('emits semantic syntax segments for highlighted code', () => { + const result = highlightMobileCode('const label: string = "Orca"', 'typescript') + + expect(result.highlighted).toBe(true) + expect(result.segments).toEqual( + expect.arrayContaining([ + { text: 'const', kind: 'keyword' }, + { text: 'string', kind: 'type' }, + { text: '"Orca"', kind: 'string' } + ]) + ) + }) + + it('preserves diff line state while highlighting code inside the line', () => { + const lines: MobileDiffLine[] = [ + { kind: 'delete', text: 'const oldLabel = "Old"', oldLineNumber: 4 }, + { kind: 'add', text: 'const newLabel = "New"', newLineNumber: 4 } + ] + + const highlighted = highlightMobileDiffLines(lines, 'typescript') + + expect(highlighted[0]?.kind).toBe('delete') + expect(highlighted[0]?.oldLineNumber).toBe(4) + expect(highlighted[0]?.segments).toContainEqual({ text: 'const', kind: 'keyword' }) + expect(highlighted[1]?.kind).toBe('add') + expect(highlighted[1]?.newLineNumber).toBe(4) + expect(highlighted[1]?.segments).toContainEqual({ text: '"New"', kind: 'string' }) + }) + + it('falls back to plain text when segment caps would create too many React Native nodes', () => { + const result = highlightMobileCode('const label: string = "Orca"', 'typescript', 1_000, 1) + + expect(result.highlighted).toBe(false) + expect(result.segments).toEqual([{ text: 'const label: string = "Orca"', kind: 'plain' }]) + }) + + it('caps highlighted diff lines and keeps later rows renderable as plain text', () => { + const lines: MobileDiffLine[] = Array.from({ length: 520 }, (_, index) => ({ + kind: 'add', + text: `const value${index} = "mobile"`, + newLineNumber: index + 1 + })) + + const highlighted = highlightMobileDiffLines(lines, 'typescript') + + expect(highlighted).toHaveLength(520) + expect(highlighted.filter((line) => line.highlighted)).toHaveLength(500) + expect(highlighted[519]).toEqual({ + ...lines[519], + highlighted: false, + segments: [{ text: lines[519]!.text, kind: 'plain' }] + }) + }) + + it('stops attempting diff highlighting after a token-dense line exceeds the segment cap', () => { + const denseLine = Array.from( + { length: 120 }, + (_, index) => `const value${index}: string = "${index}"` + ).join('; ') + const highlighted = highlightMobileDiffLines( + [ + { kind: 'add', text: denseLine, newLineNumber: 1 }, + { kind: 'add', text: 'const later = "plain"', newLineNumber: 2 } + ] satisfies MobileDiffLine[], + 'typescript' + ) + + expect(highlighted[0]?.highlighted).toBe(false) + expect(highlighted[1]).toMatchObject({ + highlighted: false, + segments: [{ text: 'const later = "plain"', kind: 'plain' }] + }) + }) + + it('builds plain diff syntax rows without changing diff metadata', () => { + const lines: MobileDiffLine[] = [{ kind: 'delete', text: 'plain', oldLineNumber: 9 }] + + expect(buildPlainMobileDiffSyntaxLines(lines)).toEqual([ + { + ...lines[0], + highlighted: false, + segments: [{ text: 'plain', kind: 'plain' }] + } + ]) + }) +}) diff --git a/mobile/src/session/mobile-file-syntax.ts b/mobile/src/session/mobile-file-syntax.ts new file mode 100644 index 00000000000..f716fdc96ce --- /dev/null +++ b/mobile/src/session/mobile-file-syntax.ts @@ -0,0 +1,218 @@ +import { common, createLowlight } from 'lowlight' +import { detectMobileFileLanguage } from './mobile-file-language' + +export type MobileSyntaxTokenKind = + | 'plain' + | 'comment' + | 'keyword' + | 'string' + | 'number' + | 'type' + | 'function' + | 'variable' + | 'meta' + +export type MobileSyntaxSegment = { + text: string + kind: MobileSyntaxTokenKind +} + +export type MobileHighlightedDiffLine = TLine & { + segments: MobileSyntaxSegment[] + highlighted: boolean +} + +type LowlightNode = { + type: string + value?: string + properties?: { + className?: unknown + } + children?: LowlightNode[] +} + +const lowlight = createLowlight(common) +const MAX_FILE_HIGHLIGHT_CHARS = 48_000 +const MAX_FILE_HIGHLIGHT_SEGMENTS = 3_000 +const MAX_DIFF_HIGHLIGHT_CHARS = 24_000 +const MAX_DIFF_HIGHLIGHT_LINES = 500 +const MAX_DIFF_HIGHLIGHT_SEGMENTS = 4_000 +const MAX_DIFF_LINE_HIGHLIGHT_SEGMENTS = 96 + +const LANGUAGE_ALIASES: Record = { + javascriptreact: 'javascript', + jsx: 'javascript', + typescriptreact: 'typescript', + tsx: 'typescript', + sh: 'bash', + shell: 'bash', + zsh: 'bash', + fish: 'bash', + yml: 'yaml' +} + +export function resolveMobileSyntaxLanguage(filePath: string, preferredLanguage?: string): string { + const detected = detectMobileFileLanguage(filePath, preferredLanguage) + const normalized = LANGUAGE_ALIASES[detected] ?? detected + return lowlight.registered(normalized) ? normalized : 'plaintext' +} + +export function highlightMobileCode( + code: string, + language: string, + maxHighlightChars = MAX_FILE_HIGHLIGHT_CHARS, + maxHighlightSegments = MAX_FILE_HIGHLIGHT_SEGMENTS +): { segments: MobileSyntaxSegment[]; highlighted: boolean } { + if (code.length === 0) { + return { segments: [{ text: '', kind: 'plain' }], highlighted: false } + } + + const normalizedLanguage = LANGUAGE_ALIASES[language] ?? language + if (!lowlight.registered(normalizedLanguage) || normalizedLanguage === 'plaintext') { + return { segments: [{ text: code, kind: 'plain' }], highlighted: false } + } + + const highlightLength = getHighlightBoundary(code, maxHighlightChars) + const highlightedCode = code.slice(0, highlightLength) + try { + const tree = lowlight.highlight(normalizedLanguage, highlightedCode) as LowlightNode + const segments = mergeAdjacentSegments(flattenLowlightNodes(tree.children ?? [], 'plain')) + if (segments.length > maxHighlightSegments) { + return { segments: [{ text: code, kind: 'plain' }], highlighted: false } + } + if (highlightLength < code.length) { + appendSegment(segments, { text: code.slice(highlightLength), kind: 'plain' }) + } + return { segments, highlighted: true } + } catch { + return { segments: [{ text: code, kind: 'plain' }], highlighted: false } + } +} + +export function highlightMobileDiffLines( + lines: TLine[], + language: string +): MobileHighlightedDiffLine[] { + let attemptedChars = 0 + let attemptedLines = 0 + let highlightedSegments = 0 + let exhaustedHighlightBudget = false + + return lines.map((line) => { + const canHighlight = + !exhaustedHighlightBudget && + attemptedLines < MAX_DIFF_HIGHLIGHT_LINES && + attemptedChars + line.text.length <= MAX_DIFF_HIGHLIGHT_CHARS && + highlightedSegments < MAX_DIFF_HIGHLIGHT_SEGMENTS + if (!canHighlight) { + return plainHighlightedLine(line) + } + + attemptedLines += 1 + attemptedChars += line.text.length + const result = highlightMobileCode( + line.text, + language, + Math.min(MAX_FILE_HIGHLIGHT_CHARS, 8_000), + MAX_DIFF_LINE_HIGHLIGHT_SEGMENTS + ) + if ( + !result.highlighted || + highlightedSegments + result.segments.length > MAX_DIFF_HIGHLIGHT_SEGMENTS + ) { + exhaustedHighlightBudget = true + return plainHighlightedLine(line) + } + + highlightedSegments += result.segments.length + return { ...line, ...result } + }) +} + +export function buildPlainMobileDiffSyntaxLines( + lines: TLine[] +): MobileHighlightedDiffLine[] { + return lines.map((line) => plainHighlightedLine(line)) +} + +function getHighlightBoundary(code: string, maxHighlightChars: number): number { + if (code.length <= maxHighlightChars) { + return code.length + } + const boundary = code.lastIndexOf('\n', maxHighlightChars) + return boundary > 0 ? boundary + 1 : maxHighlightChars +} + +function flattenLowlightNodes( + nodes: LowlightNode[], + inheritedKind: MobileSyntaxTokenKind +): MobileSyntaxSegment[] { + const segments: MobileSyntaxSegment[] = [] + for (const node of nodes) { + if (node.type === 'text') { + appendSegment(segments, { text: node.value ?? '', kind: inheritedKind }) + continue + } + if (node.type !== 'element') { + continue + } + const kind = tokenKindForClasses(node.properties?.className) ?? inheritedKind + for (const segment of flattenLowlightNodes(node.children ?? [], kind)) { + appendSegment(segments, segment) + } + } + return segments +} + +function tokenKindForClasses(className: unknown): MobileSyntaxTokenKind | null { + const classes = Array.isArray(className) + ? className.filter((value): value is string => typeof value === 'string') + : typeof className === 'string' + ? className.split(/\s+/) + : [] + + const tokens = new Set(classes.map((value) => value.replace(/^hljs-/, ''))) + if (hasAny(tokens, ['comment', 'quote'])) return 'comment' + if (hasAny(tokens, ['keyword', 'selector-tag', 'tag', 'name'])) return 'keyword' + if (hasAny(tokens, ['string', 'regexp', 'symbol', 'bullet'])) return 'string' + if (hasAny(tokens, ['number', 'literal'])) return 'number' + if (hasAny(tokens, ['type', 'built_in', 'class', 'title.class'])) return 'type' + if (hasAny(tokens, ['title.function', 'function', 'title'])) return 'function' + if (hasAny(tokens, ['attr', 'attribute', 'property', 'variable', 'params'])) return 'variable' + if (hasAny(tokens, ['meta', 'doctag', 'subst', 'section'])) return 'meta' + return null +} + +function hasAny(values: Set, candidates: string[]): boolean { + return candidates.some((candidate) => values.has(candidate)) +} + +function mergeAdjacentSegments(segments: MobileSyntaxSegment[]): MobileSyntaxSegment[] { + const merged: MobileSyntaxSegment[] = [] + for (const segment of segments) { + appendSegment(merged, segment) + } + return merged +} + +function appendSegment(segments: MobileSyntaxSegment[], segment: MobileSyntaxSegment): void { + if (!segment.text) { + return + } + const previous = segments.at(-1) + if (previous?.kind === segment.kind) { + previous.text += segment.text + return + } + segments.push({ ...segment }) +} + +function plainHighlightedLine( + line: TLine +): MobileHighlightedDiffLine { + return { + ...line, + segments: [{ text: line.text, kind: 'plain' }], + highlighted: false + } +} diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts new file mode 100644 index 00000000000..cff24aeb0df --- /dev/null +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) + +function sliceBetween(startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('mobile session startup', () => { + it('auto-creates one terminal for an initially empty connected session', () => { + expect(source).toContain('const initialEmptySessionAutoCreateRef = useRef(null)') + expect(source).toContain('initialEmptySessionAutoCreateRef.current = null') + + const autoCreateEffect = sliceBetween( + 'if (\n !client ||\n !showEmptyState', + 'const terminalSummary =' + ) + expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current === worktreeId') + expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current = worktreeId') + expect(autoCreateEffect).toContain("setCreateError('')") + expect(autoCreateEffect).toContain('void handleCreateTerminal()') + }) +}) diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts new file mode 100644 index 00000000000..7570b696834 --- /dev/null +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { + getTerminalRecordsFromSessionTabs, + mergeTerminalListWithKnownRecords, + type MobileTerminalSessionTab, + type TerminalRecord +} from './mobile-terminal-records' + +const lightTheme = { + mode: 'light' as const, + theme: { + background: '#ffffff', + foreground: '#111111' + } +} + +const darkTheme = { + mode: 'dark' as const, + theme: { + background: '#111111', + foreground: '#eeeeee' + } +} + +describe('mobile terminal records', () => { + it('keeps session-tab terminal themes when terminal.list omits them', () => { + const terminalList: TerminalRecord[] = [ + { handle: 'pty-1', title: 'Terminal', isActive: true }, + { handle: 'pty-2', title: 'Logs', isActive: false } + ] + const currentTerminals: TerminalRecord[] = [ + { handle: 'pty-1', title: 'Terminal', terminalTheme: darkTheme, isActive: true } + ] + const sessionTabs: MobileTerminalSessionTab[] = [ + { + type: 'terminal', + id: 'term-1::leaf-1', + title: 'Terminal', + terminal: 'pty-1', + terminalTheme: lightTheme, + isActive: true + } + ] + + expect(mergeTerminalListWithKnownRecords(terminalList, currentTerminals, sessionTabs)).toEqual([ + { handle: 'pty-1', title: 'Terminal', terminalTheme: lightTheme, isActive: true }, + { handle: 'pty-2', title: 'Logs', isActive: false } + ]) + }) + + it('falls back to the current terminal theme while waiting for session tabs', () => { + const terminalList: TerminalRecord[] = [{ handle: 'pty-1', title: 'Terminal', isActive: true }] + const currentTerminals: TerminalRecord[] = [ + { handle: 'pty-1', title: 'Terminal', terminalTheme: darkTheme, isActive: true } + ] + + expect(mergeTerminalListWithKnownRecords(terminalList, currentTerminals, [])).toEqual([ + { handle: 'pty-1', title: 'Terminal', terminalTheme: darkTheme, isActive: true } + ]) + }) + + it('ignores pending terminal tabs without a handle', () => { + expect( + getTerminalRecordsFromSessionTabs([ + { + type: 'terminal', + id: 'pending', + title: 'Terminal', + terminal: null, + terminalTheme: lightTheme, + isActive: true + } + ]) + ).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts new file mode 100644 index 00000000000..688bef87def --- /dev/null +++ b/mobile/src/session/mobile-terminal-records.ts @@ -0,0 +1,104 @@ +import type { MobileTerminalTheme } from '../terminal/TerminalWebView' + +export type TerminalRecord = { + handle: string + title: string + terminalTheme?: MobileTerminalTheme + isActive: boolean +} + +export type MobileTerminalSessionTab = { + type: 'terminal' + id: string + title: string + parentTabId?: string + leafId?: string + status?: 'pending-handle' | 'ready' + terminal: string | null + terminalTheme?: MobileTerminalTheme + isActive: boolean +} + +type MobileSessionTabLike = + | MobileTerminalSessionTab + | { + type: string + title?: string + terminal?: unknown + terminalTheme?: MobileTerminalTheme + isActive?: boolean + } + +export function mergeTerminalRecordsByCurrentOrder( + terminalTabs: TerminalRecord[], + currentTerminals: TerminalRecord[] +): TerminalRecord[] { + if (currentTerminals.length === 0) { + return terminalTabs + } + const terminalTabsByHandle = new Map(terminalTabs.map((tab) => [tab.handle, tab])) + const currentHandles = new Set(currentTerminals.map((terminal) => terminal.handle)) + return [ + ...currentTerminals.map((terminal) => terminalTabsByHandle.get(terminal.handle) ?? terminal), + ...terminalTabs.filter((terminal) => !currentHandles.has(terminal.handle)) + ] +} + +export function getTerminalRecordsFromSessionTabs( + tabs: readonly MobileSessionTabLike[] +): TerminalRecord[] { + return tabs.flatMap((tab): TerminalRecord[] => { + if (tab.type !== 'terminal' || typeof tab.terminal !== 'string') { + return [] + } + return [ + { + handle: tab.terminal, + title: tab.title || 'Terminal', + terminalTheme: tab.terminalTheme, + isActive: tab.isActive === true + } + ] + }) +} + +export function mergeTerminalListWithKnownRecords( + terminalList: TerminalRecord[], + currentTerminals: TerminalRecord[], + sessionTabs: readonly MobileSessionTabLike[] +): TerminalRecord[] { + const currentTerminalsByHandle = new Map( + currentTerminals.map((terminal) => [terminal.handle, terminal]) + ) + const sessionTerminalsByHandle = new Map( + getTerminalRecordsFromSessionTabs(sessionTabs).map((terminal) => [terminal.handle, terminal]) + ) + return terminalList.map((terminal) => { + const sessionTerminal = sessionTerminalsByHandle.get(terminal.handle) + const currentTerminal = currentTerminalsByHandle.get(terminal.handle) + // Why: terminal.list summaries can omit the mobile theme; keep the richer + // session-tab/current record so polling cannot reset TerminalWebView. + return { + ...terminal, + terminalTheme: + sessionTerminal?.terminalTheme ?? currentTerminal?.terminalTheme ?? terminal.terminalTheme + } + }) +} + +export function terminalRecordsEqual( + a: readonly TerminalRecord[], + b: readonly TerminalRecord[] +): boolean { + return ( + a.length === b.length && + a.every( + (terminal, index) => + terminal.handle === b[index]?.handle && + terminal.title === b[index]?.title && + JSON.stringify(terminal.terminalTheme ?? null) === + JSON.stringify(b[index]?.terminalTheme ?? null) && + terminal.isActive === b[index]?.isActive + ) + ) +} diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index ca00fe5a47b..07972000495 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -10,6 +10,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [ 'copilot', 'opencode', 'pi', + 'omp', 'gemini', 'antigravity', 'aider', @@ -22,6 +23,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [ 'autohand', 'cline', 'codebuff', + 'command-code', 'continue', 'cursor', 'droid', @@ -40,6 +42,7 @@ export const MOBILE_TUI_AGENT_LABELS: Record = { copilot: 'GitHub Copilot', opencode: 'OpenCode', pi: 'Pi', + omp: 'OMP', gemini: 'Gemini', antigravity: 'Antigravity', aider: 'Aider', @@ -52,6 +55,7 @@ export const MOBILE_TUI_AGENT_LABELS: Record = { autohand: 'Autohand Code', cline: 'Cline', codebuff: 'Codebuff', + 'command-code': 'Command Code', continue: 'Continue', cursor: 'Cursor', droid: 'Droid', @@ -67,6 +71,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> grok: 'x.ai', copilot: 'github.com', opencode: 'opencode.ai', + omp: 'omp.sh', gemini: 'gemini.google.com', antigravity: 'antigravity.google', goose: 'goose-docs.ai', @@ -78,6 +83,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> autohand: 'autohand.ai', cline: 'cline.bot', codebuff: 'codebuff.com', + 'command-code': 'commandcode.ai', continue: 'continue.dev', cursor: 'cursor.com', droid: 'factory.ai', @@ -96,6 +102,7 @@ export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record = { copilot: 'copilot', opencode: 'opencode', pi: 'pi', + omp: 'omp', gemini: 'gemini', antigravity: 'agy', aider: 'aider', @@ -108,6 +115,7 @@ export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record = { autohand: 'autohand', cline: 'cline', codebuff: 'codebuff', + 'command-code': 'command-code', continue: 'continue', cursor: 'cursor-agent', droid: 'droid', diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index e1afc25f0b0..97de19e656a 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,7 +1,8 @@ -import { useRef, useCallback, forwardRef, useImperativeHandle } from 'react' +import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native' import { WebView } from 'react-native-webview' import type { WebViewMessageEvent } from 'react-native-webview' +import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' import { colors } from '../theme/mobile-theme' type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' @@ -20,6 +21,8 @@ export type TerminalKeyboardAvoidanceMetrics = { altScreen: boolean } +export type MobileTerminalTheme = RuntimeMobileTerminalTheme + export type TerminalSelectionEvents = { onSelectionMode?: (active: boolean) => void onSelectionCopy?: (text: string) => void @@ -28,6 +31,7 @@ export type TerminalSelectionEvents = { onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput?: (bytes: string) => void + onTerminalTap?: () => void } export type TerminalWebViewHandle = { @@ -48,18 +52,52 @@ export type TerminalWebViewHandle = { type Props = { style?: StyleProp + terminalTheme?: MobileTerminalTheme onWebReady?: () => void } & TerminalSelectionEvents type TerminalMessage = | { type: 'write'; id?: number; data: string } - | { type: 'init'; id?: number; cols: number; rows: number; initialData?: string } + | { + type: 'init' + id?: number + cols: number + rows: number + initialData?: string + terminalTheme?: MobileTerminalTheme + } | { type: 'resize'; id?: number; cols: number; rows: number } | { type: 'clear'; id?: number } | { type: 'measure'; id?: number; containerHeight?: number } | { type: 'reset-zoom'; id?: number } | { type: 'cancel-select'; id?: number } | { type: 'do-select-all'; id?: number } + | { type: 'set-theme'; id?: number; terminalTheme?: MobileTerminalTheme } + +const DEFAULT_TERMINAL_THEME: MobileTerminalTheme['theme'] = { + background: colors.terminalBg, + foreground: '#c0caf5', + cursor: '#c0caf5', + cursorAccent: colors.terminalBg, + selectionBackground: '#33467c', + selectionForeground: '#c0caf5', + black: '#15161e', + red: '#f7768e', + green: '#9ece6a', + yellow: '#e0af68', + blue: '#7aa2f7', + magenta: '#bb9af7', + cyan: '#7dcfff', + white: '#a9b1d6', + brightBlack: '#414868', + brightRed: '#f7768e', + brightGreen: '#9ece6a', + brightYellow: '#e0af68', + brightBlue: '#7aa2f7', + brightMagenta: '#bb9af7', + brightCyan: '#7dcfff', + brightWhite: '#c0caf5' +} // Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor // positioning designed for the desktop's terminal dimensions (~150+ cols). @@ -95,6 +133,46 @@ const XTERM_HTML = ` display: inline-block; } .xterm { -webkit-user-select: none; user-select: none; } + .xterm .xterm-viewport { + overflow-y: hidden !important; + scrollbar-width: none !important; + -ms-overflow-style: none; + } + .xterm .xterm-viewport::-webkit-scrollbar { + display: none !important; + width: 0 !important; + height: 0 !important; + background: transparent !important; + } + .xterm .xterm-scrollable-element > .xterm-scrollbar, + .xterm .xterm-scrollbar { + display: none !important; + width: 0 !important; + opacity: 0 !important; + pointer-events: none !important; + } + #scroll-indicator { + position: fixed; + top: 4px; + right: 3px; + bottom: 4px; + width: 3px; + pointer-events: none; + opacity: 0; + transition: opacity 120ms linear; + z-index: 7; + } + #scroll-indicator.visible { opacity: 0.72; } + #scroll-thumb { + position: absolute; + top: 0; + right: 0; + width: 3px; + min-height: 24px; + border-radius: 999px; + background: ${colors.textSecondary}; + will-change: transform, height; + } /* Why: selection overlay sits in unscaled viewport coords, above the transformed surface, so handle hit areas and Copy menu positions don't depend on getTotalScale() for their on-screen size. */ @@ -171,6 +249,7 @@ const XTERM_HTML = `
        +
        @@ -187,6 +266,9 @@ const XTERM_HTML = ` var C1_CSI = String.fromCharCode(155); var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; var term = null; + var scrollIndicator = document.getElementById('scroll-indicator'); + var scrollThumb = document.getElementById('scroll-thumb'); + var scrollIndicatorHideTimer = null; var writeQueue = []; var writesDraining = false; var afterDrainCallbacks = []; @@ -195,8 +277,13 @@ const XTERM_HTML = ` var userScale = 1; var panX = 0; var panY = 0; + var smoothScrollOffsetY = 0; + var pendingNormalScrollDeltaY = 0; + var normalScrollFrameId = null; var initRows = 24; var terminalGeneration = 0; + var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)}; + var terminalTheme = defaultTheme; var activeAltScreenSnapshot = false; var trackedMouseTrackingMode = 'none'; var sgrMouseMode = false; @@ -253,9 +340,57 @@ const XTERM_HTML = ` function updateTransform() { surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')'; + updateScrollIndicator(false); if (selMode === 'select') repositionOverlay(); } + function updateScrollIndicator(reveal) { + if (!scrollIndicator || !scrollThumb || !term || !term.buffer || !term.buffer.active) return; + var buffer = term.buffer.active; + var maxViewportY = buffer.baseY || 0; + if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) { + scrollIndicator.classList.remove('visible'); + return; + } + var trackHeight = Math.max(0, window.innerHeight - 8); + var totalRows = maxViewportY + (term.rows || 0); + if (trackHeight <= 0 || totalRows <= 0) return; + var thumbHeight = Math.max(24, trackHeight * (term.rows || 0) / totalRows); + var maxTop = Math.max(0, trackHeight - thumbHeight); + var top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0; + scrollThumb.style.height = thumbHeight + 'px'; + scrollThumb.style.transform = 'translateY(' + top + 'px)'; + if (!reveal) return; + scrollIndicator.classList.add('visible'); + if (scrollIndicatorHideTimer) clearTimeout(scrollIndicatorHideTimer); + scrollIndicatorHideTimer = setTimeout(function() { + scrollIndicator.classList.remove('visible'); + scrollIndicatorHideTimer = null; + }, 550); + } + + function normalizeTerminalTheme(input) { + var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object' + ? input.theme + : null; + if (!source) return defaultTheme; + var next = {}; + var keys = Object.keys(defaultTheme); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (typeof source[key] === 'string') next[key] = source[key]; + } + return Object.assign({}, defaultTheme, next); + } + + function applyTerminalTheme(input) { + terminalTheme = normalizeTerminalTheme(input); + var background = terminalTheme.background || '${colors.terminalBg}'; + document.documentElement.style.background = background; + document.body.style.background = background; + if (term) term.options.theme = terminalTheme; + } + function getCellHeight() { if (!term || !term._core) return 15; var core = term._core; @@ -376,6 +511,7 @@ const XTERM_HTML = ` userScale = 1; panX = 0; panY = 0; + smoothScrollOffsetY = 0; updateTransform(); adjustRowsForViewport(); @@ -494,7 +630,7 @@ const XTERM_HTML = ` pumpWrites(terminalGeneration); } - function init(cols, rows, initialData) { + function init(cols, rows, initialData, nextTheme) { terminalGeneration++; var gen = terminalGeneration; ready = false; @@ -503,6 +639,7 @@ const XTERM_HTML = ` afterDrainCallbacks = []; initRows = rows || 24; firstDataPending = true; + smoothScrollOffsetY = 0; mouseModeScanTail = ''; trackedMouseTrackingMode = 'none'; sgrMouseMode = false; @@ -535,32 +672,11 @@ const XTERM_HTML = ` oldSurface.removeAttribute('id'); } + applyTerminalTheme(nextTheme); term = new Terminal({ cols: cols || 80, rows: rows || 24, - theme: { - background: '${colors.terminalBg}', - foreground: '#c0caf5', - cursor: '#c0caf5', - cursorAccent: '${colors.terminalBg}', - selectionBackground: '#33467c', - black: '#15161e', - red: '#f7768e', - green: '#9ece6a', - yellow: '#e0af68', - blue: '#7aa2f7', - magenta: '#bb9af7', - cyan: '#7dcfff', - white: '#a9b1d6', - brightBlack: '#414868', - brightRed: '#f7768e', - brightGreen: '#9ece6a', - brightYellow: '#e0af68', - brightBlue: '#7aa2f7', - brightMagenta: '#bb9af7', - brightCyan: '#7dcfff', - brightWhite: '#c0caf5' - }, + theme: terminalTheme, fontFamily: '"Menlo", "Consolas", "DejaVu Sans Mono", monospace', fontSize: 13, scrollback: 5000, @@ -693,7 +809,7 @@ const XTERM_HTML = ` if (handledMessageIds.length > 256) handledMessageIds.shift(); } if (msg.type === 'init') { - init(msg.cols, msg.rows, msg.initialData); + init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme); } else if (msg.type === 'resize') { resize(msg.cols, msg.rows); } else if (msg.type === 'write') { @@ -718,6 +834,8 @@ const XTERM_HTML = ` measureFitDimensions(msg.containerHeight); } else if (msg.type === 'reset-zoom') { applyFitScale('reset-zoom-msg'); + } else if (msg.type === 'set-theme') { + applyTerminalTheme(msg.terminalTheme); } else if (msg.type === 'cancel-select') { if (selMode === 'select') cancelSelect(); } else if (msg.type === 'do-select-all') { @@ -849,6 +967,7 @@ const XTERM_HTML = ` function attachTermObservers() { if (!term) return; try { term.onLineFeed(logFeedAndEvict); } catch (e) {} + try { term.onScroll(function() { updateScrollIndicator(false); }); } catch (e) {} // Why: emit modes on every parsed write so RN's mirror stays current // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. try { @@ -975,6 +1094,10 @@ const XTERM_HTML = ` return mode !== 'none' && mode !== 'x10'; } + function shouldRouteScrollToTerminalInput() { + return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); + } + function buildMouseWheelScrollInput(lines, clientX, clientY) { var count = Math.min(Math.abs(lines), 32); if (count === 0) return ''; @@ -1024,6 +1147,83 @@ const XTERM_HTML = ` term.scrollLines(lines); } + function clampNormalScrollLines(lines) { + if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0; + var buffer = term.buffer.active; + if (lines > 0) { + return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY)); + } + return Math.max(lines, -buffer.viewportY); + } + + function canScrollNormalBufferDelta(deltaY) { + if (!term || !term.buffer || !term.buffer.active || deltaY === 0) return false; + var buffer = term.buffer.active; + if (deltaY > 0) return buffer.viewportY < buffer.baseY; + return buffer.viewportY > 0; + } + + function applyNormalBufferScrollDelta(deltaY) { + if (!term || deltaY === 0) return false; + var effectiveCellH = getCellHeight() * getTotalScale(); + if (effectiveCellH <= 0) return false; + if (!canScrollNormalBufferDelta(deltaY)) { + resetSmoothScrollOffset(); + return false; + } + smoothScrollOffsetY -= deltaY; + var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH); + if (lines !== 0) { + var applied = clampNormalScrollLines(lines); + if (applied !== 0) { + term.scrollLines(applied); + // Why: xterm's renderer is row-based. Buffer touch pixels and only + // commit whole rows so TUI canvas layers do not shimmer between + // fractional transforms and xterm repaints. + smoothScrollOffsetY += applied * effectiveCellH; + } + if (applied !== lines) smoothScrollOffsetY = 0; + } + var limit = effectiveCellH - 1; + if (smoothScrollOffsetY > limit) smoothScrollOffsetY = limit; + if (smoothScrollOffsetY < -limit) smoothScrollOffsetY = -limit; + updateScrollIndicator(true); + return true; + } + + function enqueueNormalBufferScrollDelta(deltaY) { + if (!term || deltaY === 0) return false; + if (!canScrollNormalBufferDelta(deltaY)) { + resetSmoothScrollOffset(); + return false; + } + pendingNormalScrollDeltaY += deltaY; + if (normalScrollFrameId !== null) return true; + // Why: dense terminal rows are expensive to repaint. Coalesce touchmove + // deltas into one xterm row-scroll per frame instead of repainting from + // the input event stream. + normalScrollFrameId = requestAnimationFrame(function() { + normalScrollFrameId = null; + var delta = pendingNormalScrollDeltaY; + pendingNormalScrollDeltaY = 0; + if (!applyNormalBufferScrollDelta(delta)) { + resetSmoothScrollOffset(); + } + }); + return true; + } + + function resetSmoothScrollOffset() { + pendingNormalScrollDeltaY = 0; + if (normalScrollFrameId !== null) { + cancelAnimationFrame(normalScrollFrameId); + normalScrollFrameId = null; + } + if (smoothScrollOffsetY === 0) return; + smoothScrollOffsetY = 0; + updateScrollIndicator(false); + } + function cellToViewportPx(col, absRow) { if (!term) return { x: 0, y: 0 }; var cellW = getCellWidth(); @@ -1339,6 +1539,9 @@ const XTERM_HTML = ` return; } if (dispatch.mode === 'surface') { + if (e.touches.length === 0 && longPressOrigin && selMode !== 'select') { + notify({ type: 'terminal-tap' }); + } clearLongPress(); if (e.touches.length === 0) { dispatch.mode = 'idle'; @@ -1392,6 +1595,15 @@ const XTERM_HTML = ` pinchDist: 0, pinchScale: 0, pinchSurfX: 0, pinchSurfY: 0 }; + function updateTouchVelocity(deltaY, dt) { + if (dt <= 0) return; + var instantVelocity = deltaY / dt; + if (!isFinite(instantVelocity)) return; + // Why: touchmove cadence is uneven in WebView. Blend recent samples so + // momentum launch doesn't inherit a one-frame spike or stall. + ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45; + } + function getDistance(a, b) { var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; return Math.sqrt(dx * dx + dy * dy); @@ -1413,6 +1625,7 @@ const XTERM_HTML = ` } if (e.touches.length === 2) { ts.isPinching = true; + smoothScrollOffsetY = 0; ts.pinchDist = getDistance(e.touches[0], e.touches[1]); ts.pinchScale = userScale; var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; @@ -1458,20 +1671,30 @@ const XTERM_HTML = ` var dt = now - ts.lastTime; if (userScale > 1.05) { + smoothScrollOffsetY = 0; panX += x - ts.lastX; panY += y - ts.lastY; clampPan(); updateTransform(); } else { var deltaY = ts.lastY - y; - if (dt > 0) ts.velY = deltaY / dt; ts.lastTime = now; - var effectiveCellH = getCellHeight() * currentScale; - ts.accumDelta += deltaY; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, x, y); + if (shouldRouteScrollToTerminalInput()) { + updateTouchVelocity(deltaY, dt); + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += deltaY; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, x, y); + } + } else { + if (enqueueNormalBufferScrollDelta(deltaY)) { + updateTouchVelocity(deltaY, dt); + } else { + ts.velY = 0; + } } } ts.lastX = x; @@ -1501,17 +1724,26 @@ const XTERM_HTML = ` if (e.touches.length === 0 && userScale <= 1.05) { var vel = ts.velY; - var FRICTION = 0.95; - var MIN_VEL = 0.02; + var FRICTION = 0.972; + var MIN_VEL = 0.012; function momentumStep() { vel *= FRICTION; if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } - var effectiveCellH = getCellHeight() * currentScale; - ts.accumDelta += vel * 16; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, ts.lastX, ts.lastY); + var delta = vel * 16; + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += delta; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, ts.lastX, ts.lastY); + } + } else { + if (!applyNormalBufferScrollDelta(delta)) { + ts.momentumId = null; + return; + } } ts.momentumId = requestAnimationFrame(momentumStep); } @@ -1561,6 +1793,7 @@ const XTERM_HTML = ` export const TerminalWebView = forwardRef(function TerminalWebView( { style, + terminalTheme, onWebReady, onSelectionMode, onSelectionCopy, @@ -1568,7 +1801,8 @@ export const TerminalWebView = forwardRef(function onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, - onTerminalInput + onTerminalInput, + onTerminalTap }, ref ) { @@ -1576,6 +1810,7 @@ export const TerminalWebView = forwardRef(function const isWebReadyRef = useRef(false) const pendingMessagesRef = useRef([]) const messageIdRef = useRef(0) + const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme]) const measureResolveRef = useRef< ((result: { cols: number; rows: number } | null) => void) | null >(null) @@ -1670,6 +1905,8 @@ export const TerminalWebView = forwardRef(function } else if (msg.type === 'terminal-input') { const bytes = typeof msg.bytes === 'string' ? msg.bytes : '' if (bytes.length > 0) onTerminalInput?.(bytes) + } else if (msg.type === 'terminal-tap') { + onTerminalTap?.() } else if (msg.type === 'keyboard-avoidance-metrics') { const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0 const rows = typeof msg.rows === 'number' ? msg.rows : 0 @@ -1702,7 +1939,8 @@ export const TerminalWebView = forwardRef(function onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, - onTerminalInput + onTerminalInput, + onTerminalTap ] ) @@ -1710,6 +1948,10 @@ export const TerminalWebView = forwardRef(function isWebReadyRef.current = false }, []) + useEffect(() => { + postMessage({ type: 'set-theme', terminalTheme }) + }, [postMessage, terminalThemeKey, terminalTheme]) + useImperativeHandle( ref, () => ({ @@ -1733,7 +1975,7 @@ export const TerminalWebView = forwardRef(function readyPromiseRef.current = new Promise((resolve) => { readyResolveRef.current = resolve }) - postMessage({ type: 'init', cols, rows, initialData }) + postMessage({ type: 'init', cols, rows, initialData, terminalTheme }) }, resize(cols: number, rows: number) { postMessage({ type: 'resize', cols, rows }) @@ -1778,7 +2020,7 @@ export const TerminalWebView = forwardRef(function await Promise.race([p, new Promise((resolve) => setTimeout(resolve, 3000))]) } }), - [postMessage, sendToWebView] + [postMessage, sendToWebView, terminalTheme] ) return ( @@ -1788,7 +2030,7 @@ export const TerminalWebView = forwardRef(function style={[styles.webview, style]} originWhitelist={['*']} javaScriptEnabled - scrollEnabled={true} + scrollEnabled={false} scalesPageToFit={false} onLoadStart={handleLoadStart} onMessage={handleMessage} diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts new file mode 100644 index 00000000000..0bad02da6d0 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_LIVE_INPUT_MAX_BYTES, + getTerminalLiveSpecialKeyBytes, + isTerminalLiveInputWithinByteLimit +} from './terminal-live-input' + +describe('terminal live input', () => { + it('maps phone keyboard special keys to PTY bytes', () => { + expect(getTerminalLiveSpecialKeyBytes('Backspace')).toBe('\x7f') + expect(getTerminalLiveSpecialKeyBytes('Enter')).toBeNull() + expect(getTerminalLiveSpecialKeyBytes('a')).toBeNull() + }) + + it('enforces the paste-sized byte budget', () => { + expect(isTerminalLiveInputWithinByteLimit('hello')).toBe(true) + expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES))).toBe(true) + expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES + 1))).toBe( + false + ) + expect( + isTerminalLiveInputWithinByteLimit('é'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES / 2 + 1)) + ).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts new file mode 100644 index 00000000000..87b6544fc74 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input.ts @@ -0,0 +1,19 @@ +const TERMINAL_LIVE_INPUT_MAX_BYTES = 256 * 1024 + +const encoder = new TextEncoder() + +export function getTerminalLiveSpecialKeyBytes(key: string): string | null { + if (key === 'Backspace') { + return '\x7f' + } + return null +} + +export function isTerminalLiveInputWithinByteLimit( + text: string, + maxBytes = TERMINAL_LIVE_INPUT_MAX_BYTES +): boolean { + return encoder.encode(text).byteLength <= maxBytes +} + +export { TERMINAL_LIVE_INPUT_MAX_BYTES } diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts new file mode 100644 index 00000000000..bf5b7f7e27f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') + +function sliceBetween(startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('TerminalWebView scroll routing', () => { + it('routes alternate-screen and mouse-aware scroll before smooth normal scroll', () => { + expect(source).toContain( + 'return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive();' + ) + + const touchMoveBlock = sliceBetween( + "targetSurface.addEventListener('touchmove'", + '}, { capture: true, passive: false });' + ) + expect(touchMoveBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan( + touchMoveBlock.indexOf('if (enqueueNormalBufferScrollDelta(deltaY))') + ) + expect(touchMoveBlock).toContain('routeScrollLines(lines, x, y);') + + const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)') + expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan( + momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(delta))') + ) + expect(momentumBlock).toContain('routeScrollLines(lines, ts.lastX, ts.lastY);') + }) + + it('does not rubber-band normal scroll at scrollback edges', () => { + expect(source).toContain('function canScrollNormalBufferDelta(deltaY)') + const smoothScrollBlock = sliceBetween( + 'function applyNormalBufferScrollDelta(deltaY)', + 'function enqueueNormalBufferScrollDelta(deltaY)' + ) + expect(smoothScrollBlock).toContain('if (!canScrollNormalBufferDelta(deltaY))') + expect(smoothScrollBlock).toContain('resetSmoothScrollOffset();') + expect(smoothScrollBlock).toContain('return false;') + expect(smoothScrollBlock).toContain('return true;') + + const touchMoveBlock = sliceBetween( + "targetSurface.addEventListener('touchmove'", + '}, { capture: true, passive: false });' + ) + expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(deltaY))') + expect(touchMoveBlock).toContain('ts.velY = 0;') + + const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)') + expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(delta))') + expect(momentumBlock).toContain('ts.momentumId = null;') + }) + + it('coalesces normal touch scroll row commits onto animation frames', () => { + const enqueueBlock = sliceBetween( + 'function enqueueNormalBufferScrollDelta(deltaY)', + 'function resetSmoothScrollOffset()' + ) + expect(enqueueBlock).toContain('pendingNormalScrollDeltaY += deltaY;') + expect(enqueueBlock).toContain('if (normalScrollFrameId !== null) return true;') + expect(enqueueBlock).toContain('normalScrollFrameId = requestAnimationFrame(function()') + expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(delta)') + + const resetBlock = sliceBetween( + 'function resetSmoothScrollOffset()', + 'function cellToViewportPx' + ) + expect(resetBlock).toContain('pendingNormalScrollDeltaY = 0;') + expect(resetBlock).toContain('cancelAnimationFrame(normalScrollFrameId);') + }) + + it('hides xterm scrollbars and drives the mobile scroll indicator from committed rows', () => { + expect(source).toContain('
        ') + expect(source).toContain('.xterm .xterm-viewport::-webkit-scrollbar') + expect(source).toContain('.xterm .xterm-scrollable-element > .xterm-scrollbar') + expect(source).toContain('overflow-y: hidden !important;') + expect(source).toContain('display: none !important;') + expect(source).toContain('function updateScrollIndicator(reveal)') + expect(source).toContain('buffer.viewportY / maxViewportY') + expect(source).not.toContain('fractionalRows') + expect(source).toContain('scrollThumb.style.transform =') + expect(source).toContain('updateScrollIndicator(true);') + }) + + it('does not apply fractional smooth scroll transforms to terminal content', () => { + const updateTransformBlock = sliceBetween( + 'function updateTransform()', + 'function updateScrollIndicator(reveal)' + ) + expect(updateTransformBlock).toContain( + "surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';" + ) + expect(source).not.toContain("querySelector('.xterm-screen')") + expect(source).not.toContain('updateTerminalScreenTransform') + expect(updateTransformBlock).not.toContain("getVisualPanY() + 'px) scale('") + expect(updateTransformBlock).not.toContain('smoothScrollOffsetY') + }) + + it('smooths velocity samples and uses lower friction for mobile momentum', () => { + expect(source).toContain('function updateTouchVelocity(deltaY, dt)') + expect(source).toContain('ts.velY * 0.55 + instantVelocity * 0.45') + expect(source).toContain('var FRICTION = 0.972;') + expect(source).toContain('var MIN_VEL = 0.012;') + }) +}) diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts index d0f80c671e0..83dadecec6a 100644 --- a/mobile/src/theme/mobile-theme.ts +++ b/mobile/src/theme/mobile-theme.ts @@ -6,6 +6,7 @@ export const colors = { bgPanel: '#1a1a1a', bgRaised: '#242424', borderSubtle: '#2a2a2a', + editorSurface: '#1e1e1e', textPrimary: '#e0e0e0', textSecondary: '#888888', @@ -16,6 +17,19 @@ export const colors = { statusGreen: '#22c55e', statusAmber: '#f59e0b', statusRed: '#ef4444', + gitDecorationAdded: '#81b88b', + gitDecorationDeleted: '#c74e39', + diffAddedBg: 'rgba(129, 184, 139, 0.1)', + diffDeletedBg: 'rgba(199, 78, 57, 0.11)', + + syntaxComment: '#6a9955', + syntaxKeyword: '#569cd6', + syntaxString: '#ce9178', + syntaxNumber: '#b5cea8', + syntaxType: '#4ec9b0', + syntaxFunction: '#dcdcaa', + syntaxVariable: '#9cdcfe', + syntaxMeta: '#c586c0', // Terminal WebView background (Tokyonight) — separate from app chrome terminalBg: '#1a1b26' diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 2dd0c7e4a02..8c7d6a4a4e4 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -196,7 +196,7 @@ final class Provider { var action = try runAction() do { return try renderActionResult(action: action, snapshot: observe(params: params)) - } catch let error as ProviderError where (error.code == "window_not_found" || error.code == "window_stale") && (requestedWindowId(params) != nil || requestedWindowIndex(params) != nil) { + } catch let error as ProviderError where (error.code == "window_not_found" || error.code == "window_stale") && hasRequestedWindowSelector(params) { var fallbackParams = params fallbackParams.removeValue(forKey: "windowId") fallbackParams.removeValue(forKey: "windowIndex") @@ -209,6 +209,8 @@ final class Provider { private func observe(params: [String: JSONValue]) throws -> Snapshot { let query = try requiredString(params, "app") + let windowId = try requestedWindowId(params) + let windowIndex = try requestedWindowIndex(params) let app = try resolveApp(query) if params["restoreWindow"]?.bool == true { recoverWindow(app) @@ -216,8 +218,8 @@ final class Provider { let snapshot = try buildSnapshot( app: app, includeScreenshot: params["noScreenshot"]?.bool != true, - windowId: requestedWindowId(params), - windowIndex: requestedWindowIndex(params), + windowId: windowId, + windowIndex: windowIndex, restoreWindow: params["restoreWindow"]?.bool == true ) let keys = [query, app.name, app.bundleId ?? ""].filter { !$0.isEmpty }.map { $0.lowercased() } @@ -226,13 +228,13 @@ final class Provider { if !isExplicitSnapshotNamespace(namespace) { snapshots[key] = snapshot snapshots[snapshotWindowKey(key, snapshot.windowId)] = snapshot - if let windowIndex = requestedWindowIndex(params) { + if let windowIndex { snapshots[snapshotWindowIndexKey(key, windowIndex)] = snapshot } } snapshots[namespacedSnapshotKey(namespace, key)] = snapshot snapshots[namespacedSnapshotKey(namespace, snapshotWindowKey(key, snapshot.windowId))] = snapshot - if let windowIndex = requestedWindowIndex(params) { + if let windowIndex { snapshots[namespacedSnapshotKey(namespace, snapshotWindowIndexKey(key, windowIndex))] = snapshot } } @@ -240,7 +242,7 @@ final class Provider { } private func currentSnapshot(params: [String: JSONValue]) throws -> Snapshot { - let cached = cachedSnapshot(params: params) + let cached = try cachedSnapshot(params: params) // Why: cached AX frames can be stale after a window move or resize, and // stale geometry can turn an intended action into a misclick. let snapshot = try observe(params: params.merging(["noScreenshot": .bool(true)]) { _, replacement in replacement }) @@ -256,15 +258,15 @@ final class Provider { return snapshot } - private func cachedSnapshot(params: [String: JSONValue]) -> Snapshot? { + private func cachedSnapshot(params: [String: JSONValue]) throws -> Snapshot? { guard let query = params["app"]?.string, !query.isEmpty else { return nil } let namespace = snapshotNamespace(params) - if let targetWindowId = requestedWindowId(params) { + if let targetWindowId = try requestedWindowId(params) { let windowKey = snapshotWindowKey(query.lowercased(), targetWindowId) return snapshots[namespacedSnapshotKey(namespace, windowKey)] ?? (isExplicitSnapshotNamespace(namespace) ? nil : snapshots[windowKey]) } - if let targetWindowIndex = requestedWindowIndex(params) { + if let targetWindowIndex = try requestedWindowIndex(params) { let windowKey = snapshotWindowIndexKey(query.lowercased(), targetWindowIndex) return snapshots[namespacedSnapshotKey(namespace, windowKey)] ?? (isExplicitSnapshotNamespace(namespace) ? nil : snapshots[windowKey]) @@ -275,16 +277,15 @@ final class Provider { } private func validateRequestedElements(cached: Snapshot?, current: Snapshot, params: [String: JSONValue]) throws { - let requestedKeys = ["elementIndex", "fromElementIndex", "toElementIndex"].filter { - params[$0]?.number != nil + let requestedIndexes = try ["elementIndex", "fromElementIndex", "toElementIndex"].compactMap { key -> Int? in + guard params[key]?.number != nil else { return nil } + return try optionalInteger(params, key) } - guard !requestedKeys.isEmpty else { return } + guard !requestedIndexes.isEmpty else { return } guard let cached else { throw ProviderError.coded("element_not_found", "element indexes require a fresh get-app-state snapshot for this app/window") } - for key in requestedKeys { - guard let value = params[key]?.number else { continue } - let index = Int(value) + for index in requestedIndexes { guard let expected = cached.elements[index], let actual = current.elements[index] else { throw ProviderError.coded("element_not_found", "element \(index) is stale; run get-app-state again and use a fresh element index") } @@ -577,9 +578,9 @@ final class Provider { private func click(params: [String: JSONValue]) throws -> [String: Any] { let snapshot = try currentSnapshot(params: params) let button = params["mouseButton"]?.string ?? "left" - let count = Int(params["clickCount"]?.number ?? 1) - if let elementIndex = params["elementIndex"]?.number { - let record = try element(snapshot, Int(elementIndex)) + let count = try optionalInteger(params, "clickCount") ?? 1 + if let elementIndex = try optionalInteger(params, "elementIndex") { + let record = try element(snapshot, elementIndex) if count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) { return actionMetadata(path: "accessibility", actionName: actionName) } @@ -618,7 +619,7 @@ final class Provider { private func performSecondaryAction(params: [String: JSONValue]) throws -> [String: Any] { let snapshot = try currentSnapshot(params: params) - let record = try element(snapshot, Int(try requiredNumber(params, "elementIndex"))) + let record = try element(snapshot, try requiredInteger(params, "elementIndex")) let requested = try requiredString(params, "action") let action = record.actions.first { SnapshotRenderHeuristics.prettyAction($0).caseInsensitiveCompare(requested) == .orderedSame || $0.caseInsensitiveCompare(requested) == .orderedSame } guard let action else { @@ -632,7 +633,7 @@ final class Provider { private func setValue(params: [String: JSONValue]) throws -> [String: Any] { let snapshot = try currentSnapshot(params: params) - let record = try element(snapshot, Int(try requiredNumber(params, "elementIndex"))) + let record = try element(snapshot, try requiredInteger(params, "elementIndex")) guard isSettable(record.element, kAXValueAttribute as String) else { throw ProviderError.coded("value_not_settable", "element \(record.index) is not settable") } @@ -691,11 +692,12 @@ final class Provider { let snapshot = try currentSnapshot(params: params) let direction = try requiredString(params, "direction") let pages = params["pages"]?.number ?? 1 - if let elementIndex = params["elementIndex"]?.number { - let record = try element(snapshot, Int(elementIndex)) + if let elementIndex = try optionalInteger(params, "elementIndex") { + let record = try element(snapshot, elementIndex) let action = "AXScroll\(direction.capitalized)ByPage" - if pages.rounded() == pages, record.actions.contains(action) { - for _ in 0.. Int { + guard let value = boundedInteger(try requiredNumber(params, key), as: Int.self) else { + throw ProviderError.coded("invalid_argument", "\(key) is out of range") + } + return value +} + +private func optionalInteger(_ params: [String: JSONValue], _ key: String) throws -> Int? { + guard let raw = params[key]?.number else { return nil } + guard let value = boundedInteger(raw, as: Int.self) else { + throw ProviderError.coded("invalid_argument", "\(key) is out of range") + } + return value +} + private func parsePid(_ query: String) -> pid_t? { guard query.hasPrefix("pid:") else { return nil } guard let pid = Int32(query.dropFirst(4)), pid > 0 else { return nil } @@ -949,9 +967,16 @@ private func openBundle(_ bundleId: String) { process.waitUntilExit() } -private func requestedWindowId(_ params: [String: JSONValue]) -> CGWindowID? { - guard let value = params["windowId"]?.number, value >= 0 else { return nil } - return CGWindowID(UInt32(value)) +private func hasRequestedWindowSelector(_ params: [String: JSONValue]) -> Bool { + params["windowId"] != nil || params["windowIndex"] != nil +} + +private func requestedWindowId(_ params: [String: JSONValue]) throws -> CGWindowID? { + guard let raw = params["windowId"] else { return nil } + guard let value = raw.number, value >= 0, let id = boundedInteger(value, as: UInt32.self) else { + throw ProviderError.coded("invalid_argument", "windowId is out of range") + } + return CGWindowID(id) } private func snapshotWindowKey(_ query: String, _ windowId: CGWindowID) -> String { @@ -980,9 +1005,12 @@ private func isExplicitSnapshotNamespace(_ namespace: String) -> Bool { namespace != "default" } -private func requestedWindowIndex(_ params: [String: JSONValue]) -> Int? { - guard let value = params["windowIndex"]?.number, value >= 0 else { return nil } - return Int(value) +private func requestedWindowIndex(_ params: [String: JSONValue]) throws -> Int? { + guard let raw = params["windowIndex"] else { return nil } + guard let value = raw.number, value >= 0, let index = boundedInteger(value, as: Int.self) else { + throw ProviderError.coded("invalid_argument", "windowIndex is out of range") + } + return index } private func usableWindow(_ element: AXUIElement) -> Bool { @@ -1788,7 +1816,9 @@ private enum Input { } static func scroll(pid: pid_t, at point: CGPoint, direction: String, pages: Double) throws { - let delta = Int32(max(1, (12 * pages).rounded())) + guard let delta = boundedInteger(max(1, (12 * pages).rounded()), as: Int32.self) else { + throw ProviderError.coded("invalid_argument", "pages is out of range") + } let wheel1: Int32 = direction == "up" ? delta : direction == "down" ? -delta : 0 let wheel2: Int32 = direction == "left" ? delta : direction == "right" ? -delta : 0 guard let event = CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 2, wheel1: wheel1, wheel2: wheel2, wheel3: 0) else { @@ -2056,6 +2086,7 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { windowController = PermissionWindowController( + initialPermission: initialPermission, terminateWhenDragAssistantCloses: initialPermission != nil ) if let initialPermission { @@ -2067,6 +2098,10 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate { } } + func applicationDidBecomeActive(_ notification: Notification) { + windowController?.refreshPermissions() + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { initialPermission == nil } @@ -2074,9 +2109,11 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate { private final class PermissionWindowController: NSWindowController { private var dragAssistant: PermissionDragAssistantController? + private var dragAssistantPermission: PermissionKind? + private let initialPermission: PermissionKind? private let terminateWhenDragAssistantCloses: Bool - convenience init(terminateWhenDragAssistantCloses: Bool = false) { + convenience init(initialPermission: PermissionKind? = nil, terminateWhenDragAssistantCloses: Bool = false) { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 300, height: 315), styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], @@ -2089,13 +2126,20 @@ private final class PermissionWindowController: NSWindowController { window.backgroundColor = PermissionPalette.background window.center() window.isReleasedWhenClosed = false - self.init(window: window, terminateWhenDragAssistantCloses: terminateWhenDragAssistantCloses) - window.contentView = PermissionView(frame: window.contentView?.bounds ?? .zero) { [weak self] permission in - self?.showDragAssistant(for: permission) - } + self.init(window: window, initialPermission: initialPermission, terminateWhenDragAssistantCloses: terminateWhenDragAssistantCloses) + window.contentView = PermissionView( + frame: window.contentView?.bounds ?? .zero, + showDragAssistant: { [weak self] permission in + self?.showDragAssistant(for: permission) + }, + close: { [weak self] in + self?.closePermissionWindow() + } + ) } - init(window: NSWindow?, terminateWhenDragAssistantCloses: Bool) { + init(window: NSWindow?, initialPermission: PermissionKind?, terminateWhenDragAssistantCloses: Bool) { + self.initialPermission = initialPermission self.terminateWhenDragAssistantCloses = terminateWhenDragAssistantCloses super.init(window: window) } @@ -2106,9 +2150,13 @@ private final class PermissionWindowController: NSWindowController { private func showDragAssistant(for permission: PermissionKind) { dragAssistant?.close() + dragAssistantPermission = permission dragAssistant = PermissionDragAssistantController( permission: permission, fallbackVisibleFrame: window?.screen?.visibleFrame, + onRefreshPermissions: { [weak self] in + self?.refreshPermissions() + }, onClose: { [weak self] in if self?.terminateWhenDragAssistantCloses == true { NSApp.terminate(nil) @@ -2118,13 +2166,57 @@ private final class PermissionWindowController: NSWindowController { dragAssistant?.showWhenReady() } + private func closeDragAssistant() { + dragAssistant?.close() + dragAssistant = nil + dragAssistantPermission = nil + } + + private func completeDragAssistant() { + guard let dragAssistant else { + dragAssistantPermission = nil + if terminateWhenDragAssistantCloses { + NSApp.terminate(nil) + } + return + } + self.dragAssistant = nil + dragAssistantPermission = nil + dragAssistant.complete() + } + + private func closePermissionWindow() { + // Why: the floating assistant is a separate retained window controller and + // can keep the helper app alive after the main permission window closes. + closeDragAssistant() + window?.close() + } + func openPermission(_ permission: PermissionKind) { permission.requestAndOpenSettings() showDragAssistant(for: permission) } + + func refreshPermissions() { + if let initialPermission, initialPermission.isGranted { + // Why: targeted permission helpers should finish once the requested + // grant lands, even if other Computer Use permissions remain unset. + completeDragAssistant() + return + } + if dragAssistantPermission?.isGranted == true { + // Why: after one grant in full setup, the remaining missing permission + // needs fresh guidance instead of the old assistant's instructions. + closeDragAssistant() + } + if PermissionKind.allCases.allSatisfy(\.isGranted) { + closeDragAssistant() + } + (window?.contentView as? PermissionView)?.refreshPermissions() + } } -private enum PermissionKind { +private enum PermissionKind: CaseIterable { case accessibility case screenshots @@ -2148,6 +2240,42 @@ private enum PermissionKind { } } + var title: String { + switch self { + case .accessibility: + "Accessibility" + case .screenshots: + "Screenshots" + } + } + + var detail: String { + switch self { + case .accessibility: + "Read and control app interfaces" + case .screenshots: + "Capture windows for visual state" + } + } + + var icon: NSImage { + switch self { + case .accessibility: + NSImage(systemSymbolName: "figure", accessibilityDescription: "Accessibility") ?? NSImage() + case .screenshots: + NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "Screen Recording") ?? NSImage() + } + } + + var isGranted: Bool { + switch self { + case .accessibility: + accessibilityTrusted() + case .screenshots: + screenCaptureTrusted() + } + } + func requestAndOpenSettings() { switch self { case .accessibility: @@ -2162,9 +2290,13 @@ private enum PermissionKind { private final class PermissionView: NSView { private let appURL = Bundle.main.bundleURL private let showDragAssistant: (PermissionKind) -> Void + private let close: () -> Void + private var contentStack: NSStackView? + private var contentConstraints: [NSLayoutConstraint] = [] - init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void) { + init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void, close: @escaping () -> Void) { self.showDragAssistant = showDragAssistant + self.close = close super.init(frame: frameRect) wantsLayer = true layer?.backgroundColor = PermissionPalette.background.cgColor @@ -2176,6 +2308,10 @@ private final class PermissionView: NSView { } private func build() { + NSLayoutConstraint.deactivate(contentConstraints) + contentStack?.removeFromSuperview() + contentConstraints = [] + let stack = NSStackView() stack.orientation = .vertical stack.alignment = .leading @@ -2183,6 +2319,7 @@ private final class PermissionView: NSView { stack.distribution = .gravityAreas stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) + contentStack = stack let icon = NSImageView(image: NSWorkspace.shared.icon(forFile: appURL.path)) icon.imageScaling = .scaleProportionallyUpOrDown @@ -2192,9 +2329,12 @@ private final class PermissionView: NSView { icon.heightAnchor.constraint(equalToConstant: 58) ]) - let title = label("Enable Orca Computer Use", size: 22, weight: .bold) + let missingPermissions = PermissionKind.allCases.filter { !$0.isGranted } + let ready = missingPermissions.isEmpty + + let title = label(ready ? "Computer Use is Ready" : "Enable Orca Computer Use", size: 22, weight: .bold) let subtitle = label( - "Grant permissions so Orca can use apps when you ask.", + ready ? "Orca can use local apps when you ask." : "Grant permissions so Orca can use apps when you ask.", size: 12, weight: .regular ) @@ -2211,41 +2351,32 @@ private final class PermissionView: NSView { header.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true subtitle.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -10).isActive = true - stack.addArrangedSubview(permissionRow( - icon: NSImage(systemSymbolName: "figure", accessibilityDescription: "Accessibility"), - title: "Accessibility", - detail: "Read and control app interfaces", - buttonTitle: "Allow" - ) { - PermissionKind.accessibility.requestAndOpenSettings() - self.showDragAssistant(.accessibility) - }) + if ready { + stack.addArrangedSubview(doneButton()) + } else { + for permission in missingPermissions { + stack.addArrangedSubview(permissionRow(permission: permission) { [weak self] in + permission.requestAndOpenSettings() + self?.showDragAssistant(permission) + }) + } + } - stack.addArrangedSubview(permissionRow( - icon: NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "Screen Recording"), - title: "Screenshots", - detail: "Capture windows for visual state", - buttonTitle: "Allow" - ) { - PermissionKind.screenshots.requestAndOpenSettings() - self.showDragAssistant(.screenshots) - }) - - NSLayoutConstraint.activate([ + contentConstraints = [ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 18), stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -18), stack.topAnchor.constraint(equalTo: topAnchor, constant: 22), stack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -20) - ]) + ] + NSLayoutConstraint.activate(contentConstraints) } - private func permissionRow( - icon: NSImage?, - title: String, - detail: String, - buttonTitle: String, - action: @escaping () -> Void - ) -> NSView { + func refreshPermissions() { + // Why: TCC grants can change in System Settings while this window stays open. + build() + } + + private func permissionRow(permission: PermissionKind, action: @escaping () -> Void) -> NSView { let row = NSView() row.wantsLayer = true row.layer?.cornerRadius = 14 @@ -2254,13 +2385,13 @@ private final class PermissionView: NSView { row.layer?.backgroundColor = PermissionPalette.card.cgColor row.translatesAutoresizingMaskIntoConstraints = false - let iconView = NSImageView(image: icon ?? NSImage()) + let iconView = NSImageView(image: permission.icon) iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 30, weight: .regular) iconView.contentTintColor = .controlAccentColor iconView.translatesAutoresizingMaskIntoConstraints = false - let titleLabel = label(title, size: 13, weight: .bold) - let detailLabel = label(detail, size: 11, weight: .regular) + let titleLabel = label(permission.title, size: 13, weight: .bold) + let detailLabel = label(permission.detail, size: 11, weight: .regular) detailLabel.textColor = PermissionPalette.secondaryText let textStack = NSStackView(views: [titleLabel, detailLabel]) textStack.orientation = .vertical @@ -2268,7 +2399,7 @@ private final class PermissionView: NSView { textStack.spacing = 4 textStack.translatesAutoresizingMaskIntoConstraints = false - let button = NSButton(title: buttonTitle, target: nil, action: nil) + let button = NSButton(title: "Allow", target: nil, action: nil) button.bezelStyle = .rounded button.controlSize = .regular button.font = NSFont.systemFont(ofSize: 13, weight: .semibold) @@ -2278,8 +2409,8 @@ private final class PermissionView: NSView { .foregroundColor: NSColor.white, .font: NSFont.systemFont(ofSize: 13, weight: .semibold) ] - button.attributedTitle = NSAttributedString(string: buttonTitle, attributes: buttonTitleAttributes) - button.attributedAlternateTitle = NSAttributedString(string: buttonTitle, attributes: buttonTitleAttributes) + button.attributedTitle = NSAttributedString(string: "Allow", attributes: buttonTitleAttributes) + button.attributedAlternateTitle = NSAttributedString(string: "Allow", attributes: buttonTitleAttributes) let target = ButtonTarget(action) button.target = target button.action = #selector(ButtonTarget.run) @@ -2306,6 +2437,29 @@ private final class PermissionView: NSView { return row } + private func doneButton() -> NSView { + let button = NSButton(title: "Done", target: nil, action: nil) + button.bezelStyle = .rounded + button.controlSize = .regular + button.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + button.contentTintColor = .white + button.bezelColor = .controlAccentColor + let buttonTitleAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.white, + .font: NSFont.systemFont(ofSize: 13, weight: .semibold) + ] + button.attributedTitle = NSAttributedString(string: "Done", attributes: buttonTitleAttributes) + button.attributedAlternateTitle = NSAttributedString(string: "Done", attributes: buttonTitleAttributes) + let target = ButtonTarget(close) + button.target = target + button.action = #selector(ButtonTarget.run) + objc_setAssociatedObject(button, "orca-action", target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + button.translatesAutoresizingMaskIntoConstraints = false + button.widthAnchor.constraint(greaterThanOrEqualToConstant: 82).isActive = true + button.heightAnchor.constraint(equalToConstant: 32).isActive = true + return button + } + private func label(_ text: String, size: CGFloat, weight: NSFont.Weight) -> NSTextField { let label = NSTextField(labelWithString: text) label.font = NSFont.systemFont(ofSize: size, weight: weight) @@ -2323,11 +2477,19 @@ private final class PermissionDragAssistantController: NSWindowController { } private let fallbackVisibleFrame: NSRect? + private let onRefreshPermissions: () -> Void private let onClose: () -> Void private var hasSeenSettingsWindow = false private var followTimer: Timer? + private var isDismissed = false + private var scheduledShowWorkItems: [DispatchWorkItem] = [] - convenience init(permission: PermissionKind, fallbackVisibleFrame: NSRect?, onClose: @escaping () -> Void) { + convenience init( + permission: PermissionKind, + fallbackVisibleFrame: NSRect?, + onRefreshPermissions: @escaping () -> Void, + onClose: @escaping () -> Void + ) { let window = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 390, height: 92), styleMask: [.borderless, .nonactivatingPanel], @@ -2347,15 +2509,25 @@ private final class PermissionDragAssistantController: NSWindowController { window.isMovable = false window.isMovableByWindowBackground = false window.hasShadow = true - self.init(window: window, fallbackVisibleFrame: fallbackVisibleFrame, onClose: onClose) - window.contentView = PermissionDragAssistantView(permission: permission, appURL: Bundle.main.bundleURL) { [weak self, weak window] in - window?.close() - self?.onClose() + self.init( + window: window, + fallbackVisibleFrame: fallbackVisibleFrame, + onRefreshPermissions: onRefreshPermissions, + onClose: onClose + ) + window.contentView = PermissionDragAssistantView(permission: permission, appURL: Bundle.main.bundleURL) { [weak self] in + self?.dismissFromCloseButton() } } - init(window: NSWindow?, fallbackVisibleFrame: NSRect?, onClose: @escaping () -> Void) { + init( + window: NSWindow?, + fallbackVisibleFrame: NSRect?, + onRefreshPermissions: @escaping () -> Void, + onClose: @escaping () -> Void + ) { self.fallbackVisibleFrame = fallbackVisibleFrame + self.onRefreshPermissions = onRefreshPermissions self.onClose = onClose super.init(window: window) } @@ -2365,31 +2537,52 @@ private final class PermissionDragAssistantController: NSWindowController { } func showWhenReady() { + guard !isDismissed else { return } startFollowingSettingsWindow() schedulePositionAndShow() } override func close() { + isDismissed = true + scheduledShowWorkItems.forEach { $0.cancel() } + scheduledShowWorkItems.removeAll() followTimer?.invalidate() followTimer = nil super.close() } + private func dismissFromCloseButton() { + // Why: closing the NSWindow directly skips this controller's timer cleanup, + // letting the assistant reappear while System Settings remains visible. + complete() + } + + func complete() { + close() + onClose() + } + private func schedulePositionAndShow() { + scheduledShowWorkItems.forEach { $0.cancel() } + scheduledShowWorkItems.removeAll() let delays = [0.12, 0.25, 0.4, 0.65, 0.95, 1.35, 1.8, 2.5] for (index, delay) in delays.enumerated() { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, self.window?.isVisible != true else { return } + let workItem = DispatchWorkItem { [weak self] in + guard let self, !self.isDismissed, self.window?.isVisible != true else { return } if let settingsWindow = self.systemSettingsWindowState(), settingsWindow.isVisible { self.positionNearSettingsWindow(settingsWindow.frame) + guard !self.isDismissed else { return } self.showWindow(nil) self.window?.orderFrontRegardless() } else if index == delays.count - 1 && self.systemSettingsIsFrontmost() { self.positionFallback() + guard !self.isDismissed else { return } self.showWindow(nil) self.window?.orderFrontRegardless() } } + scheduledShowWorkItems.append(workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } } @@ -2397,12 +2590,18 @@ private final class PermissionDragAssistantController: NSWindowController { followTimer?.invalidate() followTimer = Timer.scheduledTimer(withTimeInterval: 0.35, repeats: true) { [weak self] _ in Task { @MainActor in - self?.syncVisibilityWithSettingsWindow() + guard let self, !self.isDismissed else { return } + self.syncVisibilityWithSettingsWindow() } } } private func syncVisibilityWithSettingsWindow() { + guard !isDismissed else { + followTimer?.invalidate() + followTimer = nil + return + } guard let window else { followTimer?.invalidate() followTimer = nil @@ -2418,10 +2617,14 @@ private final class PermissionDragAssistantController: NSWindowController { // Why: System Settings can stay visible on one display while the user // works on another; follow actual occlusion instead of app focus. if let settingsWindow, settingsWindow.isVisible { + onRefreshPermissions() + guard !isDismissed else { return } positionNearSettingsWindow(settingsWindow.frame) + guard !isDismissed else { return } if !window.isVisible { showWindow(nil) } + guard !isDismissed else { return } window.orderFrontRegardless() } else if window.isVisible { window.orderOut(nil) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/NumericArgumentParsing.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/NumericArgumentParsing.swift new file mode 100644 index 00000000000..46ff554f6d7 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/NumericArgumentParsing.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Converts a JSON-derived number to a fixed-width integer, truncating toward +/// zero like `Int(Double)` does, but returning nil instead of trapping when the +/// value is non-finite or outside the destination type's range. A malformed +/// request (e.g. `elementIndex: 1e300`) would otherwise crash the whole agent +/// process on the unchecked `Int(Double)` cast. +public func boundedInteger(_ value: Double, as type: T.Type = T.self) -> T? { + T(exactly: value.rounded(.towardZero)) +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/NumericArgumentParsingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/NumericArgumentParsingTests.swift new file mode 100644 index 00000000000..72c77838286 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/NumericArgumentParsingTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import OrcaComputerUseMacOSCore + +final class NumericArgumentParsingTests: XCTestCase { + func testConvertsIntegralValues() { + XCTAssertEqual(boundedInteger(5.0, as: Int.self), 5) + XCTAssertEqual(boundedInteger(0.0, as: Int.self), 0) + XCTAssertEqual(boundedInteger(-3.0, as: Int.self), -3) + } + + func testTruncatesTowardZeroLikeIntInit() { + XCTAssertEqual(boundedInteger(5.9, as: Int.self), 5) + XCTAssertEqual(boundedInteger(-5.9, as: Int.self), -5) + } + + // The crash this guards against: Int(1e300) traps because the value is + // finite but outside Int's range. A malformed request must not take the + // agent down. + func testReturnsNilForOutOfRangeValues() { + XCTAssertNil(boundedInteger(1e300, as: Int.self)) + XCTAssertNil(boundedInteger(-1e300, as: Int.self)) + } + + func testReturnsNilForNonFiniteValues() { + XCTAssertNil(boundedInteger(.nan, as: Int.self)) + XCTAssertNil(boundedInteger(.infinity, as: Int.self)) + XCTAssertNil(boundedInteger(-.infinity, as: Int.self)) + } + + func testHonorsUnsignedDestinationBounds() { + XCTAssertEqual(boundedInteger(4.0, as: UInt32.self), 4) + XCTAssertNil(boundedInteger(-1.0, as: UInt32.self)) + XCTAssertNil(boundedInteger(1e300, as: UInt32.self)) + } + + func testHonorsSignedFixedWidthDestinationBounds() { + XCTAssertEqual(boundedInteger(Double(Int32.max), as: Int32.self), Int32.max) + XCTAssertNil(boundedInteger(Double(Int32.max) + 1, as: Int32.self)) + XCTAssertNil(boundedInteger(Double(Int32.min) - 1, as: Int32.self)) + } +} diff --git a/package.json b/package.json index 5ddd3db72f5..bd626f360ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.23-rc.3", + "version": "1.4.34", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -30,9 +30,10 @@ "typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false", "typecheck:tsc:web": "tsc --noEmit -p config/tsconfig.web.json --composite false", "typecheck:tsc": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false", - "start": "node config/scripts/ensure-native-runtime.mjs --runtime=electron && electron-vite preview", - "dev": "node config/scripts/ensure-native-runtime.mjs --runtime=electron && node config/scripts/run-electron-vite-dev.mjs", - "dev-stable-name": "node config/scripts/ensure-native-runtime.mjs --runtime=electron && node config/scripts/run-electron-vite-dev.mjs --stable-name", + "ensure:electron-runtime": "node config/scripts/ensure-native-runtime.mjs --runtime=electron", + "start": "pnpm run ensure:electron-runtime && electron-vite preview", + "dev": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs", + "dev-stable-name": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name", "dev:web": "vite --config vite.web.config.ts --host 127.0.0.1", "build:relay": "node config/scripts/build-relay.mjs", "build:computer-macos": "node config/scripts/build-computer-macos.mjs", @@ -41,19 +42,19 @@ "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/install-dev-cli.mjs", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build:web": "vite build --config vite.web.config.ts && node config/scripts/verify-web-build.mjs", - "build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:computer-macos && pnpm run build:electron-vite && pnpm run build:web && pnpm run build:cli", - "build:release": "pnpm run build:relay && pnpm run build:computer-macos && pnpm run verify:computer-native && pnpm run build:electron-vite && pnpm run build:web && pnpm run build:cli", - "postinstall": "pnpm rebuild electron && node config/scripts/rebuild-native-deps.mjs", + "build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:computer-macos && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", + "build:release": "pnpm run build:relay && pnpm run build:computer-macos && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", + "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "rebuild:node": "pnpm rebuild better-sqlite3 node-pty", - "build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir", - "build:win": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --win", + "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", + "build:win": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", - "build:mac": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --mac", - "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", - "build:linux": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --linux", - "test:e2e": "npx playwright test --config tests/playwright.config.ts --project electron-headless", - "test:e2e:headful": "npx playwright test --config tests/playwright.config.ts --project electron-headful", + "build:mac": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", + "build:linux": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux", + "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", + "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts" }, "dependencies": { @@ -81,15 +82,15 @@ "@tiptap/pm": "^3.22.5", "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", - "@xterm/addon-fit": "0.12.0-beta.216", - "@xterm/addon-ligatures": "0.11.0-beta.216", - "@xterm/addon-search": "0.17.0-beta.216", - "@xterm/addon-serialize": "0.15.0-beta.216", - "@xterm/addon-unicode11": "0.10.0-beta.216", - "@xterm/addon-web-links": "0.13.0-beta.216", - "@xterm/addon-webgl": "0.20.0-beta.215", - "@xterm/headless": "6.1.0-beta.216", - "@xterm/xterm": "6.1.0-beta.216", + "@xterm/addon-fit": "0.12.0-beta.220", + "@xterm/addon-ligatures": "0.11.0-beta.220", + "@xterm/addon-search": "0.17.0-beta.220", + "@xterm/addon-serialize": "0.15.0-beta.220", + "@xterm/addon-unicode11": "0.10.0-beta.220", + "@xterm/addon-web-links": "0.13.0-beta.220", + "@xterm/addon-webgl": "0.20.0-beta.219", + "@xterm/headless": "6.1.0-beta.220", + "@xterm/xterm": "6.1.0-beta.220", "agent-browser": "~0.24.1", "better-sqlite3": "^12.9.0", "class-variance-authority": "^0.7.1", @@ -202,15 +203,14 @@ "@parcel/watcher", "better-sqlite3", "cpu-features", - "electron", "esbuild", "node-pty", "sherpa-onnx" ], "patchedDependencies": { "node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch", - "@xterm/addon-ligatures@0.11.0-beta.216": "config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch", - "@xterm/addon-webgl@0.20.0-beta.215": "config/patches/@xterm__addon-webgl@0.20.0-beta.215.patch" + "@xterm/addon-ligatures@0.11.0-beta.220": "config/patches/@xterm__addon-ligatures@0.11.0-beta.220.patch", + "@xterm/addon-webgl@0.20.0-beta.219": "config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f88558b1cb..a38afa4facc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,14 +5,14 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - '@xterm/addon-ligatures@0.11.0-beta.216': + '@xterm/addon-ligatures@0.11.0-beta.220': hash: df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634 - path: config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch - '@xterm/addon-webgl@0.20.0-beta.215': - hash: 4923357da531172cda1fd570a33fd852349d9eb6317f4befa8b3af18c221039d - path: config/patches/@xterm__addon-webgl@0.20.0-beta.215.patch + path: config/patches/@xterm__addon-ligatures@0.11.0-beta.220.patch + '@xterm/addon-webgl@0.20.0-beta.219': + hash: b7465b48cca8d596e3bf0e441f939100c95adaddef13e85df089ed0ad6f6b1ea + path: config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch node-pty@1.1.0: - hash: 93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4 + hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282 path: config/patches/node-pty@1.1.0.patch importers: @@ -92,32 +92,32 @@ importers: specifier: ^3.22.5 version: 3.22.5 '@xterm/addon-fit': - specifier: 0.12.0-beta.216 - version: 0.12.0-beta.216(@xterm/xterm@6.1.0-beta.216) + specifier: 0.12.0-beta.220 + version: 0.12.0-beta.220(@xterm/xterm@6.1.0-beta.220) '@xterm/addon-ligatures': - specifier: 0.11.0-beta.216 - version: 0.11.0-beta.216(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.216) + specifier: 0.11.0-beta.220 + version: 0.11.0-beta.220(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.220) '@xterm/addon-search': - specifier: 0.17.0-beta.216 - version: 0.17.0-beta.216(@xterm/xterm@6.1.0-beta.216) + specifier: 0.17.0-beta.220 + version: 0.17.0-beta.220(@xterm/xterm@6.1.0-beta.220) '@xterm/addon-serialize': - specifier: 0.15.0-beta.216 - version: 0.15.0-beta.216(@xterm/xterm@6.1.0-beta.216) + specifier: 0.15.0-beta.220 + version: 0.15.0-beta.220(@xterm/xterm@6.1.0-beta.220) '@xterm/addon-unicode11': - specifier: 0.10.0-beta.216 - version: 0.10.0-beta.216(@xterm/xterm@6.1.0-beta.216) + specifier: 0.10.0-beta.220 + version: 0.10.0-beta.220(@xterm/xterm@6.1.0-beta.220) '@xterm/addon-web-links': - specifier: 0.13.0-beta.216 - version: 0.13.0-beta.216(@xterm/xterm@6.1.0-beta.216) + specifier: 0.13.0-beta.220 + version: 0.13.0-beta.220(@xterm/xterm@6.1.0-beta.220) '@xterm/addon-webgl': - specifier: 0.20.0-beta.215 - version: 0.20.0-beta.215(patch_hash=4923357da531172cda1fd570a33fd852349d9eb6317f4befa8b3af18c221039d)(@xterm/xterm@6.1.0-beta.216) + specifier: 0.20.0-beta.219 + version: 0.20.0-beta.219(patch_hash=b7465b48cca8d596e3bf0e441f939100c95adaddef13e85df089ed0ad6f6b1ea)(@xterm/xterm@6.1.0-beta.220) '@xterm/headless': - specifier: 6.1.0-beta.216 - version: 6.1.0-beta.216 + specifier: 6.1.0-beta.220 + version: 6.1.0-beta.220 '@xterm/xterm': - specifier: 6.1.0-beta.216 - version: 6.1.0-beta.216 + specifier: 6.1.0-beta.220 + version: 6.1.0-beta.220 agent-browser: specifier: ~0.24.1 version: 0.24.1 @@ -162,7 +162,7 @@ importers: version: 0.55.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4) + version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282) pdfjs-dist: specifier: ^5.7.284 version: 5.7.284 @@ -3018,47 +3018,47 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} - '@xterm/addon-fit@0.12.0-beta.216': - resolution: {integrity: sha512-IgKE3ngNodSnmj1O+EEYpKQZkSbAUbghPlCWd8G32RL0piIMqb3FX3BuYLnWZeLNoD9iMtublLMG1T9XjGeVvA==} + '@xterm/addon-fit@0.12.0-beta.220': + resolution: {integrity: sha512-lTiEjsdJBYTvIdA8USs90j+pu7/9oCQEh8JYBDJoRxzzAcvk4V1GlEA++Ztspbfk4nSh2HVxwJvdeTD6ffAQlg==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.216 + '@xterm/xterm': ^6.1.0-beta.220 - '@xterm/addon-ligatures@0.11.0-beta.216': - resolution: {integrity: sha512-NJrK8MRi64Kb1Kxz5Jmrnd7H2gTlBqM3wHfObneD7T4xGFoPZkeX2/Kit4IcHt2Y5CTAQFAc92DilsncVm1HnQ==} + '@xterm/addon-ligatures@0.11.0-beta.220': + resolution: {integrity: sha512-fw/mSPqwgBNVX2ejW3rKhXablP3pelKJWYlt1QIr40+zZ//ERcHZWZNLB1I9fojiGW++tCnIHKh0GPpI2h5HgA==} engines: {node: '>8.0.0'} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.216 + '@xterm/xterm': ^6.1.0-beta.220 - '@xterm/addon-search@0.17.0-beta.216': - resolution: {integrity: sha512-p//4yv5aB2eyQcCFmPQ/s7tTFSTwKbtxs89xP1gvMbT9QuZgjW9FNqaQKb1GLDWtMpSfZdy7RrUp4GiCg3PVJQ==} + '@xterm/addon-search@0.17.0-beta.220': + resolution: {integrity: sha512-2QwmGiZ8sn1ISYLuHIt0heUYN0wR6d+AWFZRGsekXbLCvRw/qjclCSKsDsIO1bTMBjey5K1MeZbse6kTKx+kDg==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.216 + '@xterm/xterm': ^6.1.0-beta.220 - '@xterm/addon-serialize@0.15.0-beta.216': - resolution: {integrity: sha512-scj/83XfYHg5LiDscYDP9dnadUMZt8To1tbdncQP/dZq2pD4TsTMokW1pciVYYazLBxCeKZybbVLEuOo7IPCqQ==} + '@xterm/addon-serialize@0.15.0-beta.220': + resolution: {integrity: sha512-ssZ8rnSU0EOdDsFS5cI5LHKy/T0rQfZBE9iPph7rUxSAwZXbMGz0Vrsob79IKRvfE6oMZlFn+NE0Gg/mKq62Iw==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.216 + '@xterm/xterm': ^6.1.0-beta.220 - '@xterm/addon-unicode11@0.10.0-beta.216': - resolution: {integrity: sha512-i7TrEHOTzUEOClH1+6IHoHy7bR/XHVRBjHc5e0u6A1HucFkAlCU+bqUY8EfwNOh1/iUjuB06EtNh6BM1o/ZAlA==} + '@xterm/addon-unicode11@0.10.0-beta.220': + resolution: {integrity: sha512-83VMUPOp7VKWsVlw+a6TrZP38VAQM7xigu0JcSj2gVO1xsYRyme9wlgKDX+lmgWrfJPSjgB0vfXpcxI4dt/Wvw==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.216 + '@xterm/xterm': ^6.1.0-beta.220 - '@xterm/addon-web-links@0.13.0-beta.216': - resolution: {integrity: sha512-ZP3BDy1na/37TZHO8FB+XHJFoO8muyPoOzkaL2X28n5A9ZFQPbf834EMRsvDczKnfQvtZcve+ZCmMJj1ZHGjow==} + '@xterm/addon-web-links@0.13.0-beta.220': + resolution: {integrity: sha512-XILFvFY0lOpTafIQxUOkS6x/MfZzarW3WbHZ40KEzAE8ImJqwOQiiqG17noLMUDD6rSOv7BRWNJACq4FflBNvg==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.216 + '@xterm/xterm': ^6.1.0-beta.220 - '@xterm/addon-webgl@0.20.0-beta.215': - resolution: {integrity: sha512-oCbH3YxiGOzRcKxwTfSRCA1TqpoT/AitO2X5MuqD14DVnb4Z3rTKQYfHBA7R6HA7U4K9OzmtIsi5+VyEIEaWsg==} + '@xterm/addon-webgl@0.20.0-beta.219': + resolution: {integrity: sha512-IaAcDDP0lhHIdYJuTFKZIMsRqlN8iR5U8lW9MiTK4mUAjettqZN2z/tkSsl4yV3GZ9/siOZofDl+GO1TK7y7ww==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.216 + '@xterm/xterm': ^6.1.0-beta.220 - '@xterm/headless@6.1.0-beta.216': - resolution: {integrity: sha512-HCut3ABwBv5zVUAB+5KVTW4svr/fCeVMW9xtaJIhmXZsNHOrhVFOGX7E2CYzRa8xdX/aI2JdTIhgLl8xjcHEoA==} + '@xterm/headless@6.1.0-beta.220': + resolution: {integrity: sha512-oc+cI28eP/ktv91ddk1MxsEdfS41dnNvB3eMjNxfP7v+SaM7aXgXAL8Kj0/zzbpX/dG0nbA9wi3YvsybzCN3OQ==} - '@xterm/xterm@6.1.0-beta.216': - resolution: {integrity: sha512-87rfymzVje5eYUlGG94hz1WkOYvFRcFDGdiOAbg4d8xt8OGSGR2nMNU4I1n5MDE1RBPBqRd+WVJ5w7q3pwMoZA==} + '@xterm/xterm@6.1.0-beta.220': + resolution: {integrity: sha512-rwEKE75wfwfNWeGnjl0iQRA1W50HigKoGcGMeF3o5CqDSdI9IjmwzpV9xHXqLH6CYj6s9N1g6bbKFVDou9dJ/A==} abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} @@ -8922,39 +8922,39 @@ snapshots: '@xmldom/xmldom@0.9.10': optional: true - '@xterm/addon-fit@0.12.0-beta.216(@xterm/xterm@6.1.0-beta.216)': + '@xterm/addon-fit@0.12.0-beta.220(@xterm/xterm@6.1.0-beta.220)': dependencies: - '@xterm/xterm': 6.1.0-beta.216 + '@xterm/xterm': 6.1.0-beta.220 - '@xterm/addon-ligatures@0.11.0-beta.216(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.216)': + '@xterm/addon-ligatures@0.11.0-beta.220(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.220)': dependencies: - '@xterm/xterm': 6.1.0-beta.216 + '@xterm/xterm': 6.1.0-beta.220 lru-cache: 6.0.0 opentype.js: 0.8.0 - '@xterm/addon-search@0.17.0-beta.216(@xterm/xterm@6.1.0-beta.216)': + '@xterm/addon-search@0.17.0-beta.220(@xterm/xterm@6.1.0-beta.220)': dependencies: - '@xterm/xterm': 6.1.0-beta.216 + '@xterm/xterm': 6.1.0-beta.220 - '@xterm/addon-serialize@0.15.0-beta.216(@xterm/xterm@6.1.0-beta.216)': + '@xterm/addon-serialize@0.15.0-beta.220(@xterm/xterm@6.1.0-beta.220)': dependencies: - '@xterm/xterm': 6.1.0-beta.216 + '@xterm/xterm': 6.1.0-beta.220 - '@xterm/addon-unicode11@0.10.0-beta.216(@xterm/xterm@6.1.0-beta.216)': + '@xterm/addon-unicode11@0.10.0-beta.220(@xterm/xterm@6.1.0-beta.220)': dependencies: - '@xterm/xterm': 6.1.0-beta.216 + '@xterm/xterm': 6.1.0-beta.220 - '@xterm/addon-web-links@0.13.0-beta.216(@xterm/xterm@6.1.0-beta.216)': + '@xterm/addon-web-links@0.13.0-beta.220(@xterm/xterm@6.1.0-beta.220)': dependencies: - '@xterm/xterm': 6.1.0-beta.216 + '@xterm/xterm': 6.1.0-beta.220 - '@xterm/addon-webgl@0.20.0-beta.215(patch_hash=4923357da531172cda1fd570a33fd852349d9eb6317f4befa8b3af18c221039d)(@xterm/xterm@6.1.0-beta.216)': + '@xterm/addon-webgl@0.20.0-beta.219(patch_hash=b7465b48cca8d596e3bf0e441f939100c95adaddef13e85df089ed0ad6f6b1ea)(@xterm/xterm@6.1.0-beta.220)': dependencies: - '@xterm/xterm': 6.1.0-beta.216 + '@xterm/xterm': 6.1.0-beta.220 - '@xterm/headless@6.1.0-beta.216': {} + '@xterm/headless@6.1.0-beta.220': {} - '@xterm/xterm@6.1.0-beta.216': {} + '@xterm/xterm@6.1.0-beta.220': {} abbrev@4.0.0: {} @@ -11367,7 +11367,7 @@ snapshots: undici: 6.25.0 which: 6.0.1 - node-pty@1.1.0(patch_hash=93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4): + node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282): dependencies: node-addon-api: 7.1.1 diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 868e66516e6..a134a8a2a9e 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -10,4 +10,31 @@ describe('parseArgs', () => { expect(parsed.flags.get('value')).toBe('') expect(parsed.flags.get('json')).toBe(true) }) + + it('accepts a flag value that starts with -- via the = form', () => { + const parsed = parseArgs(['terminal', 'send', '--text=--help']) + + expect(parsed.commandPath).toEqual(['terminal', 'send']) + expect(parsed.flags.get('text')).toBe('--help') + }) + + it('splits --flag=value on the first = so values may contain =', () => { + const parsed = parseArgs(['set', 'cookie', '--value=a=b=c']) + + expect(parsed.flags.get('value')).toBe('a=b=c') + }) + + it('treats --flag= as an empty string value', () => { + const parsed = parseArgs(['--value=']) + + expect(parsed.flags.get('value')).toBe('') + }) + + it('still parses boolean flags and space-separated values', () => { + const parsed = parseArgs(['tab', 'create', '--json', '--url', 'https://example.com']) + + expect(parsed.commandPath).toEqual(['tab', 'create']) + expect(parsed.flags.get('json')).toBe(true) + expect(parsed.flags.get('url')).toBe('https://example.com') + }) }) diff --git a/src/cli/args.ts b/src/cli/args.ts index 5b993306321..02992e3b30f 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -29,7 +29,17 @@ export function parseArgs(argv: string[]): ParsedArgs { continue } - const flag = token.slice(2) + const assignment = token.slice(2) + // Why: `--flag=value` is the only unambiguous way to pass a value that + // itself starts with `--` (e.g. `--text=--help`); the space-separated form + // treats a `--`-leading next token as a new flag, so it can't express one. + const equalsIndex = assignment.indexOf('=') + if (equalsIndex !== -1) { + flags.set(assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1)) + continue + } + + const flag = assignment const hasNext = i + 1 < argv.length const next = argv[i + 1] if (!hasNext || next.startsWith('--')) { @@ -65,7 +75,9 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { return false } if ( - ['automations', 'repo', 'worktree', 'terminal', 'computer', 'note'].includes(commandPath[0]) + ['automations', 'repo', 'worktree', 'terminal', 'file', 'computer', 'note'].includes( + commandPath[0] + ) ) { return false } @@ -87,6 +99,7 @@ export function isCommandGroup(commandPath: string[]): boolean { 'repo', 'worktree', 'terminal', + 'file', 'tab', 'cookie', 'intercept', @@ -98,8 +111,10 @@ export function isCommandGroup(commandPath: string[]): boolean { 'storage', 'orchestration', 'computer', + 'agent', 'environment' ].includes(commandPath[0])) || + (commandPath.length === 2 && commandPath[0] === 'agent' && commandPath[1] === 'hooks') || (commandPath.length === 2 && commandPath[0] === 'storage' && ['local', 'session'].includes(commandPath[1])) diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index 161368f7a0e..86bbee3039a 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -1,5 +1,4 @@ /* eslint-disable max-lines -- Why: this file groups every CLI browser-command test (page targeting, profiles, waits, viewport) so test-fixture imports and the runtime-client mock stay shared in one place. */ -import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() @@ -90,7 +89,7 @@ describe('orca cli browser page targeting', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) expect(callMock).toHaveBeenNthCalledWith(2, 'browser.snapshot', { page: 'page-1', - worktree: `path:${path.resolve('/tmp/repo/feature')}` + worktree: 'id:repo::/tmp/repo/feature' }) }) @@ -124,7 +123,7 @@ describe('orca cli browser page targeting', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'browser.tabSwitch', { index: undefined, page: 'page-2', - worktree: `path:${path.resolve('/tmp/repo/feature')}` + worktree: 'id:repo::/tmp/repo/feature' }) }) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 97698764522..c560fd75f11 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -4,6 +4,7 @@ import { CORE_HANDLERS } from './handlers/core' import { AUTOMATION_HANDLERS } from './handlers/automations' import { REPO_HANDLERS } from './handlers/repo' import { WORKTREE_HANDLERS } from './handlers/worktree' +import { FILE_HANDLERS } from './handlers/file' import { TERMINAL_HANDLERS } from './handlers/terminal' import { BROWSER_NAV_HANDLERS } from './handlers/browser-nav' import { BROWSER_INTERACT_HANDLERS } from './handlers/browser-interact' @@ -16,6 +17,7 @@ import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' import { COMPUTER_HANDLERS } from './handlers/computer' import { ENVIRONMENT_HANDLERS } from './handlers/environment' +import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks' export type HandlerContext = { flags: Map @@ -33,6 +35,7 @@ function buildHandlers(): Map { AUTOMATION_HANDLERS, REPO_HANDLERS, WORKTREE_HANDLERS, + FILE_HANDLERS, TERMINAL_HANDLERS, BROWSER_NAV_HANDLERS, BROWSER_INTERACT_HANDLERS, @@ -44,6 +47,7 @@ function buildHandlers(): Map { BROWSER_STORAGE_HANDLERS, ORCHESTRATION_HANDLERS, COMPUTER_HANDLERS, + AGENT_HOOK_HANDLERS, ENVIRONMENT_HANDLERS ] for (const group of groups) { diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts new file mode 100644 index 00000000000..fdd6b3114f1 --- /dev/null +++ b/src/cli/handlers/agent-hooks.ts @@ -0,0 +1,163 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' +import { homedir } from 'os' +import { dirname, join } from 'path' +import { randomUUID } from 'crypto' +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { RuntimeClientError, type RuntimeClient, type RuntimeRpcSuccess } from '../runtime-client' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { getDefaultPersistedState } from '../../shared/constants' +import type { PersistedState } from '../../shared/types' +import { + applyAgentStatusHooksEnabled, + getManagedAgentHookStatuses +} from '../../main/agent-hooks/managed-agent-hook-controls' +import { getDefaultUserDataPath } from '../runtime-client' + +type AgentHookCommandResult = { + enabled: boolean + settingsPath: string + appliedBy: 'runtime' | 'offline' + statuses: AgentHookInstallStatus[] +} + +function getDataPath(): string { + return join(getDefaultUserDataPath(), 'orca-data.json') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readPersistedState(dataPath: string): PersistedState { + if (!existsSync(dataPath)) { + return getDefaultPersistedState(homedir()) + } + try { + const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) + if (!isRecord(parsed)) { + throw new Error('file does not contain a JSON object') + } + return parsed as PersistedState + } catch (error) { + throw new RuntimeClientError( + 'runtime_error', + `Could not read ${dataPath}: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function writePersistedState(dataPath: string, state: PersistedState): void { + mkdirSync(dirname(dataPath), { recursive: true }) + const tmpPath = join(dirname(dataPath), `.${Date.now()}-${randomUUID()}.tmp`) + let renamed = false + try { + writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8') + renameSync(tmpPath, dataPath) + renamed = true + } finally { + if (!renamed && existsSync(tmpPath)) { + try { + unlinkSync(tmpPath) + } catch { + // best effort + } + } + } +} + +function readEnabledFromDisk(): boolean { + const state = readPersistedState(getDataPath()) + return state.settings?.agentStatusHooksEnabled !== false +} + +function updateEnabledOnDisk(enabled: boolean): string { + const dataPath = getDataPath() + const state = readPersistedState(dataPath) + state.settings = { + ...getDefaultPersistedState(homedir()).settings, + ...state.settings, + agentStatusHooksEnabled: enabled + } + writePersistedState(dataPath, state) + return dataPath +} + +async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise { + try { + const status = await client.getCliStatus() + if (!status.result.runtime.reachable) { + return false + } + await client.call( + 'settings.update', + { agentStatusHooksEnabled: enabled }, + { timeoutMs: 10_000 } + ) + return true + } catch { + return false + } +} + +function localSuccess(result: TResult): RuntimeRpcSuccess { + return { + id: 'local', + ok: true, + result, + _meta: { + runtimeId: 'local' + } + } +} + +function formatAgentHookCommandResult(result: AgentHookCommandResult): string { + const statusSummary = result.statuses + .map((status) => `${status.agent}: ${status.state}`) + .join('\n') + return [ + `agentStatusHooksEnabled: ${result.enabled}`, + `appliedBy: ${result.appliedBy}`, + `settingsPath: ${result.settingsPath}`, + statusSummary + ] + .filter(Boolean) + .join('\n') +} + +async function setAgentHooksEnabled( + client: RuntimeClient, + enabled: boolean +): Promise { + const updatedRuntime = await updateRunningRuntime(client, enabled) + const settingsPath = updatedRuntime ? getDataPath() : updateEnabledOnDisk(enabled) + const statuses = updatedRuntime + ? getManagedAgentHookStatuses() + : applyAgentStatusHooksEnabled(enabled) + return { + enabled, + settingsPath, + appliedBy: updatedRuntime ? 'runtime' : 'offline', + statuses + } +} + +export const AGENT_HOOK_HANDLERS: Record = { + 'agent hooks status': async ({ json }) => { + const result: AgentHookCommandResult = { + enabled: readEnabledFromDisk(), + settingsPath: getDataPath(), + appliedBy: 'offline', + statuses: getManagedAgentHookStatuses() + } + printResult(localSuccess(result), json, formatAgentHookCommandResult) + }, + 'agent hooks off': async ({ client, json }) => { + const result = await setAgentHooksEnabled(client, false) + printResult(localSuccess(result), json, formatAgentHookCommandResult) + }, + 'agent hooks on': async ({ client, json }) => { + const result = await setAgentHooksEnabled(client, true) + printResult(localSuccess(result), json, formatAgentHookCommandResult) + } +} diff --git a/src/cli/handlers/computer.test.ts b/src/cli/handlers/computer.test.ts index dca8f050ba7..62f86363a48 100644 --- a/src/cli/handlers/computer.test.ts +++ b/src/cli/handlers/computer.test.ts @@ -1,5 +1,4 @@ /* eslint-disable max-lines -- Why: computer CLI coverage shares one mocked runtime setup across command contracts. */ -import path from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() @@ -71,7 +70,7 @@ describe('orca computer CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) expect(callMock).toHaveBeenNthCalledWith(2, 'computer.listApps', { - worktree: `path:${path.resolve('/tmp/repo')}` + worktree: 'id:repo::/tmp/repo' }) }) @@ -142,7 +141,7 @@ describe('orca computer CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'computer.getAppState', { app: 'Finder', - worktree: `path:${path.resolve('/tmp/repo')}`, + worktree: 'id:repo::/tmp/repo', noScreenshot: true, restoreWindow: true }) @@ -176,7 +175,7 @@ describe('orca computer CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'computer.listWindows', { app: 'Finder', - worktree: `path:${path.resolve('/tmp/repo')}` + worktree: 'id:repo::/tmp/repo' }) const output = vi.mocked(console.log).mock.calls[0][0] expect(output).toContain('[0] id:42 "Recents"') @@ -241,7 +240,7 @@ describe('orca computer CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'computer.drag', { app: 'Finder', - worktree: `path:${path.resolve('/tmp/repo')}`, + worktree: 'id:repo::/tmp/repo', fromElementIndex: undefined, toElementIndex: undefined, fromX: 1, diff --git a/src/cli/handlers/file.test.ts b/src/cli/handlers/file.test.ts new file mode 100644 index 00000000000..b4e11d020db --- /dev/null +++ b/src/cli/handlers/file.test.ts @@ -0,0 +1,329 @@ +/* eslint-disable max-lines -- Why: file CLI coverage shares one mocked runtime setup across command contracts. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +vi.mock('../runtime-client', () => { + class RuntimeClient { + readonly isRemote: boolean + call = callMock + getCliStatus = vi.fn() + openOrca = vi.fn() + + constructor( + _userDataPath?: string, + _requestTimeoutMs?: number, + remotePairingCode?: string | null, + environmentSelector?: string | null + ) { + this.isRemote = Boolean(remotePairingCode || environmentSelector) + } + } + + class RuntimeClientError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.code = code + } + } + + class RuntimeRpcFailureError extends RuntimeClientError { + readonly response: unknown + + constructor(response: unknown) { + super('runtime_error', 'runtime_error') + this.response = response + } + } + + return { + RuntimeClient, + RuntimeClientError, + RuntimeRpcFailureError + } +}) + +import { main } from '../index' +import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from '../test-fixtures' + +describe('orca file CLI handlers', () => { + beforeEach(() => { + vi.restoreAllMocks() + callMock.mockReset() + process.exitCode = undefined + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('prints group help with file commands', async () => { + await main(['file', '--help'], '/tmp/repo') + + const output = vi.mocked(console.log).mock.calls[0][0] + expect(output).toContain('open') + expect(output).toContain('diff') + expect(output).toContain('open-changed') + }) + + it('opens a positional path in the inferred current worktree', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]), + okFixture('req_open', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }) + ) + + await main(['file', 'open', 'src/App.tsx'], '/tmp/repo/src') + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { + worktree: 'id:repo::/tmp/repo', + relativePath: 'src/App.tsx' + }) + expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened src/App.tsx.') + }) + + it('opens a staged diff for an explicit worktree without cwd inference', async () => { + queueFixtures( + callMock, + okFixture('req_diff', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }) + ) + + await main( + ['file', 'diff', '--path', 'src/App.tsx', '--staged', '--worktree', 'id:wt-1'], + '/tmp/elsewhere' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + staged: true + }) + }) + + it('rejects --worktree without a value before cwd inference or RPC calls', async () => { + const priorExitCode = process.exitCode + + await main(['file', 'open', 'src/App.tsx', '--worktree'], '/tmp/repo/src') + + expect(callMock).not.toHaveBeenCalled() + expect(vi.mocked(console.error).mock.calls[0][0]).toContain('Missing value for --worktree.') + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('opens git-changed files as diffs by default', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]), + okFixture('req_status', { + entries: [ + { path: 'src/App.tsx', status: 'modified', area: 'unstaged' }, + { path: 'package.json', status: 'modified', area: 'staged' }, + { path: 'docs/new.md', status: 'untracked', area: 'untracked' } + ], + conflictOperation: 'unknown' + }), + okFixture('req_diff_1', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }), + okFixture('req_diff_2', { + worktree: 'wt-1', + relativePath: 'package.json', + kind: 'text', + opened: true + }), + okFixture('req_diff_3', { + worktree: 'wt-1', + relativePath: 'docs/new.md', + kind: 'markdown', + opened: true + }) + ) + + await main(['file', 'open-changed'], '/tmp/repo/src') + + expect(callMock).toHaveBeenNthCalledWith(2, 'git.status', { + worktree: 'id:repo::/tmp/repo' + }) + expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', { + worktree: 'id:repo::/tmp/repo', + relativePath: 'src/App.tsx', + staged: false + }) + expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', { + worktree: 'id:repo::/tmp/repo', + relativePath: 'package.json', + staged: true + }) + expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', { + worktree: 'id:repo::/tmp/repo', + relativePath: 'docs/new.md', + staged: false + }) + expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened 3 changed file targets.') + }) + + it('skips unresolved conflict entries in diff mode without opening a normal diff', async () => { + queueFixtures( + callMock, + okFixture('req_status', { + entries: [ + { + path: 'src/conflicted.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'unresolved' + }, + { path: 'src/App.tsx', status: 'modified', area: 'staged' } + ], + conflictOperation: 'merge' + }), + okFixture('req_diff', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }) + ) + + await main(['file', 'open-changed', '--worktree', 'id:wt-1'], '/tmp/elsewhere') + + expect(callMock).toHaveBeenCalledTimes(2) + expect(callMock).toHaveBeenNthCalledWith(1, 'git.status', { worktree: 'id:wt-1' }) + expect(callMock).toHaveBeenNthCalledWith(2, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + staged: true + }) + const output = vi.mocked(console.log).mock.calls[0][0] + expect(output).toContain('Opened 1 changed file targets.') + expect(output).toContain( + 'src/conflicted.ts: unresolved conflict may not have a single diff target' + ) + }) + + it('opens changed files in both edit and diff modes while skipping deleted edit targets', async () => { + queueFixtures( + callMock, + okFixture('req_status', { + entries: [ + { path: 'src/App.tsx', status: 'modified', area: 'unstaged' }, + { path: 'src/App.tsx', status: 'modified', area: 'staged' }, + { path: 'docs/old.md', status: 'deleted', area: 'unstaged' } + ], + conflictOperation: 'unknown' + }), + okFixture('req_open', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }), + okFixture('req_diff_1', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }), + okFixture('req_diff_2', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }), + okFixture('req_diff_3', { + worktree: 'wt-1', + relativePath: 'docs/old.md', + kind: 'markdown', + opened: true + }) + ) + + await main( + ['file', 'open-changed', '--mode', 'both', '--worktree', 'id:wt-1'], + '/tmp/elsewhere' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'git.status', { worktree: 'id:wt-1' }) + expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx' + }) + expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + staged: false + }) + expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + staged: true + }) + expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'docs/old.md', + staged: false + }) + const output = vi.mocked(console.log).mock.calls[0][0] + expect(output).toContain('Opened 4 changed file targets.') + expect(output).toContain('docs/old.md: deleted file has no edit target') + }) + + it('requires an explicit worktree for remote file commands', async () => { + const priorExitCode = process.exitCode + + await main(['file', 'open-changed', '--pairing-code', 'remote-runtime'], '/tmp/repo/src') + + expect(callMock).not.toHaveBeenCalled() + expect(vi.mocked(console.error).mock.calls[0][0]).toContain( + 'Remote file commands require --worktree' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects --mode without a value before cwd inference or RPC calls', async () => { + const priorExitCode = process.exitCode + + await main(['file', 'open-changed', '--mode'], '/tmp/repo/src') + + expect(callMock).not.toHaveBeenCalled() + expect(vi.mocked(console.error).mock.calls[0][0]).toContain( + 'Missing value for --mode. Use edit, diff, or both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('validates mode before resolving a worktree', async () => { + const priorExitCode = process.exitCode + + await main(['file', 'open-changed', '--mode', 'invalid'], '/tmp/repo/src') + + expect(callMock).not.toHaveBeenCalled() + expect(vi.mocked(console.error).mock.calls[0][0]).toContain( + 'Invalid --mode. Use edit, diff, or both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) +}) diff --git a/src/cli/handlers/file.ts b/src/cli/handlers/file.ts new file mode 100644 index 00000000000..8431e860899 --- /dev/null +++ b/src/cli/handlers/file.ts @@ -0,0 +1,215 @@ +import type { GitStatusEntry, GitStatusResult } from '../../shared/git-status-types' +import type { RuntimeFileOpenResult } from '../../shared/runtime-types' +import type { CommandHandler, HandlerContext } from '../dispatch' +import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { printResult } from '../format' +import { RuntimeClientError } from '../runtime-client' +import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' + +type FileOpenMode = 'edit' | 'diff' +type OpenChangedMode = FileOpenMode | 'both' + +type FileOpenRecord = { + path: string + mode: FileOpenMode + staged?: boolean + opened: boolean + kind?: RuntimeFileOpenResult['kind'] + skipped?: boolean + reason?: string +} + +type FileOpenChangedResult = { + worktree: string + mode: OpenChangedMode + opened: FileOpenRecord[] + skipped: FileOpenRecord[] + totalChanged: number +} + +async function getFileWorktreeSelector({ flags, cwd, client }: HandlerContext): Promise { + const worktree = flags.get('worktree') + if (flags.has('worktree') && (typeof worktree !== 'string' || worktree.length === 0)) { + throw new RuntimeClientError('invalid_argument', 'Missing value for --worktree.') + } + const explicit = await getOptionalWorktreeSelector(flags, 'worktree', cwd, client) + if (explicit) { + return explicit + } + if (client.isRemote) { + throw new RuntimeClientError( + 'invalid_argument', + 'Remote file commands require --worktree because the client cwd cannot identify a server worktree.' + ) + } + return await resolveCurrentWorktreeSelector(cwd, client) +} + +function getOpenChangedMode(flags: Map): OpenChangedMode { + const value = flags.get('mode') + if (flags.has('mode') && (typeof value !== 'string' || value.length === 0)) { + throw new RuntimeClientError( + 'invalid_argument', + 'Missing value for --mode. Use edit, diff, or both.' + ) + } + const mode = getOptionalStringFlag(flags, 'mode') ?? 'diff' + if (mode === 'edit' || mode === 'diff' || mode === 'both') { + return mode + } + throw new RuntimeClientError('invalid_argument', 'Invalid --mode. Use edit, diff, or both.') +} + +function canOpenEntryForEdit(entry: GitStatusEntry): string | null { + if (entry.status === 'deleted') { + return 'deleted file has no edit target' + } + if (entry.conflictStatus === 'unresolved') { + return 'unresolved conflict may not have a single editable file' + } + return null +} + +async function openFileEdit( + ctx: HandlerContext, + worktree: string, + path: string +): Promise { + const result = await ctx.client.call('files.open', { + worktree, + relativePath: path + }) + return { + path, + mode: 'edit', + opened: result.result.opened, + kind: result.result.kind, + ...(result.result.opened ? {} : { skipped: true, reason: `${result.result.kind} file` }) + } +} + +async function openFileDiff( + ctx: HandlerContext, + worktree: string, + path: string, + staged: boolean +): Promise { + const result = await ctx.client.call('files.openDiff', { + worktree, + relativePath: path, + staged + }) + return { + path, + mode: 'diff', + staged, + opened: result.result.opened, + kind: result.result.kind + } +} + +function formatOpenChangedResult(result: FileOpenChangedResult): string { + if (result.totalChanged === 0) { + return 'No changed files.' + } + const lines = [`Opened ${result.opened.length} changed file targets.`] + if (result.skipped.length > 0) { + lines.push(`Skipped ${result.skipped.length} changed file targets:`) + for (const skipped of result.skipped) { + lines.push(`- ${skipped.path}: ${skipped.reason ?? 'not opened'}`) + } + } + return lines.join('\n') +} + +function formatFileOpen(result: RuntimeFileOpenResult): string { + return result.opened + ? `Opened ${result.relativePath}.` + : `Did not open ${result.relativePath}: ${result.kind} file.` +} + +function formatFileDiff(result: RuntimeFileOpenResult): string { + return `Opened diff for ${result.relativePath}.` +} + +export const FILE_HANDLERS: Record = { + 'file open': async (ctx) => { + const relativePath = getRequiredStringFlag(ctx.flags, 'path') + const worktree = await getFileWorktreeSelector(ctx) + const result = await ctx.client.call('files.open', { + worktree, + relativePath + }) + printResult(result, ctx.json, formatFileOpen) + }, + 'file diff': async (ctx) => { + const relativePath = getRequiredStringFlag(ctx.flags, 'path') + const staged = ctx.flags.get('staged') === true + const worktree = await getFileWorktreeSelector(ctx) + const result = await ctx.client.call('files.openDiff', { + worktree, + relativePath, + staged + }) + printResult(result, ctx.json, formatFileDiff) + }, + 'file open-changed': async (ctx) => { + const mode = getOpenChangedMode(ctx.flags) + const worktree = await getFileWorktreeSelector(ctx) + const status = await ctx.client.call('git.status', { worktree }) + const opened: FileOpenRecord[] = [] + const skipped: FileOpenRecord[] = [] + const openedEditPaths = new Set() + + for (const entry of status.result.entries) { + if (mode === 'edit' || mode === 'both') { + const editSkipReason = canOpenEntryForEdit(entry) + if (editSkipReason) { + skipped.push({ + path: entry.path, + mode: 'edit', + opened: false, + skipped: true, + reason: editSkipReason + }) + } else if (!openedEditPaths.has(entry.path)) { + openedEditPaths.add(entry.path) + const record = await openFileEdit(ctx, worktree, entry.path) + const records = record.opened ? opened : skipped + records.push(record) + } + } + + if (mode === 'diff' || mode === 'both') { + const staged = entry.area === 'staged' + if (entry.conflictStatus === 'unresolved') { + skipped.push({ + path: entry.path, + mode: 'diff', + staged, + opened: false, + skipped: true, + reason: 'unresolved conflict may not have a single diff target' + }) + } else { + opened.push(await openFileDiff(ctx, worktree, entry.path, staged)) + } + } + } + + printResult( + { + ...status, + result: { + worktree, + mode, + opened, + skipped, + totalChanged: status.result.entries.length + } + }, + ctx.json, + formatOpenChangedResult + ) + } +} diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts new file mode 100644 index 00000000000..210e2d343a6 --- /dev/null +++ b/src/cli/handlers/orchestration.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +// Why: isolate the handler's flag-to-param mapping; printResult only writes output. +vi.mock('../format', () => ({ printResult: vi.fn() })) + +import { ORCHESTRATION_HANDLERS } from './orchestration' + +describe('orchestration reset CLI handler', () => { + beforeEach(() => { + callMock.mockReset().mockResolvedValue({ result: { reset: 'all' } }) + }) + + const invoke = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration reset']({ + flags, + client: { call: callMock }, + json: true + } as never) + + it('sends all: true for a bare `reset` (no scope flag)', async () => { + await invoke(new Map()) + expect(callMock).toHaveBeenCalledWith('orchestration.reset', { + all: true, + tasks: undefined, + messages: undefined + }) + }) + + it('sends only the tasks scope for --tasks', async () => { + await invoke(new Map([['tasks', true]])) + expect(callMock).toHaveBeenCalledWith('orchestration.reset', { + all: undefined, + tasks: true, + messages: undefined + }) + }) + + it('sends only the all scope for --all (no implicit extra scopes)', async () => { + await invoke(new Map([['all', true]])) + expect(callMock).toHaveBeenCalledWith('orchestration.reset', { + all: true, + tasks: undefined, + messages: undefined + }) + }) +}) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 5398c55aadf..baa72bba969 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -438,8 +438,9 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration reset': async ({ flags, client, json }) => { + const hasScopeFlag = flags.has('all') || flags.has('tasks') || flags.has('messages') const result = await client.call<{ reset: string }>('orchestration.reset', { - all: flags.has('all') ? true : undefined, + all: flags.has('all') || !hasScopeFlag ? true : undefined, tasks: flags.has('tasks') ? true : undefined, messages: flags.has('messages') ? true : undefined }) diff --git a/src/cli/help.ts b/src/cli/help.ts index a269e5404d4..1e1202dc345 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -42,6 +42,11 @@ Worktrees: worktree rm Remove a worktree from Orca and git worktree ps Show a compact orchestration summary across worktrees +Files: + file open Open a workspace file in the Orca editor + file diff Open a workspace file diff in the Orca editor + file open-changed Open all git-changed files for a workspace + Terminals: terminal list List live Orca-managed terminals terminal show Show terminal metadata and preview @@ -167,6 +172,9 @@ Common Commands: orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--parent-worktree |--no-parent] [--json] orca worktree rm --worktree [--force] [--run-hooks] [--json] orca worktree ps [--limit ] [--json] + orca file open [--worktree ] [--json] + orca file diff [--staged] [--worktree ] [--json] + orca file open-changed [--mode edit|diff|both] [--worktree ] [--json] orca terminal list [--worktree ] [--limit ] [--json] orca terminal show [--terminal ] [--json] orca terminal read [--terminal ] [--cursor ] [--limit ] [--json] @@ -252,6 +260,8 @@ Examples: $ orca worktree current $ orca worktree set --worktree active --comment "waiting on review" $ orca worktree ps --limit 10 + $ orca file open-changed --mode diff + $ orca file open src/App.tsx $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json $ orca terminal send --terminal term_123 --text "hi" --enter $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json @@ -353,6 +363,7 @@ export function formatFlagHelp(flag: string): string { json: '--json Emit machine-readable JSON', key: '--key Key or combo to press, e.g. Escape or CmdOrCtrl+L', limit: '--limit Maximum number of rows to return', + mode: '--mode Mode such as edit, diff, or both', 'mouse-button': '--mouse-button Mouse button: left, right, or middle', name: '--name Name for the new worktree or automation', 'no-parent': '--no-parent Force no parent lineage for unrelated work', @@ -360,7 +371,7 @@ export function formatFlagHelp(flag: string): string { pages: '--pages Number of scroll pages', 'parent-worktree': '--parent-worktree Parent selector; create infers the caller/current worktree by default', - path: '--path Filesystem path to the repo', + path: '--path Path argument for the command', query: '--query Search text for matching refs', ref: '--ref Base ref to persist for the repo', repo: '--repo Repo selector such as id:, name:, or path:', @@ -378,6 +389,7 @@ export function formatFlagHelp(flag: string): string { '--worktree Worktree selector such as id:, branch:, issue:, path:, or active/current', workspace: '--workspace Existing worktree selector for automation runs', prompt: '--prompt Automation prompt to pass to the agent', + staged: '--staged Open staged source-control changes', provider: '--provider Agent id such as codex, claude, or gemini', trigger: '--trigger Automation schedule preset, cron, or RRULE', schedule: '--schedule Alias for --trigger', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index ec97b542096..b1efaa23cb8 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -182,7 +182,10 @@ describe('orca cli worktree awareness', () => { it('shows the enclosing worktree for `worktree current`', async () => { queueFixtures( callMock, - worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]), + worktreeListFixture([ + buildWorktree('/tmp/repo/feature', 'feature/foo'), + buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'duplicate-repo') + ]), okFixture('req_1', { worktree: { id: 'repo::/tmp/repo/feature', @@ -197,7 +200,7 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { - worktree: `path:${path.resolve('/tmp/repo/feature')}` + worktree: 'id:repo::/tmp/repo/feature' }) expect(logSpy).toHaveBeenCalledTimes(1) }) @@ -245,7 +248,7 @@ describe('orca cli worktree awareness', () => { ) expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', { - worktree: `path:${path.resolve('/tmp/repo/feature')}`, + worktree: 'id:repo::/tmp/repo/feature', displayName: undefined, linkedIssue: undefined, comment: 'hello', @@ -332,7 +335,7 @@ describe('orca cli worktree awareness', () => { displayName: undefined, linkedIssue: undefined, comment: undefined, - parentWorktree: `path:${path.resolve('/tmp/repo/parent')}`, + parentWorktree: 'id:repo::/tmp/repo/parent', noParent: false }) }) @@ -437,7 +440,7 @@ describe('orca cli worktree awareness', () => { runHooks: false, activate: true, parentWorktree: undefined, - cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`, + cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, callerTerminalHandle: undefined }) @@ -541,7 +544,7 @@ describe('orca cli worktree awareness', () => { comment: undefined, runHooks: false, activate: false, - parentWorktree: `path:${path.resolve('/tmp/repo/parent')}`, + parentWorktree: 'id:repo-1::/tmp/repo/parent', noParent: false, callerTerminalHandle: undefined }) @@ -666,7 +669,7 @@ describe('orca cli worktree awareness', () => { runHooks: false, activate: false, parentWorktree: undefined, - cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`, + cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, callerTerminalHandle: 'term_parent' }) @@ -882,7 +885,7 @@ describe('orca cli worktree awareness', () => { runHooks: true, activate: true, parentWorktree: undefined, - cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`, + cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, callerTerminalHandle: undefined }) @@ -1273,7 +1276,7 @@ describe('orca cli worktree awareness', () => { await main(['worktree', 'show', '--worktree', 'current', '--json'], '/tmp/repo/feature/src') expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { - worktree: `path:${path.resolve('/tmp/repo/feature')}` + worktree: 'id:repo::/tmp/repo/feature' }) }) @@ -1308,6 +1311,54 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') }) + it('passes all reset scope explicitly for no-flag orchestration reset', async () => { + callMock.mockResolvedValueOnce(okFixture('req_reset', { reset: 'all' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['orchestration', 'reset'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('orchestration.reset', { + all: true, + tasks: undefined, + messages: undefined + }) + }) + + it.each([ + { + args: ['orchestration', 'reset', '--all'], + params: { all: true, tasks: undefined, messages: undefined }, + reset: 'all' + }, + { + args: ['orchestration', 'reset', '--tasks'], + params: { all: undefined, tasks: true, messages: undefined }, + reset: 'tasks' + }, + { + args: ['orchestration', 'reset', '--messages'], + params: { all: undefined, tasks: undefined, messages: true }, + reset: 'messages' + }, + { + args: ['orchestration', 'reset', '--tasks', '--messages'], + params: { all: undefined, tasks: true, messages: true }, + reset: 'tasks' + }, + { + args: ['orchestration', 'reset', '--all', '--tasks'], + params: { all: true, tasks: true, messages: undefined }, + reset: 'all' + } + ])('passes explicit reset flags through for $args', async ({ args, params, reset }) => { + callMock.mockResolvedValueOnce(okFixture('req_reset', { reset })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(args, '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('orchestration.reset', params) + }) + it('rejects unknown task-update status with an enum-aware error', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_coord' const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -1397,7 +1448,7 @@ describe('orca cli worktree awareness', () => { await main(['terminal', 'list', '--worktree', 'active', '--json'], '/tmp/repo/feature/src') expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.list', { - worktree: `path:${path.resolve('/tmp/repo/feature')}`, + worktree: 'id:repo::/tmp/repo/feature', limit: undefined }) }) @@ -1610,7 +1661,7 @@ describe('orca cli worktree awareness', () => { prompt: 'Review open changes', agentId: 'codex', repo: undefined, - workspace: `path:${path.resolve('/tmp/repo/feature')}`, + workspace: 'id:repo-1::/tmp/repo/feature', workspaceMode: 'existing', baseBranch: undefined, reuseSession: undefined, @@ -1823,7 +1874,7 @@ describe('orca cli worktree awareness', () => { 2, 'automation.create', expect.objectContaining({ - workspace: `path:${path.resolve('/tmp/repo/feature')}`, + workspace: 'id:repo-1::/tmp/repo/feature', workspaceMode: 'existing', reuseSession: true }) @@ -1950,7 +2001,7 @@ describe('orca cli worktree awareness', () => { prompt: 'Review open changes', agentId: 'codex', repo: undefined, - workspace: `path:${path.resolve('/tmp/repo/feature')}`, + workspace: 'id:repo-1::/tmp/repo/feature', workspaceMode: 'existing', baseBranch: undefined, timezone: undefined, @@ -1982,7 +2033,7 @@ describe('orca cli worktree awareness', () => { prompt: undefined, agentId: undefined, repo: undefined, - workspace: `path:${path.resolve('/tmp/repo/feature')}`, + workspace: 'id:repo-1::/tmp/repo/feature', workspaceMode: undefined, baseBranch: undefined, reuseSession: undefined, diff --git a/src/cli/index.ts b/src/cli/index.ts index 247b73cea61..d8218a07000 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -17,7 +17,9 @@ export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { - return commandPath[0] === 'environment' || commandPath[0] === 'serve' + return ( + commandPath[0] === 'environment' || commandPath[0] === 'serve' || commandPath[0] === 'agent' + ) } export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index c56d4f152e0..a94878ca87f 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -69,10 +69,10 @@ export async function resolveCurrentWorktreeSelector( } // Why: users expect "active/current" to mean the enclosing managed worktree - // even from nested subdirectories. The CLI resolves that shell-local concept - // to the deepest matching worktree root, then hands the runtime a normal - // path selector so selector semantics stay centralized in one layer. - return buildCurrentWorktreeSelector(enclosingWorktree.path) + // even from nested subdirectories. Resolve to the concrete runtime id here: + // duplicate repo registrations can expose the same Git worktree path, and a + // path selector would throw selector_ambiguous after losing the repo id. + return `id:${enclosingWorktree.id}` } export async function getOptionalWorktreeSelector( diff --git a/src/cli/specs/agent-hooks.ts b/src/cli/specs/agent-hooks.ts new file mode 100644 index 00000000000..b328189f383 --- /dev/null +++ b/src/cli/specs/agent-hooks.ts @@ -0,0 +1,26 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const AGENT_HOOK_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['agent', 'hooks', 'status'], + summary: 'Show whether Orca-managed agent status hooks are enabled', + usage: 'orca agent hooks status [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca agent hooks status', 'orca agent hooks status --json'] + }, + { + path: ['agent', 'hooks', 'off'], + summary: 'Disable Orca-managed agent status hooks and remove local hook entries', + usage: 'orca agent hooks off [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca agent hooks off'] + }, + { + path: ['agent', 'hooks', 'on'], + summary: 'Enable Orca-managed agent status hooks', + usage: 'orca agent hooks on [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca agent hooks on'] + } +] diff --git a/src/cli/specs/file.ts b/src/cli/specs/file.ts new file mode 100644 index 00000000000..8518e31becb --- /dev/null +++ b/src/cli/specs/file.ts @@ -0,0 +1,48 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const FILE_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['file', 'open'], + summary: 'Open a workspace file in the Orca editor', + usage: 'orca file open [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'path', 'worktree'], + positionalArgs: ['path'], + notes: [ + 'The path is relative to the selected worktree. When --worktree is omitted, local CLI calls infer the current Orca worktree from cwd.' + ], + examples: [ + 'orca file open src/App.tsx', + 'orca file open --path docs/readme.md --worktree active' + ] + }, + { + path: ['file', 'diff'], + summary: 'Open a workspace file diff in the Orca editor', + usage: 'orca file diff [--staged] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'path', 'staged', 'worktree'], + positionalArgs: ['path'], + notes: [ + 'Diffs default to unstaged changes. Pass --staged to open the staged source-control diff.' + ], + examples: [ + 'orca file diff src/App.tsx', + 'orca file diff --path package.json --staged --worktree branch:feature' + ] + }, + { + path: ['file', 'open-changed'], + summary: 'Open all git-changed files for a workspace', + usage: 'orca file open-changed [--mode edit|diff|both] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'mode', 'worktree'], + notes: [ + 'For v1, changed files come from git status for the selected worktree.', + 'The default mode is diff. Edit mode skips deleted files because there is no file to open.' + ], + examples: [ + 'orca file open-changed', + 'orca file open-changed --mode both', + 'orca file open-changed --mode diff --worktree active' + ] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index 4695f831adb..ccffbac65ef 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -3,16 +3,20 @@ import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced' import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { AUTOMATION_COMMAND_SPECS } from './automations' import { CORE_COMMAND_SPECS } from './core' +import { FILE_COMMAND_SPECS } from './file' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' import { ENVIRONMENT_COMMAND_SPECS } from './environment' +import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, + ...FILE_COMMAND_SPECS, ...AUTOMATION_COMMAND_SPECS, ...BROWSER_BASIC_COMMAND_SPECS, ...BROWSER_ADVANCED_COMMAND_SPECS, ...ORCHESTRATION_COMMAND_SPECS, ...COMPUTER_COMMAND_SPECS, + ...AGENT_HOOK_COMMAND_SPECS, ...ENVIRONMENT_COMMAND_SPECS ] diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index ac79fd00f92..4f23c76900c 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -136,8 +136,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['orchestration', 'reset'], - summary: 'Reset orchestration state', - usage: 'orca orchestration reset [--all] [--tasks] [--messages] [--json]', + summary: 'Reset orchestration state (one scope; bare command resets all)', + usage: 'orca orchestration reset [--all | --tasks | --messages] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages'] } ] diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts new file mode 100644 index 00000000000..80d5c493833 --- /dev/null +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -0,0 +1,366 @@ +/* eslint-disable max-lines -- Why: the orchestrator tests cover local, SSH, + retry, and post-generation race guards; splitting would duplicate mocks. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings, Repo } from '../../shared/types' +import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id' + +const { + gitExecFileAsyncMock, + getGitUsernameMock, + getSshGitUsernameMock, + getSshGitProviderMock, + generateBranchNameMock, + resolveTextGenerationParamsMock, + prepareLocalEnvMock, + computeBranchNameMock +} = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + getGitUsernameMock: vi.fn(() => 'you'), + getSshGitUsernameMock: vi.fn(async () => 'you'), + getSshGitProviderMock: vi.fn(() => undefined), + generateBranchNameMock: vi.fn(), + resolveTextGenerationParamsMock: vi.fn(), + prepareLocalEnvMock: vi.fn(async () => ({ ok: true as const })), + computeBranchNameMock: vi.fn((leaf: string) => `you/${leaf}`) +})) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('../git/repo', () => ({ getGitUsername: getGitUsernameMock })) +vi.mock('../git/git-username', () => ({ getSshGitUsername: getSshGitUsernameMock })) +vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) +vi.mock('../text-generation/commit-message-text-generation', () => ({ + generateBranchNameFromContext: generateBranchNameMock, + resolveTextGenerationParams: resolveTextGenerationParamsMock +})) +vi.mock('../text-generation/commit-message-agent-environment', () => ({ + prepareLocalCommitMessageAgentEnv: prepareLocalEnvMock +})) +vi.mock('../ipc/worktree-logic', () => ({ computeBranchName: computeBranchNameMock })) + +import { + maybeAutoRenameBranchOnFirstWork, + resetFirstWorkBranchRenameState, + type FirstWorkBranchRenameDeps, + type FirstWorkBranchRenameEvent +} from './first-work-branch-rename' + +const REPO_ID = 'repo1' +const WORKTREE_ID = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt` +const TAB_ID = 'tab-1' +const PANE_KEY = `${TAB_ID}:leaf-1` + +const noUpstreamError = new Error("fatal: no upstream configured for branch 'Nautilus'") + +function gitResponder(opts: { + currentBranch: string + hasUpstream: boolean + existingRefs?: string[] +}) { + return async (args: string[]) => { + if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) { + if (opts.hasUpstream) { + return { stdout: 'origin/x\n', stderr: '' } + } + throw noUpstreamError + } + if (args[0] === 'rev-parse') { + return { stdout: `${opts.currentBranch}\n`, stderr: '' } + } + if (args[0] === 'show-ref') { + const ref = args.at(-1) ?? '' + if ((opts.existingRefs ?? []).includes(ref)) { + return { stdout: '', stderr: '' } + } + throw new Error('not found') + } + if (args[0] === 'branch' && args[1] === '-m') { + return { stdout: '', stderr: '' } + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + } +} + +function makeDeps(overrides: Partial = {}): { + deps: FirstWorkBranchRenameDeps + onRenamed: ReturnType + setDisplayName: ReturnType +} { + const onRenamed = vi.fn() + const setDisplayName = vi.fn() + const settings = { autoRenameBranchFromWork: true } as unknown as GlobalSettings + const repo = { id: REPO_ID, path: '/repo', connectionId: undefined } as unknown as Repo + return { + onRenamed, + setDisplayName, + deps: { + getSettings: () => settings, + getRepo: () => repo, + getAgentEnvResolvers: () => undefined, + getCurrentDisplayName: () => 'Nautilus-8', + canRenameOrcaCreatedBranch: () => true, + setDisplayName, + resolveWorktreeIdForTab: () => WORKTREE_ID, + onRenamed, + ...overrides + } + } +} + +function workingEvent( + overrides: Partial = {} +): FirstWorkBranchRenameEvent { + return { + paneKey: PANE_KEY, + tabId: TAB_ID, + worktreeId: undefined, + state: 'working', + prompt: 'Fix the auth bug', + assistantMessage: undefined, + isReplay: false, + ...overrides + } +} + +describe('maybeAutoRenameBranchOnFirstWork', () => { + beforeEach(() => { + resetFirstWorkBranchRenameState() + vi.clearAllMocks() + getGitUsernameMock.mockReturnValue('you') + getSshGitUsernameMock.mockResolvedValue('you') + getSshGitProviderMock.mockReturnValue(undefined) + computeBranchNameMock.mockImplementation((leaf: string) => `you/${leaf}`) + prepareLocalEnvMock.mockResolvedValue({ ok: true }) + resolveTextGenerationParamsMock.mockReturnValue({ + ok: true, + params: { agentId: 'claude', model: 'm' } + }) + generateBranchNameMock.mockResolvedValue({ success: true, slug: 'fix-auth' }) + gitExecFileAsyncMock.mockImplementation( + gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false }) + ) + }) + + it('renames a fresh creature branch and its display name from the generated slug', async () => { + const { deps, onRenamed, setDisplayName } = makeDeps() + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth'], + expect.objectContaining({ cwd: '/repo/wt' }) + ) + expect(resolveTextGenerationParamsMock).toHaveBeenCalledWith( + expect.anything(), + 'local', + 'branchName', + expect.objectContaining({ id: REPO_ID }) + ) + expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Fix auth') + expect(onRenamed).toHaveBeenCalledWith(REPO_ID) + }) + + it('leaves a user-customized display name untouched while still renaming the branch', async () => { + const { deps, setDisplayName } = makeDeps({ getCurrentDisplayName: () => 'My cool feature' }) + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth'], + expect.objectContaining({ cwd: '/repo/wt' }) + ) + expect(setDisplayName).not.toHaveBeenCalled() + }) + + it('resolves the worktree from the tab when the hook payload omits worktreeId', async () => { + // workingEvent() carries worktreeId: undefined; resolveWorktreeIdForTab supplies it. + const { deps, onRenamed } = makeDeps() + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(onRenamed).toHaveBeenCalledWith(REPO_ID) + }) + + it('skips when no worktree can be resolved for the tab', async () => { + const { deps } = makeDeps({ resolveWorktreeIdForTab: () => undefined }) + await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does nothing when the setting is off', async () => { + const settings = { autoRenameBranchFromWork: false } as unknown as GlobalSettings + const { deps } = makeDeps({ getSettings: () => settings }) + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('ignores replayed events and non-working states', async () => { + const { deps } = makeDeps() + await maybeAutoRenameBranchOnFirstWork(workingEvent({ isReplay: true }), deps) + await maybeAutoRenameBranchOnFirstWork(workingEvent({ state: 'done' }), deps) + await maybeAutoRenameBranchOnFirstWork(workingEvent({ prompt: ' ' }), deps) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does not re-attempt after a successful rename', async () => { + const { deps } = makeDeps() + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + generateBranchNameMock.mockClear() + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(generateBranchNameMock).not.toHaveBeenCalled() + }) + + it('retries on a later event after a transient failure (does not poison the worktree)', async () => { + generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' }) + const { deps, onRenamed } = makeDeps() + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(onRenamed).not.toHaveBeenCalled() + + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth'], + expect.objectContaining({ cwd: '/repo/wt' }) + ) + expect(onRenamed).toHaveBeenCalledWith(REPO_ID) + }) + + it('leaves a user-named branch untouched', async () => { + gitExecFileAsyncMock.mockImplementation( + gitResponder({ currentBranch: 'you/my-feature', hasUpstream: false }) + ) + const { deps, onRenamed } = makeDeps() + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(generateBranchNameMock).not.toHaveBeenCalled() + expect(onRenamed).not.toHaveBeenCalled() + }) + + it('refuses to rename a branch that already has an upstream', async () => { + gitExecFileAsyncMock.mockImplementation( + gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: true }) + ) + const { deps, onRenamed } = makeDeps() + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(generateBranchNameMock).not.toHaveBeenCalled() + expect(onRenamed).not.toHaveBeenCalled() + }) + + it('leaves ineligible branches untouched even when their leaf is a creature name', async () => { + const { deps, onRenamed } = makeDeps({ canRenameOrcaCreatedBranch: () => false }) + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(generateBranchNameMock).not.toHaveBeenCalled() + expect(onRenamed).not.toHaveBeenCalled() + }) + + it('suffixes when the generated branch name already exists', async () => { + gitExecFileAsyncMock.mockImplementation( + gitResponder({ + currentBranch: 'you/Nautilus', + hasUpstream: false, + existingRefs: ['refs/heads/you/fix-auth'] + }) + ) + const { deps } = makeDeps() + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth-2'], + expect.objectContaining({ cwd: '/repo/wt' }) + ) + }) + + it('does not rename when the branch changes while generation is running', async () => { + let branchReadCount = 0 + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') { + branchReadCount += 1 + return { stdout: `${branchReadCount === 1 ? 'you/Nautilus' : 'you/manual'}\n`, stderr: '' } + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) { + throw noUpstreamError + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'you/Nautilus\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/you/Nautilus')) { + throw new Error('not found') + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) + const { deps, onRenamed } = makeDeps() + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth'], + expect.anything() + ) + expect(onRenamed).not.toHaveBeenCalled() + }) + + it('does not rename when the branch gains an upstream while generation is running', async () => { + let upstreamReadCount = 0 + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') { + return { stdout: 'you/Nautilus\n', stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'you/Nautilus\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) { + upstreamReadCount += 1 + if (upstreamReadCount === 1) { + throw noUpstreamError + } + return { stdout: 'origin/you/Nautilus\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/you/Nautilus')) { + throw new Error('not found') + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) + const { deps, onRenamed } = makeDeps() + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth'], + expect.anything() + ) + expect(onRenamed).not.toHaveBeenCalled() + }) + + it('uses the SSH git provider and remote generation target for remote worktrees', async () => { + getSshGitUsernameMock.mockResolvedValue('remote-user') + const provider = { + exec: vi.fn(gitResponder({ currentBranch: 'remote-user/Nautilus', hasUpstream: false })), + renameCurrentBranch: vi.fn(async () => undefined), + executeCommitMessagePlan: vi.fn() + } + getSshGitProviderMock.mockReturnValue(provider as never) + const repo = { id: REPO_ID, path: '/repo', connectionId: 'ssh-1' } as unknown as Repo + const { deps } = makeDeps({ getRepo: () => repo }) + + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(prepareLocalEnvMock).not.toHaveBeenCalled() + expect(generateBranchNameMock).toHaveBeenCalledWith( + { firstPrompt: 'Fix the auth bug', assistantMessage: undefined }, + { agentId: 'claude', model: 'm' }, + expect.objectContaining({ + kind: 'remote', + cwd: '/repo/wt', + missingBinaryLocation: 'remote PATH' + }) + ) + expect(computeBranchNameMock).toHaveBeenCalledWith('fix-auth', expect.anything(), 'remote-user') + expect(provider.exec).not.toHaveBeenCalledWith(['branch', '-m', 'you/fix-auth'], '/repo/wt') + expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth') + }) + + it('retries when the SSH provider is unavailable on the first working event', async () => { + const provider = { + exec: vi.fn(gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })), + renameCurrentBranch: vi.fn(async () => undefined), + executeCommitMessagePlan: vi.fn() + } + getSshGitProviderMock.mockReturnValueOnce(undefined).mockReturnValue(provider as never) + const repo = { id: REPO_ID, path: '/repo', connectionId: 'ssh-1' } as unknown as Repo + const { deps, onRenamed } = makeDeps({ getRepo: () => repo }) + + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(onRenamed).not.toHaveBeenCalled() + + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth') + expect(onRenamed).toHaveBeenCalledWith(REPO_ID) + }) +}) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts new file mode 100644 index 00000000000..ac427172753 --- /dev/null +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -0,0 +1,262 @@ +// Why: when "Auto-Rename Branch From Work" is on, the first time an agent +// starts working in a freshly-created workspace we replace the auto-generated +// creature branch (e.g. `you/Nautilus`) with a short, work-derived name. This +// module owns the orchestration: gate on the signal, enforce the safety +// guardrails, summarize the prompt via the configured agent, and rename. +import type { GlobalSettings, Repo } from '../../shared/types' +import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { parsePaneKey } from '../../shared/stable-pane-id' +import { + humanizeBranchSlug, + isAutoGeneratedCreatureBranchName +} from '../../shared/branch-name-from-work' +import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' +import { computeBranchName } from '../ipc/worktree-logic' +import { gitExecFileAsync } from '../git/runner' +import { getGitUsername } from '../git/repo' +import { getSshGitUsername } from '../git/git-username' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import type { SshGitProvider } from '../providers/ssh-git-provider' +import { + branchHasUpstream, + renameCurrentBranch, + resolveUniqueBranchName, + type GitExec +} from '../git/branch-rename' +import { + generateBranchNameFromContext, + resolveTextGenerationParams, + type CommitMessageGenerationTarget +} from '../text-generation/commit-message-text-generation' +import { + prepareLocalCommitMessageAgentEnv, + type CommitMessageAgentEnvironmentResolvers +} from '../text-generation/commit-message-agent-environment' + +export type FirstWorkBranchRenameEvent = { + paneKey: string + /** Agent-echoed worktree id; unreliable, used only as a fallback. */ + worktreeId: string | undefined + tabId: string | undefined + state: string + prompt: string | undefined + assistantMessage: string | undefined + isReplay: boolean | undefined +} + +export type FirstWorkBranchRenameDeps = { + getSettings: () => GlobalSettings + getRepo: (repoId: string) => Repo | undefined + getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined + /** Current sidebar display name for the worktree, if one is stored. */ + getCurrentDisplayName: (worktreeId: string) => string | undefined + /** True only for Orca-created worktrees whose branch Orca is allowed to rename. */ + canRenameOrcaCreatedBranch: (worktreeId: string) => boolean + /** Persist a new sidebar display name for the worktree. */ + setDisplayName: (worktreeId: string, displayName: string) => void + /** Authoritative tab→worktree resolution from the session's tab map. */ + resolveWorktreeIdForTab: (tabId: string) => string | undefined + /** Invalidate caches + notify the renderer so the new branch name surfaces. */ + onRenamed: (repoId: string) => void +} + +// Why: `inFlight` guards against concurrent runs while generation (seconds) is +// pending; `settled` short-circuits worktrees we've reached a definitive verdict +// on (renamed, or permanently ineligible) so we never re-probe git on later +// `working` events — important because the hook fires every turn for every pane. +// A *transient* failure (agent not reachable yet, brief detached HEAD) is NOT +// settled, so the real first prompt can still succeed on a later event. +const inFlightWorktreeIds = new Set() +const settledWorktreeIds = new Set() + +/** Test seam: clear the per-process dedupe sets. */ +export function resetFirstWorkBranchRenameState(): void { + inFlightWorktreeIds.clear() + settledWorktreeIds.clear() +} + +export async function maybeAutoRenameBranchOnFirstWork( + event: FirstWorkBranchRenameEvent, + deps: FirstWorkBranchRenameDeps +): Promise { + // Cheap gates first — the signal must be a live "work has begun" event. + if (event.isReplay || event.state !== 'working') { + return + } + if (!deps.getSettings().autoRenameBranchFromWork) { + return + } + // Resolve the worktree from the tab (authoritative) rather than the + // agent-echoed worktreeId, which is not present on every hook event. + const tabId = parsePaneKey(event.paneKey)?.tabId ?? event.tabId + const worktreeId = (tabId ? deps.resolveWorktreeIdForTab(tabId) : undefined) ?? event.worktreeId + if (!worktreeId) { + return + } + // Short-circuit settled/in-flight worktrees before any logging or work. + if (settledWorktreeIds.has(worktreeId) || inFlightWorktreeIds.has(worktreeId)) { + return + } + const prompt = event.prompt?.trim() + if (!prompt) { + return + } + inFlightWorktreeIds.add(worktreeId) + try { + // `settled` true means a definitive verdict (renamed or permanently + // ineligible); false means a transient bail that should retry later. + const settled = await runAutoRename(worktreeId, prompt, event.assistantMessage, deps) + if (settled) { + settledWorktreeIds.add(worktreeId) + } + } catch (error) { + // Why: best-effort, opt-in convenience. A failure must never disrupt the + // agent the user actually launched, so swallow after logging. + console.warn('[auto-branch-rename] rename attempt failed:', error) + } finally { + inFlightWorktreeIds.delete(worktreeId) + } +} + +/** + * Returns true when a definitive verdict was reached (renamed, or permanently + * ineligible — so stop re-probing), false for a transient bail worth retrying. + */ +async function runAutoRename( + worktreeId: string, + prompt: string, + assistantMessage: string | undefined, + deps: FirstWorkBranchRenameDeps +): Promise { + // Why: when the feature appears not to fire, the question is always "which + // gate bailed?" — this names the gate so a single log line answers it. + // `stop` = permanent (don't retry); `retry` = transient (try again later). + const stop = (reason: string): true => { + console.info(`[auto-branch-rename] skip (${reason}) for ${worktreeId}`) + return true + } + const retry = (reason: string): false => { + console.info(`[auto-branch-rename] retry-later (${reason}) for ${worktreeId}`) + return false + } + + const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) + const parsed = splitWorktreeId(worktreeId) + if (!repo || !parsed) { + return stop('unresolved repo or worktree id') + } + const worktreePath = parsed.worktreePath + + const provider = repo.connectionId ? (getSshGitProvider(repo.connectionId) ?? null) : null + if (repo.connectionId && !provider) { + return retry('ssh provider unavailable') + } + const exec: GitExec = provider + ? (args) => provider.exec(args, worktreePath) + : (args) => gitExecFileAsync(args, { cwd: worktreePath }) + + const currentBranch = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() + if (!currentBranch || currentBranch === 'HEAD') { + return retry(`no checked-out branch (${currentBranch || 'empty'})`) + } + if (!deps.canRenameOrcaCreatedBranch(worktreeId)) { + return stop(`worktree is not eligible for auto-rename`) + } + // Only rename a branch Orca auto-named — never one the user chose. + const leaf = currentBranch.slice(currentBranch.lastIndexOf('/') + 1) + if (!isAutoGeneratedCreatureBranchName(leaf)) { + return stop(`branch "${currentBranch}" is not an auto-generated name`) + } + // Never rewrite a branch that's already been published. + if (await branchHasUpstream(exec)) { + return stop(`branch "${currentBranch}" already has an upstream`) + } + + const settings = deps.getSettings() + const hostKey = getCommitMessageModelDiscoveryHostKey(repo.connectionId ?? null) + const resolvedParams = resolveTextGenerationParams(settings, hostKey, 'branchName', repo) + if (!resolvedParams.ok) { + return stop(`no generation agent: ${resolvedParams.error}`) + } + const params = resolvedParams.params + + const target = await resolveGenerationTarget(worktreePath, params.agentId, provider, deps) + if (!target) { + return retry('could not prepare generation environment') + } + + const generated = await generateBranchNameFromContext( + { firstPrompt: prompt, assistantMessage }, + params, + target + ) + if (!generated.success) { + // Transient: the agent may be momentarily busy/unreachable — let a later + // event retry rather than permanently leaving the creature name. + return retry(`generation failed: ${generated.error}`) + } + + // Re-validate after generation (it can take seconds): the branch must be the + // same unpublished creature branch we started from, or we leave it alone. + const branchNow = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() + if (branchNow !== currentBranch) { + return retry(`branch changed during generation (${currentBranch} -> ${branchNow})`) + } + if (await branchHasUpstream(exec)) { + return retry(`branch "${currentBranch}" was pushed during generation`) + } + + const username = provider + ? (await getSshGitUsername(provider, repo.path)) || null + : getGitUsername(repo.path) || null + const newBranch = await resolveUniqueBranchName( + exec, + generated.slug, + (slugLeaf) => computeBranchName(slugLeaf, settings, username), + currentBranch + ) + if (!newBranch || newBranch === currentBranch) { + return stop(`no distinct unique branch name for slug "${generated.slug}"`) + } + + await (provider + ? provider.renameCurrentBranch(worktreePath, newBranch) + : renameCurrentBranch(exec, newBranch)) + + // Keep the sidebar name in sync with the branch — but only when it is still + // the auto-generated creature name, so a name the user typed is left alone. + const currentDisplayName = deps.getCurrentDisplayName(worktreeId) + const newDisplayName = humanizeBranchSlug(generated.slug) + const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName) + if (updateDisplay) { + deps.setDisplayName(worktreeId, newDisplayName) + } + + deps.onRenamed(repo.id) + const displayLog = updateDisplay + ? `display "${currentDisplayName ?? ''}" -> "${newDisplayName}"` + : `display kept ("${currentDisplayName}")` + console.info(`[auto-branch-rename] renamed ${currentBranch} -> ${newBranch}; ${displayLog}`) + return true +} + +async function resolveGenerationTarget( + worktreePath: string, + agentId: string, + provider: SshGitProvider | null, + deps: FirstWorkBranchRenameDeps +): Promise { + if (provider) { + return { + kind: 'remote', + cwd: worktreePath, + execute: (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + missingBinaryLocation: 'remote PATH' + } + } + const localEnv = await prepareLocalCommitMessageAgentEnv(agentId, deps.getAgentEnvResolvers()) + if (!localEnv.ok) { + return null + } + return { kind: 'local', cwd: worktreePath, ...(localEnv.env ? { env: localEnv.env } : {}) } +} diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 59d354933e2..0b69077d3b1 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -18,6 +18,8 @@ export type HookCommandConfig = { type: 'command' command: string timeout?: number + async?: boolean + statusMessage?: string [key: string]: unknown } diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts new file mode 100644 index 00000000000..98df61b8a4f --- /dev/null +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -0,0 +1,110 @@ +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import type { HookInstallAgent } from '../../shared/telemetry-events' +import type { GlobalSettings } from '../../shared/types' +import { antigravityHookService } from '../antigravity/hook-service' +import { claudeHookService } from '../claude/hook-service' +import { codexHookService } from '../codex/hook-service' +import { copilotHookService } from '../copilot/hook-service' +import { cursorHookService } from '../cursor/hook-service' +import { droidHookService } from '../droid/hook-service' +import { commandCodeHookService } from '../command-code/hook-service' +import { geminiHookService } from '../gemini/hook-service' +import { grokHookService } from '../grok/hook-service' +import { hermesHookService } from '../hermes/hook-service' + +export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => void] +type ManagedHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] +type ManagedHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus] + +export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [ + ['claude', () => claudeHookService.install()], + ['codex', () => codexHookService.install()], + ['gemini', () => geminiHookService.install()], + ['antigravity', () => antigravityHookService.install()], + ['cursor', () => cursorHookService.install()], + ['droid', () => droidHookService.install()], + ['command-code', () => commandCodeHookService.install()], + ['grok', () => grokHookService.install()], + ['copilot', () => copilotHookService.install()], + ['hermes', () => hermesHookService.install()] +] + +const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ + ['claude', () => claudeHookService.remove()], + ['codex', () => codexHookService.remove()], + ['gemini', () => geminiHookService.remove()], + ['antigravity', () => antigravityHookService.remove()], + ['cursor', () => cursorHookService.remove()], + ['droid', () => droidHookService.remove()], + ['command-code', () => commandCodeHookService.remove()], + ['grok', () => grokHookService.remove()], + ['copilot', () => copilotHookService.remove()], + ['hermes', () => hermesHookService.remove()] +] + +const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ + ['claude', () => claudeHookService.getStatus()], + ['codex', () => codexHookService.getStatus()], + ['gemini', () => geminiHookService.getStatus()], + ['antigravity', () => antigravityHookService.getStatus()], + ['cursor', () => cursorHookService.getStatus()], + ['droid', () => droidHookService.getStatus()], + ['command-code', () => commandCodeHookService.getStatus()], + ['grok', () => grokHookService.getStatus()], + ['copilot', () => copilotHookService.getStatus()], + ['hermes', () => hermesHookService.getStatus()] +] + +export function isAgentStatusHooksEnabled( + settings: Pick | null | undefined +): boolean { + return settings?.agentStatusHooksEnabled !== false +} + +export function installManagedAgentHooks(): void { + for (const [agent, install] of MANAGED_AGENT_HOOK_INSTALLERS) { + try { + install() + } catch (error) { + console.warn(`[agent-hooks] Failed to install ${agent} managed hooks:`, error) + } + } +} + +function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallStatus { + return { + agent, + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: error instanceof Error ? error.message : String(error) + } +} + +export function removeManagedAgentHooks(): AgentHookInstallStatus[] { + return LOCAL_MANAGED_HOOK_REMOVERS.map(([agent, remove]) => { + try { + return remove() + } catch (error) { + return errorStatus(agent, error) + } + }) +} + +export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] { + return LOCAL_MANAGED_HOOK_STATUS_READERS.map(([agent, getStatus]) => { + try { + return getStatus() + } catch (error) { + return errorStatus(agent, error) + } + }) +} + +export function applyAgentStatusHooksEnabled(enabled: boolean): AgentHookInstallStatus[] { + if (enabled) { + installManagedAgentHooks() + return getManagedAgentHookStatuses() + } + return removeManagedAgentHooks() +} diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index b4cc662b407..86eb35a9f83 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -10,6 +10,7 @@ vi.mock('electron', () => ({ import { CodexHookService } from '../codex/hook-service' import { CursorHookService } from '../cursor/hook-service' +import { CommandCodeHookService } from '../command-code/hook-service' import { GeminiHookService } from '../gemini/hook-service' import { AntigravityHookService } from '../antigravity/hook-service' import { ClaudeHookService } from '../claude/hook-service' @@ -24,9 +25,12 @@ type FakeFs = { failRenameTo: Set } -function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } { +function createFakeSftp(initialFiles: Record = {}): { + sftp: SFTPWrapper + fs: FakeFs +} { const fs: FakeFs = { - files: new Map(), + files: new Map(Object.entries(initialFiles)), dirs: new Set(['/']), modes: new Map(), failRenameTo: new Set() @@ -135,6 +139,11 @@ describe('remote hook service installers', () => { path: '/home/dev/.orca/agent-hooks/cursor-hook.sh', install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev') }, + { + path: '/home/dev/.orca/agent-hooks/command-code-hook.sh', + install: (sftp: SFTPWrapper) => + new CommandCodeHookService().installRemote(sftp, '/home/dev') + }, { path: '/home/dev/.orca/agent-hooks/grok-hook.sh', install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, '/home/dev') @@ -203,25 +212,28 @@ describe('remote hook service installers', () => { expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') }) - it('installs remote Gemini, Antigravity, Cursor, and Grok configs using their CLI-specific schemas', async () => { + it('installs remote Gemini, Antigravity, Cursor, Command Code, and Grok configs using their CLI-specific schemas', async () => { const gemini = createFakeSftp() const antigravity = createFakeSftp() const cursor = createFakeSftp() + const commandCode = createFakeSftp() const grok = createFakeSftp() await new GeminiHookService().installRemote(gemini.sftp, '/home/dev') await new AntigravityHookService().installRemote(antigravity.sftp, '/home/dev') await new CursorHookService().installRemote(cursor.sftp, '/home/dev') + await new CommandCodeHookService().installRemote(commandCode.sftp, '/home/dev') await new GrokHookService().installRemote(grok.sftp, '/home/dev') const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as { hooks: Record } - for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse']) { + for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool']) { const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh') expect(command).toMatch(/^if \[ -x /) } + expect(geminiConfig.hooks.PreToolUse).toBeUndefined() const antigravityConfig = JSON.parse( antigravity.fs.files.get('/home/dev/.gemini/config/hooks.json')! @@ -265,6 +277,21 @@ describe('remote hook service installers', () => { expect(definition?.hooks).toBeUndefined() } + const commandCodeConfig = JSON.parse( + commandCode.fs.files.get('/home/dev/.commandcode/settings.json')! + ) as { + hooks: Record + } + for (const eventName of ['PreToolUse', 'PostToolUse', 'Stop']) { + const definition = commandCodeConfig.hooks[eventName]?.[0] + const command = definition?.hooks?.[0]?.command + expect(command).toContain('/home/dev/.orca/agent-hooks/command-code-hook.sh') + expect(command).toMatch(/^if \[ -x /) + } + expect(commandCodeConfig.hooks.PreToolUse?.[0]?.matcher).toBe('.*') + expect(commandCodeConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*') + expect(commandCodeConfig.hooks.Stop?.[0]?.matcher).toBeUndefined() + const grokConfig = JSON.parse(grok.fs.files.get('/home/dev/.grok/hooks/orca-status.json')!) as { hooks: Record } @@ -335,6 +362,54 @@ describe('remote hook service installers', () => { expect(postToolCommands.some((command) => command.includes('antigravity-hook.sh'))).toBe(true) }) + it('removes stale remote Gemini PreToolUse hooks while preserving user-authored hooks', async () => { + const { sftp, fs } = createFakeSftp() + fs.files.set( + '/home/dev/.gemini/settings.json', + `${JSON.stringify( + { + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: + "if [ -x '/tmp/old/agent-hooks/gemini-hook.sh' ]; then /bin/sh '/tmp/old/agent-hooks/gemini-hook.sh'; fi" + } + ] + }, + { + hooks: [ + { + type: 'command', + command: 'echo user-authored' + } + ] + } + ] + } + }, + null, + 2 + )}\n` + ) + + await new GeminiHookService().installRemote(sftp, '/home/dev') + + const config = JSON.parse(fs.files.get('/home/dev/.gemini/settings.json')!) as { + hooks: Record + } + const preToolCommands = config.hooks.PreToolUse.flatMap((definition) => + (definition.hooks ?? []).map((hook) => hook.command) + ) + expect(preToolCommands).toEqual(['echo user-authored']) + const beforeToolCommands = config.hooks.BeforeTool.flatMap((definition) => + (definition.hooks ?? []).map((hook) => hook.command) + ) + expect(beforeToolCommands.some((command) => command.includes('gemini-hook.sh'))).toBe(true) + }) + it('installs remote Copilot hooks under the user-level hooks directory', async () => { const { sftp, fs } = createFakeSftp() fs.dirs.add('/home/dev/.copilot') diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index a8ef3d9109e..7100bec01b1 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -5,6 +5,7 @@ import { codexHookService } from '../codex/hook-service' import { geminiHookService } from '../gemini/hook-service' import { antigravityHookService } from '../antigravity/hook-service' import { cursorHookService } from '../cursor/hook-service' +import { commandCodeHookService } from '../command-code/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' @@ -19,6 +20,7 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], + ['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)], ['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)], ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)] ] diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index f950abc6e9b..3f4c2d6449f 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -973,6 +973,30 @@ describe('AgentHookServer listener replay', () => { expect(rendererListener).not.toHaveBeenCalled() }) + it('marks listener replay callbacks as replayed', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'cached task', agentType: 'codex' } + }, + 'conn-1' + ) + + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + isReplay: true, + payload: expect.objectContaining({ state: 'working', prompt: 'cached task' }) + }) + ) + }) + it('unsubscribes status-change listeners without removing the remaining listeners', () => { const server = new AgentHookServer() const removed = vi.fn() @@ -1235,6 +1259,242 @@ describe('AgentHookServer listener replay', () => { } }) + it('lets Claude permission clear when approved PostToolUse matches the preceding tool use id', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }, + tool_use_id: 'toolu-approved-by-claude' + }) + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' } + }) + await postClaudeHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }, + tool_use_id: 'toolu-approved-by-claude' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'rm -rf /tmp/orca-2824-permission-target' + }) + ]) + } finally { + server.stop() + } + }) + + it('lets Claude permission clear by tool use id when tool input is not previewable', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-a' }, + tool_use_id: 'toolu-approved-opaque' + }) + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-a' } + }) + await postClaudeHook({ + hook_event_name: 'PostToolUse', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-a' }, + tool_use_id: 'toolu-approved-opaque' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude', + toolName: 'BespokeTool' + }) + ]) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible for unpreviewable tool input with another tool use id', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-a' }, + tool_use_id: 'toolu-permission-owner-opaque' + }) + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-a' } + }) + await postClaudeHook({ + hook_event_name: 'PostToolUse', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-b' }, + tool_use_id: 'toolu-other-opaque' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'BespokeTool' + }) + ]) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible when another tool use completes after permission', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + tool_use_id: 'toolu-permission-owner' + }) + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' } + }) + await postClaudeHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + tool_use_id: 'toolu-other-subagent' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + }) + ]) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible when an explicit agent type reports another tool use id', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + agent_type: 'main', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + tool_use_id: 'toolu-permission-owner-type' + }) + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + agent_type: 'main', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' } + }) + await postClaudeHook({ + hook_event_name: 'PostToolUse', + agent_type: 'main', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + tool_use_id: 'toolu-other-type' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + }) + ]) + } finally { + server.stop() + } + }) + it('lets Claude permission clear when same explicit agent type starts the approved tool', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -1765,6 +2025,110 @@ describe('AgentHookServer listener replay', () => { } }) + it('tracks Codex agent statuses from form-encoded managed hook posts', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const listener = vi.fn() + server.setListener(listener) + const postCodexHook = async (payload: Record): Promise => { + const params = new URLSearchParams({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'production', + version: env.ORCA_AGENT_HOOK_VERSION ?? '', + payload: JSON.stringify(payload) + }) + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: params + }) + expect(response.status).toBe(204) + } + + await postCodexHook({ + hook_event_name: 'UserPromptSubmit', + prompt: 'ship codex hook status' + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + state: 'working', + agentType: 'codex', + prompt: 'ship codex hook status', + toolName: undefined, + toolInput: undefined + }) + ]) + + await postCodexHook({ + hook_event_name: 'PreToolUse', + tool_name: 'exec_command', + tool_input: { cmd: 'pnpm test', workdir: '/repo' } + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + agentType: 'codex', + prompt: 'ship codex hook status', + toolName: 'exec_command', + toolInput: 'pnpm test' + }) + ]) + + await postCodexHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'exec_command', + tool_input: { cmd: 'git push', workdir: '/repo' } + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'waiting', + agentType: 'codex', + prompt: 'ship codex hook status', + toolName: 'exec_command', + toolInput: 'git push' + }) + ]) + + await postCodexHook({ + hook_event_name: 'Stop', + last_assistant_message: 'done' + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + agentType: 'codex', + prompt: 'ship codex hook status', + lastAssistantMessage: 'done' + }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: expect.objectContaining({ + state: 'done', + agentType: 'codex', + prompt: 'ship codex hook status', + lastAssistantMessage: 'done' + }) + }) + ) + } finally { + server.stop() + } + }) + it('accepts Hermes plugin hook posts on /hook/hermes', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -2291,11 +2655,11 @@ describe('Codex hook normalization', () => { }) describe('Gemini hook normalization', () => { - it('PreToolUse surfaces toolName + toolInput', () => { + it('BeforeTool surfaces toolName + toolInput', () => { const result = _internals.normalizeHookPayload( 'gemini', buildBody({ - hook_event_name: 'PreToolUse', + hook_event_name: 'BeforeTool', tool_name: 'read_file', tool_input: { path: '/src/index.ts' } }), @@ -2310,7 +2674,7 @@ describe('Gemini hook normalization', () => { const result = _internals.normalizeHookPayload( 'gemini', buildBody({ - hook_event_name: 'PreToolUse', + hook_event_name: 'BeforeTool', tool_name: 'run_shell_command', args: { command: 'git status' } }), @@ -2324,7 +2688,7 @@ describe('Gemini hook normalization', () => { _internals.normalizeHookPayload( 'gemini', buildBody({ - hook_event_name: 'PreToolUse', + hook_event_name: 'BeforeTool', tool_name: 'read_file', tool_input: { path: '/stale.ts' } }), @@ -2930,6 +3294,30 @@ describe('Pi hook normalization', () => { expect(result?.payload.prompt).toBe('rename this fn') }) + it('OMP uses Pi-compatible events but keeps OMP agent attribution', () => { + const started = _internals.normalizeHookPayload( + 'omp', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'status for omp' }), + 'production' + ) + expect(started?.payload).toMatchObject({ + state: 'working', + prompt: 'status for omp', + agentType: 'omp' + }) + + const done = _internals.normalizeHookPayload( + 'omp', + buildBody({ hook_event_name: 'agent_end' }), + 'production' + ) + expect(done?.payload).toMatchObject({ + state: 'done', + prompt: 'status for omp', + agentType: 'omp' + }) + }) + it('agent_start without a prompt keeps the cached prompt from the current turn', () => { _internals.normalizeHookPayload( 'pi', diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index b619aed120a..0ff58601ddb 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -251,6 +251,8 @@ function isClaudePermissionResumingApprovedTool( previous: EnrichedAgentHookEventPayload, next: AgentHookEventPayload ): boolean { + const previousToolUseId = previous.toolUseId?.trim() || undefined + const nextToolUseId = next.toolUseId?.trim() || undefined const previousAgentId = previous.toolAgentId?.trim() || undefined const nextAgentId = next.toolAgentId?.trim() || undefined const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined @@ -269,19 +271,84 @@ function isClaudePermissionResumingApprovedTool( hasMatchingConcreteAgentId && previous.payload.toolInput === undefined && next.payload.toolInput === undefined + const hasMatchingToolUseId = + previousToolUseId !== undefined && previousToolUseId === nextToolUseId + const hasConflictingToolUseId = + previousToolUseId !== undefined && + nextToolUseId !== undefined && + previousToolUseId !== nextToolUseId + const sameUnknownInputFromToolUseId = + hasMatchingToolUseId && + previous.payload.toolInput === undefined && + next.payload.toolInput === undefined return ( - next.hookEventName === 'PreToolUse' && - typeof next.toolUseId === 'string' && - next.toolUseId.trim().length > 0 && + (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && + nextToolUseId !== undefined && + !hasConflictingToolUseId && // Why: subagents can share `agent_type`; a concrete agent id is the // strongest available signal that the permission owner resumed execution. - (hasMatchingConcreteAgentId || hasSameExplicitAgentType) && + // Claude's approval path omits identity but preserves the original + // tool_use_id on PostToolUse, so that exact id is also a safe clear signal. + (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && sameToolName && - (sameKnownToolInput || sameUnknownInputFromConcreteAgent) + (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) ) } +function shouldInheritClaudeToolUseIdForPermission( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): boolean { + if ( + previous?.payload.agentType !== 'claude' || + previous.payload.state !== 'working' || + previous.hookEventName !== 'PreToolUse' || + typeof previous.toolUseId !== 'string' || + previous.toolUseId.trim().length === 0 || + next.payload.agentType !== 'claude' || + next.payload.state !== 'waiting' || + next.hookEventName !== 'PermissionRequest' || + next.toolUseId !== undefined + ) { + return false + } + const sameKnownToolInput = + previous.payload.toolInput !== undefined && + previous.payload.toolInput === next.payload.toolInput + const sameUnknownToolInput = + previous.payload.toolInput === undefined && next.payload.toolInput === undefined + if ( + previous.toolAgentId !== next.toolAgentId || + previous.toolAgentType !== next.toolAgentType || + previous.payload.toolName === undefined || + previous.payload.toolName !== next.payload.toolName || + (!sameKnownToolInput && !sameUnknownToolInput) + ) { + return false + } + return true +} + +function attachClaudePermissionToolUseId( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): AgentHookEventPayload { + const inheritedToolUseId = previous?.toolUseId + if ( + !shouldInheritClaudeToolUseIdForPermission(previous, next) || + typeof inheritedToolUseId !== 'string' + ) { + return next + } + return { + ...next, + // Why: Claude emits PermissionRequest without tool_use_id, then reports the + // approved command as PostToolUse with the original PreToolUse id. + toolUseId: inheritedToolUseId + } +} + export class AgentHookServer { private server: ReturnType | null = null private port = 0 @@ -335,7 +402,7 @@ export class AgentHookServer { // listener is the bare AgentHookEventPayload because the shared module // never reads from this map; only this class does, and only enriched // values are ever inserted. - listener(payload as EnrichedAgentHookEventPayload) + listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) } catch (err) { console.error('[agent-hooks] replay listener threw', err) } @@ -466,7 +533,8 @@ export class AgentHookServer { const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined - if (previous && shouldKeepClaudePermissionVisible(previous, payload)) { + const effectivePayload = attachClaudePermissionToolUseId(previous, payload) + if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { return previous } // Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already @@ -474,9 +542,9 @@ export class AgentHookServer { if ( previous?.payload.state === 'done' && previous.payload.interrupted === true && - payload.payload.state === 'done' && - previous.payload.agentType === payload.payload.agentType && - previous.payload.prompt === payload.payload.prompt && + effectivePayload.payload.state === 'done' && + previous.payload.agentType === effectivePayload.payload.agentType && + previous.payload.prompt === effectivePayload.payload.prompt && Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS ) { return previous @@ -484,19 +552,22 @@ export class AgentHookServer { if ( previous?.payload.state === 'done' && previous.payload.interrupted === true && - payload.payload.state === 'working' && - previous.payload.agentType === payload.payload.agentType && - previous.payload.prompt === payload.payload.prompt && - (payload.isReplay === true || - (payload.hasExplicitPrompt !== true && + effectivePayload.payload.state === 'working' && + previous.payload.agentType === effectivePayload.payload.agentType && + previous.payload.prompt === effectivePayload.payload.prompt && + (effectivePayload.isReplay === true || + (effectivePayload.hasExplicitPrompt !== true && Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) ) { return previous } - if (payload.payload.state !== 'done' || payload.payload.lastAssistantMessage) { - this.clearAssistantMessageRetry(payload.paneKey) + if ( + effectivePayload.payload.state !== 'done' || + effectivePayload.payload.lastAssistantMessage + ) { + this.clearAssistantMessageRetry(effectivePayload.paneKey) } - const enriched = this.attachStatusTiming(payload) + const enriched = this.attachStatusTiming(effectivePayload) this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 4b4575fd6c2..805be4227e2 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -13,9 +13,22 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const testState = { - fakeHomeDir: '' + fakeHomeDir: '', + userDataDir: '', + previousUserDataPath: undefined as string | undefined } +vi.mock('electron', () => ({ + app: { + getPath: (name: string) => { + if (name === 'userData') { + return testState.userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + } + } +})) + vi.mock('node:os', async () => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const actual = await vi.importActual('node:os') @@ -30,11 +43,22 @@ const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTr beforeEach(() => { testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) + testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-user-data-')) + testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = testState.userDataDir }) afterEach(() => { rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + rmSync(testState.userDataDir, { recursive: true, force: true }) + if (testState.previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath + } testState.fakeHomeDir = '' + testState.userDataDir = '' + testState.previousUserDataPath = undefined }) describe('markCursorWorkspaceTrusted', () => { @@ -119,10 +143,20 @@ describe('markCodexProjectTrusted', () => { const realpath = realpathSync(workspace) markCodexProjectTrusted(workspace) const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + const runtimeConfigPath = join( + testState.userDataDir, + 'codex-runtime-home', + 'home', + 'config.toml' + ) expect(existsSync(configPath)).toBe(true) + expect(existsSync(runtimeConfigPath)).toBe(true) const written = readFileSync(configPath, 'utf-8') + const runtimeWritten = readFileSync(runtimeConfigPath, 'utf-8') expect(written).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`) expect(written).toContain('trust_level = "trusted"') + expect(runtimeWritten).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`) + expect(runtimeWritten).toContain('trust_level = "trusted"') } finally { rmSync(workspace, { recursive: true, force: true }) } @@ -133,7 +167,9 @@ describe('markCodexProjectTrusted', () => { const realpath = realpathSync(workspace) try { const codexDir = join(testState.fakeHomeDir, '.codex') + const runtimeCodexDir = join(testState.userDataDir, 'codex-runtime-home', 'home') mkdirSync(codexDir, { recursive: true }) + mkdirSync(runtimeCodexDir, { recursive: true }) writeFileSync( join(codexDir, 'config.toml'), [ @@ -146,14 +182,31 @@ describe('markCodexProjectTrusted', () => { ].join('\n'), 'utf-8' ) + writeFileSync( + join(runtimeCodexDir, 'config.toml'), + [ + 'sandbox_mode = "workspace-write"', + '', + `[projects."${escapeTomlBasicString(realpath)}"]`, + 'notes = "keep-runtime"', + 'trust_level = "untrusted"', + '' + ].join('\n'), + 'utf-8' + ) markCodexProjectTrusted(workspace) const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8') + const runtimeWritten = readFileSync(join(runtimeCodexDir, 'config.toml'), 'utf-8') expect(written).toContain('model = "gpt-5.5"') expect(written).toContain('notes = "keep"') expect(written).toContain('trust_level = "trusted"') expect(written).not.toContain('trust_level = "untrusted"') + expect(runtimeWritten).toContain('sandbox_mode = "workspace-write"') + expect(runtimeWritten).toContain('notes = "keep-runtime"') + expect(runtimeWritten).toContain('trust_level = "trusted"') + expect(runtimeWritten).not.toContain('trust_level = "untrusted"') } finally { rmSync(workspace, { recursive: true, force: true }) } diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index f71ed9e4408..56220d41f4a 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' +import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' /** @@ -109,6 +110,9 @@ export function markCodexProjectTrusted(workspacePath: string): void { const absPath = canonicalize(workspacePath) const configPath = join(homedir(), '.codex', 'config.toml') upsertProjectTrustLevel(configPath, absPath, 'trusted') + // Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust + // preset must also update the runtime config Codex will actually read. + upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted') } function canonicalize(p: string): string { diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index f86067e7c91..e0e2ad9aed1 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -75,6 +75,8 @@ describe('AntigravityHookService', () => { expect(script).toContain('/hook/antigravity') expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}') expect(script).toContain('payload=$(cat)') + expect(script).toContain("payload='{}'") + expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi') expect(script).toContain('{"decision":""}') }) @@ -154,6 +156,8 @@ describe('AntigravityHookService', () => { ) expect(script).toContain('/hook/antigravity') expect(script).toContain('hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT') + expect(script).toContain('[string]::IsNullOrWhiteSpace($inputData)) { @{} }') + expect(script).not.toContain('[string]::IsNullOrWhiteSpace($inputData)) { exit 0 }') }) }) diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index 356f1fa8c19..8a60eca877e 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -120,7 +120,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'fi', 'payload=$(cat)', 'if [ -z "$payload" ]; then', - ' exit 0', + // Why: some Antigravity hook events can arrive without stdin. Still post + // the event name so Orca shows a status row instead of silently dropping it. + " payload='{}'", 'fi', 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -158,7 +160,7 @@ function getWindowsWrapperScript(eventName: string): string { } function buildWindowsAntigravityHookPostCommand(): string { - return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes | Out-Null } catch {}"` + return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); try { $payload=if ([string]::IsNullOrWhiteSpace($inputData)) { @{} } else { $inputData | ConvertFrom-Json }; $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=$payload } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes | Out-Null } catch {}"` } function isRecord(value: unknown): value is Record { diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index be6c0c693c6..5a1937ec35f 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -70,6 +70,7 @@ function mockBrowserManager( getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId), getGuestWebContentsId: vi.fn(() => null), ensureWebviewVisible: vi.fn(async () => () => {}), + acquireAutomationVisibility: vi.fn(async () => () => {}), ...overrides } as unknown as BrowserManager } @@ -420,6 +421,55 @@ describe('AgentBrowserBridge', () => { expect(snapshotIdx).toBeLessThan(clickIdx) }) + it('acquires an automation visibility lease while running snapshot commands', async () => { + const lifecycleEvents: string[] = [] + const restore = vi.fn(() => { + lifecycleEvents.push('restore-100') + }) + const acquireAutomationVisibility = vi.fn(async (webContentsId: number) => { + lifecycleEvents.push(`acquire-${webContentsId}`) + return restore + }) + + const b = new AgentBrowserBridge( + mockBrowserManager(undefined, undefined, { + acquireAutomationVisibility + }) + ) + b.setActiveTab(100) + + let releaseSnapshot: (() => void) | null = null + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: Function) => { + if (args.includes('close')) { + cb(null, JSON.stringify({ success: true, data: null }), '') + return + } + if (args.includes('snapshot')) { + lifecycleEvents.push('command-snapshot') + releaseSnapshot = () => { + cb(null, JSON.stringify({ success: true, data: { snapshot: 'tree' } }), '') + } + return + } + cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') + } + ) + + const snapshot = b.snapshot() + + await vi.waitFor(() => { + expect(releaseSnapshot).not.toBeNull() + }) + expect(lifecycleEvents).toEqual(['acquire-100', 'command-snapshot']) + expect(restore).not.toHaveBeenCalled() + + releaseSnapshot!() + + await expect(snapshot).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'tree' }) + expect(lifecycleEvents).toEqual(['acquire-100', 'command-snapshot', 'restore-100']) + }) + it('serializes screenshot visibility prep across sessions', async () => { vi.useFakeTimers() try { @@ -432,8 +482,8 @@ describe('AgentBrowserBridge', () => { ['tab-2', 'wt-2'] ]) const lifecycleEvents: string[] = [] - const ensureWebviewVisibleMock = vi.fn(async (webContentsId: number) => { - lifecycleEvents.push(`ensure-${webContentsId}`) + const acquireAutomationVisibilityMock = vi.fn(async (webContentsId: number) => { + lifecycleEvents.push(`acquire-${webContentsId}`) return () => { lifecycleEvents.push(`restore-${webContentsId}`) } @@ -449,7 +499,7 @@ describe('AgentBrowserBridge', () => { const b = new AgentBrowserBridge( mockBrowserManager(tabs, worktrees, { - ensureWebviewVisible: ensureWebviewVisibleMock + acquireAutomationVisibility: acquireAutomationVisibilityMock }) ) b.setActiveTab(1, 'wt-1') @@ -489,9 +539,9 @@ describe('AgentBrowserBridge', () => { await Promise.resolve() await vi.advanceTimersByTimeAsync(300) - expect(lifecycleEvents).toContain('ensure-1') + expect(lifecycleEvents).toContain('acquire-1') expect(lifecycleEvents).toContain('command-orca-tab-tab-1') - expect(lifecycleEvents).not.toContain('ensure-2') + expect(lifecycleEvents).not.toContain('acquire-2') expect(releaseFirstScreenshot).not.toBeNull() releaseFirstScreenshot!() @@ -503,7 +553,9 @@ describe('AgentBrowserBridge', () => { await Promise.resolve() await Promise.resolve() - expect(lifecycleEvents.indexOf('restore-1')).toBeLessThan(lifecycleEvents.indexOf('ensure-2')) + expect(lifecycleEvents.indexOf('restore-1')).toBeLessThan( + lifecycleEvents.indexOf('acquire-2') + ) await vi.advanceTimersByTimeAsync(300) await expect(second).resolves.toEqual({ @@ -665,7 +717,9 @@ describe('AgentBrowserBridge', () => { ) const runningSnapshot = bridge.snapshot() - await Promise.resolve() + await vi.waitFor(() => { + expect(resolveRunningCommand).not.toBeNull() + }) const destroyPromise = ( bridge as unknown as { destroySession: (name: string) => Promise } diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 41c7f2a4943..21bce47e4c1 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -86,6 +86,11 @@ type AgentBrowserExecOptions = { timeoutError?: BrowserError } +type EnqueueTargetedCommandOptions = { + ensureSession?: boolean + ensureVisible?: boolean +} + type AgentBrowserBridgeOptions = { onTabsChanged?: (worktreeId?: string) => void } @@ -388,10 +393,10 @@ export class AgentBrowserBridge { private readonly sessions = new Map() private readonly commandQueues = new Map() private readonly processingQueues = new Set() - // Why: screenshot prep temporarily changes shared renderer visibility/focus - // state. Per-session queues only serialize commands within one browser tab, so - // concurrent screenshots on different tabs can otherwise interleave - // ensureWebviewVisible()/restore and blank each other's capture. + // Why: screenshot prep temporarily changes shared renderer paintability state. + // Per-session queues only serialize commands within one browser tab, so + // concurrent screenshots on different tabs can otherwise interleave hidden + // surface leases and blank each other's capture. private screenshotTurn: Promise = Promise.resolve() private readonly agentBrowserBin: string // Why: when a process swap destroys a session that had active intercept patterns, @@ -1139,9 +1144,14 @@ export class AgentBrowserBridge { ): Promise { // Why: agent-browser writes the screenshot to a temp file and returns // { "path": "/tmp/screenshot-xxx.png" }. We read the file and return base64. - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format) - }) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format) + }, + { ensureVisible: false } + ) } async fullPageScreenshot( @@ -1149,14 +1159,19 @@ export class AgentBrowserBridge { worktreeId?: string, browserPageId?: string ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => { - return this.captureFullPageScreenshotCommand( - sessionName, - target.webContentsId, - 500, - format === 'jpeg' ? 'jpeg' : 'png' - ) - }) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName, target) => { + return this.captureFullPageScreenshotCommand( + sessionName, + target.webContentsId, + 500, + format === 'jpeg' ? 'jpeg' : 'png' + ) + }, + { ensureVisible: false } + ) } private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult { @@ -1180,12 +1195,12 @@ export class AgentBrowserBridge { return this.withSerializedScreenshotAccess(async () => { const session = this.sessions.get(sessionName) const restore = session - ? await this.browserManager.ensureWebviewVisible(session.webContentsId) + ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) : () => {} try { - // Why: after focusing the window and unhiding the webview, the compositor + // Why: after acquiring the hidden paintability lease, the compositor // needs a short settle period to produce a painted frame. Waiting inside - // the global screenshot lock prevents another tab from stealing visible + // the global screenshot lock prevents another tab from changing lease // state before the current capture actually hits CDP. await new Promise((r) => setTimeout(r, settleMs)) const raw = await this.execAgentBrowser(sessionName, commandArgs) @@ -1205,11 +1220,11 @@ export class AgentBrowserBridge { return this.withSerializedScreenshotAccess(async () => { const session = this.sessions.get(sessionName) const restore = session - ? await this.browserManager.ensureWebviewVisible(session.webContentsId) + ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) : () => {} try { // Why: full-page capture still depends on the guest compositor producing - // a fresh frame. Wait after activating the target webview so the direct + // a fresh frame. Wait after the target webview is paintable so the direct // CDP capture sees the live page instead of a stale surface. await new Promise((r) => setTimeout(r, settleMs)) const wc = this.getWebContents(webContentsId) @@ -1691,8 +1706,11 @@ export class AgentBrowserBridge { worktreeId: string | undefined, execute: (sessionName: string) => Promise ): Promise { - return this.enqueueTargetedCommand(worktreeId, undefined, async (sessionName) => - execute(sessionName) + return this.enqueueTargetedCommand( + worktreeId, + undefined, + async (sessionName) => execute(sessionName), + { ensureVisible: false } ) } @@ -1700,7 +1718,7 @@ export class AgentBrowserBridge { worktreeId: string | undefined, browserPageId: string | undefined, execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, - options: { ensureSession?: boolean } = {} + options: EnqueueTargetedCommandOptions = {} ): Promise { const target = this.resolveCommandTarget(worktreeId, browserPageId) const sessionName = `orca-tab-${target.browserPageId}` @@ -1716,7 +1734,13 @@ export class AgentBrowserBridge { this.commandQueues.set(sessionName, queue) } queue.push({ - execute: (() => execute(sessionName, target)) as () => Promise, + execute: (() => + this.executeWithVisibleTarget( + sessionName, + target, + execute, + options + )) as () => Promise, resolve: resolve as (value: unknown) => void, reject }) @@ -1724,6 +1748,26 @@ export class AgentBrowserBridge { }) } + private async executeWithVisibleTarget( + sessionName: string, + target: ResolvedBrowserCommandTarget, + execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, + options: EnqueueTargetedCommandOptions + ): Promise { + if (options.ensureVisible === false) { + return execute(sessionName, target) + } + + // Why: inactive browser panes are display:none in the renderer; the + // automation lease makes only this target paintable without selecting it. + const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId) + try { + return await execute(sessionName, target) + } finally { + restore() + } + } + private async processQueue(sessionName: string): Promise { if (this.processingQueues.has(sessionName)) { return diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 4bc358ab5f6..66bd5ad3d73 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -11,7 +11,12 @@ vi.mock('electron', () => ({ session: { fromPartition: sessionFromPartitionMock } })) -import { importCookiesFromFile, detectInstalledBrowsers } from './browser-cookie-import' +import { + buildChromiumCookieInsertParams, + importCookiesFromFile, + detectInstalledBrowsers, + type ChromiumCookieColumnInfo +} from './browser-cookie-import' import { writeFileSync, mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -233,3 +238,59 @@ describe('detectInstalledBrowsers', () => { } }) }) + +describe('buildChromiumCookieInsertParams', () => { + it('fills target-only NOT NULL Chromium cookie columns instead of inserting null', () => { + const decryptedValue = Buffer.from('decrypted-cookie-value') + const columns: ChromiumCookieColumnInfo[] = [ + { name: 'creation_utc', type: 'INTEGER', notnull: 1 }, + { name: 'host_key', type: 'TEXT', notnull: 1 }, + { name: 'top_frame_site_key', type: 'TEXT', notnull: 1 }, + { name: 'name', type: 'TEXT', notnull: 1 }, + { name: 'value', type: 'TEXT', notnull: 1 }, + { name: 'encrypted_value', type: 'BLOB', notnull: 1 }, + { name: 'source_port', type: 'INTEGER', notnull: 1 }, + { name: 'last_update_utc', type: 'INTEGER', notnull: 1 }, + { name: 'has_cross_site_ancestor', type: 'INTEGER', notnull: 1, dflt_value: '0' } + ] + const sourceRow = { + creation_utc: 133_000_000_000_000n, + host_key: '.example.com', + name: 'sid' + } + + const params = buildChromiumCookieInsertParams(columns, sourceRow, decryptedValue) + + expect(params).toEqual([ + 133_000_000_000_000n, + '.example.com', + '', + 'sid', + decryptedValue, + Buffer.alloc(0), + -1, + 133_000_000_000_000n, + 0 + ]) + }) + + it('preserves null for nullable columns without defaults', () => { + const decryptedValue = Buffer.from('decrypted-cookie-value') + const columns: ChromiumCookieColumnInfo[] = [ + { name: 'creation_utc', type: 'INTEGER', notnull: 1 }, + { name: 'host_key', type: 'TEXT', notnull: 1 }, + { name: 'nullable_metadata', type: 'TEXT', notnull: 0 }, + { name: 'target_only_nullable_metadata', type: 'TEXT', notnull: 0 }, + { name: 'last_update_utc', type: 'INTEGER', notnull: 1 } + ] + const sourceRow = { + creation_utc: 133_000_000_000_000n, + host_key: '.example.com', + nullable_metadata: null + } + + const params = buildChromiumCookieInsertParams(columns, sourceRow, decryptedValue) + + expect(params).toEqual([133_000_000_000_000n, '.example.com', null, null, 133_000_000_000_000n]) + }) +}) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 2da3a85b224..6ab200c423e 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -32,6 +32,13 @@ function getDiagLogPath(): string { } return _diagLog } +function reasonWithDiagLog(reason: string): string { + return `${reason} Details were written to ${getDiagLogPath()}.` +} +function describeImportError(err: unknown): string { + const raw = err instanceof Error && err.message ? err.message : String(err) + return raw.replace(/\s+/g, ' ').slice(0, 180) +} function diag(msg: string): void { const line = `[${new Date().toISOString()}] ${msg}\n` try { @@ -761,6 +768,120 @@ type EncryptionKeyResult = { fallbackKey?: Buffer } +export type ChromiumCookieColumnInfo = { + name: string + type?: string + notnull?: number | bigint + dflt_value?: unknown +} + +function parseSqliteDefaultValue(raw: unknown, type: string): string | number | Buffer | null { + if (raw === null || raw === undefined) { + return null + } + if (typeof raw !== 'string') { + return typeof raw === 'number' || typeof raw === 'bigint' ? Number(raw) : String(raw) + } + + const trimmed = raw.trim() + if (!trimmed || trimmed.toUpperCase() === 'NULL') { + return null + } + if (/^X''$/i.test(trimmed) || type.includes('BLOB')) { + return Buffer.alloc(0) + } + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1).replaceAll("''", "'") + } + if (type.includes('INT')) { + const numeric = Number(trimmed) + return Number.isFinite(numeric) ? numeric : 0 + } + return trimmed +} + +function normalizeSqliteCookieValue(value: unknown): string | number | bigint | Buffer | null { + if (value instanceof Uint8Array) { + return Buffer.from(value) + } + if (value === undefined || value === null) { + return null + } + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { + return value + } + return String(value) +} + +function isSqliteNotNull(column: ChromiumCookieColumnInfo): boolean { + return Number(column.notnull ?? 0) !== 0 +} + +function fallbackChromiumCookieColumnValue( + column: ChromiumCookieColumnInfo, + sourceRow: Record +): string | number | bigint | Buffer | null { + const type = (column.type ?? '').toUpperCase() + const defaultValue = parseSqliteDefaultValue(column.dflt_value, type) + if (defaultValue !== null) { + return defaultValue + } + if (!isSqliteNotNull(column)) { + return null + } + + switch (column.name) { + case 'value': + case 'encrypted_value': + return Buffer.alloc(0) + case 'top_frame_site_key': + return '' + case 'source_port': + return -1 + case 'last_update_utc': + return normalizeSqliteCookieValue(sourceRow.creation_utc) ?? 0 + default: + if (type.includes('BLOB')) { + return Buffer.alloc(0) + } + if (type.includes('INT')) { + return 0 + } + return '' + } +} + +export function buildChromiumCookieInsertParams( + targetColumns: ChromiumCookieColumnInfo[], + sourceRow: Record, + decryptedValue: Buffer +): (string | number | bigint | Buffer | null)[] { + return targetColumns.map((column) => { + if (column.name === 'encrypted_value') { + return Buffer.alloc(0) + } + if (column.name === 'value') { + return decryptedValue + } + + const sourceHasColumn = Object.prototype.hasOwnProperty.call(sourceRow, column.name) + const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null + if (sourceValue !== null) { + return sourceValue + } + if (sourceHasColumn && !isSqliteNotNull(column)) { + return null + } + + // Why: Chromium cookie DB columns drift across Chrome/Electron versions. + // Missing NOT NULL target columns must get safe Chromium defaults, not NULL. + return fallbackChromiumCookieColumnValue(column, sourceRow) + }) +} + function getEncryptionKey( keychainService: string, keychainAccount: string, @@ -1382,10 +1503,10 @@ export async function importCookiesFromBrowser( sourceDb = new DatabaseSync(tmpCookiesPath, { readOnly: true, readBigInts: true }) stagingDb = new DatabaseSync(stagingCookiesPath) - type PragmaRow = { name: string } - const targetCols: string[] = ( - stagingDb.prepare('PRAGMA table_info(cookies)').all() as PragmaRow[] - ).map((r) => r.name) + const targetColumnInfo = stagingDb + .prepare('PRAGMA table_info(cookies)') + .all() as ChromiumCookieColumnInfo[] + const targetCols: string[] = targetColumnInfo.map((r) => r.name) const colList = targetCols.join(', ') stagingDb.exec('DELETE FROM cookies') @@ -1511,25 +1632,7 @@ export async function importCookiesFromBrowser( expirationDate: expiresUtc > 0 ? expiresUtc : undefined }) - const params = targetCols.map((col) => { - if (col === 'encrypted_value') { - return Buffer.alloc(0) - } - if (col === 'value') { - return decryptedValue - } - const v = sourceRow[col] - if (v instanceof Uint8Array) { - return Buffer.from(v) - } - if (v === undefined || v === null) { - return null - } - if (typeof v === 'number' || typeof v === 'bigint' || typeof v === 'string') { - return v - } - return String(v) - }) + const params = buildChromiumCookieInsertParams(targetColumnInfo, sourceRow, decryptedValue) insertStmt.run(...params) imported++ } @@ -1636,7 +1739,9 @@ export async function importCookiesFromBrowser( diag(` SQLite import failed: ${err}`) return { ok: false, - reason: `Could not import cookies from ${browser.label}. Check the diag log for details.` + reason: reasonWithDiagLog( + `Could not import cookies from ${browser.label}: ${describeImportError(err)}.` + ) } } } diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 89ec920799c..bbdefb15c76 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -249,6 +249,166 @@ describe('browserManager', () => { restore() }) + it('acquires renderer automation visibility without changing active browser state', async () => { + const rendererExecuteJavaScriptMock = vi + .fn() + .mockResolvedValueOnce('lease-1') + .mockResolvedValueOnce(true) + const guest = { + id: 1707, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + const renderer = { + id: rendererWebContentsId, + isDestroyed: vi.fn(() => false), + executeJavaScript: rendererExecuteJavaScriptMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return renderer + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'page-automation', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const restore = await browserManager.acquireAutomationVisibility(guest.id) + const acquireScript = rendererExecuteJavaScriptMock.mock.calls[0]?.[0] + expect(acquireScript).toContain('__orcaBrowserAutomationVisibility') + expect(acquireScript).toContain('bridge.acquire("page-automation")') + expect(acquireScript).not.toContain('setActiveBrowserTab') + expect(acquireScript).not.toContain('setActiveTabType') + + restore() + + const releaseScript = rendererExecuteJavaScriptMock.mock.calls[1]?.[0] + expect(releaseScript).toContain('bridge.release("lease-1")') + }) + + it('returns a no-op automation visibility restore when renderer acquire hangs', async () => { + vi.useFakeTimers() + + const rendererExecuteJavaScriptMock = vi.fn().mockReturnValueOnce(new Promise(() => {})) + const guest = { + id: 1708, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + const renderer = { + id: rendererWebContentsId, + isDestroyed: vi.fn(() => false), + executeJavaScript: rendererExecuteJavaScriptMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return renderer + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'page-hung-acquire', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const restorePromise = browserManager.acquireAutomationVisibility(guest.id) + await vi.advanceTimersByTimeAsync(2_000) + const restore = await restorePromise + + restore() + + expect(rendererExecuteJavaScriptMock).toHaveBeenCalledTimes(1) + }) + + it('releases a delayed automation visibility token after acquire timeout', async () => { + vi.useFakeTimers() + + let resolveAcquire: (token: string) => void = () => {} + const acquirePromise = new Promise((resolve) => { + resolveAcquire = resolve + }) + const rendererExecuteJavaScriptMock = vi + .fn() + .mockReturnValueOnce(acquirePromise) + .mockResolvedValueOnce(true) + const guest = { + id: 1709, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + const renderer = { + id: rendererWebContentsId, + isDestroyed: vi.fn(() => false), + executeJavaScript: rendererExecuteJavaScriptMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return renderer + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'page-delayed-acquire', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const restorePromise = browserManager.acquireAutomationVisibility(guest.id) + await vi.advanceTimersByTimeAsync(2_000) + const restore = await restorePromise + + restore() + expect(rendererExecuteJavaScriptMock).toHaveBeenCalledTimes(1) + + resolveAcquire('late-lease-1') + await Promise.resolve() + await Promise.resolve() + + expect(rendererExecuteJavaScriptMock).toHaveBeenCalledTimes(2) + const releaseScript = rendererExecuteJavaScriptMock.mock.calls[1]?.[0] + expect(releaseScript).toContain('bridge.release("late-lease-1")') + }) + it('restores the previously focused browser workspace after screenshot prep changes tabs', async () => { const rendererExecuteJavaScriptMock = vi .fn() diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 1f02c82239f..c7d7f958fbe 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -44,6 +44,74 @@ import { } from '../../shared/browser-annotation-viewport-bridge' import type { KeybindingOverrides } from '../../shared/keybindings' +const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 + +function resolveWithTimeout( + promise: Promise, + timeoutMs: number, + fallbackValue: T +): Promise<{ value: T; timedOut: boolean }> { + let timeoutId: ReturnType | null = null + const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => { + timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs) + }) + return Promise.race([ + promise.then((value) => ({ value, timedOut: false })), + timeoutPromise + ]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId) + } + }) +} + +function releaseAutomationVisibilityToken(renderer: Electron.WebContents, token: string): void { + if (renderer.isDestroyed()) { + return + } + renderer + .executeJavaScript( + `(function() { + var bridge = window.__orcaBrowserAutomationVisibility; + if (!bridge || typeof bridge.release !== 'function') return false; + return bridge.release(${JSON.stringify(token)}); + })()` + ) + .catch(() => {}) +} + +function cleanupLateAutomationVisibilityToken( + renderer: Electron.WebContents, + acquirePromise: Promise +): void { + acquirePromise + .then((lateToken) => { + if (typeof lateToken !== 'string' || lateToken.length === 0) { + return + } + // Why: the renderer creates the lease before waiting for paint; if main's + // acquire timeout wins, release the eventual token so hidden webviews do + // not stay paintable indefinitely. + releaseAutomationVisibilityToken(renderer, lateToken) + }) + .catch(() => {}) +} + +function createNoopRestoreForTimedOutAutomationAcquire( + renderer: Electron.WebContents, + acquirePromise: Promise, + timedOut: boolean +): () => void { + if (timedOut) { + cleanupLateAutomationVisibilityToken(renderer, acquirePromise) + } + return () => {} +} + +function isAutomationVisibilityToken(token: unknown): token is string { + return typeof token === 'string' && token.length > 0 +} + // Why: mobile presets need a touch-capable UA or responsive sites serve the // desktop variant based on UA sniffing. This is the Chrome DevTools default // iPhone UA template; we splice in the guest session's real Chrome major so @@ -437,6 +505,42 @@ export class BrowserManager { } } + async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return () => {} + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + if (!renderer || renderer.isDestroyed()) { + return () => {} + } + + // Why: agent browser commands need a paintable webview for lazy-loading + // sites, but must not steal the user's visible Orca tab/worktree. + const acquirePromise = renderer + .executeJavaScript( + `(async function() { + var bridge = window.__orcaBrowserAutomationVisibility; + if (!bridge || typeof bridge.acquire !== 'function') return null; + return await bridge.acquire(${JSON.stringify(browserPageId)}); + })()` + ) + .catch(() => null) + const { value: token, timedOut } = await resolveWithTimeout( + acquirePromise, + AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, + null + ) + + if (!isAutomationVisibilityToken(token)) { + return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut) + } + + return () => { + releaseAutomationVisibilityToken(renderer, token) + } + } + attachGuestPolicies(guest: Electron.WebContents): void { if (this.policyAttachedGuestIds.has(guest.id)) { return diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 18d9fc89931..697e9448d46 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -33,6 +33,7 @@ function installModuleMocks( setPermissionCheckHandler: vi.fn(), setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), + removeListener: vi.fn(), clearStorageData: vi.fn().mockResolvedValue(undefined), clearCache: vi.fn().mockResolvedValue(undefined) })) diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index e6915755175..d49995eed67 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -41,6 +41,7 @@ describe('BrowserSessionRegistry', () => { setPermissionCheckHandler: vi.fn(), setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), + removeListener: vi.fn(), clearStorageData: vi.fn().mockResolvedValue(undefined), clearCache: vi.fn().mockResolvedValue(undefined) }) @@ -143,6 +144,22 @@ describe('BrowserSessionRegistry', () => { expect(browserSessionRegistry.getProfile(profile!.id)).toBeNull() }) + it('clears session policy callbacks when deleting a profile', async () => { + const profile = browserSessionRegistry.createProfile('isolated', 'Policy Delete Test') + expect(profile).not.toBeNull() + const mockSession = sessionFromPartitionMock.mock.results[0]?.value + const downloadHandler = mockSession.on.mock.calls.find( + ([eventName]) => eventName === 'will-download' + )?.[1] + + await expect(browserSessionRegistry.deleteProfile(profile!.id)).resolves.toBe(true) + + expect(mockSession.removeListener).toHaveBeenCalledWith('will-download', downloadHandler) + expect(mockSession.setPermissionRequestHandler).toHaveBeenLastCalledWith(null) + expect(mockSession.setPermissionCheckHandler).toHaveBeenLastCalledWith(null) + expect(mockSession.setDisplayMediaRequestHandler).toHaveBeenLastCalledWith(null) + }) + it('refuses to delete the default profile', async () => { const deleted = await browserSessionRegistry.deleteProfile('default') expect(deleted).toBe(false) diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 18d98cf8faf..43bc0fb675f 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -3,6 +3,7 @@ per-partition permission/download policies. Splitting further would scatter the security boundary across modules. */ import { app, session } from 'electron' +import type { Session } from 'electron' import { randomUUID } from 'node:crypto' import { copyFileSync, @@ -374,6 +375,7 @@ class BrowserSessionRegistry { // lingering after the user deletes an imported or isolated session profile. try { const sess = session.fromPartition(profile.partition) + this.clearSessionPolicies(profile.partition, sess) await sess.clearStorageData() await sess.clearCache() } catch { @@ -449,6 +451,13 @@ class BrowserSessionRegistry { // session partitions would silently allow permissions and downloads that the // shared partition correctly denies. private readonly configuredPartitions = new Set() + private readonly handleWillDownload = ( + _event: Electron.Event, + item: Electron.DownloadItem, + webContents: Electron.WebContents + ): void => { + browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) + } private setupSessionPolicies(partition: string): void { if (this.configuredPartitions.has(partition)) { @@ -518,9 +527,19 @@ class BrowserSessionRegistry { sess.setDisplayMediaRequestHandler((_request, callback) => { callback({ video: undefined, audio: undefined }) }) - sess.on('will-download', (_event, item, webContents) => { - browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) - }) + sess.removeListener('will-download', this.handleWillDownload) + sess.on('will-download', this.handleWillDownload) + } + + private clearSessionPolicies(partition: string, sess: Session): void { + // Why: isolated/imported browser partitions can be deleted while the + // Electron Session object survives; clear policy callbacks and listener + // bookkeeping so removed profiles do not leave retained closures behind. + this.configuredPartitions.delete(partition) + sess.removeListener('will-download', this.handleWillDownload) + sess.setPermissionRequestHandler(null) + sess.setPermissionCheckHandler(null) + sess.setDisplayMediaRequestHandler(null) } } diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index ddb3f3d1aee..ea373013062 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -94,6 +94,14 @@ describe('CdpWsProxy', () => { expect(proxy.getPort()).toBeGreaterThan(0) }) + it('does not retain an extra startup server error listener after binding', () => { + const server = ( + proxy as unknown as { httpServer: { listenerCount: (event: string) => number } } + ).httpServer + + expect(server.listenerCount('error')).toBeLessThanOrEqual(1) + }) + it('attaches debugger on start', () => { expect(mock.webContents.debugger.attach).toHaveBeenCalledWith('1.3') }) diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index ae257b40cec..a5aa51ffe40 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -24,6 +24,9 @@ export class CdpWsProxy { return new Promise((resolve, reject) => { this.httpServer = createServer((req, res) => this.handleHttpRequest(req, res)) this.wss = new WebSocketServer({ server: this.httpServer }) + const onListenError = (error: Error): void => { + reject(error) + } this.wss.on('connection', (ws) => { if (this.client) { this.client.close() @@ -37,6 +40,7 @@ export class CdpWsProxy { }) }) this.httpServer.listen(0, '127.0.0.1', () => { + this.httpServer?.removeListener('error', onListenError) const addr = this.httpServer!.address() if (typeof addr === 'object' && addr) { this.port = addr.port @@ -45,7 +49,7 @@ export class CdpWsProxy { reject(new Error('Failed to bind proxy server')) } }) - this.httpServer.on('error', reject) + this.httpServer.once('error', onListenError) }) } diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 00bfdc5cbe1..3514e666267 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -3,6 +3,9 @@ // or the script body that lands on the remote box. Local install behavior // is exercised through `installer-utils.test.ts` and the per-CLI status // audit; this file covers ONLY the SFTP-backed path added in commit #8. +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import { vi, describe, expect, it } from 'vitest' vi.mock('electron', () => ({ @@ -98,8 +101,76 @@ function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } { return { sftp, fs } } +describe('ClaudeHookService.install', () => { + it('installs managed hooks into Claude settings and preserves user Bedrock settings', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-hooks-')) + vi.stubEnv('HOME', tmpHome) + try { + const legacyPath = join(tmpHome, '.claude', 'settings.json') + mkdirSync(join(tmpHome, '.claude'), { recursive: true }) + writeFileSync( + legacyPath, + JSON.stringify({ + apiKeyHelper: '/opt/company/claude-key-helper', + awsAuthRefresh: '/opt/company/aws-refresh', + awsCredentialExport: '/opt/company/aws-export', + env: { + CLAUDE_CODE_USE_BEDROCK: '1', + AWS_REGION: 'us-west-2' + }, + hooks: { + Stop: [ + { + hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] + }, + { + hooks: [ + { + type: 'command', + command: '/Users/old/.orca/agent-hooks/claude-hook.sh' + } + ] + } + ] + } + }) + ) + + const status = new ClaudeHookService().install() + expect(status.state).toBe('installed') + + const legacy = JSON.parse(readFileSync(legacyPath, 'utf-8')) + expect(legacy).toMatchObject({ + apiKeyHelper: '/opt/company/claude-key-helper', + awsAuthRefresh: '/opt/company/aws-refresh', + awsCredentialExport: '/opt/company/aws-export', + env: { + CLAUDE_CODE_USE_BEDROCK: '1', + AWS_REGION: 'us-west-2' + } + }) + const legacyCommands = legacy.hooks.Stop.flatMap( + (definition: { hooks: { command: string }[] }) => + definition.hooks.map((hook) => hook.command) + ) + expect(legacyCommands).toContain('/usr/local/bin/user-hook') + expect(legacyCommands.some((command: string) => command.includes('claude-hook.sh'))).toBe( + true + ) + expect( + legacyCommands.some((command: string) => + command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh') + ) + ).toBe(false) + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) +}) + describe('ClaudeHookService.installRemote', () => { - it('writes settings.json + managed script under the remote $HOME', async () => { + it('writes Claude settings + managed script under the remote $HOME', async () => { const svc = new ClaudeHookService() const { sftp, fs } = createFakeSftp() const status = await svc.installRemote(sftp, '/home/dev') @@ -131,16 +202,17 @@ describe('ClaudeHookService.installRemote', () => { expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) }) - it('reports parse error when remote settings.json is malformed', async () => { + it('reports parse error when remote settings.json cannot be parsed', async () => { const svc = new ClaudeHookService() const { sftp, fs } = createFakeSftp() fs.files.set('/home/dev/.claude/settings.json', 'not json') const status = await svc.installRemote(sftp, '/home/dev') expect(status.state).toBe('error') - expect(status.detail).toContain('Could not parse') + expect(status.managedHooksPresent).toBe(false) + expect(status.detail).toContain('Could not parse remote Claude settings.json') }) - it('preserves user-authored hook entries on a fresh install', async () => { + it('preserves user-authored hook entries while sweeping old managed entries', async () => { const svc = new ClaudeHookService() const { sftp, fs } = createFakeSftp() fs.files.set( @@ -150,6 +222,15 @@ describe('ClaudeHookService.installRemote', () => { Stop: [ { hooks: [{ type: 'command', command: '/usr/local/bin/my-user-hook' }] + }, + { + hooks: [ + { + type: 'command', + command: + 'if [ -x /home/dev/.orca/agent-hooks/claude-hook.sh ]; then /bin/sh /home/dev/.orca/agent-hooks/claude-hook.sh; fi' + } + ] } ] } @@ -157,10 +238,11 @@ describe('ClaudeHookService.installRemote', () => { ) await svc.installRemote(sftp, '/home/dev') const parsed = JSON.parse(fs.files.get('/home/dev/.claude/settings.json')!) - // Original user-authored entry survives alongside the new managed entry. + // Original user-authored entry survives, while stale Orca entries are + // replaced with the current managed hook command. const stopDefs = parsed.hooks.Stop as { hooks: { command: string }[] }[] const userCmds = stopDefs.flatMap((d) => d.hooks.map((h) => h.command)) expect(userCmds).toContain('/usr/local/bin/my-user-hook') - expect(userCmds.some((c) => c.includes('claude-hook.sh'))).toBe(true) + expect(userCmds.filter((c) => c.includes('claude-hook.sh'))).toHaveLength(1) }) }) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 63148e5a2e5..4fa638b6968 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -1,72 +1,26 @@ -import { homedir } from 'os' -import { join } from 'path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { - createManagedCommandMatcher, buildWindowsAgentHookPostCommand, - getSharedManagedScriptPath, readHooksJson, - removeManagedCommands, - wrapPosixHookCommand, writeHooksJson, - writeManagedScript, - type HookDefinition + writeManagedScript } from '../agent-hooks/installer-utils' import { readHooksJsonRemote, writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' - -const CLAUDE_EVENTS = [ - { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, - // Why: PreToolUse gives the dashboard a live readout of the in-flight tool - // (name + input preview) before it completes. Without it, a long-running - // Bash/Task step looks like a silent gap between prompt and Stop. - { - eventName: 'PreToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PostToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PostToolUseFailure', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PermissionRequest', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - } -] as const - -function getConfigPath(): string { - return join(homedir(), '.claude', 'settings.json') -} - -function getManagedScriptFileName(): string { - return process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' -} - -function getManagedScriptPath(): string { - return getSharedManagedScriptPath(getManagedScriptFileName()) -} - -function getManagedCommand(scriptPath: string): string { - if (process.platform === 'win32') { - // Why: on Windows, Claude Code runs hooks through Git Bash (`/usr/bin/bash`). - // A path with single backslashes (e.g. `C:\Users\…\claude-hook.cmd`) is - // interpreted by bash as a string with escape sequences, so `\U`, `\A`, etc. - // collapse and the launcher fails with `command not found`. Emit forward - // slashes — Windows accepts them in path arguments and bash leaves them - // intact, so the same JSON value works through every shell layer. - return scriptPath.replaceAll('\\', '/') - } - return wrapPosixHookCommand(scriptPath) -} +import { + applyManagedHooks, + CLAUDE_EVENTS, + getConfigPath, + getManagedCommand, + getManagedScriptPath, + getRemoteConfigPath, + getRemoteManagedCommand, + removeManagedHooks +} from './hook-settings' function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { @@ -198,41 +152,21 @@ export class ClaudeHookService { } const command = getManagedCommand(scriptPath) - const nextHooks = { ...config.hooks } - - // Why: match by script filename (not exact command string) so a fresh - // install sweeps stale entries left by older builds or a different - // Electron userData path (dev vs. prod). Without this, repeated installs - // accumulate duplicate hook entries pointing at defunct scripts. - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) - - for (const event of CLAUDE_EVENTS) { - const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] - const cleaned = removeManagedCommands(current, isManagedCommand) - const definition: HookDefinition = { - ...event.definition, - hooks: [{ type: 'command', command }] - } - nextHooks[event.eventName] = [...cleaned, definition] - } - - config.hooks = nextHooks + const nextConfig = applyManagedHooks(config, command) writeManagedScript(scriptPath, getManagedScript()) - writeHooksJson(configPath, config) + writeHooksJson(configPath, nextConfig) return this.getStatus() } - // Why: install Orca's managed Claude hooks on the remote box rather than - // the local Mac/Linux machine. Caller passes the user's SFTP handle from - // the SshConnection plus the resolved remote `$HOME` (used to compute - // ~/.claude/settings.json on the target). POSIX-only by design — see - // docs/design/agent-status-over-ssh.md §3 / §6 (Windows-remote deferred). + // Why: install Orca's Claude hook settings on the remote box rather than the + // local machine. Caller passes the user's SFTP handle plus the resolved + // remote `$HOME`; POSIX-only by design (Windows-remote deferred). async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { // Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths - // (`~/.claude/settings.json`) and a `.sh` managed script body. The remote - // platform is gated by the relay's capability RPC at a higher layer; we - // cannot detect it from `process.platform` here (that's the local box). - const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.claude/settings.json` + // and a `.sh` managed script body. The remote platform is gated by the + // relay's capability RPC at a higher layer; we cannot detect it from + // `process.platform` here (that's the local box). + const remoteConfigPath = getRemoteConfigPath(remoteHome) const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/claude-hook.sh` // Why: SFTP reads/writes fail far more often than local fs (network drops, // EACCES on remote dirs, disk full, channel closed). Wrap the entire @@ -255,20 +189,8 @@ export class ClaudeHookService { // Why: the POSIX wrapper is identical regardless of where the script // lands; only the path differs. Reuse the same wrapper helper. - const command = wrapPosixHookCommand(remoteScriptPath) - const nextHooks = { ...config.hooks } - const isManagedCommand = createManagedCommandMatcher('claude-hook.sh') - - for (const event of CLAUDE_EVENTS) { - const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] - const cleaned = removeManagedCommands(current, isManagedCommand) - const definition: HookDefinition = { - ...event.definition, - hooks: [{ type: 'command', command }] - } - nextHooks[event.eventName] = [...cleaned, definition] - } - config.hooks = nextHooks + const command = getRemoteManagedCommand(remoteScriptPath) + const nextConfig = applyManagedHooks(config, command, 'claude-hook.sh') // Why: write the script first, then the settings — settings.json // referencing a missing script body would fire `command not found` on @@ -279,7 +201,7 @@ export class ClaudeHookService { // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is // running on Windows; never derive remote script syntax from local OS. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) - await writeHooksJsonRemote(sftp, remoteConfigPath, config) + await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) return { agent: 'claude', @@ -311,28 +233,10 @@ export class ClaudeHookService { detail: 'Could not parse Claude settings.json' } } - - const nextHooks = { ...config.hooks } - // Why: same broad matcher as install(), so remove() also cleans up stale - // entries from older builds even if the current scriptPath has moved. - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) - for (const [eventName, definitions] of Object.entries(nextHooks)) { - // Why: a malformed settings.json entry (non-array value for an event - // name) would make removeManagedCommands throw via definitions.flatMap. - // Skip — we cannot sweep something we cannot parse, and remove() must - // fail open so a broken user config never blocks uninstall. - if (!Array.isArray(definitions)) { - continue - } - const cleaned = removeManagedCommands(definitions, isManagedCommand) - if (cleaned.length === 0) { - delete nextHooks[eventName] - } else { - nextHooks[eventName] = cleaned - } + const { config: nextConfig, changed } = removeManagedHooks(config) + if (changed) { + writeHooksJson(configPath, nextConfig) } - config.hooks = nextHooks - writeHooksJson(configPath, config) return this.getStatus() } } diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts new file mode 100644 index 00000000000..e4e3bc07662 --- /dev/null +++ b/src/main/claude/hook-settings.ts @@ -0,0 +1,115 @@ +import { homedir } from 'os' +import { join } from 'path' +import { + createManagedCommandMatcher, + getSharedManagedScriptPath, + removeManagedCommands, + wrapPosixHookCommand, + type HookDefinition, + type HooksConfig +} from '../agent-hooks/installer-utils' + +export const CLAUDE_EVENTS = [ + { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: PreToolUse gives the dashboard a live readout of the in-flight tool + // (name + input preview) before it completes. + { + eventName: 'PreToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUseFailure', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PermissionRequest', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + } +] as const + +export function getConfigPath(): string { + return join(homedir(), '.claude', 'settings.json') +} + +export function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' +} + +export function getManagedScriptPath(): string { + return getSharedManagedScriptPath(getManagedScriptFileName()) +} + +export function getRemoteConfigPath(remoteHome: string): string { + return `${remoteHome.replace(/\/$/, '')}/.claude/settings.json` +} + +export function getManagedCommand(scriptPath: string): string { + if (process.platform === 'win32') { + // Why: Claude Code runs hooks through Git Bash on Windows; forward slashes + // survive that shell layer while native Windows APIs still accept them. + return scriptPath.replaceAll('\\', '/') + } + return wrapPosixHookCommand(scriptPath) +} + +export function getRemoteManagedCommand(scriptPath: string): string { + return wrapPosixHookCommand(scriptPath) +} + +export function applyManagedHooks( + config: HooksConfig, + command: string, + scriptFileName = getManagedScriptFileName() +): HooksConfig { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + + for (const event of CLAUDE_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [{ type: 'command', command }] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + + return { ...config, hooks: nextHooks } +} + +export function removeManagedHooks( + config: HooksConfig, + scriptFileName = getManagedScriptFileName() +): { + config: HooksConfig + changed: boolean +} { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + let changed = false + + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) { + changed = true + } + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + return { + config: { ...config, hooks: nextHooks }, + changed + } +} diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index e742df38eeb..fcf877af8e1 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -20,7 +20,8 @@ describe('packaged CLI assets', () => { expect.arrayContaining([ 'node_modules/ws/**', 'node_modules/tweetnacl/**', - 'node_modules/zod/**' + 'node_modules/zod/**', + 'node_modules/yaml/**' ]) ) }) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 880ac16e602..6e00232ce27 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmodSync, existsSync, + lstatSync, mkdtempSync, mkdirSync, + readlinkSync, readFileSync, rmSync, statSync, @@ -14,7 +16,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { GlobalSettings } from '../../shared/types' -const testState = { userDataDir: '', fakeHomeDir: '' } +const testState = { + userDataDir: '', + fakeHomeDir: '', + previousUserDataPath: undefined as string | undefined +} vi.mock('electron', () => ({ app: { @@ -32,10 +38,12 @@ vi.mock('node:os', async () => { function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' + const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, refreshLocalBaseRefOnWorktreeCreate: false, + autoRenameBranchFromWork: false, branchPrefix: 'git-username', branchPrefixCustom: '', theme: 'system', @@ -107,19 +115,52 @@ function createSettings(overrides: Partial = {}): GlobalSettings keepComputerAwakeWhileAgentsRun: false, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, + terminalJISYenToBackslash: false, experimentalMobile: false, mobileAutoRestoreFitMs: null, experimentalPet: false, experimentalActivity: true, + experimentalTerminalAttention: false, experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, ...overrides, - appFontFamily + appFontFamily, + agentStatusHooksEnabled } } +function getSystemCodexHomePath(): string { + return join(testState.fakeHomeDir, '.codex') +} + +function getSystemCodexAuthPath(): string { + return join(getSystemCodexHomePath(), 'auth.json') +} + +function getRuntimeCodexHomePath(): string { + return join(testState.userDataDir, 'codex-runtime-home', 'home') +} + +function getRuntimeCodexAuthPath(): string { + return join(getRuntimeCodexHomePath(), 'auth.json') +} + +function normalizeLinkTarget(linkTarget: string): string { + return process.platform === 'win32' + ? linkTarget.replace(/^\\\\\?\\/, '').toLowerCase() + : linkTarget +} + +function expectResourceLinkedOrCopied(targetPath: string, sourcePath: string): void { + expect(existsSync(targetPath)).toBe(true) + if (!lstatSync(targetPath).isSymbolicLink()) { + return + } + expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath)) +} + function createStore(settings: GlobalSettings) { return { getSettings: vi.fn(() => settings), @@ -184,17 +225,24 @@ describe('CodexRuntimeHomeService', () => { vi.clearAllMocks() testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-runtime-home-')) testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) - mkdirSync(join(testState.fakeHomeDir, '.codex'), { recursive: true }) + testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = testState.userDataDir + mkdirSync(getSystemCodexHomePath(), { recursive: true }) + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) }) afterEach(() => { rmSync(testState.userDataDir, { recursive: true, force: true }) rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + if (testState.previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath + } }) it('captures the existing ~/.codex auth as the system-default snapshot', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') @@ -211,9 +259,9 @@ describe('CodexRuntimeHomeService', () => { } }) - it('materializes the active managed account auth into ~/.codex on startup', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + it('materializes the active managed account auth into the runtime home on startup', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -248,8 +296,8 @@ describe('CodexRuntimeHomeService', () => { }) it('restores the system-default snapshot when no managed account is selected', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -286,7 +334,7 @@ describe('CodexRuntimeHomeService', () => { }) it('removes runtime auth when restoring a no-login system default', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -322,7 +370,7 @@ describe('CodexRuntimeHomeService', () => { }) it('removes runtime auth when deselecting with a missing system-default snapshot', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') writeFileSync(runtimeAuthPath, managedAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) @@ -353,9 +401,9 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('removes runtime auth when deselecting with a corrupt system-default snapshot', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + it('repairs a corrupt system-default snapshot from the live ~/.codex auth on deselect', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ @@ -375,7 +423,6 @@ describe('CodexRuntimeHomeService', () => { activeCodexManagedAccountId: null }) const store = createStore(settings) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -391,15 +438,15 @@ describe('CodexRuntimeHomeService', () => { settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(existsSync(snapshotPath)).toBe(false) - expect(warnSpy).toHaveBeenCalledWith( - '[codex-runtime-home] Ignoring invalid system-default auth snapshot' - ) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(existsSync(snapshotPath)).toBe(true) + expect(JSON.parse(readFileSync(snapshotPath, 'utf-8'))).toEqual({ + authJson: '{"account":"system"}\n' + }) }) it('clears an invalid active account selection and removes untrusted runtime auth', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const missingManagedHomePath = join( testState.userDataDir, @@ -434,8 +481,46 @@ describe('CodexRuntimeHomeService', () => { expect(warnSpy).toHaveBeenCalled() }) + it('clears an invalid active account selection and restores live system default auth', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = '{"account":"system"}\n' + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + rmSync(runtimeAuthPath, { force: true }) + const missingManagedHomePath = join( + testState.userDataDir, + 'codex-accounts', + 'account-1', + 'home' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath: missingManagedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) + expect(warnSpy).toHaveBeenCalled() + }) + it('clears an unknown active account id and removes untrusted runtime auth', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"stale-managed"}\n', 'utf-8') const settings = createSettings({ activeCodexManagedAccountId: 'missing-account' @@ -449,18 +534,144 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('returns ~/.codex for Codex launch and rate-limit preparation', async () => { + it('returns the Orca-managed runtime home for Codex launch and rate-limit preparation', async () => { const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch()).toBe(join(testState.fakeHomeDir, '.codex')) - expect(service.prepareForRateLimitFetch()).toBe(join(testState.fakeHomeDir, '.codex')) - expect(existsSync(join(testState.fakeHomeDir, '.codex'))).toBe(true) + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + expect(existsSync(getRuntimeCodexHomePath())).toBe(true) + }) + + it('mirrors later system Codex config changes before launch', async () => { + const systemCodexHome = getSystemCodexHomePath() + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "first"\n', 'utf-8') + const store = createStore(createSettings()) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.prepareForCodexLaunch() + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "second"\n', 'utf-8') + service.prepareForCodexLaunch() + + expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe( + 'model = "second"\n' + ) + }) + + it('links system Codex user resources into the managed runtime home before launch', async () => { + const systemCodexHome = getSystemCodexHomePath() + mkdirSync(join(systemCodexHome, 'skills', 'review'), { recursive: true }) + writeFileSync(join(systemCodexHome, 'skills', 'review', 'SKILL.md'), 'review skill\n', 'utf-8') + mkdirSync(join(systemCodexHome, 'plugins'), { recursive: true }) + writeFileSync(join(systemCodexHome, 'plugins', 'plugin.json'), '{"name":"plugin"}\n', 'utf-8') + writeFileSync(join(systemCodexHome, 'profile-v2'), 'profile\n', 'utf-8') + const store = createStore(createSettings()) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.prepareForCodexLaunch() + + const runtimeSkillsPath = join(getRuntimeCodexHomePath(), 'skills') + const runtimePluginsPath = join(getRuntimeCodexHomePath(), 'plugins') + const runtimeProfilePath = join(getRuntimeCodexHomePath(), 'profile-v2') + expectResourceLinkedOrCopied(runtimeSkillsPath, join(systemCodexHome, 'skills')) + expectResourceLinkedOrCopied(runtimePluginsPath, join(systemCodexHome, 'plugins')) + expectResourceLinkedOrCopied(runtimeProfilePath, join(systemCodexHome, 'profile-v2')) + expect(readFileSync(join(runtimeSkillsPath, 'review', 'SKILL.md'), 'utf-8')).toBe( + 'review skill\n' + ) + expect(readFileSync(runtimeProfilePath, 'utf-8')).toBe('profile\n') + }) + + it('bridges system Codex sessions before launch without replacing runtime sessions', async () => { + const systemMissingRuntimeSessionPath = join( + getSystemCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-old.jsonl' + ) + const systemConflictSessionPath = join( + getSystemCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-conflict.jsonl' + ) + const runtimeConflictSessionPath = join( + getRuntimeCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-conflict.jsonl' + ) + mkdirSync(join(getSystemCodexHomePath(), 'sessions', '2026', '05', '26'), { recursive: true }) + mkdirSync(join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26'), { + recursive: true + }) + writeFileSync(systemMissingRuntimeSessionPath, '{"id":"old"}\n', 'utf-8') + writeFileSync(systemConflictSessionPath, '{"id":"system-conflict"}\n', 'utf-8') + writeFileSync(runtimeConflictSessionPath, '{"id":"runtime-conflict"}\n', 'utf-8') + writeFileSync(join(getSystemCodexHomePath(), 'state_5.sqlite'), 'sqlite\n', 'utf-8') + const store = createStore(createSettings()) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.prepareForCodexLaunch() + + const runtimeMissingSessionPath = join( + getRuntimeCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-old.jsonl' + ) + expect(readFileSync(runtimeMissingSessionPath, 'utf-8')).toBe('{"id":"old"}\n') + expectResourceLinkedOrCopied(runtimeMissingSessionPath, systemMissingRuntimeSessionPath) + expect(readFileSync(runtimeConflictSessionPath, 'utf-8')).toBe('{"id":"runtime-conflict"}\n') + expect(existsSync(join(getRuntimeCodexHomePath(), 'state_5.sqlite'))).toBe(false) + }) + + it('does not replace runtime-owned Codex files while linking user resources', async () => { + const systemCodexHome = getSystemCodexHomePath() + mkdirSync(join(systemCodexHome, 'sessions'), { recursive: true }) + mkdirSync(join(systemCodexHome, 'skills'), { recursive: true }) + writeFileSync(join(systemCodexHome, 'auth.json'), '{"account":"system"}\n', 'utf-8') + writeFileSync(join(systemCodexHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8') + writeFileSync(join(systemCodexHome, 'skills', 'system.md'), 'system\n', 'utf-8') + writeFileSync(join(getRuntimeCodexHomePath(), 'hooks.json'), '{"hooks":{"Stop":[]}}\n', 'utf-8') + writeFileSync(join(getRuntimeCodexHomePath(), 'history.jsonl'), '{"id":"runtime"}\n', 'utf-8') + const store = createStore(createSettings()) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.prepareForCodexLaunch() + + expect(readFileSync(join(getRuntimeCodexHomePath(), 'auth.json'), 'utf-8')).toBe( + '{"account":"system"}\n' + ) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'hooks.json'), 'utf-8')).toBe( + '{"hooks":{"Stop":[]}}\n' + ) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'history.jsonl'), 'utf-8')).toBe( + '{"id":"runtime"}\n' + ) + expect(existsSync(join(getRuntimeCodexHomePath(), 'sessions'))).toBe(false) + expectResourceLinkedOrCopied( + join(getRuntimeCodexHomePath(), 'skills'), + join(systemCodexHome, 'skills') + ) }) it('does not overwrite auth.json when no managed account was ever active', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"original"}\n', 'utf-8') const store = createStore(createSettings()) @@ -473,9 +684,208 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-switch"}\n') }) - it('does not overwrite auth.json after deselection + external change', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + it('refreshes the runtime auth when the system-default auth changes later', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-1"}\n', 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-1"}\n') + + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-2"}\n', 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-2"}\n') + }) + + it('reads back system-default token refreshes from runtime auth', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-old') + const refreshedAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system-refreshed' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + expect( + JSON.parse( + readFileSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json'), + 'utf-8' + ) + ) + ).toEqual({ authJson: refreshedAuth }) + }) + + it('reads back system-default token refreshes after restart when the snapshot proves the baseline', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-old') + const refreshedAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system-refreshed' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.syncForCurrentSelection() + + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + }) + + it('keeps a local runtime logout when the system-default auth still exists', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(runtimeAuthPath, { force: true }) + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + + it('keeps a local runtime logout after restart when the system-default auth still exists', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const settings = createSettings() + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(runtimeAuthPath, { force: true }) + service.syncForCurrentSelection() + new CodexRuntimeHomeService(store as never) + + expect(existsSync(runtimeAuthPath)).toBe(false) + expect( + existsSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json') + ) + ).toBe(true) + }) + + it('mirrors a fresh external system-default login after a persisted local runtime logout', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') + const settings = createSettings() + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(runtimeAuthPath, { force: true }) + service.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-new"}\n', 'utf-8') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-new"}\n') + expect( + existsSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json') + ) + ).toBe(false) + }) + + it('mirrors a fresh external system-default login after a same-process local runtime logout', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(runtimeAuthPath, { force: true }) + service.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-new"}\n', 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-new"}\n') + expect( + existsSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json') + ) + ).toBe(false) + }) + + it('clears the mirrored runtime auth after an external system-default logout', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(getSystemCodexAuthPath(), { force: true }) + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + + it('clears mirrored runtime auth after restart when the system-default auth was deleted', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const settings = createSettings() + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + rmSync(getSystemCodexAuthPath(), { force: true }) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + + it('clears refreshed runtime auth after an external system-default logout', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const refreshedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'refreshed') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + rmSync(getSystemCodexAuthPath(), { force: true }) + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + expect( + existsSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json') + ) + ).toBe(true) + }) + + it('persists runtime auth refreshes after returning to system default', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const refreshedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'refreshed') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -507,16 +917,61 @@ describe('CodexRuntimeHomeService', () => { // Deselect managed account — should restore system default once settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) - // External tool changes auth — subsequent syncs must not overwrite - writeFileSync(runtimeAuthPath, '{"account":"external-tool"}\n', 'utf-8') + // Codex used to refresh tokens directly in ~/.codex. With an Orca-owned + // runtime home, the same refresh must be read back to the system default. + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') service.syncForCurrentSelection() - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-tool"}\n') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedAuth) + }) + + it('does not write stale managed runtime auth back to system default', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + const staleManagedRefresh = createCodexAuthJson( + 'managed@example.com', + 'acct-managed', + 'managed-refreshed' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + writeFileSync(runtimeAuthPath, staleManagedRefresh, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) }) it('removes untrusted runtime auth on restart when persisted active account is invalid', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const settings = createSettings({ codexManagedAccounts: [ @@ -546,7 +1001,7 @@ describe('CodexRuntimeHomeService', () => { }) it('imports legacy managed-home history into the shared runtime history', async () => { - const runtimeHomePath = join(testState.fakeHomeDir, '.codex') + const runtimeHomePath = getRuntimeCodexHomePath() const runtimeHistoryPath = join(runtimeHomePath, 'history.jsonl') writeFileSync(runtimeHistoryPath, '{"id":"shared-1"}\n', 'utf-8') const managedHomePath = createManagedAuth( @@ -577,7 +1032,7 @@ describe('CodexRuntimeHomeService', () => { return } - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, @@ -615,7 +1070,7 @@ describe('CodexRuntimeHomeService', () => { return } - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, @@ -686,7 +1141,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - const runtimeHistoryPath = join(testState.fakeHomeDir, '.codex', 'history.jsonl') + const runtimeHistoryPath = join(getRuntimeCodexHomePath(), 'history.jsonl') expect(readFileSync(runtimeHistoryPath, 'utf-8')).toContain('legacy-1') writeFileSync( @@ -703,7 +1158,7 @@ describe('CodexRuntimeHomeService', () => { }) it('clears system-default snapshot via clearSystemDefaultSnapshot', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const store = createStore(createSettings()) @@ -722,7 +1177,7 @@ describe('CodexRuntimeHomeService', () => { }) it('reads back CLI-refreshed tokens into managed storage on subsequent sync', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed') @@ -749,7 +1204,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Simulate CLI refreshing the token in ~/.codex/auth.json + // Simulate CLI refreshing the token in runtime CODEX_HOME/auth.json. writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') // Next sync should read back the refreshed token to managed storage @@ -760,7 +1215,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects runtime read-back from a different Codex identity', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') @@ -797,7 +1252,7 @@ describe('CodexRuntimeHomeService', () => { }) it('routes runtime read-back from a different Codex identity to its matching account', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') const account1RefreshedAuth = createCodexAuthJson( @@ -844,7 +1299,7 @@ describe('CodexRuntimeHomeService', () => { // An older account-1 Codex process refreshed the shared runtime file after // Orca selected account-2. Persist the refresh to account-1, then restore - // the selected account in ~/.codex. + // the selected account in runtime CODEX_HOME. writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') service.syncForCurrentSelection() @@ -854,7 +1309,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects ambiguous Codex read-back instead of choosing a managed account', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const originalAuth = createCodexAuthJson('same@example.com', 'acct-same', 'original') const refreshedAuth = createCodexAuthJson('same@example.com', 'acct-same', 'refreshed') @@ -916,7 +1371,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects runtime read-back without a positive selected-account identity match', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') const accountOnlyAuth = `${JSON.stringify({ @@ -956,7 +1411,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects same-email runtime read-back when account ids differ from sparse managed metadata', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const selectedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'selected') const staleLivePtyAuth = createCodexAuthJson('user@example.com', 'acct-stale', 'stale') @@ -991,7 +1446,7 @@ describe('CodexRuntimeHomeService', () => { }) it('reads back same-account refreshes for sparse managed metadata using stored auth identity', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const originalAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'original') const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'refreshed') @@ -1026,7 +1481,7 @@ describe('CodexRuntimeHomeService', () => { }) it('reads back strong account-id refreshes when the runtime auth has no email', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') const refreshedAuth = `${JSON.stringify({ @@ -1066,7 +1521,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects unverifiable Codex read-back on first sync after restart', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"tokens":"refreshed-while-down"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, @@ -1101,7 +1556,7 @@ describe('CodexRuntimeHomeService', () => { }) it('reads back verified same-account refreshes on first sync after restart', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original', 1_000) const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed', 2_000) writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') @@ -1134,7 +1589,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects older same-account Codex auth on first sync after restart', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() const staleRuntimeAuth = createCodexAuthJson('user@example.com', 'acct-1', 'stale', 1_000) const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed-newer', 2_000) writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') @@ -1167,7 +1622,7 @@ describe('CodexRuntimeHomeService', () => { }) it('does not contaminate the incoming Codex account during account switch', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const managedHomePath1 = createManagedAuth( testState.userDataDir, @@ -1220,7 +1675,7 @@ describe('CodexRuntimeHomeService', () => { }) it('does not carry the reauth read-back skip across Codex account switches', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') @@ -1276,7 +1731,7 @@ describe('CodexRuntimeHomeService', () => { }) it('does not apply inactive-account Codex reauth skip to the active account', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') const account1RefreshedAuth = createCodexAuthJson( @@ -1329,8 +1784,8 @@ describe('CodexRuntimeHomeService', () => { }) it('restores system default when unverified runtime auth appears before deselect', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -1374,8 +1829,8 @@ describe('CodexRuntimeHomeService', () => { }) it('restores system default after same-identity managed Codex refresh on deselect', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') const externalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'external') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) @@ -1412,8 +1867,8 @@ describe('CodexRuntimeHomeService', () => { }) it('restores system default when stale Codex credentials are rejected on deselect', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) @@ -1450,7 +1905,7 @@ describe('CodexRuntimeHomeService', () => { }) it('keeps external Codex logout when deselecting managed account', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, @@ -1485,9 +1940,46 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) + it('keeps external system-default logout when managed runtime auth still exists', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') + rmSync(getSystemCodexAuthPath(), { force: true }) + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + it('captures a fresh system-default snapshot when re-entering managed mode', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system-1"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-1"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -1516,7 +2008,7 @@ describe('CodexRuntimeHomeService', () => { settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() - writeFileSync(runtimeAuthPath, '{"account":"system-2"}\n', 'utf-8') + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-2"}\n', 'utf-8') settings.activeCodexManagedAccountId = 'account-1' service.syncForCurrentSelection() @@ -1527,7 +2019,7 @@ describe('CodexRuntimeHomeService', () => { }) it('reads back refreshed tokens for the outgoing Codex account before switching', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const account1Original = createCodexAuthJson('one@example.com', 'acct-1', 'one-original') const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed') @@ -1576,7 +2068,7 @@ describe('CodexRuntimeHomeService', () => { }) it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') const reauthedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'reauthed') @@ -1616,7 +2108,7 @@ describe('CodexRuntimeHomeService', () => { }) it('preserves conflicting legacy session files under deterministic names', async () => { - const runtimeSessionsDir = join(testState.fakeHomeDir, '.codex', 'sessions') + const runtimeSessionsDir = join(getRuntimeCodexHomePath(), 'sessions') mkdirSync(runtimeSessionsDir, { recursive: true }) writeFileSync(join(runtimeSessionsDir, 'session.json'), '{"turns":[1]}', 'utf-8') const managedHomePath = createManagedAuth( diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 3c099ce575b..981e7aec70c 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -12,12 +12,18 @@ import { rmSync, statSync } from 'node:fs' -import { homedir } from 'node:os' import { dirname, extname, join, parse, relative } from 'node:path' import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/types' import type { Store } from '../persistence' import { writeFileAtomically } from './fs-utils' +import { + getOrcaManagedCodexHomePath, + getSystemCodexHomePath, + syncSystemCodexResourcesIntoManagedHome +} from '../codex/codex-home-paths' +import { syncSystemCodexSessionsIntoManagedHome } from '../codex/codex-session-bridge' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' type CodexAuthIdentity = { email: string | null @@ -29,6 +35,16 @@ type CodexSystemDefaultSnapshot = { authJson: string | null } +type CodexRuntimeLogoutMarker = { + systemDefaultAuthJson: string | null + loggedOutAt: number +} + +type CodexRuntimeLogoutMarkerStatus = + | { kind: 'missing' } + | { kind: 'applies' } + | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } + type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' type CodexReadBackMatch = | { @@ -40,12 +56,11 @@ type CodexReadBackMatch = | { kind: 'none' | 'ambiguous' } export class CodexRuntimeHomeService { - // Why: tracks whether auth.json is currently managed by Orca. When null, - // Orca does NOT own auth.json and must not overwrite external changes - // (e.g. user running `codex login` or another auth tool). The snapshot - // restore only fires on the managed→system-default transition. + // Why: tracks whether the runtime auth.json currently mirrors a managed + // account. When null, runtime auth follows the user's system-default + // ~/.codex/auth.json instead of being written back to a managed account. private lastSyncedAccountId: string | null = null - // Why: tracks the auth.json content Orca last wrote to ~/.codex/auth.json. + // Why: tracks the auth.json content Orca last wrote to the runtime CODEX_HOME. // Between syncs, if the file differs, Codex CLI refreshed the token — so // Orca writes back the refreshed token to managed storage before overwriting. // On managed→system-default transition, if the file differs, an external @@ -67,16 +82,22 @@ export class CodexRuntimeHomeService { prepareForCodexLaunch(): string { this.syncForCurrentSelection() + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() + syncSystemCodexSessionsIntoManagedHome() return this.getRuntimeHomePath() } prepareForRateLimitFetch(): string { this.syncForCurrentSelection() + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() return this.getRuntimeHomePath() } syncForCurrentSelection(): void { const settings = this.store.getSettings() + const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) if (this.lastSyncedAccountId === null) { this.captureSystemDefaultSnapshot({ force: false }) } @@ -98,16 +119,42 @@ export class CodexRuntimeHomeService { if (settings.activeCodexManagedAccountId) { this.store.updateSettings({ activeCodexManagedAccountId: null }) } - // Why: only restore the snapshot when transitioning FROM a managed - // account back to system default. When no managed account was ever - // active, auth.json belongs to the user and Orca must not touch it. - // This prevents overwriting external auth changes (codex login or other - // tools) on every PTY launch / rate-limit fetch. + // Why: only restore the system-default mirror when transitioning FROM a + // managed account. When no managed account was ever active, later syncs + // should mirror the user's current ~/.codex/auth.json instead of + // replaying an old snapshot on every PTY launch / rate-limit fetch. if (this.lastSyncedAccountId !== null) { this.restoreSystemDefaultSnapshot({ detectExternalLogin: outgoingReadBackResult !== 'rejected' }) this.lastSyncedAccountId = null + } else if (!runtimeAuthExistedBeforeSync) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + if (logoutMarkerStatus.kind === 'applies') { + this.lastWrittenAuthJson = null + } else if ( + logoutMarkerStatus.kind === 'system-default-changed' && + logoutMarkerStatus.systemDefaultAuthJson !== null + ) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else if (logoutMarkerStatus.kind === 'system-default-changed') { + // Why: a real ~/.codex logout after a local runtime logout should + // keep runtime auth absent instead of restoring the stale snapshot. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + } else if (this.lastWrittenAuthJson === null) { + // Why: Orca-launched Codex sessions now use an Orca-owned CODEX_HOME + // even when no managed account is selected. Seed that runtime home + // from the user's current system-default auth once so dev/prod Orca + // terminals stay logged in without mutating ~/.codex on startup. + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else { + this.persistRuntimeLogoutMarker() + } + } else { + this.clearRuntimeLogoutMarker() + this.syncRuntimeAuthWithSystemDefault() } return } @@ -129,10 +176,10 @@ export class CodexRuntimeHomeService { this.captureSystemDefaultSnapshot({ force: true }) } - // Why: Codex CLI refreshes expired OAuth tokens and writes them back to - // ~/.codex/auth.json. If we detect the runtime file differs from what Orca - // last wrote, the CLI must have refreshed — so we preserve those tokens - // back to managed storage before overwriting runtime with managed state. + // Why: Codex CLI refreshes expired OAuth tokens in CODEX_HOME/auth.json. + // If we detect the runtime file differs from what Orca last wrote, the CLI + // must have refreshed — so we preserve those tokens back to managed + // storage before overwriting runtime with managed state. if (this.lastSyncedAccountId === activeAccount.id) { if (this.skipNextReadBackForAccountId === activeAccount.id) { this.skipNextReadBackForAccountId = null @@ -303,6 +350,58 @@ export class CodexRuntimeHomeService { ) } + private runtimeAuthMatchesSystemDefaultIdentity( + runtimeAuthContents: string, + systemDefaultAuthContents: string + ): boolean { + const runtimeIdentity = this.readIdentityFromAuthContents(runtimeAuthContents) + const systemDefaultIdentity = this.readIdentityFromAuthContents(systemDefaultAuthContents) + if (!runtimeIdentity || !systemDefaultIdentity) { + return false + } + + // Why: stale managed Codex PTYs share the same runtime home. Only read a + // runtime refresh back into ~/.codex when the auth still claims the same + // system-default identity Orca mirrored earlier. + if ( + systemDefaultIdentity.email && + runtimeIdentity.email && + systemDefaultIdentity.email !== runtimeIdentity.email + ) { + return false + } + if ( + !this.identityFieldMatches( + systemDefaultIdentity.providerAccountId, + runtimeIdentity.providerAccountId + ) + ) { + return false + } + if ( + !this.identityFieldMatches( + systemDefaultIdentity.workspaceAccountId, + runtimeIdentity.workspaceAccountId + ) + ) { + return false + } + + const strongIdentityMatches = Boolean( + (systemDefaultIdentity.providerAccountId && runtimeIdentity.providerAccountId) || + (systemDefaultIdentity.workspaceAccountId && runtimeIdentity.workspaceAccountId) + ) + const emailMatches = Boolean( + systemDefaultIdentity.email && + runtimeIdentity.email && + systemDefaultIdentity.email === runtimeIdentity.email + ) + return ( + strongIdentityMatches || + (emailMatches && !runtimeIdentity.providerAccountId && !runtimeIdentity.workspaceAccountId) + ) + } + private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { const runtimeFreshness = this.readFreshnessFromAuthContents(runtimeAuthContents) const managedFreshness = this.readFreshnessFromAuthContents(managedAuthContents) @@ -441,9 +540,7 @@ export class CodexRuntimeHomeService { } private getRuntimeHomePath(): string { - const runtimeHomePath = join(homedir(), '.codex') - mkdirSync(runtimeHomePath, { recursive: true }) - return runtimeHomePath + return getOrcaManagedCodexHomePath() } private getRuntimeAuthPath(): string { @@ -454,6 +551,10 @@ export class CodexRuntimeHomeService { return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') } + private getRuntimeLogoutMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') + } + private getRuntimeMetadataDir(): string { const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') mkdirSync(metadataDir, { recursive: true }) @@ -490,8 +591,8 @@ export class CodexRuntimeHomeService { } // Why: migration is intentionally one-shot. Re-importing every startup - // would keep replaying stale managed-home state back into ~/.codex and - // make the shared runtime feel nondeterministic. + // would keep replaying stale managed-home state back into the shared + // runtime and make it feel nondeterministic. writeFileAtomically( this.getMigrationMarkerPath(), `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` @@ -618,79 +719,153 @@ export class CodexRuntimeHomeService { return } - const runtimeAuthPath = this.getRuntimeAuthPath() + const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') const snapshot: CodexSystemDefaultSnapshot = { authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null } writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) } - private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { - // Why: detect whether an external tool (e.g. `codex auth login`) overwrote - // auth.json while a managed account was active. If so, that external login - // becomes the new system default — skip the stale snapshot restore. - if (options.detectExternalLogin && this.detectExternalLoginAndUpdateSnapshot()) { + private syncRuntimeAuthWithSystemDefault(): void { + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (!existsSync(runtimeAuthPath)) { return } + try { + const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') + if (!existsSync(systemDefaultAuthPath)) { + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null + if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + if ( + mirroredSystemDefaultAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + } + return + } + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + if (runtimeAuth !== systemDefaultAuth) { + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null + if ( + mirroredSystemDefaultAuth !== null && + systemDefaultAuth === mirroredSystemDefaultAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + // Why: system-default Codex now refreshes tokens inside Orca's + // runtime CODEX_HOME. Read that refresh back to ~/.codex so the next + // sync does not overwrite fresh runtime credentials with stale ones. + this.writeSystemDefaultAuth(runtimeAuth) + this.captureSystemDefaultSnapshot({ force: true }) + this.lastWrittenAuthJson = runtimeAuth + return + } + // Why: the unmanaged path used to read ~/.codex directly. Mirror later + // external logins/logouts into Orca's runtime home so ordinary Orca + // Codex sessions keep matching the user's current system-default state. + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth) + } + } catch (error) { + console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) + } + } + + private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { const snapshotPath = this.getSystemDefaultSnapshotPath() - if (!existsSync(snapshotPath)) { - rmSync(this.getRuntimeAuthPath(), { force: true }) + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (existsSync(systemDefaultAuthPath)) { + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth) + return + } + + if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { + // Why: once Orca owns the runtime CODEX_HOME, deleting auth.json there is + // a local logout signal for Orca-launched Codex sessions, not a reason to + // rewrite the user's real ~/.codex snapshot back into place. + this.persistRuntimeLogoutMarker() this.lastWrittenAuthJson = null return } + if (options.detectExternalLogin) { + // Why: while a managed account is selected, the runtime auth file exists + // with managed credentials. If ~/.codex/auth.json vanished meanwhile, + // switching back must preserve that external system-default logout. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + return + } + + if (!existsSync(snapshotPath)) { + this.captureSystemDefaultSnapshot({ force: true }) + } + const snapshot = this.readSystemDefaultSnapshot(snapshotPath) if (!snapshot) { console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') rmSync(snapshotPath, { force: true }) - rmSync(this.getRuntimeAuthPath(), { force: true }) - this.lastWrittenAuthJson = null + this.captureSystemDefaultSnapshot({ force: true }) + const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!refreshedSnapshot) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + return + } + if (refreshedSnapshot.authJson === null) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + return + } + this.writeRuntimeAuth(refreshedSnapshot.authJson) return } if (snapshot.authJson === null) { - rmSync(this.getRuntimeAuthPath(), { force: true }) + rmSync(runtimeAuthPath, { force: true }) this.lastWrittenAuthJson = null return } this.writeRuntimeAuth(snapshot.authJson) } - // Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot(). - // If the runtime auth.json differs from what Orca last wrote, something - // external changed it. That external state should become the new system - // default rather than being overwritten by a potentially stale snapshot. - private detectExternalLoginAndUpdateSnapshot(): boolean { - if (this.lastWrittenAuthJson === null) { - return false - } + private writeSystemDefaultAuth(contents: string): void { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + mkdirSync(dirname(systemDefaultAuthPath), { recursive: true }) + writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 }) + this.ensureOwnerOnlyMode(systemDefaultAuthPath) + } - const runtimeAuthPath = this.getRuntimeAuthPath() - if (!existsSync(runtimeAuthPath)) { - const snapshotPath = this.getSystemDefaultSnapshotPath() - rmSync(snapshotPath, { force: true }) - this.lastWrittenAuthJson = null - return true - } - - try { - const currentAuth = readFileSync(runtimeAuthPath, 'utf-8') - if (currentAuth === this.lastWrittenAuthJson) { - return false - } - } catch { - return false - } - - const snapshotPath = this.getSystemDefaultSnapshotPath() - rmSync(snapshotPath, { force: true }) + private clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { + // Why: when the real ~/.codex auth disappears, Orca should treat that as an + // external logout for unmanaged sessions, even if runtime auth had already + // refreshed inside Orca's CODEX_HOME. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() this.lastWrittenAuthJson = null - return true + } + + private readSystemDefaultAuth(): string | null { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null } private writeRuntimeAuth(contents: string): void { // Why: auth.json contains sensitive credentials. Restrict to owner-only // so other users on a shared Linux/macOS machine cannot read it. + this.clearRuntimeLogoutMarker() if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) { this.ensureOwnerOnlyMode(this.getRuntimeAuthPath()) this.lastWrittenAuthJson = contents @@ -719,6 +894,59 @@ export class CodexRuntimeHomeService { } } + private getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus { + const marker = this.readRuntimeLogoutMarker() + if (!marker) { + return { kind: 'missing' } + } + const systemDefaultAuthJson = this.readSystemDefaultAuth() + if (systemDefaultAuthJson === marker.systemDefaultAuthJson) { + return { kind: 'applies' } + } + this.clearRuntimeLogoutMarker() + return { kind: 'system-default-changed', systemDefaultAuthJson } + } + + private persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void { + const marker: CodexRuntimeLogoutMarker = { + systemDefaultAuthJson, + loggedOutAt: Date.now() + } + writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, { + mode: 0o600 + }) + } + + private readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown + } catch { + return null + } + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + !('systemDefaultAuthJson' in parsed) || + !('loggedOutAt' in parsed) + ) { + return null + } + const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown } + if ( + (marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') || + typeof marker.loggedOutAt !== 'number' + ) { + return null + } + return marker as CodexRuntimeLogoutMarker + } + + private clearRuntimeLogoutMarker(): void { + rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) + } + private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { let rawContents: string try { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index a203f85b4f8..e9424198127 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -25,10 +25,12 @@ vi.mock('node:os', async () => { function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' + const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, refreshLocalBaseRefOnWorktreeCreate: false, + autoRenameBranchFromWork: false, branchPrefix: 'git-username', branchPrefixCustom: '', theme: 'system', @@ -100,16 +102,19 @@ function createSettings(overrides: Partial = {}): GlobalSettings keepComputerAwakeWhileAgentsRun: false, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, + terminalJISYenToBackslash: false, experimentalMobile: false, mobileAutoRestoreFitMs: null, experimentalPet: false, experimentalActivity: true, + experimentalTerminalAttention: false, experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, ...overrides, - appFontFamily + appFontFamily, + agentStatusHooksEnabled } } diff --git a/src/main/codex-usage/scanner-paths.test.ts b/src/main/codex-usage/scanner-paths.test.ts new file mode 100644 index 00000000000..56d672be1a3 --- /dev/null +++ b/src/main/codex-usage/scanner-paths.test.ts @@ -0,0 +1,366 @@ +/* eslint-disable max-lines -- Why: path discovery and legacy bridge scan preference cases need shared mocked homes to keep filesystem behavior realistic. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { linkSync, lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import type * as NodeOs from 'node:os' +import { join } from 'path' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: homedirMock + } +}) + +import { + getCodexSessionDirectories, + getCodexSessionsDirectory, + listCodexSessionFiles, + scanCodexUsageFiles +} from './scanner' + +const originalCodexHome = process.env.CODEX_HOME +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function usageRecord( + timestamp: string, + inputTokens: number, + totalInputTokens = inputTokens +): string { + return `${JSON.stringify({ + timestamp, + type: 'event_msg', + payload: { + type: 'token_count', + info: { + model: 'gpt-5-codex', + last_token_usage: { + input_tokens: inputTokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: inputTokens + }, + total_token_usage: { + input_tokens: totalInputTokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: totalInputTokens + } + } + } + })}\n` +} + +function totalOnlyUsageRecord(timestamp: string, totalInputTokens: number): string { + return `${JSON.stringify({ + timestamp, + type: 'event_msg', + payload: { + type: 'token_count', + info: { + model: 'gpt-5-codex', + total_token_usage: { + input_tokens: totalInputTokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: totalInputTokens + } + } + } + })}\n` +} + +beforeEach(() => { + delete process.env.CODEX_HOME + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-usage-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-usage-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('getCodexSessionsDirectory', () => { + it('defaults to Orca-managed Codex runtime sessions', () => { + expect(getCodexSessionsDirectory()).toBe( + join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + ) + }) + + it('ignores an ambient CODEX_HOME override', () => { + process.env.CODEX_HOME = '/tmp/explicit-codex-home' + + expect(getCodexSessionsDirectory()).toBe( + join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + ) + }) +}) + +describe('listCodexSessionFiles', () => { + it('scans both Orca-managed and system Codex session homes', async () => { + const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + const systemSessionsDir = join(fakeHomeDir, '.codex', 'sessions') + mkdirSync(runtimeSessionsDir, { recursive: true }) + mkdirSync(systemSessionsDir, { recursive: true }) + const runtimeSessionPath = join(runtimeSessionsDir, 'runtime.jsonl') + const systemSessionPath = join(systemSessionsDir, 'system.jsonl') + writeFileSync(runtimeSessionPath, '{}\n', 'utf-8') + writeFileSync(systemSessionPath, '{}\n', 'utf-8') + + expect(getCodexSessionDirectories()).toEqual([runtimeSessionsDir, systemSessionsDir]) + expect(await listCodexSessionFiles()).toEqual([runtimeSessionPath, systemSessionPath].sort()) + }) + + it('dedupes managed session aliases that point at system sessions', async () => { + const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + const systemSessionsDir = join(fakeHomeDir, '.codex', 'sessions') + mkdirSync(runtimeSessionsDir, { recursive: true }) + mkdirSync(systemSessionsDir, { recursive: true }) + const systemSessionPath = join(systemSessionsDir, 'system.jsonl') + const runtimeSessionPath = join(runtimeSessionsDir, 'system.jsonl') + writeFileSync(systemSessionPath, '{}\n', 'utf-8') + linkSync(systemSessionPath, runtimeSessionPath) + + const files = await listCodexSessionFiles() + + expect(files).toHaveLength(1) + expect(files[0] === runtimeSessionPath || files[0] === systemSessionPath).toBe(true) + }) + + it('does not scan both sides of a diverged legacy copied session bridge', async () => { + const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + const runtimeBridgeMarkerDir = join( + userDataDir, + 'codex-runtime-home', + 'home', + '.orca-session-copies' + ) + const systemSessionsDir = join(fakeHomeDir, '.codex', 'sessions') + mkdirSync(runtimeSessionsDir, { recursive: true }) + mkdirSync(runtimeBridgeMarkerDir, { recursive: true }) + mkdirSync(systemSessionsDir, { recursive: true }) + const systemSessionPath = join(systemSessionsDir, 'system.jsonl') + const runtimeSessionPath = join(runtimeSessionsDir, 'system.jsonl') + writeFileSync(systemSessionPath, '{}\n', 'utf-8') + writeFileSync(runtimeSessionPath, '{}\n', 'utf-8') + const sourceStat = lstatSync(systemSessionPath) + const targetStat = lstatSync(runtimeSessionPath) + writeFileSync( + join(runtimeBridgeMarkerDir, 'system.jsonl.json'), + `${JSON.stringify( + { + sourcePath: systemSessionPath, + sourceSize: sourceStat.size, + sourceMtimeMs: sourceStat.mtimeMs, + targetSize: targetStat.size, + targetMtimeMs: targetStat.mtimeMs + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync(runtimeSessionPath, '{"runtime":"resumed"}\n', 'utf-8') + + expect(await listCodexSessionFiles()).toEqual([runtimeSessionPath]) + }) + + it('keeps both sides of a legacy copied session bridge after both sides diverge', async () => { + const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + const runtimeBridgeMarkerDir = join( + userDataDir, + 'codex-runtime-home', + 'home', + '.orca-session-copies' + ) + const systemSessionsDir = join(fakeHomeDir, '.codex', 'sessions') + mkdirSync(runtimeSessionsDir, { recursive: true }) + mkdirSync(runtimeBridgeMarkerDir, { recursive: true }) + mkdirSync(systemSessionsDir, { recursive: true }) + const systemSessionPath = join(systemSessionsDir, 'system.jsonl') + const runtimeSessionPath = join(runtimeSessionsDir, 'system.jsonl') + writeFileSync(systemSessionPath, '{}\n', 'utf-8') + writeFileSync(runtimeSessionPath, '{}\n', 'utf-8') + const sourceStat = lstatSync(systemSessionPath) + const targetStat = lstatSync(runtimeSessionPath) + writeFileSync( + join(runtimeBridgeMarkerDir, 'system.jsonl.json'), + `${JSON.stringify( + { + sourcePath: systemSessionPath, + sourceSize: sourceStat.size, + sourceMtimeMs: sourceStat.mtimeMs, + targetSize: targetStat.size, + targetMtimeMs: targetStat.mtimeMs + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync(systemSessionPath, '{"system":"resumed"}\n', 'utf-8') + writeFileSync(runtimeSessionPath, '{"runtime":"resumed"}\n', 'utf-8') + + expect(await listCodexSessionFiles()).toEqual([runtimeSessionPath, systemSessionPath].sort()) + }) + + it('parses only source-side suffix after both sides of a legacy copy diverge', async () => { + const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + const runtimeBridgeMarkerDir = join( + userDataDir, + 'codex-runtime-home', + 'home', + '.orca-session-copies' + ) + const systemSessionsDir = join(fakeHomeDir, '.codex', 'sessions') + mkdirSync(runtimeSessionsDir, { recursive: true }) + mkdirSync(runtimeBridgeMarkerDir, { recursive: true }) + mkdirSync(systemSessionsDir, { recursive: true }) + const systemSessionPath = join(systemSessionsDir, 'system.jsonl') + const runtimeSessionPath = join(runtimeSessionsDir, 'system.jsonl') + const copiedPrefix = [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'legacy-session', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + usageRecord('2026-05-26T12:00:00.000Z', 10) + ].join('') + writeFileSync(systemSessionPath, copiedPrefix, 'utf-8') + writeFileSync(runtimeSessionPath, copiedPrefix, 'utf-8') + const sourceStat = lstatSync(systemSessionPath) + const targetStat = lstatSync(runtimeSessionPath) + writeFileSync( + join(runtimeBridgeMarkerDir, 'system.jsonl.json'), + `${JSON.stringify( + { + sourcePath: systemSessionPath, + sourceSize: sourceStat.size, + sourceMtimeMs: sourceStat.mtimeMs, + targetSize: targetStat.size, + targetMtimeMs: targetStat.mtimeMs + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + systemSessionPath, + `${copiedPrefix}${usageRecord('2026-05-26T12:01:00.000Z', 3, 13)}` + ) + writeFileSync( + runtimeSessionPath, + `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 5, 15)}` + ) + + const result = await scanCodexUsageFiles([], []) + + expect( + result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(18) + expect( + result.dailyAggregates.reduce((total, aggregate) => total + aggregate.eventCount, 0) + ).toBe(3) + }) + + it('treats a leading total-only source suffix record as baseline', async () => { + const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + const runtimeBridgeMarkerDir = join( + userDataDir, + 'codex-runtime-home', + 'home', + '.orca-session-copies' + ) + const systemSessionsDir = join(fakeHomeDir, '.codex', 'sessions') + mkdirSync(runtimeSessionsDir, { recursive: true }) + mkdirSync(runtimeBridgeMarkerDir, { recursive: true }) + mkdirSync(systemSessionsDir, { recursive: true }) + const systemSessionPath = join(systemSessionsDir, 'system.jsonl') + const runtimeSessionPath = join(runtimeSessionsDir, 'system.jsonl') + const copiedPrefix = [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'legacy-session', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + usageRecord('2026-05-26T12:00:00.000Z', 10) + ].join('') + writeFileSync(systemSessionPath, copiedPrefix, 'utf-8') + writeFileSync(runtimeSessionPath, copiedPrefix, 'utf-8') + const sourceStat = lstatSync(systemSessionPath) + const targetStat = lstatSync(runtimeSessionPath) + writeFileSync( + join(runtimeBridgeMarkerDir, 'system.jsonl.json'), + `${JSON.stringify( + { + sourcePath: systemSessionPath, + sourceSize: sourceStat.size, + sourceMtimeMs: sourceStat.mtimeMs, + targetSize: targetStat.size, + targetMtimeMs: targetStat.mtimeMs + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + systemSessionPath, + [ + copiedPrefix, + totalOnlyUsageRecord('2026-05-26T12:01:00.000Z', 13), + totalOnlyUsageRecord('2026-05-26T12:02:00.000Z', 17) + ].join('') + ) + writeFileSync( + runtimeSessionPath, + `${copiedPrefix}${usageRecord('2026-05-26T12:03:00.000Z', 5, 15)}` + ) + + const result = await scanCodexUsageFiles([], []) + + expect( + result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(19) + expect( + result.dailyAggregates.reduce((total, aggregate) => total + aggregate.eventCount, 0) + ).toBe(3) + }) +}) diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index d7251b684ea..d060d6c5b2e 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -1,4 +1,15 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + import { attributeCodexUsageEvent, parseCodexUsageRecord } from './scanner' describe('parseCodexUsageRecord', () => { diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index 130336204db..aeccbe791c4 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -1,11 +1,12 @@ /* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */ -import { homedir } from 'os' import { basename, join, win32, posix } from 'path' import { createReadStream } from 'fs' import { realpath, readdir, stat } from 'fs/promises' import { createInterface } from 'readline' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge' import type { CodexUsageAttributedEvent, CodexUsageDailyAggregate, @@ -45,13 +46,13 @@ type CodexUsageParseContext = { currentCwd: string | null currentModel: string | null previousTotals: CodexUsageRawUsage | null + totalOnlyBaselinePending?: boolean } type CodexUsageDeltaResolution = | { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null } | { kind: 'baseline'; nextTotals: CodexUsageRawUsage } -const DEFAULT_CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions') const YIELD_EVERY_FILES = 10 function ensureNumber(value: unknown): number { @@ -108,19 +109,93 @@ async function walkJsonlFiles(dirPath: string): Promise { } export function getCodexSessionsDirectory(): string { - const codexHome = process.env.CODEX_HOME?.trim() - if (codexHome) { - return join(codexHome, 'sessions') - } - return DEFAULT_CODEX_SESSIONS_DIR + // Why: Orca-launched Codex processes receive an Orca-owned CODEX_HOME, so + // callers that need the primary runtime path should not consult ambient + // shell CODEX_HOME. + return join(getOrcaManagedCodexHomePath(), 'sessions') +} + +export function getCodexSessionDirectories(): string[] { + // Why: upgraded users still have ordinary Codex history under ~/.codex, while + // new Orca-launched sessions are written under Orca's managed runtime home. + return [getCodexSessionsDirectory(), join(getSystemCodexHomePath(), 'sessions')].filter( + (dirPath, index, allDirPaths) => allDirPaths.indexOf(dirPath) === index + ) } export async function listCodexSessionFiles(): Promise { - try { - return (await walkJsonlFiles(getCodexSessionsDirectory())).sort() - } catch { - return [] + const files: string[] = [] + for (const dirPath of getCodexSessionDirectories()) { + try { + files.push(...(await walkJsonlFiles(dirPath))) + } catch { + // Missing or unreadable history in one home should not hide the other. + } } + return dedupeCodexSessionFileAliases(files) +} + +async function dedupeCodexSessionFileAliases(files: string[]): Promise { + const excludedAliases = new Set() + for (const filePath of files) { + const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) + if (!legacyCopyBridge) { + continue + } + if (legacyCopyBridge.sourceSkipBytes !== null) { + continue + } + excludedAliases.add( + await getPhysicalFileAliasKey( + legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath + ) + ) + } + + const seenAliases = new Set() + const uniqueFiles: string[] = [] + for (const filePath of [...new Set(files)].sort()) { + const aliasKey = await getCodexSessionFileAliasKey(filePath) + if (excludedAliases.has(aliasKey)) { + continue + } + if (seenAliases.has(aliasKey)) { + continue + } + seenAliases.add(aliasKey) + uniqueFiles.push(filePath) + } + return uniqueFiles +} + +async function getCodexSessionFileAliasKey(filePath: string): Promise { + return getPhysicalFileAliasKey(filePath) +} + +async function getPhysicalFileAliasKey(filePath: string): Promise { + try { + const fileStat = await stat(filePath) + if (fileStat.ino !== 0) { + return `${fileStat.dev}:${fileStat.ino}` + } + } catch {} + return `path:${await canonicalizePath(filePath)}` +} + +function getLegacySourceSkipBytesByPath(files: string[]): Map { + const sourceSkipBytesByPath = new Map() + for (const filePath of files) { + const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) + if (!legacyCopyBridge || legacyCopyBridge.sourceSkipBytes === null) { + continue + } + const existing = sourceSkipBytesByPath.get(legacyCopyBridge.sourcePath) ?? 0 + sourceSkipBytesByPath.set( + legacyCopyBridge.sourcePath, + Math.max(existing, legacyCopyBridge.sourceSkipBytes) + ) + } + return sourceSkipBytesByPath } export async function getProcessedFileInfo(filePath: string): Promise { @@ -792,6 +867,13 @@ export function parseCodexUsageRecord( const record = info as Record const totalUsage = normalizeRawUsage(record.total_token_usage) const lastUsage = normalizeRawUsage(record.last_token_usage) + if (context.totalOnlyBaselinePending) { + context.totalOnlyBaselinePending = false + if (totalUsage && !lastUsage && !context.previousTotals) { + context.previousTotals = totalUsage + return null + } + } const resolvedUsage = resolveCodexUsageDelta(totalUsage, lastUsage, context.previousTotals) if (!resolvedUsage) { return null @@ -840,11 +922,15 @@ export function parseCodexUsageRecord( export async function parseCodexUsageFile( filePath: string, - worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[] + worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[], + options: { skipInitialBytes?: number } = {} ): Promise { const processedFile = await getProcessedFileInfo(filePath) const lines = createInterface({ - input: createReadStream(filePath, { encoding: 'utf-8' }), + input: createReadStream(filePath, { + encoding: 'utf-8', + start: options.skipInitialBytes ?? 0 + }), crlfDelay: Infinity }) const events: CodexUsageAttributedEvent[] = [] @@ -853,7 +939,10 @@ export async function parseCodexUsageFile( sessionCwd: null, currentCwd: null, currentModel: null, - previousTotals: null + previousTotals: null, + // Why: suffix-only legacy copy parsing lacks the copied prefix context. A + // leading total-only snapshot is a baseline, not the suffix's billable delta. + totalOnlyBaselinePending: (options.skipInitialBytes ?? 0) > 0 } for await (const line of lines) { @@ -885,18 +974,25 @@ export async function scanCodexUsageFiles( const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file])) const processedFiles: CodexUsagePersistedFile[] = [] const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees) + const legacySourceSkipBytesByPath = getLegacySourceSkipBytesByPath(files) const sessionsById = new Map() const dailyByKey = new Map() for (const [index, filePath] of files.entries()) { + const legacySourceSkipBytes = legacySourceSkipBytesByPath.get(filePath) ?? 0 const fileInfo = await getProcessedFileInfo(filePath) const previous = previousByPath.get(filePath) const canReuse = - previous && previous.mtimeMs === fileInfo.mtimeMs && previous.size === fileInfo.size + legacySourceSkipBytes === 0 && + previous && + previous.mtimeMs === fileInfo.mtimeMs && + previous.size === fileInfo.size const processed = canReuse ? previous - : await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths) + : await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, { + skipInitialBytes: legacySourceSkipBytes + }) processedFiles.push(processed) mergeSessions(sessionsById, processed.sessions) diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts new file mode 100644 index 00000000000..17865439a5d --- /dev/null +++ b/src/main/codex/codex-config-mirror.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as NodeOs from 'node:os' +import { join } from 'node:path' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: homedirMock + } +}) + +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function getSystemCodexHomePath(): string { + return join(fakeHomeDir, '.codex') +} + +function getSystemConfigPath(): string { + return join(getSystemCodexHomePath(), 'config.toml') +} + +function getRuntimeConfigPath(): string { + return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml') +} + +beforeEach(() => { + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + mkdirSync(getSystemCodexHomePath(), { recursive: true }) +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('syncSystemConfigIntoManagedCodexHome', () => { + it('seeds a missing runtime config without copying system hook trust', () => { + writeFileSync( + getSystemConfigPath(), + [ + 'model = "system-model"', + '', + '[hooks.state."system-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:system"', + '', + '[projects."/repo"]', + 'trust_level = "trusted"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).toContain('[projects."/repo"]') + expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + }) + + it('normalizes deprecated codex_hooks feature flag only in runtime config', () => { + writeFileSync( + getSystemConfigPath(), + ['model = "system-model"', '', '[features]', 'codex_hooks = true', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('[features]\nhooks = true') + expect(runtimeConfig).not.toContain('codex_hooks') + expect(readFileSync(getSystemConfigPath(), 'utf-8')).toContain('codex_hooks = true') + }) + + it('drops deprecated codex_hooks when the new hooks flag already exists', () => { + writeFileSync( + getSystemConfigPath(), + ['[features]', 'hooks = true', 'codex_hooks = true', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('[features]\nhooks = true') + expect(runtimeConfig).not.toContain('codex_hooks') + }) + + it('mirrors system config updates while preserving runtime-owned trust sections', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + 'model = "runtime-model"', + '', + '[hooks.state."runtime-hooks:stop:0:0"]', + 'enabled = false', + 'trusted_hash = "sha256:runtime"', + '', + '[projects."/repo"]', + 'trust_level = "trusted"', + '', + '[projects."/runtime-only"]', + 'trust_level = "trusted"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [ + 'model = "system-model"', + '', + '[projects."/repo"] # explicit revocation', + 'trust_level = "untrusted"', + '', + '[projects."/system-only"]', + 'trust_level = "trusted"', + '', + '[hooks.state."system-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:system"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).not.toContain('model = "runtime-model"') + expect(runtimeConfig).toContain('[projects."/repo"]') + expect(runtimeConfig).toContain('[projects."/runtime-only"]') + expect(runtimeConfig).toContain('[projects."/system-only"]') + expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') + expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1) + }) + + it('does not treat TOML table headers inside multiline strings as sections', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + '[hooks.state."runtime-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:runtime"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [ + 'instructions = """', + '[hooks.state."inside-basic-string"]', + 'trusted_hash = "not-a-section"', + '"""', + '', + "literal_instructions = '''", + '[hooks.state."inside-literal-string"]', + "'''", + '', + '[model_providers.openai]', + 'name = "OpenAI"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('[hooks.state."inside-basic-string"]') + expect(runtimeConfig).toContain('[hooks.state."inside-literal-string"]') + expect(runtimeConfig).toContain('[model_providers.openai]') + expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') + }) + + it('does not let triple quotes in comments affect runtime-owned section mirroring', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + '# example: """ in a comment', + "# example: ''' in a comment", + 'model = "runtime-model"', + '', + '[hooks.state."runtime-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:runtime"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [ + '# system example: """ in a comment', + "# system example: ''' in a comment", + 'model = "system-model"', + '', + '[hooks.state."system-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:system"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') + expect(runtimeConfig).toContain('trusted_hash = "sha256:runtime"') + expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + expect(runtimeConfig).not.toContain('trusted_hash = "sha256:system"') + }) + + it('does not create a runtime config when neither system nor runtime config exists', () => { + syncSystemConfigIntoManagedCodexHome() + + expect(existsSync(getRuntimeConfigPath())).toBe(false) + }) +}) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts new file mode 100644 index 00000000000..3e35511bfa2 --- /dev/null +++ b/src/main/codex/codex-config-mirror.ts @@ -0,0 +1,327 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' + +function getRuntimeCodexConfigTomlPath(): string { + return join(getOrcaManagedCodexHomePath(), 'config.toml') +} + +function getSystemCodexConfigTomlPath(): string { + return join(getSystemCodexHomePath(), 'config.toml') +} + +export function syncSystemConfigIntoManagedCodexHome(): void { + try { + syncSystemConfigIntoManagedCodexHomeUnsafe() + } catch (error) { + console.warn('[codex-config] Failed to mirror system Codex config:', error) + } +} + +function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { + const systemConfigPath = getSystemCodexConfigTomlPath() + const runtimeConfigPath = getRuntimeCodexConfigTomlPath() + const systemConfigExists = existsSync(systemConfigPath) + const runtimeConfigExists = existsSync(runtimeConfigPath) + if (!systemConfigExists && !runtimeConfigExists) { + return + } + + const systemConfig = normalizeDeprecatedCodexHookFeatureFlag( + systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '' + ) + if (!runtimeConfigExists) { + // Why: trust blocks reference a hooks.json path, so system-home hook trust + // entries are not valid in Orca's runtime CODEX_HOME until install remaps them. + writeFileAtomically(runtimeConfigPath, stripRuntimeOwnedTomlSections(systemConfig)) + return + } + + const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') + const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig) + if (mergedConfig !== runtimeConfig) { + writeFileAtomically(runtimeConfigPath, mergedConfig) + } +} + +function normalizeDeprecatedCodexHookFeatureFlag(config: string): string { + if (!config.includes('codex_hooks')) { + return config + } + + const lines = config.split('\n') + const featureSections: { start: number; end: number }[] = [] + let featureStart: number | null = null + + for (let index = 0; index <= lines.length; index += 1) { + const line = lines[index] + const isHeader = line === undefined || /^[ \t]*\[[^\]]+\][ \t]*(?:#.*)?$/.test(line) + if (!isHeader) { + continue + } + + if (featureStart !== null) { + featureSections.push({ start: featureStart, end: index }) + featureStart = null + } + if (line !== undefined && /^[ \t]*\[features\][ \t]*(?:#.*)?$/.test(line)) { + featureStart = index + } + } + + for (const section of featureSections.reverse()) { + normalizeFeatureSectionLines(lines, section.start + 1, section.end) + } + return lines.join('\n') +} + +function normalizeFeatureSectionLines(lines: string[], start: number, end: number): void { + const deprecatedIndexes: number[] = [] + let hasHooksKey = false + for (let index = start; index < end; index += 1) { + const line = lines[index] ?? '' + if (/^[ \t]*hooks[ \t]*=/.test(line)) { + hasHooksKey = true + } + if (/^[ \t]*codex_hooks[ \t]*=/.test(line)) { + deprecatedIndexes.push(index) + } + } + if (deprecatedIndexes.length === 0) { + return + } + + if (!hasHooksKey) { + const firstDeprecatedIndex = deprecatedIndexes.shift() + if (firstDeprecatedIndex !== undefined) { + // Why: Codex 0.133 warns on the old key. Mirror into Orca's runtime + // config using the new key without rewriting the user's real config. + lines[firstDeprecatedIndex] = lines[firstDeprecatedIndex]!.replace( + /^([ \t]*)codex_hooks([ \t]*=)/, + '$1hooks$2' + ) + } + } + + for (const index of deprecatedIndexes.reverse()) { + lines.splice(index, 1) + } +} + +function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string { + const runtimeSections = getTomlSections(runtimeConfig) + const runtimeProjectHeaders = new Set( + runtimeSections + .filter((section) => isRuntimeProjectTomlSection(section.header)) + .map((section) => getTomlSectionHeaderKey(section.header)) + ) + const systemUntrustedProjectHeaders = new Set( + getTomlSections(systemConfig) + .filter((section) => isRuntimeProjectTomlSection(section.header)) + .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') + .map((section) => getTomlSectionHeaderKey(section.header)) + ) + // Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook + // trust and project trust are written under Orca's managed CODEX_HOME and + // must survive the copy unless the user explicitly revoked project trust in + // the system config. + return joinTomlBlocks([ + stripRuntimeOwnedTomlSections(systemConfig, runtimeProjectHeaders), + ...runtimeSections + .filter((section) => isRuntimePreservedTomlSection(section.header)) + .filter( + (section) => + !isRuntimeProjectTomlSection(section.header) || + !systemUntrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) + ) + .map((section) => section.block) + ]) +} + +type TomlSection = { + header: string + block: string + start: number +} + +type TomlMultilineState = { + basic: boolean + literal: boolean +} + +type TomlMultilineMode = 'basic' | 'literal' | null + +function stripRuntimeOwnedTomlSections( + config: string, + runtimeProjectHeaders = new Set() +): string { + const lines = config.split('\n') + const sections = getTomlSections(config) + const firstSectionIndex = sections[0]?.start ?? -1 + const preamble = firstSectionIndex === -1 ? config : lines.slice(0, firstSectionIndex).join('\n') + return joinTomlBlocks([ + preamble, + ...sections + .filter((section) => !isRuntimeHookTrustTomlSection(section.header)) + .filter( + (section) => + !isRuntimeProjectTomlSection(section.header) || + !runtimeProjectHeaders.has(getTomlSectionHeaderKey(section.header)) || + getProjectTrustLevel(section.block) === 'untrusted' + ) + .map((section) => section.block) + ]) +} + +function getTomlSections(config: string): TomlSection[] { + const lines = config.split('\n') + const sections: TomlSection[] = [] + let sectionStart = -1 + let sectionHeader: string | null = null + let multilineState: TomlMultilineState = { basic: false, literal: false } + + for (let index = 0; index < lines.length; index += 1) { + const header = isInsideTomlMultilineString(multilineState) + ? null + : getTomlTableHeader(lines[index] ?? '') + if (!header) { + multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '') + continue + } + + if (sectionStart !== -1) { + sections.push({ + header: sectionHeader ?? '', + block: lines.slice(sectionStart, index).join('\n'), + start: sectionStart + }) + } + sectionStart = index + sectionHeader = header + multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '') + } + + if (sectionStart !== -1) { + sections.push({ + header: sectionHeader ?? '', + block: lines.slice(sectionStart).join('\n'), + start: sectionStart + }) + } + return sections +} + +function isRuntimePreservedTomlSection(header: string): boolean { + return isRuntimeHookTrustTomlSection(header) || isRuntimeProjectTomlSection(header) +} + +function isRuntimeHookTrustTomlSection(header: string): boolean { + return header.trimStart().startsWith('[hooks.state.') +} + +function isRuntimeProjectTomlSection(header: string): boolean { + return header.trimStart().startsWith('[projects.') +} + +function getTomlSectionHeaderKey(header: string): string { + return header.trim() +} + +function getProjectTrustLevel(block: string): 'trusted' | 'untrusted' | null { + const match = + /^[ \t]*trust_level[ \t]*=[ \t]*(?:"(trusted|untrusted)"|'(trusted|untrusted)')[ \t\r]*(?:#.*)?$/m.exec( + block + ) + const trustLevel = match?.[1] ?? match?.[2] ?? null + return trustLevel === 'trusted' || trustLevel === 'untrusted' ? trustLevel : null +} + +function joinTomlBlocks(blocks: string[]): string { + const normalizedBlocks = blocks.map((block) => block.trim()).filter((block) => block.length > 0) + return normalizedBlocks.length === 0 ? '' : `${normalizedBlocks.join('\n\n')}\n` +} + +function getTomlTableHeader(line: string): string | null { + const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line) + return match?.[1] ?? null +} + +function isInsideTomlMultilineString(state: TomlMultilineState): boolean { + return state.basic || state.literal +} + +function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState { + let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null + let index = 0 + while (index < line.length) { + if (mode === 'basic') { + if (line[index] === '\\') { + index += 2 + continue + } + if (line.startsWith('"""', index)) { + mode = null + index += 3 + continue + } + index += 1 + continue + } + if (mode === 'literal') { + if (line.startsWith("'''", index)) { + mode = null + index += 3 + continue + } + index += 1 + continue + } + + const char = line[index] + if (char === '#') { + break + } + if (line.startsWith('"""', index)) { + mode = 'basic' + index += 3 + continue + } + if (line.startsWith("'''", index)) { + mode = 'literal' + index += 3 + continue + } + if (char === '"') { + index = skipTomlBasicString(line, index + 1) + continue + } + if (char === "'") { + index = skipTomlLiteralString(line, index + 1) + continue + } + index += 1 + } + return { basic: mode === 'basic', literal: mode === 'literal' } +} + +function skipTomlBasicString(line: string, startIndex: number): number { + let index = startIndex + while (index < line.length) { + const char = line[index] + if (char === '\\') { + index += 2 + continue + } + if (char === '"') { + return index + 1 + } + index += 1 + } + return index +} + +function skipTomlLiteralString(line: string, startIndex: number): number { + const endIndex = line.indexOf("'", startIndex) + return endIndex === -1 ? line.length : endIndex + 1 +} diff --git a/src/main/codex/codex-home-paths.test.ts b/src/main/codex/codex-home-paths.test.ts new file mode 100644 index 00000000000..139e06cae66 --- /dev/null +++ b/src/main/codex/codex-home-paths.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { join } from 'node:path' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +const { fsMockState } = vi.hoisted(() => ({ + fsMockState: { failSymlink: false } +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + symlinkSync: (...args: Parameters) => { + if (fsMockState.failSymlink) { + throw new Error('symlink disabled for test') + } + return actual.symlinkSync(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: homedirMock + } +}) + +import { syncSystemCodexResourcesIntoManagedHome } from './codex-home-paths' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function getSystemCodexHomePath(): string { + return join(fakeHomeDir, '.codex') +} + +function getRuntimeCodexHomePath(): string { + return join(userDataDir, 'codex-runtime-home', 'home') +} + +function normalizeLinkTarget(linkTarget: string): string { + return process.platform === 'win32' + ? linkTarget.replace(/^\\\\\?\\/, '').toLowerCase() + : linkTarget +} + +function expectSymbolicLinkTargetIfLinked(targetPath: string, sourcePath: string): void { + if (!lstatSync(targetPath).isSymbolicLink()) { + return + } + expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath)) +} + +function mockElectronAppPaths(): void { + vi.doMock('electron', () => ({ + app: { + getPath: getPathMock + } + })) +} + +beforeEach(() => { + mockElectronAppPaths() + fsMockState.failSymlink = false + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-resource-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-resource-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + mkdirSync(getSystemCodexHomePath(), { recursive: true }) +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('syncSystemCodexResourcesIntoManagedHome', () => { + it('uses ORCA_USER_DATA_PATH when Electron cannot be required', async () => { + vi.resetModules() + vi.doMock('electron', () => { + throw new Error('electron unavailable in packaged CLI') + }) + const previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + try { + const { getOrcaManagedCodexHomePath: getCliSafeManagedPath } = + await import('./codex-home-paths') + + expect(getCliSafeManagedPath()).toBe(join(userDataDir, 'codex-runtime-home', 'home')) + } finally { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + mockElectronAppPaths() + vi.resetModules() + } + }) + + it('mirrors only user resource entries into the managed runtime home', () => { + mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true }) + mkdirSync(join(getSystemCodexHomePath(), 'plugins'), { recursive: true }) + mkdirSync(join(getSystemCodexHomePath(), 'sessions'), { recursive: true }) + writeFileSync(join(getSystemCodexHomePath(), 'skills', 'review', 'SKILL.md'), 'skill\n') + writeFileSync(join(getSystemCodexHomePath(), 'plugins', 'plugin.json'), '{}\n') + writeFileSync(join(getSystemCodexHomePath(), 'auth.json'), '{"account":"system"}\n') + writeFileSync(join(getSystemCodexHomePath(), 'hooks.json'), '{"hooks":{}}\n') + writeFileSync(join(getSystemCodexHomePath(), 'history.jsonl'), '{}\n') + + syncSystemCodexResourcesIntoManagedHome() + + const runtimeSkillsPath = join(getRuntimeCodexHomePath(), 'skills') + const runtimePluginsPath = join(getRuntimeCodexHomePath(), 'plugins') + expect(readFileSync(join(runtimeSkillsPath, 'review', 'SKILL.md'), 'utf-8')).toBe('skill\n') + expect(readFileSync(join(runtimePluginsPath, 'plugin.json'), 'utf-8')).toBe('{}\n') + expectSymbolicLinkTargetIfLinked(runtimeSkillsPath, join(getSystemCodexHomePath(), 'skills')) + expect(existsSync(join(getRuntimeCodexHomePath(), 'sessions'))).toBe(false) + expect(existsSync(join(getRuntimeCodexHomePath(), 'auth.json'))).toBe(false) + expect(existsSync(join(getRuntimeCodexHomePath(), 'hooks.json'))).toBe(false) + expect(existsSync(join(getRuntimeCodexHomePath(), 'history.jsonl'))).toBe(false) + }) + + it('does not replace an existing runtime-owned resource entry', () => { + mkdirSync(join(getSystemCodexHomePath(), 'skills'), { recursive: true }) + mkdirSync(join(getRuntimeCodexHomePath(), 'skills'), { recursive: true }) + writeFileSync(join(getSystemCodexHomePath(), 'skills', 'system.md'), 'system\n') + writeFileSync(join(getRuntimeCodexHomePath(), 'skills', 'runtime.md'), 'runtime\n') + + syncSystemCodexResourcesIntoManagedHome() + + const runtimeSkillsPath = join(getRuntimeCodexHomePath(), 'skills') + expect(lstatSync(runtimeSkillsPath).isSymbolicLink()).toBe(false) + expect(readFileSync(join(runtimeSkillsPath, 'runtime.md'), 'utf-8')).toBe('runtime\n') + expect(existsSync(join(runtimeSkillsPath, 'system.md'))).toBe(false) + }) + + it('removes owned symlinks for deleted system resources without touching unrelated runtime links', () => { + const systemSkillsPath = join(getSystemCodexHomePath(), 'skills') + const runtimeSkillsPath = join(getRuntimeCodexHomePath(), 'skills') + const externalPluginsPath = join(userDataDir, 'external-plugins') + const runtimePluginsPath = join(getRuntimeCodexHomePath(), 'plugins') + mkdirSync(systemSkillsPath, { recursive: true }) + mkdirSync(externalPluginsPath, { recursive: true }) + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(join(systemSkillsPath, 'system.md'), 'system\n') + writeFileSync(join(externalPluginsPath, 'runtime.md'), 'runtime\n') + symlinkSync( + externalPluginsPath, + runtimePluginsPath, + process.platform === 'win32' ? 'junction' : undefined + ) + + syncSystemCodexResourcesIntoManagedHome() + expect(lstatSync(runtimeSkillsPath).isSymbolicLink()).toBe(true) + expectSymbolicLinkTargetIfLinked(runtimeSkillsPath, systemSkillsPath) + + rmSync(systemSkillsPath, { recursive: true, force: true }) + syncSystemCodexResourcesIntoManagedHome() + + expect(() => lstatSync(runtimeSkillsPath)).toThrow() + expect(lstatSync(runtimePluginsPath).isSymbolicLink()).toBe(true) + expectSymbolicLinkTargetIfLinked(runtimePluginsPath, externalPluginsPath) + expect(readFileSync(join(runtimePluginsPath, 'runtime.md'), 'utf-8')).toBe('runtime\n') + }) + + it('refreshes owned fallback copies when symlinks are unavailable', () => { + fsMockState.failSymlink = true + const systemProfilePath = join(getSystemCodexHomePath(), 'profile-v2') + const runtimeProfilePath = join(getRuntimeCodexHomePath(), 'profile-v2') + writeFileSync(systemProfilePath, 'first\n', 'utf-8') + + syncSystemCodexResourcesIntoManagedHome() + writeFileSync(systemProfilePath, 'second\n', 'utf-8') + syncSystemCodexResourcesIntoManagedHome() + + expect(lstatSync(runtimeProfilePath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeProfilePath, 'utf-8')).toBe('second\n') + }) +}) diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts new file mode 100644 index 00000000000..80e039d753c --- /dev/null +++ b/src/main/codex/codex-home-paths.ts @@ -0,0 +1,229 @@ +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + rmdirSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +const CODEX_SYSTEM_RESOURCE_ENTRIES = [ + 'skills', + 'plugins', + 'plugin-state', + 'profile-v2', + 'themes', + 'prompts' +] as const + +export function getSystemCodexHomePath(): string { + return join(homedir(), '.codex') +} + +export function getOrcaManagedCodexHomePath(): string { + const managedHomePath = join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') + mkdirSync(managedHomePath, { recursive: true }) + return managedHomePath +} + +function getOrcaUserDataPath(): string { + if (process.env.ORCA_USER_DATA_PATH) { + return process.env.ORCA_USER_DATA_PATH + } + // Why: CLI hook commands import this module outside Electron. Mirror the CLI + // runtime metadata path so offline hook status/on/off uses the same userData. + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'orca') + } + if (process.platform === 'win32') { + return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'orca') + } + return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'orca') +} + +export function syncSystemCodexResourcesIntoManagedHome(): void { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = getOrcaManagedCodexHomePath() + for (const entryName of CODEX_SYSTEM_RESOURCE_ENTRIES) { + linkSystemCodexResource(systemHomePath, managedHomePath, entryName) + } +} + +function linkSystemCodexResource( + systemHomePath: string, + managedHomePath: string, + entryName: string +): void { + const sourcePath = join(systemHomePath, entryName) + const targetPath = join(managedHomePath, entryName) + if (!existsSync(sourcePath)) { + removeCopiedResourceIfOwned(targetPath, managedHomePath, entryName, sourcePath) + return + } + + if (targetAlreadyPointsToSource(targetPath, sourcePath)) { + clearCopiedResourceMarker(managedHomePath, entryName) + return + } + const shouldRefreshFallbackCopy = targetIsOwnedFallbackCopy( + targetPath, + managedHomePath, + entryName, + sourcePath + ) + if (existsSync(targetPath) && !shouldRefreshFallbackCopy) { + return + } + if (shouldRefreshFallbackCopy) { + rmSync(targetPath, { recursive: true, force: true }) + } + + try { + const sourceStat = lstatSync(sourcePath) + symlinkSync( + sourcePath, + targetPath, + sourceStat.isDirectory() && process.platform === 'win32' ? 'junction' : undefined + ) + clearCopiedResourceMarker(managedHomePath, entryName) + } catch (error) { + try { + rmSync(targetPath, { recursive: true, force: true }) + // Why: Windows can reject file symlinks outside developer mode. Copy is + // a fallback for launch-time resources; mark ownership so later syncs can + // refresh the copy without touching user-created runtime resources. + cpSync(sourcePath, targetPath, { recursive: true, force: false, errorOnExist: true }) + markCopiedResource(managedHomePath, entryName, sourcePath) + } catch { + console.warn('[codex-home] Failed to link system Codex resource:', entryName, error) + } + } +} + +function targetAlreadyPointsToSource(targetPath: string, sourcePath: string): boolean { + try { + return ( + lstatSync(targetPath).isSymbolicLink() && + linkTargetsMatch(readlinkSync(targetPath), sourcePath) + ) + } catch { + return false + } +} + +function linkTargetsMatch(actualTarget: string, expectedTarget: string): boolean { + if (process.platform !== 'win32') { + return actualTarget === expectedTarget + } + return normalizeWindowsLinkTarget(actualTarget) === normalizeWindowsLinkTarget(expectedTarget) +} + +function normalizeWindowsLinkTarget(linkTarget: string): string { + return linkTarget.replace(/^\\\\\?\\/, '').toLowerCase() +} + +function getResourceCopyMarkerPath(managedHomePath: string, entryName: string): string { + return join(managedHomePath, '.orca-resource-copies', `${entryName}.json`) +} + +function markCopiedResource(managedHomePath: string, entryName: string, sourcePath: string): void { + const markerPath = getResourceCopyMarkerPath(managedHomePath, entryName) + mkdirSync(dirname(markerPath), { recursive: true }) + writeFileSync(markerPath, `${JSON.stringify({ sourcePath }, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) +} + +function readCopiedResourceSourcePath(managedHomePath: string, entryName: string): string | null { + try { + const parsed: unknown = JSON.parse( + readFileSync(getResourceCopyMarkerPath(managedHomePath, entryName), 'utf-8') + ) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const sourcePath = 'sourcePath' in parsed ? parsed.sourcePath : null + return typeof sourcePath === 'string' ? sourcePath : null + } catch { + return null + } +} + +function clearCopiedResourceMarker(managedHomePath: string, entryName: string): void { + rmSync(getResourceCopyMarkerPath(managedHomePath, entryName), { force: true }) +} + +function targetIsOwnedFallbackCopy( + targetPath: string, + managedHomePath: string, + entryName: string, + sourcePath: string +): boolean { + if (readCopiedResourceSourcePath(managedHomePath, entryName) !== sourcePath) { + return false + } + try { + return existsSync(targetPath) && !lstatSync(targetPath).isSymbolicLink() + } catch { + return false + } +} + +function removeCopiedResourceIfOwned( + targetPath: string, + managedHomePath: string, + entryName: string, + sourcePath: string +): void { + if (removeSymlinkedResourceIfOwned(targetPath, sourcePath)) { + clearCopiedResourceMarker(managedHomePath, entryName) + return + } + if (!targetIsOwnedFallbackCopy(targetPath, managedHomePath, entryName, sourcePath)) { + return + } + rmSync(targetPath, { recursive: true, force: true }) + clearCopiedResourceMarker(managedHomePath, entryName) +} + +function removeSymlinkedResourceIfOwned(targetPath: string, sourcePath: string): boolean { + try { + if (!lstatSync(targetPath).isSymbolicLink()) { + return false + } + if (!linkTargetsMatch(readlinkSync(targetPath), sourcePath)) { + return false + } + return removeSymlinkEntry(targetPath) + } catch { + return false + } +} + +function removeSymlinkEntry(targetPath: string): boolean { + try { + // Why: recursive rm can leave a broken directory symlink behind; unlink the + // link entry itself so deleted system resources do not linger in runtime home. + unlinkSync(targetPath) + return true + } catch { + if (process.platform !== 'win32') { + return false + } + } + + try { + rmdirSync(targetPath) + return true + } catch { + return false + } +} diff --git a/src/main/codex/codex-session-bridge.test.ts b/src/main/codex/codex-session-bridge.test.ts new file mode 100644 index 00000000000..3ee5ebec22e --- /dev/null +++ b/src/main/codex/codex-session-bridge.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +const { fsMockState } = vi.hoisted(() => ({ + fsMockState: { failLink: false, failSymlink: false } +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + linkSync: (...args: Parameters) => { + if (fsMockState.failLink) { + throw new Error('hardlink disabled for test') + } + return actual.linkSync(...args) + }, + symlinkSync: (...args: Parameters) => { + if (fsMockState.failSymlink) { + throw new Error('symlink disabled for test') + } + return actual.symlinkSync(...args) + } + } +}) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: homedirMock + } +}) + +import { syncSystemCodexSessionsIntoManagedHome } from './codex-session-bridge' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function getSystemCodexHomePath(): string { + return join(fakeHomeDir, '.codex') +} + +function getRuntimeCodexHomePath(): string { + return join(userDataDir, 'codex-runtime-home', 'home') +} + +function normalizeLinkTarget(linkTarget: string): string { + return process.platform === 'win32' + ? linkTarget.replace(/^\\\\\?\\/, '').toLowerCase() + : linkTarget +} + +function expectResourceLinked(targetPath: string, sourcePath: string): void { + if (lstatSync(targetPath).isSymbolicLink()) { + expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath)) + return + } + expect(lstatSync(targetPath).ino).toBe(lstatSync(sourcePath).ino) +} + +function writeLegacyCopyMarker(relativePath: string, sourcePath: string, targetPath: string): void { + const sourceStat = lstatSync(sourcePath) + const targetStat = lstatSync(targetPath) + const markerPath = join(getRuntimeCodexHomePath(), '.orca-session-copies', `${relativePath}.json`) + mkdirSync(dirname(markerPath), { recursive: true }) + writeFileSync( + markerPath, + `${JSON.stringify( + { + sourcePath, + sourceSize: sourceStat.size, + sourceMtimeMs: sourceStat.mtimeMs, + targetSize: targetStat.size, + targetMtimeMs: targetStat.mtimeMs + }, + null, + 2 + )}\n`, + 'utf-8' + ) +} + +beforeEach(() => { + fsMockState.failLink = false + fsMockState.failSymlink = false + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-session-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-session-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + mkdirSync(getSystemCodexHomePath(), { recursive: true }) +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('syncSystemCodexSessionsIntoManagedHome', () => { + it('bridges system Codex session jsonl files into the managed runtime home', () => { + const systemSessionPath = join( + getSystemCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-old.jsonl' + ) + mkdirSync(dirname(systemSessionPath), { recursive: true }) + writeFileSync(systemSessionPath, '{"type":"session_meta","id":"old"}\n', 'utf-8') + writeFileSync( + join(getSystemCodexHomePath(), 'sessions', '2026', '05', '26', 'scratch.txt'), + 'not a session\n', + 'utf-8' + ) + + syncSystemCodexSessionsIntoManagedHome() + + const runtimeSessionPath = join( + getRuntimeCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-old.jsonl' + ) + expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"type":"session_meta","id":"old"}\n') + expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) + expectResourceLinked(runtimeSessionPath, systemSessionPath) + expect( + existsSync(join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'scratch.txt')) + ).toBe(false) + }) + + it('falls back to symlinks when hardlinks are unavailable', () => { + fsMockState.failLink = true + const systemSessionPath = join( + getSystemCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-symlink-fallback.jsonl' + ) + mkdirSync(dirname(systemSessionPath), { recursive: true }) + writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8') + + syncSystemCodexSessionsIntoManagedHome() + + const runtimeSessionPath = join( + getRuntimeCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-symlink-fallback.jsonl' + ) + expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(true) + expect(normalizeLinkTarget(readlinkSync(runtimeSessionPath))).toBe( + normalizeLinkTarget(systemSessionPath) + ) + }) + + it('does not overwrite runtime-owned session files', () => { + const relativeSessionPath = join('sessions', '2026', '05', '26', 'rollout-conflict.jsonl') + const systemSessionPath = join(getSystemCodexHomePath(), relativeSessionPath) + const runtimeSessionPath = join(getRuntimeCodexHomePath(), relativeSessionPath) + mkdirSync(dirname(systemSessionPath), { recursive: true }) + mkdirSync(dirname(runtimeSessionPath), { recursive: true }) + writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8') + writeFileSync(runtimeSessionPath, '{"id":"runtime"}\n', 'utf-8') + + syncSystemCodexSessionsIntoManagedHome() + + expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"runtime"}\n') + }) + + it('replaces existing symlink bridges with hardlinks', () => { + const relativeSessionPath = join('sessions', '2026', '05', '26', 'rollout-symlink.jsonl') + const systemSessionPath = join(getSystemCodexHomePath(), relativeSessionPath) + const runtimeSessionPath = join(getRuntimeCodexHomePath(), relativeSessionPath) + mkdirSync(dirname(systemSessionPath), { recursive: true }) + mkdirSync(dirname(runtimeSessionPath), { recursive: true }) + writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8') + symlinkSync( + systemSessionPath, + runtimeSessionPath, + process.platform === 'win32' ? 'file' : undefined + ) + + syncSystemCodexSessionsIntoManagedHome() + + expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) + expectResourceLinked(runtimeSessionPath, systemSessionPath) + }) + + it('does not create independent session copies when file links are unavailable', () => { + fsMockState.failLink = true + fsMockState.failSymlink = true + const systemSessionPath = join( + getSystemCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-unlinked.jsonl' + ) + mkdirSync(dirname(systemSessionPath), { recursive: true }) + writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8') + + syncSystemCodexSessionsIntoManagedHome() + + expect( + existsSync( + join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-unlinked.jsonl') + ) + ).toBe(false) + }) + + it('replaces unchanged legacy copied sessions with links', () => { + const relativeSessionPath = join('2026', '05', '26', 'rollout-legacy.jsonl') + const systemSessionPath = join(getSystemCodexHomePath(), 'sessions', relativeSessionPath) + const runtimeSessionPath = join(getRuntimeCodexHomePath(), 'sessions', relativeSessionPath) + mkdirSync(dirname(systemSessionPath), { recursive: true }) + mkdirSync(dirname(runtimeSessionPath), { recursive: true }) + writeFileSync(systemSessionPath, '{"id":"legacy"}\n', 'utf-8') + writeFileSync(runtimeSessionPath, '{"id":"legacy"}\n', 'utf-8') + writeLegacyCopyMarker(relativeSessionPath, systemSessionPath, runtimeSessionPath) + + syncSystemCodexSessionsIntoManagedHome() + + expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"legacy"}\n') + expectResourceLinked(runtimeSessionPath, systemSessionPath) + }) + + it('preserves unchanged legacy copied sessions when relinking fails', () => { + const relativeSessionPath = join('2026', '05', '26', 'rollout-legacy-unlinked.jsonl') + const systemSessionPath = join(getSystemCodexHomePath(), 'sessions', relativeSessionPath) + const runtimeSessionPath = join(getRuntimeCodexHomePath(), 'sessions', relativeSessionPath) + mkdirSync(dirname(systemSessionPath), { recursive: true }) + mkdirSync(dirname(runtimeSessionPath), { recursive: true }) + writeFileSync(systemSessionPath, '{"id":"legacy"}\n', 'utf-8') + writeFileSync(runtimeSessionPath, '{"id":"legacy"}\n', 'utf-8') + writeLegacyCopyMarker(relativeSessionPath, systemSessionPath, runtimeSessionPath) + fsMockState.failLink = true + fsMockState.failSymlink = true + + syncSystemCodexSessionsIntoManagedHome() + + expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"legacy"}\n') + }) +}) diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts new file mode 100644 index 00000000000..c0a45b3a244 --- /dev/null +++ b/src/main/codex/codex-session-bridge.ts @@ -0,0 +1,269 @@ +import { + existsSync, + linkSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + renameSync, + rmSync, + symlinkSync +} from 'node:fs' +import { dirname, isAbsolute, join, relative, sep } from 'node:path' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' + +type LegacyCopiedSessionMarker = { + sourcePath: string + sourceSize: number + sourceMtimeMs: number + targetSize: number + targetMtimeMs: number +} + +export type LegacyCopiedCodexSessionBridgeScanPreference = { + sourcePath: string + preferManagedCopy: boolean + sourceSkipBytes: number | null +} + +export function syncSystemCodexSessionsIntoManagedHome(): void { + const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') + if (!existsSync(systemSessionsRoot)) { + return + } + + const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') + for (const systemSessionFilePath of listCodexSessionJsonlFiles(systemSessionsRoot)) { + const relativePath = relative(systemSessionsRoot, systemSessionFilePath) + const managedSessionFilePath = join(managedSessionsRoot, relativePath) + if (existsSync(managedSessionFilePath)) { + if ( + replaceSymlinkSessionBridgeWithHardlink( + systemSessionFilePath, + managedSessionFilePath, + relativePath + ) + ) { + continue + } + migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath) + continue + } + mkdirSync(dirname(managedSessionFilePath), { recursive: true }) + linkSystemCodexSessionFile(systemSessionFilePath, managedSessionFilePath, relativePath) + } +} + +function listCodexSessionJsonlFiles(rootPath: string): string[] { + const files: string[] = [] + try { + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const childPath = join(rootPath, entry.name) + if (entry.isDirectory()) { + files.push(...listCodexSessionJsonlFiles(childPath)) + continue + } + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(childPath) + } + } + } catch (error) { + console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) + } + return files.sort() +} + +function linkSystemCodexSessionFile( + sourcePath: string, + targetPath: string, + relativePath: string +): boolean { + const linked = tryLinkSystemCodexSessionFile(sourcePath, targetPath) + if (linked) { + clearLegacyCopiedSessionMarker(relativePath) + } + return linked +} + +function tryLinkSystemCodexSessionFile(sourcePath: string, targetPath: string): boolean { + if (tryHardlinkSystemCodexSessionFile(sourcePath, targetPath)) { + return true + } + try { + // Why fallback: hardlinks keep sessions visible to Codex resume, but can + // fail across volumes. A symlink is still better than a diverging copy. + symlinkSync(sourcePath, targetPath, process.platform === 'win32' ? 'file' : undefined) + return true + } catch (error) { + console.warn('[codex-session-bridge] Failed to link system Codex session:', sourcePath, error) + } + return false +} + +function tryHardlinkSystemCodexSessionFile(sourcePath: string, targetPath: string): boolean { + try { + // Why: Codex resume ignores symlinked JSONL sessions, while a hardlink + // preserves one physical log without copy divergence. + linkSync(sourcePath, targetPath) + return true + } catch { + return false + } +} + +function replaceSymlinkSessionBridgeWithHardlink( + sourcePath: string, + targetPath: string, + relativePath: string +): boolean { + let replacementPath: string | null = null + try { + const targetStat = lstatSync(targetPath) + if (!targetStat.isSymbolicLink()) { + return false + } + const linkTarget = readlinkSync(targetPath) + const absoluteLinkTarget = isAbsolute(linkTarget) + ? linkTarget + : join(dirname(targetPath), linkTarget) + if (absoluteLinkTarget !== sourcePath) { + return false + } + + replacementPath = `${targetPath}.orca-link-${process.pid}-${Date.now()}` + if (!tryHardlinkSystemCodexSessionFile(sourcePath, replacementPath)) { + return false + } + rmSync(targetPath, { force: true }) + renameSync(replacementPath, targetPath) + clearLegacyCopiedSessionMarker(relativePath) + return true + } catch (error) { + console.warn( + '[codex-session-bridge] Failed to replace symlinked Codex session bridge:', + sourcePath, + error + ) + if (replacementPath) { + rmSync(replacementPath, { force: true }) + } + } + return false +} + +function migrateLegacyCopiedSessionBridge( + sourcePath: string, + targetPath: string, + relativePath: string +): void { + const marker = readLegacyCopiedSessionMarker(relativePath) + if (!marker || marker.sourcePath !== sourcePath) { + return + } + let replacementPath: string | null = null + try { + const targetStat = lstatSync(targetPath) + if (targetStat.isSymbolicLink()) { + clearLegacyCopiedSessionMarker(relativePath) + return + } + if (!fileStatsMatchMarker(targetStat, marker, 'target')) { + return + } + replacementPath = `${targetPath}.orca-link-${process.pid}-${Date.now()}` + if (!tryLinkSystemCodexSessionFile(sourcePath, replacementPath)) { + return + } + rmSync(targetPath, { force: true }) + renameSync(replacementPath, targetPath) + clearLegacyCopiedSessionMarker(relativePath) + } catch (error) { + console.warn( + '[codex-session-bridge] Failed to migrate copied system Codex session:', + sourcePath, + error + ) + if (replacementPath) { + rmSync(replacementPath, { force: true }) + } + } +} + +export function getLegacyCopiedCodexSessionBridgeScanPreference( + sessionFilePath: string +): LegacyCopiedCodexSessionBridgeScanPreference | null { + const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') + const relativePath = relative(managedSessionsRoot, sessionFilePath) + if ( + relativePath === '' || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + return null + } + const marker = readLegacyCopiedSessionMarker(relativePath) + if (!marker) { + return null + } + + let targetMatchesMarker = false + let sourceMatchesMarker = false + try { + targetMatchesMarker = fileStatsMatchMarker(lstatSync(sessionFilePath), marker, 'target') + } catch {} + try { + sourceMatchesMarker = fileStatsMatchMarker(lstatSync(marker.sourcePath), marker, 'source') + } catch {} + + return { + sourcePath: marker.sourcePath, + // Why: legacy copied bridges share a prefix with the source. Scanner must + // choose one full log until the bridge can be replaced with a real link. + preferManagedCopy: !targetMatchesMarker || sourceMatchesMarker, + sourceSkipBytes: !targetMatchesMarker && !sourceMatchesMarker ? marker.sourceSize : null + } +} + +function getLegacySessionCopyMarkerPath(relativePath: string): string { + return join(getOrcaManagedCodexHomePath(), '.orca-session-copies', `${relativePath}.json`) +} + +function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessionMarker | null { + try { + const parsed: unknown = JSON.parse( + readFileSync(getLegacySessionCopyMarkerPath(relativePath), 'utf-8') + ) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const marker = parsed as Record + if ( + typeof marker.sourcePath !== 'string' || + typeof marker.sourceSize !== 'number' || + typeof marker.sourceMtimeMs !== 'number' || + typeof marker.targetSize !== 'number' || + typeof marker.targetMtimeMs !== 'number' + ) { + return null + } + return marker as LegacyCopiedSessionMarker + } catch { + return null + } +} + +function fileStatsMatchMarker( + stat: { size: number; mtimeMs: number }, + marker: LegacyCopiedSessionMarker, + kind: 'source' | 'target' +): boolean { + const expectedSize = kind === 'source' ? marker.sourceSize : marker.targetSize + const expectedMtimeMs = kind === 'source' ? marker.sourceMtimeMs : marker.targetMtimeMs + return stat.size === expectedSize && stat.mtimeMs === expectedMtimeMs +} + +function clearLegacyCopiedSessionMarker(relativePath: string): void { + rmSync(getLegacySessionCopyMarkerPath(relativePath), { force: true }) +} diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 447294af058..763dcca5870 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -1,6 +1,14 @@ /* eslint-disable max-lines -- Why: this suite keeps the hash fixture, TOML edit edge cases, and trust-state parser regressions together so Codex compatibility failures are easy to audit. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { @@ -219,6 +227,23 @@ describe('computeTrustKey', () => { }) ).toBe('/Users/thebr/.codex/hooks.json:pre_tool_use:0:0') }) + + it('uses Codex canonicalized source paths when hooks.json exists', () => { + const nestedDir = join(tmpDir, 'nested') + mkdirSync(nestedDir) + const hooksPath = join(nestedDir, '..', 'hooks.json') + writeFileSync(hooksPath, '{"hooks":{}}\n', 'utf-8') + + expect( + computeTrustKey({ + sourcePath: hooksPath, + eventLabel: 'user_prompt_submit', + groupIndex: 0, + handlerIndex: 0, + command: 'irrelevant' + }) + ).toBe(`${realpathSync.native(hooksPath)}:user_prompt_submit:0:0`) + }) }) describe('upsertHookTrustEntries', () => { @@ -642,6 +667,20 @@ describe('upsertProjectTrustLevel', () => { ) }) + it('uses Codex canonicalized project paths when the project exists', () => { + const nestedDir = join(tmpDir, 'nested') + const projectDir = join(tmpDir, 'project') + mkdirSync(nestedDir) + mkdirSync(projectDir) + const aliasedProjectPath = join(nestedDir, '..', 'project') + const trustedPath = realpathSync.native(aliasedProjectPath) + const trustedTomlPath = trustedPath.replaceAll('\\', '\\\\').replaceAll('"', '\\"') + + expect(upsertProjectTrustLevelInContent('', aliasedProjectPath, 'trusted')).toBe( + [`[projects."${trustedTomlPath}"]`, 'trust_level = "trusted"', ''].join('\n') + ) + }) + it('updates an existing project block without touching unrelated keys', () => { const original = [ 'model = "gpt-5.5"', diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 4d40183e291..d4f2026e155 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -5,11 +5,13 @@ import { mkdirSync, readFileSync, renameSync, + realpathSync, unlinkSync, writeFileSync } from 'fs' import { dirname, join } from 'path' import { createHash, randomUUID } from 'crypto' +import { escapeRegex } from '../../shared/string-utils' // Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in // ~/.codex/config.toml under [hooks.state.""]. Without it the hook is in @@ -101,7 +103,18 @@ export function computeTrustedHash(entry: CodexTrustEntry): string { } export function computeTrustKey(entry: CodexTrustEntry): string { - return `${entry.sourcePath}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` + return `${getCodexCanonicalTrustPath(entry.sourcePath)}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` +} + +export function getCodexCanonicalTrustPath(sourcePath: string): string { + try { + // Why: Codex canonicalizes trust paths before building config keys. On + // macOS, /var is a symlink to /private/var; trusting the raw path still + // leaves the TUI in review/trust prompts. + return realpathSync.native(sourcePath) + } catch { + return sourcePath + } } export function parseTrustKey(key: string): { @@ -232,13 +245,14 @@ export function upsertProjectTrustLevelInContent( ): string { const existing = existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent - const headerPattern = buildProjectHeaderPattern(projectPath) + const trustedProjectPath = getCodexCanonicalTrustPath(projectPath) + const headerPattern = buildProjectHeaderPattern(trustedProjectPath) const match = headerPattern.exec(existing) const eol = existing.includes('\r\n') ? '\r\n' : '\n' const trustLine = `trust_level = "${trustLevel}"` if (!match) { - const block = [`[projects."${escapeTomlString(projectPath)}"]`, trustLine].join(eol) + const block = [`[projects."${escapeTomlString(trustedProjectPath)}"]`, trustLine].join(eol) if (existing.length === 0) { return `${block}${eol}` } @@ -281,7 +295,7 @@ function buildTrustBlock(key: string, hash: string, enabled: boolean): string { // Why: TOML basic strings forbid raw control chars; escape backslash first so // later substitutions don't double-escape the inserted backslashes. -function escapeTomlString(value: string): string { +export function escapeTomlString(value: string): string { return value .replaceAll('\\', '\\\\') .replaceAll('"', '\\"') @@ -352,11 +366,6 @@ function buildProjectHeaderPattern(projectPath: string): RegExp { `(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)` ) } - -function escapeRegex(value: string): string { - return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - // Why: quoted keys can contain `]` (e.g. `[hooks.state."a]b"]`) and `[` lines // inside multi-line strings aren't headers, so we need a stateful scanner — // a flat regex misclassifies both cases. @@ -495,7 +504,7 @@ function isCompleteTableHeader(line: string): boolean { // half-written config.toml can brick a user's Codex install, so write to // tmp and rename. Random-suffix tmp name avoids cross-process races on // rapid reinstalls. -function writeConfigAtomically(configPath: string, contents: string): void { +export function writeConfigAtomically(configPath: string, contents: string): void { const dir = dirname(configPath) mkdirSync(dir, { recursive: true }) const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index a431c17fff9..fb2374f688d 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -1,8 +1,20 @@ +/* eslint-disable max-lines -- Why: this suite shares mocked homedir/userData setup across local/system Codex hook install, trust, and legacy-cleanup regressions. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, readFileSync, rmSync } from 'fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'fs' import { tmpdir } from 'os' import type * as Os from 'os' import { join } from 'path' +import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import { upsertHookTrustEntriesInContent } from './config-toml-trust' const { getPathMock, homedirMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), @@ -27,10 +39,13 @@ import { CodexHookService } from './hook-service' let tmpHome: string let userDataDir: string +let previousUserDataPath: string | undefined beforeEach(() => { tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir homedirMock.mockReturnValue(tmpHome) getPathMock.mockImplementation((name: string) => { if (name === 'userData') { @@ -43,18 +58,53 @@ beforeEach(() => { afterEach(() => { rmSync(tmpHome, { recursive: true, force: true }) rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } vi.clearAllMocks() }) +function escapeTomlBasicString(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"') +} + +function hookTrustHeader(key: string): string { + return `[hooks.state."${escapeTomlBasicString(canonicalizeHookTrustKeyForTest(key))}"]` +} + +function canonicalizeHookTrustKeyForTest(key: string): string { + const lastColon = key.lastIndexOf(':') + const secondLast = lastColon === -1 ? -1 : key.lastIndexOf(':', lastColon - 1) + const thirdLast = secondLast === -1 ? -1 : key.lastIndexOf(':', secondLast - 1) + if (thirdLast === -1) { + return key + } + const sourcePath = key.slice(0, thirdLast) + try { + return `${realpathSync.native(sourcePath)}${key.slice(thirdLast)}` + } catch { + return key + } +} + describe('CodexHookService', () => { it('installs PermissionRequest with trust so Codex approval prompts reach Orca', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + join(systemCodexHome, 'config.toml'), + 'model = "gpt-5.2-codex"\napproval_policy = "on-request"\n', + 'utf-8' + ) + const status = new CodexHookService().install() expect(status.state).toBe('installed') - const hooksConfig = JSON.parse( - readFileSync(join(tmpHome, '.codex', 'hooks.json'), 'utf-8') - ) as { + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as { hooks: Record } @@ -71,7 +121,831 @@ describe('CodexHookService', () => { expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('agent-hooks') expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook') - const trustConfig = readFileSync(join(tmpHome, '.codex', 'config.toml'), 'utf-8') + const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain('model = "gpt-5.2-codex"') + expect(trustConfig).toContain('approval_policy = "on-request"') expect(trustConfig).toContain(':permission_request:0:0') }) + + it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + const existingSystemHooks = '{"hooks":{"Stop":[{"hooks":[{"command":"user-hook"}]}]}}\n' + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(systemHooksPath, existingSystemHooks, 'utf-8') + + const devUserDataDir = mkdtempSync(join(tmpdir(), 'orca-dev-codex-user-data-')) + const prodUserDataDir = mkdtempSync(join(tmpdir(), 'orca-prod-codex-user-data-')) + try { + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return devUserDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + process.env.ORCA_USER_DATA_PATH = devUserDataDir + expect(new CodexHookService().install().state).toBe('installed') + + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return prodUserDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + process.env.ORCA_USER_DATA_PATH = prodUserDataDir + expect(new CodexHookService().install().state).toBe('installed') + + const devHooksPath = join(devUserDataDir, 'codex-runtime-home', 'home', 'hooks.json') + const prodHooksPath = join(prodUserDataDir, 'codex-runtime-home', 'home', 'hooks.json') + expect(existsSync(devHooksPath)).toBe(true) + expect(existsSync(prodHooksPath)).toBe(true) + const devHooks = JSON.parse(readFileSync(devHooksPath, 'utf-8')) as { + hooks: Record + } + const prodHooks = JSON.parse(readFileSync(prodHooksPath, 'utf-8')) as { + hooks: Record + } + expect(devHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') + expect(prodHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') + expect( + devHooks.hooks.Stop?.some((definition) => + definition.hooks?.[0]?.command?.includes('codex-hook') + ) + ).toBe(true) + expect( + prodHooks.hooks.Stop?.some((definition) => + definition.hooks?.[0]?.command?.includes('codex-hook') + ) + ).toBe(true) + expect(readFileSync(systemHooksPath, 'utf-8')).toBe(existingSystemHooks) + } finally { + process.env.ORCA_USER_DATA_PATH = userDataDir + rmSync(devUserDataDir, { recursive: true, force: true }) + rmSync(prodUserDataDir, { recursive: true, force: true }) + } + }) + + it('mirrors trusted system user hook approvals into the runtime CODEX_HOME', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: 'user-hook', + timeout: 12, + async: true, + statusMessage: 'Running user hook' + } + ] + } + ] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'user-hook', + timeoutSec: 12, + async: true, + matcher: '*', + statusMessage: 'Running user hook' + } + ]), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record< + string, + { matcher?: string; hooks?: { command?: string; statusMessage?: string }[] }[] + > + } + expect(runtimeHooks.hooks.Stop?.[0]?.matcher).toBe('*') + expect(runtimeHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') + expect(runtimeHooks.hooks.Stop?.[0]?.hooks?.[0]?.statusMessage).toBe('Running user hook') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) + }) + + it('mirrors system user hook approvals when the system trust indices are stale', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'first-stop-hook' }] }, + { hooks: [{ type: 'command', command: 'second-stop-hook' }] } + ] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'second-stop-hook' + }, + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 1, + handlerIndex: 0, + command: 'first-stop-hook' + } + ]), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:1:0`)) + }) + + it('skips plugin-placeholder system hooks when mirroring into runtime CODEX_HOME', () => { + const pluginCommands = [ + 'node "${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.mjs"', + 'node "${CLAUDE_PLUGIN_DATA}/scripts/on-stop.mjs"', + 'node "${PLUGIN_ROOT}/scripts/on-stop.mjs"', + 'node "${PLUGIN_DATA}/scripts/on-stop.mjs"' + ] + const userCommand = 'user-stop-hook' + const stopEventLabel = 'stop' as const + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { + hooks: [ + ...pluginCommands.map((command) => ({ type: 'command', command })), + { type: 'command', command: userCommand } + ] + } + ], + PreCompact: pluginCommands.map((command) => ({ + hooks: [{ type: 'command', command }] + })) + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + ...pluginCommands.map((command, handlerIndex) => ({ + sourcePath: systemHooksPath, + eventLabel: stopEventLabel, + groupIndex: 0, + handlerIndex, + command + })), + { + sourcePath: systemHooksPath, + eventLabel: stopEventLabel, + groupIndex: 0, + handlerIndex: pluginCommands.length, + command: userCommand + } + ]), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooksText = readFileSync(managedHooksPath, 'utf-8') + const runtimeHooks = JSON.parse(runtimeHooksText) as { + hooks: Record + } + const stopCommands = + runtimeHooks.hooks.Stop?.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + + expect(stopCommands).toContain(userCommand) + expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) + expect(runtimeHooks.hooks.PreCompact).toBeUndefined() + for (const command of pluginCommands) { + expect(runtimeHooksText).not.toContain(command) + } + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + for (const command of pluginCommands) { + expect(runtimeToml).not.toContain(command) + } + }) + + it('mirrors compact-event user hook approvals and disabled trust entries', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + PreCompact: [{ hooks: [{ type: 'command', command: 'pre-compact-user' }] }], + PostCompact: [{ hooks: [{ type: 'command', command: 'post-compact-disabled' }] }] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + const disabledPostCompactHeader = hookTrustHeader(`${systemHooksPath}:post_compact:0:0`) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'pre_compact', + groupIndex: 0, + handlerIndex: 0, + command: 'pre-compact-user' + }, + { + sourcePath: systemHooksPath, + eventLabel: 'post_compact', + groupIndex: 0, + handlerIndex: 0, + command: 'post-compact-disabled' + } + ]).replace( + `${disabledPostCompactHeader}\nenabled = true`, + `${disabledPostCompactHeader}\nenabled = false` + ), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + expect(runtimeHooks.hooks.PreCompact?.[0]?.hooks?.[0]?.command).toBe('pre-compact-user') + expect(runtimeHooks.hooks.PostCompact?.[0]?.hooks?.[0]?.command).toBe('post-compact-disabled') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain( + `${hookTrustHeader(`${managedHooksPath}:pre_compact:0:0`)}\nenabled = true` + ) + expect(runtimeToml).toContain( + `${hookTrustHeader(`${managedHooksPath}:post_compact:0:0`)}\nenabled = false` + ) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:pre_compact:0:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:post_compact:0:0`)) + }) + + it('removes runtime user hook trust after system approval is revoked', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }] } + })}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'user-hook' + } + ]), + 'utf-8' + ) + const service = new CodexHookService() + + expect(service.install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeUserTrustHeader = hookTrustHeader(`${managedHooksPath}:stop:0:0`) + expect(readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')).toContain( + runtimeUserTrustHeader + ) + + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + expect(service.install().state).toBe('installed') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).not.toContain(runtimeUserTrustHeader) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) + }) + + it('refreshes mirrored system user hooks when the system hooks file changes', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook-old' }] }] } + })}\n`, + 'utf-8' + ) + + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook-new' }] }] } + })}\n`, + 'utf-8' + ) + expect(service.install().state).toBe('installed') + + const managedHooksPath = join(userDataDir, 'codex-runtime-home', 'home', 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + const stopCommands = + runtimeHooks.hooks.Stop?.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + expect(stopCommands).toContain('user-hook-new') + expect(stopCommands).not.toContain('user-hook-old') + }) + + it('refreshes runtime user hooks without installing Orca-managed hooks', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-stop-hook' }] }] } + })}\n`, + 'utf-8' + ) + const disabledStopHeader = hookTrustHeader(`${systemHooksPath}:stop:0:0`) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'user-stop-hook' + } + ]).replace(`${disabledStopHeader}\nenabled = true`, `${disabledStopHeader}\nenabled = false`), + 'utf-8' + ) + + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + + const status = service.refreshRuntimeUserHooks() + + expect(status.state).toBe('not_installed') + expect(status.managedHooksPresent).toBe(false) + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + const runtimeCommands = Object.values(runtimeHooks.hooks).flatMap((definitions) => + definitions.flatMap((definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? []) + ) + expect(runtimeCommands).toEqual(['user-stop-hook']) + expect(runtimeCommands.some((command) => command.includes('codex-hook'))).toBe(false) + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain( + `${hookTrustHeader(`${managedHooksPath}:stop:0:0`)}\nenabled = false` + ) + expect(runtimeToml).not.toContain(':permission_request:0:0') + }) + + it('removes legacy Orca-managed hooks from system ~/.codex during install', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + const legacyScriptPath = join( + tmpHome, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + const legacyCommand = + process.platform === 'win32' ? legacyScriptPath : wrapPosixHookCommand(legacyScriptPath) + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'user-hook' }] }, + { hooks: [{ type: 'command', command: legacyCommand }] } + ], + SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 1, + handlerIndex: 0, + command: legacyCommand + }, + { + sourcePath: systemHooksPath, + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: legacyCommand + } + ]), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as { + hooks: Record + } + expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }]) + expect(systemHooks.hooks.SessionStart).toBeUndefined() + const systemToml = readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8') + expect(systemToml).toContain('model = "system-model"') + expect(systemToml).not.toContain(':stop:1:0') + expect(systemToml).not.toContain(':session_start:0:0') + }) + + it('removes the legacy Orca Codex profile file when it only contains managed hooks', () => { + const systemCodexHome = join(tmpHome, '.codex') + const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + profilePath, + [ + '# BEGIN ORCA AGENT STATUS HOOKS', + '[[hooks.PermissionRequest]]', + '[[hooks.PermissionRequest.hooks]]', + 'type = "command"', + 'command = "codex-hook"', + '# END ORCA AGENT STATUS HOOKS', + '' + ].join('\n'), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + expect(existsSync(profilePath)).toBe(false) + }) + + it('removes only the legacy Orca block from a user-edited Codex profile file', () => { + const systemCodexHome = join(tmpHome, '.codex') + const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + profilePath, + [ + 'model = "gpt-5.5"', + '', + '# BEGIN ORCA AGENT STATUS HOOKS', + '[[hooks.PermissionRequest]]', + '[[hooks.PermissionRequest.hooks]]', + 'type = "command"', + 'command = "codex-hook"', + '# END ORCA AGENT STATUS HOOKS', + '' + ].join('\n'), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const profileConfig = readFileSync(profilePath, 'utf-8') + expect(profileConfig).toContain('model = "gpt-5.5"') + expect(profileConfig).not.toContain('ORCA AGENT STATUS HOOKS') + expect(profileConfig).not.toContain('codex-hook') + }) + + it('cleans legacy system and profile hooks when runtime hooks.json is malformed during remove', () => { + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + mkdirSync(managedCodexHome, { recursive: true }) + writeFileSync(join(managedCodexHome, 'hooks.json'), '{not json', 'utf-8') + + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml') + const legacyScriptPath = join( + tmpHome, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + const legacyCommand = + process.platform === 'win32' ? legacyScriptPath : wrapPosixHookCommand(legacyScriptPath) + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'user-hook' }] }, + { hooks: [{ type: 'command', command: legacyCommand }] } + ], + SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + profilePath, + [ + '# BEGIN ORCA AGENT STATUS HOOKS', + '[[hooks.PermissionRequest]]', + '[[hooks.PermissionRequest.hooks]]', + 'type = "command"', + 'command = "codex-hook"', + '# END ORCA AGENT STATUS HOOKS', + '' + ].join('\n'), + 'utf-8' + ) + + const status = new CodexHookService().remove() + + expect(status.state).toBe('error') + expect(status.detail).toBe('Could not parse Codex hooks.json') + const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as { + hooks: Record + } + expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }]) + expect(systemHooks.hooks.SessionStart).toBeUndefined() + expect(existsSync(profilePath)).toBe(false) + }) + + it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + const systemTomlPath = join(systemCodexHome, 'config.toml') + const legacyProfilePath = join(systemCodexHome, 'orca-agent-status.config.toml') + const legacyScriptPath = join( + tmpHome, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + const legacyCommand = + process.platform === 'win32' ? legacyScriptPath : wrapPosixHookCommand(legacyScriptPath) + const userCommand = 'user-stop-hook' + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: userCommand }] }, + { hooks: [{ type: 'command', command: legacyCommand }] } + ], + SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + systemTomlPath, + upsertHookTrustEntriesInContent( + ['model = "system-model"', '', '[features]', 'codex_hooks = true', ''].join('\n'), + [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: userCommand + }, + { + sourcePath: systemHooksPath, + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: legacyCommand + } + ] + ), + 'utf-8' + ) + writeFileSync( + legacyProfilePath, + [ + '# BEGIN ORCA AGENT STATUS HOOKS', + '[[hooks.PermissionRequest]]', + '[[hooks.PermissionRequest.hooks]]', + 'type = "command"', + 'command = "codex-hook"', + '# END ORCA AGENT STATUS HOOKS', + '' + ].join('\n'), + 'utf-8' + ) + + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + const stopCommands = + runtimeHooks.hooks.Stop?.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + expect(stopCommands).toContain(userCommand) + expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) + expect(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain('[features]\nhooks = true') + expect(runtimeToml).not.toContain('codex_hooks') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:permission_request:0:0`)) + + const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as { + hooks: Record + } + expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: userCommand }] }]) + expect(systemHooks.hooks.SessionStart).toBeUndefined() + const systemToml = readFileSync(systemTomlPath, 'utf-8') + expect(systemToml).toContain('codex_hooks = true') + expect(systemToml).not.toContain(':session_start:0:0') + expect(existsSync(legacyProfilePath)).toBe(false) + expect(service.getStatus().state).toBe('installed') + }) + + it('removes managed trust entries when userData resolves through a symlink', () => { + const linkedUserDataDir = join(tmpHome, 'linked-user-data') + symlinkSync(userDataDir, linkedUserDataDir, process.platform === 'win32' ? 'junction' : 'dir') + process.env.ORCA_USER_DATA_PATH = linkedUserDataDir + + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + + const linkedManagedCodexHome = join(linkedUserDataDir, 'codex-runtime-home', 'home') + const linkedHooksPath = join(linkedManagedCodexHome, 'hooks.json') + let runtimeToml = readFileSync(join(linkedManagedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain(hookTrustHeader(`${linkedHooksPath}:permission_request:0:0`)) + + const status = service.remove() + + expect(status.state).toBe('not_installed') + runtimeToml = readFileSync(join(linkedManagedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).not.toContain(':permission_request:0:0') + expect(runtimeToml).not.toContain(':stop:0:0') + }) + + it('mirrors system Codex config while preserving runtime hook trust on hook install', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + mkdirSync(managedCodexHome, { recursive: true }) + writeFileSync( + join(managedCodexHome, 'config.toml'), + [ + 'model = "runtime-model"', + '', + '[hooks.state."runtime-hook"]', + 'enabled = false', + 'trusted_hash = "sha256:runtime"', + '' + ].join('\n'), + 'utf-8' + ) + + const status = new CodexHookService().install() + + expect(status.state).toBe('installed') + const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain('model = "system-model"') + expect(trustConfig).toContain('[hooks.state."runtime-hook"]') + expect(trustConfig).toContain('enabled = false') + expect(trustConfig).toContain('trusted_hash = "sha256:runtime"') + expect(trustConfig).toContain(':permission_request:0:0') + expect(trustConfig).not.toContain('model = "runtime-model"') + }) + + it('preserves runtime-only project trust while honoring system project untrust', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + join(systemCodexHome, 'config.toml'), + ['model = "system-model"', '', '[projects."/repo"]', 'trust_level = "untrusted"', ''].join( + '\n' + ), + 'utf-8' + ) + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + mkdirSync(managedCodexHome, { recursive: true }) + writeFileSync( + join(managedCodexHome, 'config.toml'), + [ + 'model = "runtime-model"', + '', + '[projects."/repo"]', + 'trust_level = "trusted"', + '', + '[projects."/runtime-only"]', + 'trust_level = "trusted"', + '' + ].join('\n'), + 'utf-8' + ) + + const status = new CodexHookService().install() + + expect(status.state).toBe('installed') + const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain('model = "system-model"') + expect(trustConfig).toContain('[projects."/repo"]\ntrust_level = "untrusted"') + expect(trustConfig).toContain('[projects."/runtime-only"]\ntrust_level = "trusted"') + expect(trustConfig).not.toContain('model = "runtime-model"') + }) }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index c431b121ce2..bda41ad1b22 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */ -import { homedir } from 'os' +import { existsSync, readFileSync, unlinkSync } from 'fs' import { join } from 'path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' @@ -7,11 +7,13 @@ import { createManagedCommandMatcher, buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, + hookDefinitionHasManagedCommand, readHooksJson, removeManagedCommands, wrapPosixHookCommand, writeHooksJson, writeManagedScript, + type HookCommandConfig, type HookDefinition } from '../agent-hooks/installer-utils' import { @@ -24,15 +26,20 @@ import { import { computeTrustKey, computeTrustedHash, + escapeTomlString, + getCodexCanonicalTrustPath, parseTrustKey, readHookTrustEntries, removeHookTrustEntries, upsertHookTrustEntriesInContent, upsertHookTrustEntries, + writeConfigAtomically, type CodexEventLabel, type CodexHookTrustState, type CodexTrustEntry } from './config-toml-trust' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. @@ -49,11 +56,11 @@ const CODEX_EVENTS = [ ] as const function getConfigPath(): string { - return join(homedir(), '.codex', 'hooks.json') + return join(getOrcaManagedCodexHomePath(), 'hooks.json') } function getCodexConfigTomlPath(): string { - return join(homedir(), '.codex', 'config.toml') + return join(getOrcaManagedCodexHomePath(), 'config.toml') } // Why: Codex's hash key uses the snake_case event label (see @@ -69,6 +76,28 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> Stop: 'stop' } +const CODEX_HOOK_EVENT_LABEL: Record = { + ...CODEX_EVENT_LABEL, + PreCompact: 'pre_compact', + PostCompact: 'post_compact' +} + +const CODEX_PLUGIN_ONLY_HOOK_PLACEHOLDERS = [ + '${CLAUDE_PLUGIN_ROOT}', + '${CLAUDE_PLUGIN_DATA}', + '${PLUGIN_ROOT}', + '${PLUGIN_DATA}' +] as const + +const LEGACY_ORCA_PROFILE_NAME = 'orca-agent-status' +const LEGACY_ORCA_PROFILE_BLOCK_START = '# BEGIN ORCA AGENT STATUS HOOKS' +const LEGACY_ORCA_PROFILE_BLOCK_END = '# END ORCA AGENT STATUS HOOKS' + +type MirroredRuntimeUserHookTrustEntry = { + entry: CodexTrustEntry + enabled: boolean +} + function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' } @@ -81,6 +110,502 @@ function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) } +function getSystemConfigPath(): string { + return join(getSystemCodexHomePath(), 'hooks.json') +} + +function getSystemCodexConfigTomlPath(): string { + return join(getSystemCodexHomePath(), 'config.toml') +} + +function getLegacyCodexProfileTomlPath(): string { + return join(getSystemCodexHomePath(), `${LEGACY_ORCA_PROFILE_NAME}.config.toml`) +} + +function collectManagedTrustEntries( + sourcePath: string, + eventName: string, + definitions: readonly HookDefinition[], + isManagedCommand: (command: string | undefined) => boolean +): CodexTrustEntry[] { + const entries: CodexTrustEntry[] = [] + definitions.forEach((definition, groupIndex) => { + const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] + hooks.forEach((hook, handlerIndex) => { + if (!isManagedCommand(hook.command)) { + return + } + const entry = createHookTrustEntry( + sourcePath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + if (entry) { + entries.push(entry) + } + }) + }) + return entries +} + +function createHookTrustEntry( + sourcePath: string, + eventName: string, + groupIndex: number, + handlerIndex: number, + definition: HookDefinition, + hook: HookCommandConfig +): CodexTrustEntry | null { + const eventLabel = CODEX_HOOK_EVENT_LABEL[eventName] + if (!eventLabel || !hook.command) { + return null + } + + return { + sourcePath, + eventLabel, + groupIndex, + handlerIndex, + command: hook.command, + ...(typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}), + ...(typeof hook.async === 'boolean' ? { async: hook.async } : {}), + ...(typeof definition.matcher === 'string' ? { matcher: definition.matcher } : {}), + ...(typeof hook.statusMessage === 'string' ? { statusMessage: hook.statusMessage } : {}) + } +} + +function removeMatchingTrustEntries(configPath: string, entries: readonly CodexTrustEntry[]): void { + if (entries.length === 0) { + return + } + + const existingEntries = readHookTrustEntries(configPath) + const ownedKeys = entries + .map((entry) => { + const key = computeTrustKey(entry) + return existingEntries.get(key)?.trustedHash === computeTrustedHash(entry) ? key : null + }) + .filter((key): key is string => key !== null) + if (ownedKeys.length > 0) { + removeHookTrustEntries(configPath, ownedKeys) + } +} + +function removeStaleRuntimeHookTrustEntries( + tomlPath: string, + runtimeHooksPath: string, + expectedEntries: readonly CodexTrustEntry[] +): void { + const expectedHashes = new Map( + expectedEntries.map((entry) => [computeTrustKey(entry), computeTrustedHash(entry)]) + ) + const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath) + const staleKeys: string[] = [] + for (const [key, state] of readHookTrustEntries(tomlPath)) { + const parsed = parseTrustKey(key) + if (!parsed || getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalRuntimeHooksPath) { + continue + } + if (expectedHashes.get(key) === state.trustedHash) { + continue + } + staleKeys.push(key) + } + if (staleKeys.length > 0) { + removeHookTrustEntries(tomlPath, staleKeys) + } +} + +function getTrustSignature(entry: CodexTrustEntry): string { + return JSON.stringify({ + eventLabel: entry.eventLabel, + command: entry.command, + timeoutSec: Math.max(1, entry.timeoutSec ?? 600), + async: entry.async ?? false, + matcher: entry.matcher ?? null, + statusMessage: entry.statusMessage ?? null + }) +} + +function commandUsesCodexPluginOnlyPlaceholder(command: string | undefined): boolean { + return ( + typeof command === 'string' && + CODEX_PLUGIN_ONLY_HOOK_PLACEHOLDERS.some((placeholder) => command.includes(placeholder)) + ) +} + +function removeCodexPluginEnvironmentCommands(definitions: HookDefinition[]): HookDefinition[] { + // Why: Orca mirrors system hooks into a plain runtime hooks.json. Plugin + // placeholders only work for Codex plugin hook sources, so copying those + // commands here strips the environment they require and turns them into 127s. + return removeManagedCommands(definitions, commandUsesCodexPluginOnlyPlaceholder) +} + +function getRuntimeHooksWithSystemUserHooks( + runtimeHooks: Record | undefined, + isManagedCommand: (command: string | undefined) => boolean +): { + hooks: Record + trustEntries: MirroredRuntimeUserHookTrustEntry[] +} { + const systemConfigPath = getSystemConfigPath() + const runtimeConfigPath = getConfigPath() + if (systemConfigPath === getConfigPath()) { + return { hooks: { ...runtimeHooks }, trustEntries: [] } + } + + const systemConfig = readHooksJson(systemConfigPath) + if (!systemConfig?.hooks) { + return { hooks: {}, trustEntries: [] } + } + + const nextHooks: Record = {} + const trustedSystemHookSignatures = getTrustedSystemUserHookSignatures( + systemConfigPath, + systemConfig.hooks, + isManagedCommand + ) + for (const [eventName, systemDefinitions] of Object.entries(systemConfig.hooks)) { + if (!Array.isArray(systemDefinitions)) { + continue + } + + const systemUserDefinitions = removeCodexPluginEnvironmentCommands( + removeManagedCommands(systemDefinitions, isManagedCommand) + ) + if (systemUserDefinitions.length === 0) { + continue + } + + // Why: runtime hooks are derived from the user's system hooks plus Orca's + // managed hooks. Reusing old runtime user-hook copies would keep deleted or + // edited ~/.codex/hooks.json entries alive for new Orca-launched sessions. + nextHooks[eventName] = dedupeHookDefinitions(systemUserDefinitions) + } + + return { + hooks: nextHooks, + trustEntries: collectMirroredRuntimeUserHookTrustEntries( + runtimeConfigPath, + nextHooks, + trustedSystemHookSignatures, + isManagedCommand + ) + } +} + +function getTrustedSystemUserHookSignatures( + systemConfigPath: string, + systemHooks: Record, + isManagedCommand: (command: string | undefined) => boolean +): Map { + const signatures = new Map() + let trustEntries: Map + try { + trustEntries = readHookTrustEntries(getSystemCodexConfigTomlPath()) + } catch (error) { + // Why: a hand-broken system config.toml should only disable user-hook + // trust mirroring; Orca's managed runtime hooks can still be installed. + console.warn('[codex-hook-service] failed to read system hook trust entries', error) + return signatures + } + const trustedHashesByEvent = getTrustedSystemHookHashesByEvent(systemConfigPath, trustEntries) + for (const [eventName, definitions] of Object.entries(systemHooks)) { + if (!Array.isArray(definitions)) { + continue + } + definitions.forEach((definition, groupIndex) => { + const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] + hooks.forEach((hook, handlerIndex) => { + if (isManagedCommand(hook.command)) { + return + } + const entry = createHookTrustEntry( + systemConfigPath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + if (!entry) { + return + } + const expectedHash = computeTrustedHash(entry) + const state = trustEntries.get(computeTrustKey(entry)) + const enabled = + state?.trustedHash === expectedHash + ? state.enabled !== false + : trustedHashesByEvent.get(entry.eventLabel)?.get(expectedHash) + if (enabled === undefined) { + return + } + const signature = getTrustSignature(entry) + // Why: runtime deduping collapses identical system hook definitions; + // if any duplicate remains enabled, keep the mirrored hook enabled. + if (enabled || !signatures.has(signature)) { + signatures.set(signature, enabled) + } + }) + }) + } + return signatures +} + +function getTrustedSystemHookHashesByEvent( + systemConfigPath: string, + trustEntries: ReadonlyMap +): Map> { + const trustedHashesByEvent = new Map>() + const canonicalSystemConfigPath = getCodexCanonicalTrustPath(systemConfigPath) + for (const [key, state] of trustEntries) { + const parsed = parseTrustKey(key) + if (!parsed || !state.trustedHash) { + continue + } + if (getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalSystemConfigPath) { + continue + } + let hashes = trustedHashesByEvent.get(parsed.eventLabel) + if (!hashes) { + hashes = new Map() + trustedHashesByEvent.set(parsed.eventLabel, hashes) + } + const enabled = state.enabled !== false + // Why: Codex trust keys include hook indices. If a user reorders hooks, + // the hash still proves the same event+command identity was approved. + if (enabled || !hashes.has(state.trustedHash)) { + hashes.set(state.trustedHash, enabled) + } + } + return trustedHashesByEvent +} + +function collectMirroredRuntimeUserHookTrustEntries( + runtimeConfigPath: string, + runtimeHooks: Record, + trustedSystemHookSignatures: ReadonlyMap, + isManagedCommand: (command: string | undefined) => boolean +): MirroredRuntimeUserHookTrustEntry[] { + if (trustedSystemHookSignatures.size === 0) { + return [] + } + + const entries: MirroredRuntimeUserHookTrustEntry[] = [] + for (const [eventName, definitions] of Object.entries(runtimeHooks)) { + if (!Array.isArray(definitions)) { + continue + } + definitions.forEach((definition, groupIndex) => { + const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] + hooks.forEach((hook, handlerIndex) => { + if (isManagedCommand(hook.command)) { + return + } + const entry = createHookTrustEntry( + runtimeConfigPath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + if (!entry) { + return + } + const signature = getTrustSignature(entry) + const enabled = trustedSystemHookSignatures.get(signature) + if (enabled !== undefined) { + entries.push({ entry, enabled }) + } + }) + }) + } + return entries +} + +function escapeRegex(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function applyMirroredRuntimeUserHookTrustStates( + tomlPath: string, + entries: readonly MirroredRuntimeUserHookTrustEntry[] +): void { + if (entries.length === 0 || !existsSync(tomlPath)) { + return + } + + const existing = readFileSync(tomlPath, 'utf-8') + let updated = existing + for (const { entry, enabled } of entries) { + const escapedKey = escapeRegex(escapeTomlString(computeTrustKey(entry))) + const pattern = new RegExp( + `(\\[hooks\\.state\\."${escapedKey}"\\]\\r?\\n[ \\t]*enabled[ \\t]*=[ \\t]*)(true|false)` + ) + updated = updated.replace(pattern, `$1${enabled}`) + } + if (updated !== existing) { + writeConfigAtomically(tomlPath, updated) + } +} + +function dedupeHookDefinitions(definitions: readonly HookDefinition[]): HookDefinition[] { + const seen = new Set() + return definitions.filter((definition) => { + const key = JSON.stringify(definition) + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +function cleanupLegacySystemManagedHooks(): void { + const legacyConfigPath = getSystemConfigPath() + const runtimeConfigPath = getConfigPath() + if (legacyConfigPath === runtimeConfigPath) { + return + } + + const config = readHooksJson(legacyConfigPath) + if (!config?.hooks) { + return + } + + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const nextHooks = { ...config.hooks } + const trustEntries: CodexTrustEntry[] = [] + let removedManagedHook = false + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const eventTrustEntries = collectManagedTrustEntries( + legacyConfigPath, + eventName, + definitions, + isManagedCommand + ) + trustEntries.push(...eventTrustEntries) + const cleaned = removeManagedCommands(definitions, isManagedCommand) + removedManagedHook ||= definitions.some((definition) => + hookDefinitionHasManagedCommand(definition, isManagedCommand) + ) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + // Why: Codex hooks moved to Orca's managed CODEX_HOME; old entries in + // ~/.codex would keep external Codex sessions reporting into Orca. + if (removedManagedHook) { + writeHooksJson(legacyConfigPath, { ...config, hooks: nextHooks }) + } + removeMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries) +} + +function stripLegacyManagedProfileBlock(content: string): string { + const start = content.indexOf(LEGACY_ORCA_PROFILE_BLOCK_START) + if (start === -1) { + return content + } + const endMarker = content.indexOf(LEGACY_ORCA_PROFILE_BLOCK_END, start) + const end = endMarker === -1 ? content.length : endMarker + LEGACY_ORCA_PROFILE_BLOCK_END.length + const before = content.slice(0, start).replace(/[ \t]*(?:\r?\n)*$/, '') + const after = content.slice(end).replace(/^(?:\r?\n)+/, '') + if (!before) { + return after + } + if (!after) { + return before.endsWith('\n') ? before : `${before}\n` + } + return `${before}\n\n${after}` +} + +function cleanupLegacyCodexProfileHooks(): void { + const profilePath = getLegacyCodexProfileTomlPath() + if (!existsSync(profilePath)) { + return + } + + const existing = readFileSync(profilePath, 'utf-8') + const next = stripLegacyManagedProfileBlock(existing) + if (next === existing) { + return + } + // Why: #2778 wrote Orca hooks into a Codex profile file. Runtime CODEX_HOME + // supersedes that representation, so remove only Orca's marked block. + if (next.trim().length === 0) { + unlinkSync(profilePath) + } else { + writeConfigAtomically(profilePath, next) + } +} + +function cleanupLegacyManagedHookRepresentations(): void { + try { + cleanupLegacySystemManagedHooks() + cleanupLegacyCodexProfileHooks() + } catch (error) { + console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error) + } +} + +function removeRuntimeManagedHookTrustEntries(configPath: string): void { + try { + const tomlPath = getCodexConfigTomlPath() + const existingEntries = readHookTrustEntries(tomlPath) + const scriptPath = getManagedScriptPath() + const command = getManagedCommand(scriptPath) + const managedEventLabels = new Set( + CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) + ) + // Why: only drop entries WE wrote. The same config.toml can contain + // user-approved trust entries for non-Orca commands, so match by hash + // equivalence to our managed command — a sourcePath-only filter would + // wipe the user's manually-approved entries. + const ourKeys: string[] = [] + const canonicalConfigPath = getCodexCanonicalTrustPath(configPath) + for (const [key, state] of existingEntries) { + const parts = parseTrustKey(key) + if (parts === null) { + continue + } + if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) { + continue + } + if (!managedEventLabels.has(parts.eventLabel)) { + continue + } + const expectedHash = computeTrustedHash({ + sourcePath: configPath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command + }) + if (state.trustedHash !== expectedHash) { + continue + } + ourKeys.push(key) + } + if (ourKeys.length > 0) { + removeHookTrustEntries(tomlPath, ourKeys) + } + } catch (error) { + // Best effort — stale trust entries are harmless once hooks.json no + // longer references the hook. Log so a programmer error doesn't disappear silently. + console.warn('[codex-hook-service] failed to clean trust entries', error) + } +} + function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ @@ -261,15 +786,15 @@ export class CodexHookService { } } - const command = getManagedCommand(scriptPath) - const nextHooks = { ...config.hooks } - const managedEvents = new Set(CODEX_EVENTS) - // Why: match by script filename (not exact command string) so a fresh // install sweeps stale entries left by older builds or a different // Electron userData path (dev vs. prod). Without this, repeated installs // accumulate duplicate hook entries pointing at defunct scripts. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const command = getManagedCommand(scriptPath) + const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) + const nextHooks = hookPlan.hooks + const managedEvents = new Set(CODEX_EVENTS) // Why: sweep managed entries out of events we no longer subscribe to // (e.g., PreToolUse from a prior install). Without this, users who @@ -298,7 +823,8 @@ export class CodexHookService { // hook sits in the "review required" pile. We compute the trust hash for // each managed entry as we install it and persist it alongside hooks.json // so the user does not have to /hooks-approve after every install. - const trustEntries: CodexTrustEntry[] = [] + const mirroredUserTrustEntries = hookPlan.trustEntries + const trustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map(({ entry }) => entry) for (const eventName of CODEX_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) @@ -326,7 +852,14 @@ export class CodexHookService { // pointing at a hook that doesn't exist. Surface failures — without this, // getStatus would report green for a hook Codex won't actually fire. try { - upsertHookTrustEntries(getCodexConfigTomlPath(), trustEntries) + const tomlPath = getCodexConfigTomlPath() + syncSystemConfigIntoManagedCodexHome() + // Why: system user hook approvals are mirrored into runtime CODEX_HOME. + // If the user later revokes approval in ~/.codex/config.toml, preserving + // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + upsertHookTrustEntries(tomlPath, trustEntries) + applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) } catch (error) { return { agent: 'codex', @@ -336,6 +869,12 @@ export class CodexHookService { detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` } } + try { + cleanupLegacySystemManagedHooks() + cleanupLegacyCodexProfileHooks() + } catch (error) { + console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error) + } return this.getStatus() } @@ -432,10 +971,58 @@ export class CodexHookService { } } - remove(): AgentHookInstallStatus { + refreshRuntimeUserHooks(): AgentHookInstallStatus { const configPath = getConfigPath() const config = readHooksJson(configPath) if (!config) { + // Why: disabled launch prep used to call remove(); preserve its legacy + // cleanup behavior even when runtime hooks.json is malformed. + cleanupLegacyManagedHookRepresentations() + return { + agent: 'codex', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) + config.hooks = hookPlan.hooks + writeHooksJson(configPath, config) + + try { + const tomlPath = getCodexConfigTomlPath() + const trustEntries = hookPlan.trustEntries.map(({ entry }) => entry) + syncSystemConfigIntoManagedCodexHome() + // Why: this path is used when Orca status hooks are disabled. The + // runtime CODEX_HOME should keep user hooks, but not Orca-managed trust. + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + upsertHookTrustEntries(tomlPath, trustEntries) + applyMirroredRuntimeUserHookTrustStates(tomlPath, hookPlan.trustEntries) + } catch (error) { + return { + agent: 'codex', + state: 'error', + configPath, + managedHooksPresent: false, + detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + } + } + + cleanupLegacyManagedHookRepresentations() + return this.getStatus() + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + const configExists = existsSync(configPath) + const config = readHooksJson(configPath) + if (!config) { + // Why: a malformed runtime hooks.json should not strand old hooks in + // ~/.codex or the legacy profile after the user disables Codex hooks. + cleanupLegacyManagedHookRepresentations() return { agent: 'codex', state: 'error', @@ -446,6 +1033,7 @@ export class CodexHookService { } const nextHooks = { ...config.hooks } + let removedManagedHooks = false // Why: same broad matcher as install(), so remove() also cleans up stale // entries from older builds even if the current scriptPath has moved. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) @@ -457,63 +1045,26 @@ export class CodexHookService { continue } const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) { + removedManagedHooks = true + } if (cleaned.length === 0) { delete nextHooks[eventName] } else { nextHooks[eventName] = cleaned } } - config.hooks = nextHooks - writeHooksJson(configPath, config) + if (configExists && removedManagedHooks) { + config.hooks = nextHooks + writeHooksJson(configPath, config) + } // Why: also drop our trust entries so config.toml doesn't accumulate dead // [hooks.state."..."] blocks across install/remove cycles. Best-effort — // a stale entry is harmless once hooks.json no longer references it. - try { - const tomlPath = getCodexConfigTomlPath() - const existingEntries = readHookTrustEntries(tomlPath) - const scriptPath = getManagedScriptPath() - const command = getManagedCommand(scriptPath) - const managedEventLabels = new Set( - CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) - ) - // Why: only drop entries WE wrote. configPath (~/.codex/hooks.json) is - // shared with Codex CLI, so user-approved trust entries for non-Orca - // commands live in the same `[hooks.state.*]` namespace. Match by hash - // equivalence to our managed command — a sourcePath-only filter would - // wipe the user's manually-approved entries. - const ourKeys: string[] = [] - for (const [key, state] of existingEntries) { - const parts = parseTrustKey(key) - if (parts === null) { - continue - } - if (parts.sourcePath !== configPath) { - continue - } - if (!managedEventLabels.has(parts.eventLabel)) { - continue - } - const expectedHash = computeTrustedHash({ - sourcePath: configPath, - eventLabel: parts.eventLabel, - groupIndex: parts.groupIndex, - handlerIndex: parts.handlerIndex, - command - }) - if (state.trustedHash !== expectedHash) { - continue - } - ourKeys.push(key) - } - if (ourKeys.length > 0) { - removeHookTrustEntries(tomlPath, ourKeys) - } - } catch (error) { - // Best effort — stale trust entries are harmless once hooks.json no - // longer references the hook. Log so a programmer error doesn't disappear silently. - console.warn('[codex-hook-service] failed to clean trust entries', error) - } + removeRuntimeManagedHookTrustEntries(configPath) + + cleanupLegacyManagedHookRepresentations() return this.getStatus() } diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts new file mode 100644 index 00000000000..2fdd7826c95 --- /dev/null +++ b/src/main/command-code/hook-service.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { createServer } from 'http' +import { tmpdir } from 'os' +import { dirname, join } from 'path' +import type { AddressInfo } from 'net' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('os', async () => { + const actual = (await vi.importActual('os')) as Record + return { + ...actual, + homedir: homedirMock + } +}) + +import { CommandCodeHookService } from './hook-service' + +describe('CommandCodeHookService', () => { + let homeDir: string + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'orca-command-code-home-')) + homedirMock.mockReturnValue(homeDir) + }) + + afterEach(() => { + vi.clearAllMocks() + rmSync(homeDir, { recursive: true, force: true }) + }) + + it('installs the managed command for Command Code status events', () => { + const status = new CommandCodeHookService().install() + + expect(status.state).toBe('installed') + expect(status.managedHooksPresent).toBe(true) + + const config = JSON.parse( + readFileSync(join(homeDir, '.commandcode', 'settings.json'), 'utf8') + ) as { + hooks: Record + } + expect(Object.keys(config.hooks).sort()).toEqual(['PostToolUse', 'PreToolUse', 'Stop'].sort()) + expect(config.hooks.PreToolUse[0].matcher).toBe('.*') + expect(config.hooks.PostToolUse[0].matcher).toBe('.*') + expect(config.hooks.Stop[0].matcher).toBeUndefined() + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook') + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -x /) + }) + + it('installs a hook script that can recover the endpoint when Command Code strips token env', () => { + new CommandCodeHookService().install() + + const scriptFileName = + process.platform === 'win32' ? 'command-code-hook.cmd' : 'command-code-hook.sh' + const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', scriptFileName), 'utf8') + + if (process.platform === 'win32') { + expect(script).toContain('sourceEndpointByPort') + expect(script).toContain('orca-dev\\agent-hooks') + expect(script).toContain('set ORCA_AGENT_HOOK_PORT=') + } else { + expect(script).toContain('Command Code strips TOKEN-like env vars') + expect(script).toContain('Command Code sanitizes hook subprocess env') + expect(script).toContain('__orca_read_ancestor_var') + expect(script).toContain('__orca_fill_from_endpoint_file') + expect(script).toContain('[ "$__orca_endpoint_port" != "$ORCA_AGENT_HOOK_PORT" ]') + expect(script).toContain('ORCA_PANE_KEY') + expect(script).toContain('orca-dev/agent-hooks') + expect(script).toContain('endpoint_port=') + } + }) + + const itPosix = process.platform === 'win32' ? it.skip : it + + itPosix('does not let stale endpoint files clobber recovered connection fields', async () => { + new CommandCodeHookService().install() + + const staleEndpointPath = join(homeDir, 'stale-endpoint.env') + writeFileSync( + staleEndpointPath, + [ + 'ORCA_AGENT_HOOK_PORT=9', + 'ORCA_AGENT_HOOK_TOKEN=stale-token', + 'ORCA_AGENT_HOOK_ENV=development', + 'ORCA_AGENT_HOOK_VERSION=1', + '' + ].join('\n') + ) + + const requests: { body: string; token: string | string[] | undefined }[] = [] + const server = createServer((req, res) => { + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + requests.push({ body, token: req.headers['x-orca-agent-hook-token'] }) + res.statusCode = 204 + res.end() + }) + }) + + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as AddressInfo + const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'command-code-hook.sh') + const child = spawn('/bin/sh', [scriptPath], { + env: { + ...process.env, + HOME: homeDir, + ORCA_AGENT_HOOK_ENDPOINT: staleEndpointPath, + ORCA_AGENT_HOOK_PORT: String(address.port), + ORCA_AGENT_HOOK_TOKEN: 'current-token', + ORCA_PANE_KEY: 'tab:leaf', + ORCA_TAB_ID: 'tab', + ORCA_WORKTREE_ID: 'worktree', + ORCA_AGENT_HOOK_ENV: 'development', + ORCA_AGENT_HOOK_VERSION: '1' + }, + stdio: ['pipe', 'ignore', 'pipe'] + }) + child.stdin.end(JSON.stringify({ hook_event_name: 'Stop' })) + + const exitCode = await new Promise((resolve, reject) => { + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('exit', (code) => { + if (stderr.trim()) { + reject(new Error(stderr)) + } else { + resolve(code) + } + }) + }) + + expect(exitCode).toBe(0) + expect(requests).toHaveLength(1) + expect(requests[0].token).toBe('current-token') + expect(new URLSearchParams(requests[0].body).get('paneKey')).toBe('tab:leaf') + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } + }) + + it('reports partial when one managed event is missing', () => { + const service = new CommandCodeHookService() + service.install() + + const configPath = join(homeDir, '.commandcode', 'settings.json') + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + hooks: Record + } + delete config.hooks.Stop + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + + const status = service.getStatus() + + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toBe('Managed hook missing for events: Stop') + }) +}) diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts new file mode 100644 index 00000000000..516d4c0bad7 --- /dev/null +++ b/src/main/command-code/hook-service.ts @@ -0,0 +1,355 @@ +/* eslint-disable max-lines */ +import { homedir } from 'os' +import { join } from 'path' +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + createManagedCommandMatcher, + buildWindowsAgentHookPostCommand, + getSharedManagedScriptPath, + readHooksJson, + removeManagedCommands, + wrapPosixHookCommand, + writeHooksJson, + writeManagedScript, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' + +const COMMAND_CODE_EVENTS = [ + { + eventName: 'PreToolUse', + definition: { matcher: '.*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUse', + definition: { matcher: '.*', hooks: [{ type: 'command', command: '' }] } + }, + { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } } +] as const + +function getConfigPath(): string { + return join(homedir(), '.commandcode', 'settings.json') +} + +function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'command-code-hook.cmd' : 'command-code-hook.sh' +} + +function getManagedScriptPath(): string { + return getSharedManagedScriptPath(getManagedScriptFileName()) +} + +function getManagedCommand(scriptPath: string): string { + return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) +} + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + 'if "%ORCA_AGENT_HOOK_PORT%"=="" if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" if not "%ORCA_AGENT_HOOK_PORT%"=="" call :sourceEndpointByPort', + 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', + 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', + 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + buildWindowsAgentHookPostCommand('command-code'), + 'exit /b 0', + ':sourceEndpointByPort', + 'if not defined APPDATA exit /b 0', + 'if exist "%APPDATA%\\orca-dev\\agent-hooks" for /r "%APPDATA%\\orca-dev\\agent-hooks" %%F in (endpoint.cmd) do call :maybeSourceEndpoint "%%~fF"', + 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" if exist "%APPDATA%\\orca\\agent-hooks" for /r "%APPDATA%\\orca\\agent-hooks" %%F in (endpoint.cmd) do call :maybeSourceEndpoint "%%~fF"', + 'exit /b 0', + ':maybeSourceEndpoint', + 'if not "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', + 'for /f "tokens=2 delims==" %%P in (\'findstr /b /c:"set ORCA_AGENT_HOOK_PORT=" "%~1" 2^>nul\') do if "%%P"=="%ORCA_AGENT_HOOK_PORT%" call "%~1" 2>nul', + 'exit /b 0', + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + '__orca_read_ancestor_var() {', + ' __orca_name="$1"', + ' __orca_pid="${PPID:-}"', + ' while [ -n "$__orca_pid" ] && [ "$__orca_pid" != "0" ] && [ "$__orca_pid" != "1" ]; do', + ' __orca_value=""', + ' if [ -r "/proc/$__orca_pid/environ" ]; then', + ' __orca_value=$(tr "\\000" "\\n" < "/proc/$__orca_pid/environ" 2>/dev/null | sed -n "s/^${__orca_name}=//p" | head -n 1)', + ' fi', + ' if [ -z "$__orca_value" ]; then', + ' __orca_value=$(ps eww -p "$__orca_pid" -o command= 2>/dev/null | tr " " "\\n" | sed -n "s/^${__orca_name}=//p" | head -n 1)', + ' fi', + ' if [ -n "$__orca_value" ]; then', + ' printf "%s\\n" "$__orca_value"', + ' return 0', + ' fi', + ' __orca_pid=$(ps -o ppid= -p "$__orca_pid" 2>/dev/null | tr -d " ")', + ' done', + ' return 1', + '}', + '__orca_fill_from_ancestor() {', + ' __orca_name="$1"', + ' case "$__orca_name" in', + ' ORCA_AGENT_HOOK_ENDPOINT) [ -z "${ORCA_AGENT_HOOK_ENDPOINT:-}" ] || return 0 ;;', + ' ORCA_AGENT_HOOK_PORT) [ -z "${ORCA_AGENT_HOOK_PORT:-}" ] || return 0 ;;', + ' ORCA_AGENT_HOOK_TOKEN) [ -z "${ORCA_AGENT_HOOK_TOKEN:-}" ] || return 0 ;;', + ' ORCA_AGENT_HOOK_ENV) [ -z "${ORCA_AGENT_HOOK_ENV:-}" ] || return 0 ;;', + ' ORCA_AGENT_HOOK_VERSION) [ -z "${ORCA_AGENT_HOOK_VERSION:-}" ] || return 0 ;;', + ' ORCA_PANE_KEY) [ -z "${ORCA_PANE_KEY:-}" ] || return 0 ;;', + ' ORCA_TAB_ID) [ -z "${ORCA_TAB_ID:-}" ] || return 0 ;;', + ' ORCA_WORKTREE_ID) [ -z "${ORCA_WORKTREE_ID:-}" ] || return 0 ;;', + ' *) return 0 ;;', + ' esac', + ' __orca_value=$(__orca_read_ancestor_var "$__orca_name") || return 0', + ' [ -n "$__orca_value" ] && export "$__orca_name=$__orca_value"', + '}', + '__orca_endpoint_value() {', + ' __orca_endpoint_name="$1"', + ' __orca_endpoint_path="$2"', + ' sed -n "s/^${__orca_endpoint_name}=//p" "$__orca_endpoint_path" 2>/dev/null | head -n 1', + '}', + '__orca_fill_from_endpoint_file() {', + ' __orca_endpoint_path="$1"', + ' [ -r "$__orca_endpoint_path" ] || return 0', + ' __orca_endpoint_port=$(__orca_endpoint_value ORCA_AGENT_HOOK_PORT "$__orca_endpoint_path")', + ' if [ -n "${ORCA_AGENT_HOOK_PORT:-}" ] && [ -n "$__orca_endpoint_port" ] && [ "$__orca_endpoint_port" != "$ORCA_AGENT_HOOK_PORT" ]; then', + ' return 0', + ' fi', + ' for __orca_endpoint_name in ORCA_AGENT_HOOK_PORT ORCA_AGENT_HOOK_TOKEN ORCA_AGENT_HOOK_ENV ORCA_AGENT_HOOK_VERSION; do', + ' eval "__orca_current=\\${$__orca_endpoint_name:-}"', + ' [ -z "$__orca_current" ] || continue', + ' __orca_endpoint_value=$(__orca_endpoint_value "$__orca_endpoint_name" "$__orca_endpoint_path")', + ' [ -n "$__orca_endpoint_value" ] && export "$__orca_endpoint_name=$__orca_endpoint_value"', + ' done', + '}', + '# Why: Command Code sanitizes hook subprocess env. The parent TUI process', + '# still has Orca pane/hook metadata, so recover it before posting.', + 'for __orca_name in ORCA_AGENT_HOOK_ENDPOINT ORCA_AGENT_HOOK_PORT ORCA_AGENT_HOOK_TOKEN ORCA_AGENT_HOOK_ENV ORCA_AGENT_HOOK_VERSION ORCA_PANE_KEY ORCA_TAB_ID ORCA_WORKTREE_ID; do', + ' __orca_fill_from_ancestor "$__orca_name"', + 'done', + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' __orca_fill_from_endpoint_file "$ORCA_AGENT_HOOK_ENDPOINT"', + 'fi', + '# Why: Command Code strips TOKEN-like env vars before invoking hooks. If', + '# ORCA_AGENT_HOOK_ENDPOINT was not exported into this PTY, recover the', + '# matching endpoint file by the unstripped loopback port.', + 'if [ -z "$ORCA_AGENT_HOOK_TOKEN" ] && [ -n "$ORCA_AGENT_HOOK_PORT" ]; then', + ' for endpoint in \\', + ' "$HOME/Library/Application Support/orca-dev/agent-hooks"/*/endpoint.env \\', + ' "$HOME/Library/Application Support/orca-dev/agent-hooks/endpoint.env" \\', + ' "${XDG_CONFIG_HOME:-$HOME/.config}/orca-dev/agent-hooks"/*/endpoint.env \\', + ' "${XDG_CONFIG_HOME:-$HOME/.config}/orca-dev/agent-hooks/endpoint.env" \\', + ' "$HOME/Library/Application Support/orca/agent-hooks/endpoint.env" \\', + ' "${XDG_CONFIG_HOME:-$HOME/.config}/orca/agent-hooks/endpoint.env"; do', + ' [ -r "$endpoint" ] || continue', + ' endpoint_port=$(sed -n "s/^ORCA_AGENT_HOOK_PORT=//p" "$endpoint" | head -n 1)', + ' if [ "$endpoint_port" = "$ORCA_AGENT_HOOK_PORT" ]; then', + ' __orca_fill_from_endpoint_file "$endpoint"', + ' break', + ' fi', + ' done', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi', + 'payload=$(cat)', + 'if [ -z "$payload" ]; then', + ' exit 0', + 'fi', + 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +function buildInstalledConfig( + config: NonNullable>, + command: string, + scriptFileName: string +): void { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const managedEvents = new Set(COMMAND_CODE_EVENTS.map((event) => event.eventName)) + + // Why: Orca owns only command-code-hook.* entries. Sweep retired managed + // events while preserving user-authored Command Code hooks. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + for (const event of COMMAND_CODE_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [{ type: 'command', command }] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + + config.hooks = nextHooks +} + +export class CommandCodeHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'command-code', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Command Code settings.json' + } + } + + const command = getManagedCommand(scriptPath) + const missing: string[] = [] + let presentCount = 0 + for (const event of COMMAND_CODE_EVENTS) { + const definitions = Array.isArray(config.hooks?.[event.eventName]) + ? config.hooks![event.eventName]! + : [] + const hasCommand = definitions.some((definition) => + (definition.hooks ?? []).some((hook) => hook.command === command) + ) + if (hasCommand) { + presentCount += 1 + } else { + missing.push(event.eventName) + } + } + + const managedHooksPresent = presentCount > 0 + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0) { + state = 'installed' + detail = null + } else if (presentCount === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = `Managed hook missing for events: ${missing.join(', ')}` + } + return { agent: 'command-code', state, configPath, managedHooksPresent, detail } + } + + install(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'command-code', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Command Code settings.json' + } + } + + buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName()) + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, config) + return this.getStatus() + } + + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const home = remoteHome.replace(/\/$/, '') + const remoteConfigPath = `${home}/.commandcode/settings.json` + const remoteScriptPath = `${home}/.orca/agent-hooks/command-code-hook.sh` + try { + const config = await readHooksJsonRemote(sftp, remoteConfigPath) + if (!config) { + return { + agent: 'command-code', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Command Code settings.json' + } + } + + buildInstalledConfig(config, wrapPosixHookCommand(remoteScriptPath), 'command-code-hook.sh') + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, config) + + return { + agent: 'command-code', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'command-code', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'command-code', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Command Code settings.json' + } + } + + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + config.hooks = nextHooks + writeHooksJson(configPath, config) + return this.getStatus() + } +} + +export const commandCodeHookService = new CommandCodeHookService() diff --git a/src/main/computer/macos-computer-use-permissions.test.ts b/src/main/computer/macos-computer-use-permissions.test.ts index 2f78b3cab23..d2c38856025 100644 --- a/src/main/computer/macos-computer-use-permissions.test.ts +++ b/src/main/computer/macos-computer-use-permissions.test.ts @@ -1,16 +1,40 @@ import { execFileSync, spawn, spawnSync } from 'child_process' +import { mkdtemp, readFile, rm, stat } from 'fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { openComputerUsePermissions } from './macos-computer-use-permissions' +import { + openComputerUsePermissions, + resetComputerUsePermissions +} from './macos-computer-use-permissions' const resolveHelperAppPathMock = vi.hoisted(() => vi.fn()) const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn()) vi.mock('child_process', () => ({ execFileSync: vi.fn(), - spawn: vi.fn(() => ({ unref: vi.fn() })), + spawn: vi.fn(() => { + const child = { + stdout: { on: vi.fn(), setEncoding: vi.fn() }, + stderr: { on: vi.fn(), setEncoding: vi.fn() }, + on: vi.fn((event: string, callback: (status: number) => void) => { + if (event === 'close') { + queueMicrotask(() => callback(0)) + } + return child + }), + unref: vi.fn() + } + return child + }), spawnSync: vi.fn() })) +vi.mock('fs/promises', () => ({ + mkdtemp: vi.fn(), + readFile: vi.fn(), + rm: vi.fn(), + stat: vi.fn() +})) + vi.mock('./macos-native-provider-paths', () => ({ resolveMacOSComputerUseAppPath: resolveHelperAppPathMock, resolveMacOSComputerUseExecutablePath: resolveHelperExecutablePathMock @@ -23,11 +47,17 @@ describe('openComputerUsePermissions', () => { vi.mocked(spawn).mockClear() vi.mocked(spawnSync).mockClear() vi.mocked(execFileSync).mockReset() + vi.mocked(mkdtemp).mockReset() + vi.mocked(readFile).mockReset() + vi.mocked(rm).mockReset() + vi.mocked(stat).mockReset() resolveHelperAppPathMock.mockReset() resolveHelperExecutablePathMock.mockReset() resolveHelperExecutablePathMock.mockReturnValue( '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos' ) + vi.mocked(mkdtemp).mockResolvedValue('/tmp/orca-computer-use-permissions-test') + vi.mocked(stat).mockResolvedValue({} as Awaited>) mockPermissionStatus('{"accessibility":"granted","screenshots":"granted"}') setPlatform('darwin') }) @@ -36,10 +66,10 @@ describe('openComputerUsePermissions', () => { setPlatform(originalPlatform) }) - it('does not launch the setup helper when all permissions are granted', () => { + it('does not launch the setup helper when all permissions are granted', async () => { resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') - expect(openComputerUsePermissions()).toEqual({ + await expect(openComputerUsePermissions()).resolves.toEqual({ platform: 'darwin', helperAppPath: '/Applications/Orca Computer Use.app', permissionId: undefined, @@ -51,14 +81,18 @@ describe('openComputerUsePermissions', () => { ], nextStep: null }) - expect(spawn).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalledWith( + '/usr/bin/open', + ['-n', '/Applications/Orca Computer Use.app', '--args', '--permissions'], + { detached: true, stdio: 'ignore' } + ) }) - it('launches the helper app in permissions mode', () => { + it('launches the helper app in permissions mode', async () => { resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}') - expect(openComputerUsePermissions()).toEqual({ + await expect(openComputerUsePermissions()).resolves.toEqual({ platform: 'darwin', helperAppPath: '/Applications/Orca Computer Use.app', permissionId: undefined, @@ -70,15 +104,14 @@ describe('openComputerUsePermissions', () => { ], nextStep: 'Grant Screen Recording to Orca Computer Use, then retry get-app-state.' }) - expect(spawn).toHaveBeenCalledTimes(1) expect(spawnSync).toHaveBeenCalledWith( '/usr/bin/pkill', - ['-f', 'orca-computer-use-macos --permission'], + ['-f', 'orca-computer-use-macos[[:space:]]+--permission([[:space:]]|$)'], { stdio: 'ignore' } ) expect(spawnSync).toHaveBeenCalledWith( '/usr/bin/pkill', - ['-f', 'orca-computer-use-macos --permissions'], + ['-f', 'orca-computer-use-macos[[:space:]]+--permissions([[:space:]]|$)'], { stdio: 'ignore' } ) expect(spawn).toHaveBeenCalledWith( @@ -88,11 +121,11 @@ describe('openComputerUsePermissions', () => { ) }) - it('launches a targeted permission helper flow', () => { + it('launches a targeted permission helper flow', async () => { resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') mockPermissionStatus('{"accessibility":"not-granted","screenshots":"not-granted"}') - expect(openComputerUsePermissions('accessibility')).toEqual({ + await expect(openComputerUsePermissions('accessibility')).resolves.toEqual({ platform: 'darwin', helperAppPath: '/Applications/Orca Computer Use.app', permissionId: 'accessibility', @@ -111,10 +144,33 @@ describe('openComputerUsePermissions', () => { ) }) - it('returns a no-op result on non-macOS platforms', () => { + it('launches a targeted permission helper even when that permission is already granted', async () => { + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}') + + await expect(openComputerUsePermissions('accessibility')).resolves.toEqual({ + platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + permissionId: 'accessibility', + openedSettings: true, + launchedHelper: true, + permissions: [ + { id: 'accessibility', status: 'granted' }, + { id: 'screenshots', status: 'not-granted' } + ], + nextStep: 'Grant Screen Recording to Orca Computer Use, then retry get-app-state.' + }) + expect(spawn).toHaveBeenCalledWith( + '/usr/bin/open', + ['-n', '/Applications/Orca Computer Use.app', '--args', '--permission', 'accessibility'], + { detached: true, stdio: 'ignore' } + ) + }) + + it('returns a no-op result on non-macOS platforms', async () => { setPlatform('linux') - expect(openComputerUsePermissions()).toEqual({ + await expect(openComputerUsePermissions()).resolves.toEqual({ platform: 'linux', helperAppPath: null, permissionId: undefined, @@ -129,27 +185,56 @@ describe('openComputerUsePermissions', () => { expect(spawn).not.toHaveBeenCalled() }) - it('throws when the helper app is missing on macOS', () => { + it('throws when the helper app is missing on macOS', async () => { resolveHelperAppPathMock.mockReturnValue(null) - expect(() => openComputerUsePermissions()).toThrow('Orca Computer Use.app was not found') + await expect(openComputerUsePermissions()).rejects.toThrow( + 'Orca Computer Use.app was not found' + ) }) - it('throws when the helper executable is missing during setup', () => { + it('throws when the helper executable is missing during setup', async () => { resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') resolveHelperExecutablePathMock.mockReturnValue(null) - expect(() => openComputerUsePermissions('accessibility')).toThrow( + await expect(openComputerUsePermissions('accessibility')).rejects.toThrow( '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos was not found' ) }) - it('reads permission status through the helper app executable', async () => { + it('wraps permission status helper launch failures', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + const child = { + stdout: { on: vi.fn(), setEncoding: vi.fn() }, + stderr: { on: vi.fn(), setEncoding: vi.fn() }, + on: vi.fn((event: string, callback: (error: Error) => void) => { + if (event === 'error') { + queueMicrotask(() => callback(new Error('spawn ENOENT /private/path'))) + } + return child + }), + unref: vi.fn() + } + vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ReturnType) + + await expect(getComputerUsePermissionStatus()).rejects.toMatchObject({ + name: 'RuntimeClientError', + code: 'accessibility_error', + message: 'Could not check permissions: failed to launch helper' + }) + expect(rm).toHaveBeenCalledWith('/tmp/orca-computer-use-permissions-test', { + recursive: true, + force: true + }) + }) + + it('reads permission status through the helper app identity', async () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}') - expect(getComputerUsePermissionStatus()).toEqual({ + await expect(getComputerUsePermissionStatus()).resolves.toEqual({ platform: 'darwin', helperAppPath: '/Applications/Orca Computer Use.app', helperUnavailableReason: null, @@ -158,18 +243,72 @@ describe('openComputerUsePermissions', () => { { id: 'screenshots', status: 'not-granted' } ] }) + expect(spawn).toHaveBeenCalledWith( + '/usr/bin/open', + [ + '-n', + '/Applications/Orca Computer Use.app', + '--args', + '--permission-status-file', + '/tmp/orca-computer-use-permissions-test/status.json' + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ) + expect(spawnSync).not.toHaveBeenCalled() + expect(readFile).toHaveBeenCalledWith( + '/tmp/orca-computer-use-permissions-test/status.json', + 'utf8' + ) + expect(rm).toHaveBeenCalledWith('/tmp/orca-computer-use-permissions-test', { + recursive: true, + force: true + }) + }) + + it('resets stale macOS TCC grants for the helper bundle id', async () => { + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + vi.mocked(readFile) + .mockResolvedValueOnce('{"accessibility":"granted","screenshots":"granted"}') + .mockResolvedValueOnce('{"accessibility":"not-granted","screenshots":"not-granted"}') + vi.mocked(execFileSync).mockReturnValueOnce('com.example.orca.computer-use\n') + vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) + + await expect(resetComputerUsePermissions()).resolves.toEqual({ + platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + helperUnavailableReason: null, + bundleId: 'com.example.orca.computer-use', + permissions: [ + { id: 'accessibility', status: 'not-granted' }, + { id: 'screenshots', status: 'not-granted' } + ] + }) expect(execFileSync).toHaveBeenCalledWith( - '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos', - ['--permission-status'], + '/usr/libexec/PlistBuddy', + [ + '-c', + 'Print :CFBundleIdentifier', + '/Applications/Orca Computer Use.app/Contents/Info.plist' + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } ) + expect(spawnSync).toHaveBeenCalledWith( + '/usr/bin/tccutil', + ['reset', 'Accessibility', 'com.example.orca.computer-use'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ) + expect(spawnSync).toHaveBeenCalledWith( + '/usr/bin/tccutil', + ['reset', 'ScreenCapture', 'com.example.orca.computer-use'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ) }) it('returns unavailable permission status when the helper app is missing on macOS', async () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') resolveHelperAppPathMock.mockReturnValue(null) - expect(getComputerUsePermissionStatus()).toEqual({ + await expect(getComputerUsePermissionStatus()).resolves.toEqual({ platform: 'darwin', helperAppPath: null, helperUnavailableReason: 'Orca Computer Use.app was not found', @@ -183,8 +322,8 @@ describe('openComputerUsePermissions', () => { }) function mockPermissionStatus(json: string): void { - vi.mocked(spawnSync).mockReturnValue({} as ReturnType) - vi.mocked(execFileSync).mockReturnValue(json) + vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) + vi.mocked(readFile).mockResolvedValue(json) } function setPlatform(platform: NodeJS.Platform): void { diff --git a/src/main/computer/macos-computer-use-permissions.ts b/src/main/computer/macos-computer-use-permissions.ts index 9ec40ef2d7d..c41fec98350 100644 --- a/src/main/computer/macos-computer-use-permissions.ts +++ b/src/main/computer/macos-computer-use-permissions.ts @@ -1,4 +1,8 @@ import { execFileSync, spawn, spawnSync } from 'child_process' +import { mkdtemp, readFile, rm, stat } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { setTimeout as delay } from 'timers/promises' import { RuntimeClientError } from './runtime-client-error' import { resolveMacOSComputerUseAppPath, @@ -6,14 +10,23 @@ import { } from './macos-native-provider-paths' import type { ComputerUsePermissionId, + ComputerUsePermissionResetResult, ComputerUsePermissionSetupResult, ComputerUsePermissionStatus, ComputerUsePermissionStatusResult } from '../../shared/computer-use-permissions-types' +const DEFAULT_COMPUTER_USE_BUNDLE_ID = 'com.stablyai.orca.computer-use' + export function openComputerUsePermissions( permissionId?: ComputerUsePermissionId -): ComputerUsePermissionSetupResult { +): Promise { + return openComputerUsePermissionsAsync(permissionId) +} + +async function openComputerUsePermissionsAsync( + permissionId?: ComputerUsePermissionId +): Promise { if (process.platform !== 'darwin') { return { platform: process.platform, @@ -33,7 +46,7 @@ export function openComputerUsePermissions( if (!helperAppPath) { throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found') } - const status = getComputerUsePermissionStatus() + const status = await getComputerUsePermissionStatus() if (status.helperUnavailableReason) { throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason) } @@ -70,16 +83,64 @@ export function openComputerUsePermissions( } } -function closeExistingPermissionHelpers(): void { - spawnSync('/usr/bin/pkill', ['-f', 'orca-computer-use-macos --permission'], { - stdio: 'ignore' - }) - spawnSync('/usr/bin/pkill', ['-f', 'orca-computer-use-macos --permissions'], { - stdio: 'ignore' - }) +export function resetComputerUsePermissions(): Promise { + return resetComputerUsePermissionsAsync() } -export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusResult { +async function resetComputerUsePermissionsAsync(): Promise { + if (process.platform !== 'darwin') { + return { + platform: process.platform, + helperAppPath: null, + helperUnavailableReason: null, + bundleId: null, + permissions: [ + { id: 'accessibility', status: 'unsupported' }, + { id: 'screenshots', status: 'unsupported' } + ] + } + } + + const helperAppPath = resolveMacOSComputerUseAppPath() + if (!helperAppPath) { + throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found') + } + + const status = await getComputerUsePermissionStatus() + if (status.helperUnavailableReason) { + throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason) + } + + const bundleId = readComputerUseBundleId(helperAppPath) + closeExistingPermissionHelpers() + resetTccPermission('Accessibility', bundleId) + resetTccPermission('ScreenCapture', bundleId) + + return { + ...(await getComputerUsePermissionStatus()), + bundleId + } +} + +function closeExistingPermissionHelpers(): void { + // Why: status probes use --permission-status-file and must not be killed + // while setup helpers are being replaced. + const setupHelperPatterns = [ + 'orca-computer-use-macos[[:space:]]+--permission([[:space:]]|$)', + 'orca-computer-use-macos[[:space:]]+--permissions([[:space:]]|$)' + ] + for (const pattern of setupHelperPatterns) { + spawnSync('/usr/bin/pkill', ['-f', pattern], { + stdio: 'ignore' + }) + } +} + +export function getComputerUsePermissionStatus(): Promise { + return getComputerUsePermissionStatusAsync() +} + +async function getComputerUsePermissionStatusAsync(): Promise { if (process.platform !== 'darwin') { return { platform: process.platform, @@ -105,7 +166,7 @@ export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusRes ) } - const raw = readPermissionStatusFromHelperExecutable(executablePath) + const raw = await readPermissionStatusFromHelperApp(helperAppPath) return { platform: process.platform, @@ -133,16 +194,111 @@ function createUnavailablePermissionStatus( } } -function readPermissionStatusFromHelperExecutable( - executablePath: string -): Partial> { - // Why: launching the nested helper via LaunchServices can make TCC evaluate - // Orca.app as responsible; the signed helper executable owns this grant. - const output = execFileSync(executablePath, ['--permission-status'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'] +async function readPermissionStatusFromHelperApp( + helperAppPath: string +): Promise>> { + const tempDir = await mkdtemp(join(tmpdir(), 'orca-computer-use-permissions-')) + const statusPath = join(tempDir, 'status.json') + try { + // Why: TCC status must be checked through the helper app identity. Directly + // execing the binary can inherit the parent app's already-granted context. + await launchPermissionStatusHelper(helperAppPath, statusPath) + + for (let attempt = 0; attempt < 50; attempt++) { + if (await fileExists(statusPath)) { + const output = await readFile(statusPath, 'utf8') + return JSON.parse(output) as Partial< + Record + > + } + await delay(100) + } + throw new RuntimeClientError('accessibility_error', 'Timed out checking permissions') + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +} + +function launchPermissionStatusHelper(helperAppPath: string, statusPath: string): Promise { + return new Promise((resolve, reject) => { + const launch = spawn( + '/usr/bin/open', + ['-n', helperAppPath, '--args', '--permission-status-file', statusPath], + { + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let stdout = '' + let stderr = '' + + launch.stdout?.setEncoding('utf8') + launch.stderr?.setEncoding('utf8') + launch.stdout?.on('data', (chunk) => { + stdout += chunk + }) + launch.stderr?.on('data', (chunk) => { + stderr += chunk + }) + launch.on('error', () => { + reject( + new RuntimeClientError( + 'accessibility_error', + 'Could not check permissions: failed to launch helper' + ) + ) + }) + launch.on('close', (status) => { + if (status === 0) { + resolve() + return + } + const detail = stderr.trim() || stdout.trim() || `exit ${status ?? 'unknown'}` + reject( + new RuntimeClientError('accessibility_error', `Could not check permissions: ${detail}`) + ) + }) }) - return JSON.parse(output) as Partial> +} + +async function fileExists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +function readComputerUseBundleId(helperAppPath: string): string { + const infoPlistPath = join(helperAppPath, 'Contents', 'Info.plist') + try { + const bundleId = execFileSync( + '/usr/libexec/PlistBuddy', + ['-c', 'Print :CFBundleIdentifier', infoPlistPath], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + } + ).trim() + return bundleId || DEFAULT_COMPUTER_USE_BUNDLE_ID + } catch { + return DEFAULT_COMPUTER_USE_BUNDLE_ID + } +} + +function resetTccPermission(service: string, bundleId: string): void { + // Why: macOS keeps TCC rows after uninstall; users need an explicit way to + // clear stale grants or denials for the helper's stable bundle identity. + const result = spawnSync('/usr/bin/tccutil', ['reset', service, bundleId], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + if (result.status === 0) { + return + } + const detail = + result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? 'unknown'}` + throw new RuntimeClientError('accessibility_error', `Could not reset ${service}: ${detail}`) } function nextPermissionStep( diff --git a/src/main/crash-reporting/process-gone-classification.test.ts b/src/main/crash-reporting/process-gone-classification.test.ts index 0e1e2623147..669dc5dbda7 100644 --- a/src/main/crash-reporting/process-gone-classification.test.ts +++ b/src/main/crash-reporting/process-gone-classification.test.ts @@ -9,6 +9,7 @@ describe('shouldRecordProcessGoneCrash', () => { expect( shouldRecordProcessGoneCrash({ source: 'renderer', + processType: 'renderer', reason: 'killed', exitCode: 15, expectedTeardown: 'renderer-reload' @@ -17,17 +18,29 @@ describe('shouldRecordProcessGoneCrash', () => { expect( shouldRecordProcessGoneCrash({ source: 'child', + processType: 'GPU', reason: 'killed', exitCode: 15, expectedTeardown: 'app-shutdown' }) ).toBe(false) - }) - it('records real crash reasons even during expected lifecycle teardown', () => { expect( shouldRecordProcessGoneCrash({ source: 'renderer', + processType: 'renderer', + reason: 'killed', + exitCode: 9, + expectedTeardown: 'app-shutdown' + }) + ).toBe(false) + }) + + it('records real crash reasons during expected renderer-only teardown', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'renderer', + processType: 'renderer', reason: 'crashed', exitCode: 5, expectedTeardown: 'renderer-reload' @@ -36,6 +49,7 @@ describe('shouldRecordProcessGoneCrash', () => { expect( shouldRecordProcessGoneCrash({ source: 'renderer', + processType: 'renderer', reason: 'oom', exitCode: null, expectedTeardown: 'renderer-reload' @@ -43,10 +57,47 @@ describe('shouldRecordProcessGoneCrash', () => { ).toBe(true) }) + it('suppresses crash-shaped GPU child exits during expected app shutdown', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'GPU', + reason: 'crashed', + exitCode: 5, + expectedTeardown: 'app-shutdown' + }) + ).toBe(false) + }) + + it('still records crash-shaped non-GPU child exits during expected app shutdown', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + reason: 'crashed', + exitCode: 5, + expectedTeardown: 'app-shutdown' + }) + ).toBe(true) + }) + + it('still records crash-shaped renderer exits during expected app shutdown', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'renderer', + processType: 'renderer', + reason: 'crashed', + exitCode: 5, + expectedTeardown: 'app-shutdown' + }) + ).toBe(true) + }) + it('skips SIGTERM killed events outside expected lifecycle teardown', () => { expect( shouldRecordProcessGoneCrash({ source: 'renderer', + processType: 'renderer', reason: 'killed', exitCode: 15, expectedTeardown: 'none' @@ -54,10 +105,118 @@ describe('shouldRecordProcessGoneCrash', () => { ).toBe(false) }) + it('skips Windows control termination killed events outside expected lifecycle teardown', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'renderer', + reason: 'killed', + exitCode: -1073741510, + expectedTeardown: 'none' + }) + ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + reason: 'killed', + exitCode: 1073807364, + expectedTeardown: 'none' + }) + ).toBe(false) + }) + + it('skips recoverable Chromium child process exits', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'GPU', + reason: 'crashed', + exitCode: -2147483645, + expectedTeardown: 'none' + }) + ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'gpu', + reason: 'killed', + exitCode: 1, + expectedTeardown: 'none' + }) + ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'network.mojom.NetworkService', + reason: 'killed', + exitCode: 9, + expectedTeardown: 'none' + }) + ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'network.mojom.NetworkService', + reason: 'crashed', + exitCode: -1, + expectedTeardown: 'none' + }) + ).toBe(false) + }) + + it('still records unknown child process crashes', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'com.orca.unexpected', + reason: 'crashed', + exitCode: 5, + expectedTeardown: 'none' + }) + ).toBe(true) + }) + + it('still records severe Chromium child process failures', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'GPU', + reason: 'oom', + exitCode: 1, + expectedTeardown: 'none' + }) + ).toBe(true) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'network.mojom.NetworkService', + reason: 'launch-failed', + exitCode: -1, + expectedTeardown: 'none' + }) + ).toBe(true) + }) + + it('still records renderer kills from the recent Linux crash-report cluster', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'renderer', + processType: 'renderer', + reason: 'killed', + exitCode: 61696, + expectedTeardown: 'none' + }) + ).toBe(true) + }) + it('records non-SIGTERM killed process exits outside expected lifecycle teardown', () => { expect( shouldRecordProcessGoneCrash({ source: 'renderer', + processType: 'renderer', reason: 'killed', exitCode: 9, expectedTeardown: 'none' @@ -69,6 +228,8 @@ describe('shouldRecordProcessGoneCrash', () => { expect( shouldRecordProcessGoneCrash({ source: 'child', + processType: 'Utility', + serviceName: 'com.orca.unexpected', reason: 'killed', exitCode: 9, expectedTeardown: 'renderer-reload' diff --git a/src/main/crash-reporting/process-gone-classification.ts b/src/main/crash-reporting/process-gone-classification.ts index 6d799524bc7..0d0f3aad98d 100644 --- a/src/main/crash-reporting/process-gone-classification.ts +++ b/src/main/crash-reporting/process-gone-classification.ts @@ -1,25 +1,75 @@ export type ProcessGoneSource = 'renderer' | 'child' export type ExpectedTeardownScope = 'none' | 'renderer-reload' | 'app-shutdown' +const WINDOWS_CONTROL_TERMINATION_EXIT_CODES = new Set([0xc000013a, 0x40010004]) +const RECOVERABLE_CHILD_PROCESS_TYPES = new Set(['gpu']) +const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set(['network.mojom.NetworkService']) +const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['crashed', 'killed']) + +function isWindowsControlTerminationExitCode(exitCode: number | null): boolean { + if (exitCode === null) { + return false + } + return WINDOWS_CONTROL_TERMINATION_EXIT_CODES.has(exitCode >>> 0) +} + +function isRecoverableChromiumChildProcess({ + source, + processType, + serviceName, + reason +}: { + source: ProcessGoneSource + processType?: string + serviceName?: string + reason: string +}): boolean { + if (source !== 'child') { + return false + } + if (!RECOVERABLE_CHILD_PROCESS_REASONS.has(reason)) { + return false + } + const normalizedProcessType = processType?.toLowerCase() + if (normalizedProcessType && RECOVERABLE_CHILD_PROCESS_TYPES.has(normalizedProcessType)) { + return true + } + return ( + normalizedProcessType === 'utility' && + serviceName !== undefined && + RECOVERABLE_UTILITY_SERVICE_NAMES.has(serviceName) + ) +} + export function shouldRecordProcessGoneCrash({ source, + processType, + serviceName, reason, exitCode, expectedTeardown }: { source: ProcessGoneSource + processType?: string + serviceName?: string reason: string exitCode: number | null expectedTeardown: ExpectedTeardownScope }): boolean { + // Why: GPU and Network Service exits are recoverable Chromium child-process + // churn; treating them as app crashes creates noisy user prompts. + if (isRecoverableChromiumChildProcess({ source, processType, serviceName, reason })) { + return false + } // Why: Electron reports intentional reload/update/quit teardown as `killed`. // Real renderer OOMs and Chromium crashes should still reach crash reporting. if (reason !== 'killed') { return true } // Why: Electron reports expected Chromium teardown during reload/update as - // `killed` + SIGTERM. Treat real crash reasons as reportable, but skip this. - if (exitCode === 15) { + // `killed` + SIGTERM or Windows control termination statuses. Treat real + // crash reasons as reportable, but skip these normal termination shapes. + if (exitCode === 15 || isWindowsControlTerminationExitCode(exitCode)) { return false } if (expectedTeardown === 'app-shutdown') { diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index f6d3670c80f..d83272bfb7b 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -5,9 +5,16 @@ import { existsSync, readFileSync, unlinkSync } from 'fs' import { connect, type Socket } from 'net' import { encodeNdjson } from './ndjson' import { getDaemonPidPath } from './daemon-spawner' -import { PROTOCOL_VERSION, type HelloMessage, type HelloResponse } from './types' +import { + PROTOCOL_VERSION, + type HelloMessage, + type HelloResponse, + type SystemResolverHealth, + type SystemResolverHealthResult +} from './types' const HEALTH_CHECK_TIMEOUT_MS = 3_000 +const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000 const KILL_WAIT_MS = 3_000 const KILL_POLL_MS = 100 const START_TIME_TOLERANCE_MS = 1_500 @@ -125,6 +132,109 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis }) } +function isSystemResolverHealth(value: unknown): value is SystemResolverHealth { + return value === 'healthy' || value === 'unhealthy' || value === 'unknown' +} + +export function getMacDaemonSystemResolverHealth( + socketPath: string, + tokenPath: string, + protocolVersion = PROTOCOL_VERSION +): Promise { + if (process.platform !== 'darwin') { + return Promise.resolve('unknown') + } + + return new Promise((resolve) => { + if (!existsSync(socketPath)) { + resolve('unknown') + return + } + + let token: string + try { + token = readFileSync(tokenPath, 'utf8').trim() + } catch { + resolve('unknown') + return + } + + let settled = false + let sock: Socket | null = null + const settle = (result: SystemResolverHealth): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + sock?.destroy() + resolve(result) + } + const timer = setTimeout(() => settle('unknown'), RESOLVER_HEALTH_CHECK_TIMEOUT_MS) + + sock = connect({ path: socketPath }) + sock.on('error', () => settle('unknown')) + sock.on('connect', () => { + const hello: HelloMessage = { + type: 'hello', + version: protocolVersion, + token, + clientId: 'resolver-health-check', + role: 'control' + } + sock?.write(encodeNdjson(hello)) + }) + + let buffer = '' + sock.on('data', (chunk: Buffer) => { + if (settled) { + return + } + buffer += chunk.toString() + for (;;) { + const newlineIdx = buffer.indexOf('\n') + if (newlineIdx === -1) { + break + } + const line = buffer.slice(0, newlineIdx) + buffer = buffer.slice(newlineIdx + 1) + if (!line) { + continue + } + + let message: Record + try { + message = JSON.parse(line) as Record + } catch { + settle('unknown') + return + } + + if (message.type === 'hello') { + if (!(message as HelloResponse).ok) { + settle('unknown') + return + } + // Why: the daemon must report health from inside its own process; + // external launchctl bsexec probes can misclassify healthy PTYs. + sock?.write(encodeNdjson({ id: 'resolver-health-1', type: 'systemResolverHealth' })) + continue + } + + if (message.id === 'resolver-health-1') { + if (!message.ok || typeof message.payload !== 'object' || message.payload === null) { + settle('unknown') + return + } + const payload = message.payload as Partial + settle(isSystemResolverHealth(payload.health) ? payload.health : 'unknown') + return + } + } + }) + }) +} + function commandLineMatchesDaemon( commandLine: string, socketPath: string, diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index f750369cc2a..ec91c7592e3 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -20,6 +20,7 @@ const { netConnectMock, forkMock, healthCheckDaemonMock, + getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, killStaleDaemonMock, getProcessStartedAtMsMock, @@ -58,15 +59,18 @@ const { }) const healthCheckDaemonMock = vi.fn(async () => true) + const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy') const getDaemonLaunchIdentityMock = vi.fn(() => 'match') const killStaleDaemonMock = vi.fn(async () => true) const getProcessStartedAtMsMock = vi.fn(() => 1_000_000) - const daemonClientMock = vi.fn().mockImplementation(() => ({ - ensureConnected: vi.fn(async () => {}), - request: vi.fn(async () => ({ sessions: [] })), - disconnect: vi.fn() - })) + const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [] })), + disconnect: vi.fn() + } + }) // Why: every DaemonSpawner constructed under test pushes into this array so // assertions can check "was the *same* spawner reused across restart?". @@ -87,6 +91,7 @@ const { netConnectMock, forkMock, healthCheckDaemonMock, + getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, killStaleDaemonMock, getProcessStartedAtMsMock, @@ -154,6 +159,7 @@ vi.mock('net', () => ({ connect: netConnectMock })) vi.mock('./daemon-health', () => ({ getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, + getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, healthCheckDaemon: healthCheckDaemonMock, killStaleDaemon: killStaleDaemonMock, getProcessStartedAtMs: getProcessStartedAtMsMock @@ -243,6 +249,8 @@ async function importFresh() { unbindLocalProviderListenersMock.mockClear() rebindLocalProviderListenersMock.mockClear() healthCheckDaemonMock.mockClear() + getMacDaemonSystemResolverHealthMock.mockReset() + getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy') getDaemonLaunchIdentityMock.mockClear() killStaleDaemonMock.mockClear() getAppPathMock.mockReset() @@ -429,6 +437,30 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(legacyAdapter.listProcesses).toHaveBeenCalled() }) + it('routes affected v9 daemon sessions through a legacy adapter on launch', async () => { + const mod = await importFresh() + probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false) + netConnectMock.mockImplementation(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, cb: () => void) { + handlers[event]?.push(cb) + if (event === 'connect') { + queueMicrotask(() => cb()) + } + return this + }, + destroy() {} + } + }) + + await mod.initDaemonPtyProvider() + + const { DaemonPtyRouter } = await import('./daemon-pty-router') + expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyRouter) + expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true) + }) + it('restart path with no legacy adapters yields a bare DaemonPtyAdapter (not wrapped in a router)', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -650,6 +682,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } return this }, + off(event: string, cb: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, disconnect: vi.fn(), unref: vi.fn() } @@ -675,6 +711,128 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) }) + it('respawns instead of reusing a protocol-healthy daemon with broken macOS resolver state', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy') + forkMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + return { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off(event: string, cb: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token') + expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled() + expect(killStaleDaemonMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token' + ) + expect(forkMock).toHaveBeenCalledWith( + '/fake/app/out/main/daemon-entry.js', + ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.objectContaining({ detached: true }) + ) + }) + + it('preserves a resolver-unhealthy daemon when it owns live sessions', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const requestMock = vi.fn(async (method: string) => { + if (method === 'listSessions') { + return { + sessions: [ + { sessionId: 'wt-1@@live', isAlive: true }, + { sessionId: 'wt-1@@dead', isAlive: false } + ] + } + } + return {} + }) + const disconnectMock = vi.fn() + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: requestMock, + disconnect: disconnectMock + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy') + + await launcher('/fake/socket', '/fake/token') + + expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token') + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(disconnectMock).toHaveBeenCalledOnce() + expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + + it('preserves a resolver-unhealthy daemon when live session state cannot be verified', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const requestMock = vi.fn(async (method: string) => { + if (method === 'listSessions') { + throw new Error('listSessions failed') + } + return {} + }) + const disconnectMock = vi.fn() + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: requestMock, + disconnect: disconnectMock + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy') + + await launcher('/fake/socket', '/fake/token') + + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(disconnectMock).toHaveBeenCalledOnce() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + it('uses the direct daemon entry when Electron app path is already out/main', async () => { probeSocketExistsMock.mockImplementation( (p?: string) => p === '/fake/app/out/main/daemon-entry.js' @@ -703,6 +861,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } return this }, + off(event: string, cb: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, disconnect: vi.fn(), unref: vi.fn() } @@ -717,6 +879,96 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) }) + it('removes detached daemon startup listeners after readiness', async () => { + healthCheckDaemonMock.mockResolvedValueOnce(false) + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const offMock = vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }) + const child = { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off: offMock, + disconnect: vi.fn(), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + + await launcher('/fake/socket', '/fake/token') + + expect(offMock).toHaveBeenCalledWith('message', expect.any(Function)) + expect(offMock).toHaveBeenCalledWith('error', expect.any(Function)) + expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function)) + expect(handlers.message).toHaveLength(0) + expect(handlers.error).toHaveLength(0) + expect(handlers.exit).toHaveLength(0) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + }) + + it('removes detached daemon startup listeners after startup error', async () => { + healthCheckDaemonMock.mockResolvedValueOnce(false) + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const offMock = vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }) + const child = { + pid: undefined, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'error') { + queueMicrotask(() => cb(new Error('startup failed'))) + } + return this + }, + off: offMock, + disconnect: vi.fn(), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + + await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow('startup failed') + + expect(offMock).toHaveBeenCalledWith('message', expect.any(Function)) + expect(offMock).toHaveBeenCalledWith('error', expect.any(Function)) + expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function)) + expect(handlers.message).toHaveLength(0) + expect(handlers.error).toHaveLength(0) + expect(handlers.exit).toHaveLength(0) + expect(child.disconnect).not.toHaveBeenCalled() + expect(child.unref).not.toHaveBeenCalled() + }) + it('keeps packaged healthy-daemon reuse independent of dev app-path identity', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 2e18330788f..fe741fe57e9 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -28,6 +28,7 @@ import { type ListSessionsResult } from './types' import { + getMacDaemonSystemResolverHealth, getDaemonLaunchIdentity, getProcessStartedAtMs, healthCheckDaemon, @@ -98,28 +99,67 @@ function probeSocket(socketPath: string): Promise { }) } +async function getAliveDaemonSessionCount( + socketPath: string, + tokenPath: string, + protocolVersion = PROTOCOL_VERSION +): Promise { + const client = new DaemonClient({ socketPath, tokenPath, protocolVersion }) + try { + await client.ensureConnected() + const result = await client.request('listSessions', undefined) + return result.sessions.filter((session) => session.isAlive).length + } catch { + return null + } finally { + client.disconnect() + } +} + +function createPreservedDaemonHandle( + runtimeDir: string, + protocolVersion = PROTOCOL_VERSION +): { shutdown(): Promise } { + return { + shutdown: async () => { + await cleanupDaemonForProtocol(runtimeDir, protocolVersion) + } + } +} + function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { return async (socketPath, tokenPath) => { const entryPath = getDaemonEntryPath() const healthy = await healthCheckDaemon(socketPath, tokenPath) if (healthy) { - // Why: dev worktrees share the same orca-dev userData, so a daemon from - // a deleted sibling checkout can pass protocol health checks while still - // pointing at missing native modules. Packaged app paths are stable and - // should preserve existing warm daemon reuse semantics. - const identity = app.isPackaged - ? 'match' - : getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) - if (identity === 'mismatch') { - console.warn('[daemon] Replacing daemon launched from a different app path') + const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) + if (resolverHealth === 'unhealthy') { + const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + if (liveSessionCount !== 0) { + console.warn( + liveSessionCount === null + ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' + : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return createPreservedDaemonHandle(runtimeDir) + } + console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) } else { - // Why: daemon is already running from a previous app session and - // responded to a protocol-level ping. Safe to reuse. - return { - shutdown: async () => { - await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) - } + // Why: dev worktrees share the same orca-dev userData, so a daemon from + // a deleted sibling checkout can pass protocol health checks while still + // pointing at missing native modules. Packaged app paths are stable and + // should preserve existing warm daemon reuse semantics. + const identity = app.isPackaged + ? 'match' + : getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) + if (identity === 'mismatch') { + console.warn('[daemon] Replacing daemon launched from a different app path') + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + } else { + // Why: daemon is already running from a previous app session and + // responded to a protocol-level ping. Safe to reuse. + return createPreservedDaemonHandle(runtimeDir) } } } @@ -150,8 +190,22 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // Wait for the daemon to signal readiness via IPC await new Promise((resolve, reject) => { - const fail = (error: Error): void => { - clearTimeout(timer) + let timer: ReturnType | undefined + let settled = false + function cleanupStartupListeners(): void { + if (timer) { + clearTimeout(timer) + } + child.off('message', onReadyMessage) + child.off('error', onStartupError) + child.off('exit', onStartupExit) + } + function fail(error: Error): void { + if (settled) { + return + } + settled = true + cleanupStartupListeners() if (child.pid) { try { process.kill(child.pid, 'SIGTERM') @@ -161,13 +215,15 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { } reject(error) } - const timer = setTimeout(() => { - fail(new Error('Daemon startup timed out')) - }, 10000) - - child.on('message', (msg: unknown) => { + function onReadyMessage(msg: unknown): void { if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') { - clearTimeout(timer) + if (settled) { + return + } + settled = true + // Why: the daemon process is detached after readiness; leaving + // startup listeners attached retains this launch promise closure. + cleanupStartupListeners() if (child.pid) { // Why: JSON pid file carries pid + process start time so later // killStaleDaemon() can verify the pid still belongs to the daemon @@ -189,15 +245,23 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { child.unref() resolve() } - }) + } - child.on('error', (err) => { + function onStartupError(err: Error): void { fail(err) - }) + } - child.on('exit', (code) => { + function onStartupExit(code: number | null): void { fail(new Error(`Daemon exited during startup with code ${code}`)) - }) + } + + timer = setTimeout(() => { + fail(new Error('Daemon startup timed out')) + }, 10000) + + child.on('message', onReadyMessage) + child.on('error', onStartupError) + child.on('exit', onStartupExit) }) return { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index e201d466c9a..8f721c44553 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -7,6 +7,19 @@ import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonServer } from './daemon-server' import { getHistorySessionDirName } from './history-paths' import type { SubprocessHandle } from './session' +import type * as DaemonHealthModule from './daemon-health' + +const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({ + getMacDaemonSystemResolverHealthMock: vi.fn(async () => 'unknown') +})) + +vi.mock('./daemon-health', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock + } +}) function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-adapter-test-')) @@ -85,6 +98,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) lastSpawnOpts = null + getMacDaemonSystemResolverHealthMock.mockReset() + getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown') }) afterEach(async () => { @@ -837,6 +852,101 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { await respawnServer?.shutdown() }) + it('preserves an unhealthy macOS resolver daemon when it owns live sessions', async () => { + const respawnFn = vi.fn() + const exits: { id: string; code: number }[] = [] + const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn }) + respawnAdapter.onExit((payload) => exits.push(payload)) + const existing = await respawnAdapter.spawn({ cols: 80, rows: 24 }) + getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy') + + const next = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true }) + + expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith( + socketPath, + tokenPath, + respawnAdapter.protocolVersion + ) + expect(respawnFn).not.toHaveBeenCalled() + expect(exits).toEqual([]) + expect(next.id).toBeDefined() + expect(next.id).not.toBe(existing.id) + + respawnAdapter.dispose() + }) + + it('preserves an unhealthy macOS resolver daemon when live sessions have not been reconciled locally', async () => { + const respawnFn = vi.fn() + const background = await adapter.spawn({ cols: 80, rows: 24 }) + const backgroundSubprocess = lastSubprocess + const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn }) + getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy') + + const next = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true }) + + expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith( + socketPath, + tokenPath, + respawnAdapter.protocolVersion + ) + expect(respawnFn).not.toHaveBeenCalled() + expect(next.id).toBeDefined() + expect(next.id).not.toBe(background.id) + expect(backgroundSubprocess.forceKill).not.toHaveBeenCalled() + + respawnAdapter.dispose() + }) + + it('replaces an unhealthy macOS resolver daemon before creating a fresh session when no sessions are active', async () => { + let respawnServer: DaemonServer | undefined + const respawnFn = vi.fn(async () => { + await server.shutdown() + rmSync(socketPath, { force: true }) + respawnServer = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + await respawnServer.start() + }) + const exits: { id: string; code: number }[] = [] + const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn }) + respawnAdapter.onExit((payload) => exits.push(payload)) + getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy') + + const replacement = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true }) + + expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith( + socketPath, + tokenPath, + respawnAdapter.protocolVersion + ) + expect(respawnFn).toHaveBeenCalledOnce() + expect(exits).toEqual([]) + expect(replacement.id).toBeDefined() + + respawnAdapter.dispose() + await respawnServer?.shutdown() + }) + + it('does not resolver-health restart attach-style spawns', async () => { + const respawnFn = vi.fn() + const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn }) + getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy') + + const result = await respawnAdapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'caller-owned-session' + }) + + expect(result.id).toBe('caller-owned-session') + expect(getMacDaemonSystemResolverHealthMock).not.toHaveBeenCalled() + expect(respawnFn).not.toHaveBeenCalled() + + respawnAdapter.dispose() + }) + it('propagates respawn failure to the caller', async () => { const respawnFn = vi.fn(async () => { throw new Error('Daemon entry file missing') diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index f5c6cedb617..c956c3a63b2 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -4,6 +4,7 @@ adapter ↔ history lifecycle logic. */ import { basename } from 'path' import { existsSync } from 'fs' import { DaemonClient } from './client' +import { getMacDaemonSystemResolverHealth } from './daemon-health' import { HistoryManager } from './history-manager' import { HistoryReader } from './history-reader' import { mintPtySessionId, parsePtySessionId } from './pty-session-id' @@ -41,6 +42,8 @@ export class TerminalKilledError extends Error { export class DaemonPtyAdapter implements IPtyProvider { readonly protocolVersion: number + private socketPath: string + private tokenPath: string private client: DaemonClient private historyManager: HistoryManager | null private historyReader: HistoryReader | null @@ -75,6 +78,8 @@ export class DaemonPtyAdapter implements IPtyProvider { constructor(opts: DaemonPtyAdapterOptions) { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION + this.socketPath = opts.socketPath + this.tokenPath = opts.tokenPath this.client = new DaemonClient({ socketPath: opts.socketPath, tokenPath: opts.tokenPath, @@ -95,14 +100,16 @@ export class DaemonPtyAdapter implements IPtyProvider { } private async doSpawn(opts: PtySpawnOptions): Promise { - await this.ensureConnected() - const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId) if (this.killedSessionTombstones.has(sessionId)) { throw new TerminalKilledError(sessionId) } + if (opts.isNewSession) { + await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() + } + // Why: detect crash-recovery history before spawning a replacement PTY so // the revived shell inherits the recovered cwd and dimensions instead of // whatever the current renderer happened to request on mount. @@ -111,12 +118,15 @@ export class DaemonPtyAdapter implements IPtyProvider { const effectiveCols = restoreInfo?.cols ?? opts.cols const effectiveRows = restoreInfo?.rows ?? opts.rows + await this.ensureConnected() + const result = await this.client.request('createOrAttach', { sessionId, cols: effectiveCols, rows: effectiveRows, cwd: effectiveCwd, env: opts.env, + envToDelete: opts.envToDelete, command: opts.command, // Why: without this, the daemon always spawns cmd.exe (COMSPEC) or // PowerShell as a fallback — regardless of which shell the renderer @@ -646,8 +656,56 @@ export class DaemonPtyAdapter implements IPtyProvider { } } - private async doRespawn(): Promise { - console.warn('[daemon] Daemon died — respawning') + private async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise { + if (!this.respawnFn) { + return + } + + const health = await getMacDaemonSystemResolverHealth( + this.socketPath, + this.tokenPath, + this.protocolVersion + ) + if (health !== 'unhealthy') { + return + } + + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified' + : `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return + } + + // Why: replacing the daemon kills its sessions without daemon-side exit + // fanout. Emit exits first so renderer panes do not write to dead PTYs. + this.fanoutSyntheticExits(-1) + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn( + '[daemon] macOS system resolver unavailable - respawning daemon' + ).finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + } + + private async getDaemonLiveSessionCount(): Promise { + try { + await this.client.ensureConnected() + const result = await this.client.request('listSessions', undefined) + return result.sessions.filter((session) => session.isAlive).length + } catch { + return null + } + } + + private async doRespawn(message = '[daemon] Daemon died — respawning'): Promise { + console.warn(message) this.removeEventListener?.() this.removeEventListener = null this.client.disconnect() diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts index 74a5d834874..fa8252488da 100644 --- a/src/main/daemon/daemon-pty-provider.ts +++ b/src/main/daemon/daemon-pty-provider.ts @@ -12,6 +12,7 @@ export type DaemonSpawnOptions = { sessionId: string cwd?: string env?: Record + envToDelete?: string[] command?: string } @@ -44,6 +45,7 @@ export class DaemonPtyProvider { rows: opts.rows, cwd: opts.cwd, env: opts.env, + envToDelete: opts.envToDelete, command: opts.command }) diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index df547bad81a..526af432444 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: daemon server RPC, auth, stream batching, and shutdown behavior share one socket/client harness; splitting would duplicate setup. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { connect, type Socket } from 'net' +import { connect, type Server, type Socket } from 'net' import { tmpdir } from 'os' import { join } from 'path' import { mkdtempSync, rmSync, readFileSync } from 'fs' @@ -44,6 +44,7 @@ function createMockSubprocess(): SubprocessHandle & { } type DaemonServerPrivate = { + server: Server | null clients: Map< string, { @@ -97,6 +98,13 @@ describe('DaemonServer', () => { expect(token.length).toBeGreaterThan(0) }) + it('removes the startup error listener after listening', async () => { + await startServer() + + const daemon = server as unknown as DaemonServerPrivate + expect(daemon.server?.listenerCount('error')).toBe(0) + }) + it('accepts client connections', async () => { await startServer() const c = await connectClient() @@ -145,6 +153,15 @@ describe('DaemonServer', () => { expect(result).toEqual({ pong: true }) }) + it('handles systemResolverHealth', async () => { + await startServer() + const c = await connectClient() + + const result = await c.request<{ health: unknown }>('systemResolverHealth', undefined) + + expect(['healthy', 'unhealthy', 'unknown']).toContain(result.health) + }) + it('handles write (fire-and-forget)', async () => { await startServer() const c = await connectClient() diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index c6e29f568a0..42c385c9464 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -5,6 +5,7 @@ import { writeFileSync, chmodSync, unlinkSync } from 'fs' import { encodeNdjson, createNdjsonParser } from './ndjson' import { TerminalHost } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import type { SubprocessHandle } from './session' import { PROTOCOL_VERSION, @@ -62,12 +63,16 @@ export class DaemonServer { async start(): Promise { return new Promise((resolve, reject) => { this.server = createServer((socket) => this.handleConnection(socket)) - - this.server.on('error', (err) => { + const onListenError = (err: Error): void => { reject(err) - }) + } + + this.server.once('error', onListenError) this.server.listen(this.socketPath, () => { + // Why: after bind, steady-state socket errors are handled per client; + // the startup promise listener would otherwise retain this closure. + this.server?.off('error', onListenError) writeFileSync(this.tokenPath, this.token, { mode: 0o600 }) try { chmodSync(this.socketPath, 0o600) @@ -212,6 +217,7 @@ export class DaemonServer { rows: p.rows, cwd: p.cwd, env: p.env, + envToDelete: p.envToDelete, command: p.command, shellOverride: p.shellOverride, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, @@ -308,6 +314,9 @@ export class DaemonServer { case 'ping': return { pong: true } + case 'systemResolverHealth': + return { health: readCurrentProcessMacSystemResolverHealth() } + case 'shutdown': if (request.payload.killSessions) { this.host.dispose() diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index 832f6c949b2..721ead728ae 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -7,6 +7,15 @@ import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' import type { SubprocessHandle } from './session' +const { forkMock } = vi.hoisted(() => ({ + forkMock: vi.fn() +})) + +vi.mock('child_process', async () => { + const actual = await vi.importActual>('child_process') + return { ...actual, fork: forkMock } +}) + function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'prod-launcher-test-')) } @@ -41,6 +50,7 @@ describe('createProductionLauncher', () => { for (const h of handles) { await h.shutdown().catch(() => {}) } + forkMock.mockReset() rmSync(dir, { recursive: true, force: true }) }) @@ -75,4 +85,51 @@ describe('createProductionLauncher', () => { await handle.shutdown() handles.pop() }) + + it('removes startup child listeners after readiness', async () => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + killed: false, + on: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }), + kill: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + }) + + const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) + handlers.message[0]?.({ type: 'ready' }) + const handle = await launch + + expect(handle.shutdown).toEqual(expect.any(Function)) + expect(handlers.message).toHaveLength(0) + expect(handlers.error).toHaveLength(0) + expect(handlers.exit).toHaveLength(0) + expect(child.disconnect).toHaveBeenCalled() + expect(child.unref).toHaveBeenCalled() + }) }) + +function socketPathFor(dir: string): string { + return join(dir, 'test.sock') +} + +function tokenPathFor(dir: string): string { + return join(dir, 'test.token') +} diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts index 6ce852bb056..10693ef7991 100644 --- a/src/main/daemon/production-launcher.ts +++ b/src/main/daemon/production-launcher.ts @@ -32,27 +32,53 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo function waitForReady(child: ChildProcess): Promise { return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - child.kill('SIGTERM') - reject(new Error('Daemon failed to signal readiness within timeout')) - }, READY_TIMEOUT_MS) - - child.on('message', (msg: unknown) => { - if (msg && typeof msg === 'object' && (msg as Record).type === 'ready') { + let timeout: ReturnType | undefined + let settled = false + function cleanupStartupListeners(): void { + if (timeout) { clearTimeout(timeout) + } + child.off('message', onMessage) + child.off('error', onError) + child.off('exit', onExit) + } + function fail(error: Error, killChild = false): void { + if (settled) { + return + } + settled = true + cleanupStartupListeners() + if (killChild) { + child.kill('SIGTERM') + } + reject(error) + } + function onMessage(msg: unknown): void { + if (msg && typeof msg === 'object' && (msg as Record).type === 'ready') { + if (settled) { + return + } + settled = true + // Why: the daemon is detached after readiness, so startup listeners + // must not keep the child process closure alive for the daemon lifetime. + cleanupStartupListeners() resolve() } - }) + } + function onError(err: Error): void { + fail(new Error(`Daemon process error: ${err.message}`)) + } + function onExit(code: number | null): void { + fail(new Error(`Daemon process exited prematurely with code ${code}`)) + } - child.on('error', (err) => { - clearTimeout(timeout) - reject(new Error(`Daemon process error: ${err.message}`)) - }) + timeout = setTimeout(() => { + fail(new Error('Daemon failed to signal readiness within timeout'), true) + }, READY_TIMEOUT_MS) - child.on('exit', (code) => { - clearTimeout(timeout) - reject(new Error(`Daemon process exited prematurely with code ${code}`)) - }) + child.on('message', onMessage) + child.on('error', onError) + child.on('exit', onExit) }) } diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index b18e60ac192..daed3cced1e 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -38,7 +38,9 @@ import { createPtySubprocess } from './pty-subprocess' const ORCA_SHELL_WRAPPER_ENV = [ 'ORCA_ATTRIBUTION_SHIM_DIR', 'ORCA_OPENCODE_CONFIG_DIR', - 'ORCA_PI_CODING_AGENT_DIR' + 'ORCA_PI_CODING_AGENT_DIR', + 'ORCA_OMP_CODING_AGENT_DIR', + 'ORCA_CODEX_HOME' ] as const const POWERSHELL_OSC133_COMMAND_ARGS = ['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)] const ZSH_SHELL_READY_DIR = /shell-ready[\\/]zsh/ @@ -269,6 +271,40 @@ describe('createPtySubprocess', () => { expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('token') }) + it('preserves explicit development agent hook endpoint files', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previousEndpoint = process.env.ORCA_AGENT_HOOK_ENDPOINT + process.env.ORCA_AGENT_HOOK_ENDPOINT = '/tmp/stale-endpoint.env' + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { + ORCA_AGENT_HOOK_ENV: 'development', + ORCA_AGENT_HOOK_PORT: '1234', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_AGENT_HOOK_VERSION: '1', + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/fresh-endpoint.env' + } + }) + } finally { + if (previousEndpoint === undefined) { + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + } else { + process.env.ORCA_AGENT_HOOK_ENDPOINT = previousEndpoint + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe('/tmp/fresh-endpoint.env') + expect(env.ORCA_AGENT_HOOK_ENV).toBe('development') + expect(env.ORCA_AGENT_HOOK_PORT).toBe('1234') + expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('token') + }) + it('forwards write calls', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -485,6 +521,61 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') }) + it('uses shell wrapper when Codex home must survive shell startup', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { + SHELL: '/bin/zsh', + CODEX_HOME: '/tmp/orca-codex-home', + ORCA_CODEX_HOME: '/tmp/orca-codex-home' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[1]).toEqual(['-l']) + expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) + expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + }) + + it('deletes requested env keys after merging daemon process env', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previousCodexHome = process.env.CODEX_HOME + process.env.CODEX_HOME = '/host/codex-home' + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { SHELL: '/bin/bash' }, + envToDelete: ['CODEX_HOME'] + }) + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = previousCodexHome + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[2].env.CODEX_HOME).toBeUndefined() + }) + it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 84e52f0bfe4..c84f003f2ee 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -32,6 +32,7 @@ export type PtySubprocessOptions = { rows: number cwd?: string env?: Record + envToDelete?: string[] command?: string /** Explicit shell executable path/basename the renderer asked for. * Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user @@ -72,9 +73,10 @@ function removeInheritedDevAgentHookEndpoint( env: Record, explicitEnv: Record | undefined ): void { - if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development') { + if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) { // Why: the daemon inherits the app process env before per-PTY env is - // merged, so dev terminals must explicitly drop a parent endpoint.env. + // merged. Strip only stale parent endpoints; a fresh explicit endpoint is + // needed by hooks whose runners scrub token-like env vars before exec. delete env.ORCA_AGENT_HOOK_ENDPOINT } } @@ -184,6 +186,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // restores clickable refs like `owner/repo#123` / `PR#123`. FORCE_HYPERLINK: '1' } as Record + for (const key of opts.envToDelete ?? []) { + delete env[key] + } // Why: the daemon is forked from Electron and can inherit the pane identity // of the terminal that launched `pn dev`; each PTY must opt into its own. removeUnspecifiedPaneIdentityEnv(env, opts.env) @@ -260,7 +265,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl ? getShellReadyLaunchConfig(shellPath) : env.ORCA_ATTRIBUTION_SHIM_DIR || env.ORCA_OPENCODE_CONFIG_DIR || - env.ORCA_PI_CODING_AGENT_DIR + env.ORCA_PI_CODING_AGENT_DIR || + env.ORCA_OMP_CODING_AGENT_DIR || + env.ORCA_CODEX_HOME ? getAttributionShellLaunchConfig(shellPath) : null if (shellLaunch) { diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index afdba671697..b4fc606696a 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -130,6 +130,7 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -155,6 +156,7 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -185,6 +187,7 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -210,9 +213,9 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('local _orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') - expect(zshenv).toContain('[[ -f "$_orca_user_zdotdir/.zshenv" ]]') - expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') + expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') + expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;') + expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') }) it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => { @@ -228,12 +231,28 @@ describePosix('daemon shell-ready launch config', () => { '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' const piRestoreLine = '[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"' + const codexRestoreLine = + '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' + const ompRestoreLine = + 'if [[ -z "${ORCA_PI_CODING_AGENT_DIR:-}" && -n "${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then' + const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) expect(zshrc).toContain(piRestoreLine) expect(zlogin).toContain(piRestoreLine) expect(bashRc).toContain(piRestoreLine) + expect(zshrc).toContain(codexRestoreLine) + expect(zlogin).toContain(codexRestoreLine) + expect(bashRc).toContain(codexRestoreLine) + // OMP launches use ORCA_OMP_CODING_AGENT_DIR; both restore lines must be + // present so a PTY of either kind has its overlay restored after rc files. + expect(zshrc).toContain(ompRestoreLine) + expect(zlogin).toContain(ompRestoreLine) + expect(bashRc).toContain(ompRestoreLine) + expect(zshrc).toContain(ompWrapperLine) + expect(zlogin).toContain(ompWrapperLine) + expect(bashRc).toContain(ompWrapperLine) }) // Why: regression guard for issue #2422. The daemon-side bash wrapper must @@ -314,6 +333,7 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice/.config/zsh') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -394,4 +414,40 @@ describePosix('daemon shell-ready launch config', () => { } } }) + + it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => { + // Why: PR #1737 sourced .zshenv inside a wrapper function, which broke + // common patterns like "typeset -U path". The fix must keep .zshenv at + // zsh top level while still capturing the ZDOTDIR it resolved. + const { getShellReadyLaunchConfig } = await importFreshShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + + const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + + expect(zshenv).toContain('unset ZDOTDIR') + expect(zshenv).toContain('_orca_zshenv_source_dir="${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"') + expect(zshenv).toContain('source "${_orca_zshenv_path}"') + expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"') + expect(zshenv).toContain( + 'export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"' + ) + expect(zshenv).toContain('export ZDOTDIR=') + }) + + it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => { + // Why: if user .zshenv returns early or doesn't set ZDOTDIR, the wrapper + // should fall back to the spawn-env ORCA_ORIG_ZDOTDIR (if present), then HOME. + const { getShellReadyLaunchConfig } = await importFreshShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + + const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + + // Save spawn-env value before sourcing user .zshenv + expect(zshenv).toContain('_orca_spawn_orig_zdotdir="${ORCA_ORIG_ZDOTDIR:-}"') + + // Fallback chain: discovered → normalized spawn-env path → HOME + expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') + }) }) diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 2955046fda4..81f7834ae2e 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -10,16 +10,14 @@ import { getPowerShellOsc133Bootstrap, isPowerShellExecutableName } from '../powershell-osc133-bootstrap' +import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { getZshEnvTemplate } from '../shell-templates' const ORCA_USER_DATA_PATH_ENV = 'ORCA_USER_DATA_PATH' const SHELL_READY_MARKER = '\\033]777;orca-shell-ready\\007' let didEnsureShellReadyWrappers = false -function quotePosixSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - function getShellReadyWrapperRoot(): string { const userDataPath = process.env[ORCA_USER_DATA_PATH_ENV] // Why: older/test launchers may not seed ORCA_USER_DATA_PATH. Keep a @@ -64,6 +62,10 @@ function resolveOriginalZdotdir(): string { ) } +function resolveOriginalZshenvSourceDir(): string { + return normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || process.env.HOME || '' +} + function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] { return [ join(root, 'zsh', '.zshenv'), @@ -99,8 +101,15 @@ __orca_restore_attribution_path # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the PTY-scoped overlay before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" -# Why: PI_CODING_AGENT_DIR is also a single-root env var users may re-export. +# Why: bare shells carry both Pi and OMP shadows so a later typed OMP can +# switch on demand. Keep Pi as the shell default unless this PTY is OMP-only. [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then + export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" +fi +${getPosixOmpShellWrapper()} +# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. +[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command exits — mirrors the zsh daemon wrapper. # Without this, bash users (default on most Linux distros) keep a stuck @@ -201,8 +210,14 @@ __orca_restore_attribution_path() { if [[ ! -o login ]]; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" - # Why: PI_CODING_AGENT_DIR must keep the same PTY-scoped overlay after rc files. + # Why: bare shells carry both Pi and OMP shadows; keep Pi as the default and + # let the OMP wrapper switch to OMP only for that command. [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" + if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then + export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" + fi + ${getPosixOmpShellWrapper()} + [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi __orca_osc133_precmd() { local exit_code=$? @@ -235,42 +250,7 @@ function ensureShellReadyWrappers(): void { const zshDir = join(root, 'zsh') const bashDir = join(root, 'bash') - const zshEnv = `# Orca daemon zsh shell-ready wrapper -_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}" -# Why: clearing ZDOTDIR lets user .zshenv use the canonical XDG idiom -# \`export ZDOTDIR="\${ZDOTDIR:-$XDG_CONFIG_HOME/zsh}"\` to compute its -# preferred dir; pre-setting it (even to HOME) defeats that default. -unset ZDOTDIR -# Why: function isolates user .zshenv \`return\` so it doesn't abort our wrapper. -# Trade-off: top-level \`setopt LOCAL_OPTIONS\`/\`LOCAL_TRAPS\`, \`TRAPEXIT\`, and -# bare \`local\`/\`typeset\` in user .zshenv become function-scoped; use \`typeset -g\` -# or \`export\` to escape. -__orca_source_user_zshenv() { - # Why: honor an externally-set ZDOTDIR (login manager, /etc/zshenv, parent - # shell) so users whose real .zshenv lives at $ZDOTDIR (not $HOME) still - # get PATH/aliases/exports loaded. Falls back to $HOME when no spawn-env - # ZDOTDIR was inherited. - local _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}" - [[ -f "$_orca_user_zdotdir/.zshenv" ]] && source "$_orca_user_zdotdir/.zshenv" -} -__orca_source_user_zshenv -unfunction __orca_source_user_zshenv -# Why: prefer the ZDOTDIR user .zshenv resolved (XDG case); else preserve -# the spawn-env value (an inherited resolution from a parent Orca PTY); -# else HOME. -export ORCA_ORIG_ZDOTDIR="\${ZDOTDIR:-\${_orca_spawn_orig_zdotdir:-$HOME}}" -unset _orca_spawn_orig_zdotdir -# Why: strip trailing slashes (matches Node-side normalizer) before the -# self-loop check, so a wrapper-shaped ZDOTDIR with one or more trailing -# slashes still gets normalized away from .zprofile/.zshrc/.zlogin. -while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do - ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}" -done -case "\${ORCA_ORIG_ZDOTDIR}" in - */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; -esac -export ZDOTDIR=${quotePosixSingle(zshDir)} -` + const zshEnv = getZshEnvTemplate(zshDir, 'daemon') const zshProfile = `# Orca daemon zsh shell-ready wrapper _orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" case "\${_orca_home%/}" in @@ -298,6 +278,11 @@ __orca_restore_attribution_path # Why: .zlogin is the final login startup file before the prompt is shown. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then + export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" +fi +${getPosixOmpShellWrapper()} +[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "${SHELL_READY_MARKER}" @@ -319,10 +304,25 @@ fi [join(bashDir, 'rcfile'), bashRc] ] as const - for (const [path, content] of files) { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, content, 'utf8') - chmodSync(path, 0o644) + try { + for (const [path, content] of files) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content, 'utf8') + chmodSync(path, 0o644) + } + } catch (error) { + // Why: wrapper file creation can fail due to read-only filesystems, permission + // issues, or disk space. Rather than crashing, log the error and continue. + // The shell will launch without the wrapper, which means no shell-ready marker + // but at least the PTY is usable. + const errorMessage = + error instanceof Error + ? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})` + : String(error) + console.error(`[daemon/shell-ready] Failed to create wrapper files in ${root}: ${errorMessage}`) + console.error('[daemon/shell-ready] Shell will launch without wrapper (no shell-ready marker)') + // Reset the flag so next attempt will try again + didEnsureShellReadyWrappers = false } } @@ -361,6 +361,7 @@ function getWrappedShellLaunchConfig( args: ['-l'], env: { ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(), + ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(), ZDOTDIR: join(root, 'zsh'), ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0' }, diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index bfa3c058c00..672eddd4805 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -12,6 +12,7 @@ export type CreateOrAttachOptions = { rows: number cwd?: string env?: Record + envToDelete?: string[] command?: string /** Explicit shell the renderer asked for (e.g. 'wsl.exe' for "New WSL * terminal" from the "+" menu). Forwarded to the subprocess spawner so the @@ -38,6 +39,7 @@ export type TerminalHostOptions = { rows: number cwd?: string env?: Record + envToDelete?: string[] command?: string shellOverride?: string terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' @@ -101,6 +103,7 @@ export class TerminalHost { rows: size.rows, cwd: opts.cwd, env: opts.env, + envToDelete: opts.envToDelete, command: opts.command, shellOverride: opts.shellOverride, terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a88fb46c659..a1b5501db18 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,10 +1,10 @@ // ─── Protocol Version ──────────────────────────────────────────────── // Why: daemons can survive app updates. Bump for IPC wire-shape changes, or // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. -// Why: bumped from 6 -> 7 so existing daemons restart with the headless -// emulator's mouse-mode snapshot tracking for mobile alternate-screen TUIs. -export const PROTOCOL_VERSION = 7 -export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6] as const +// Why: bumped from 9 -> 10 so affected v9 daemons are preserved as legacy +// instead of being resolver-health replaced while they own live PTY sessions. +export const PROTOCOL_VERSION = 10 +export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const // ─── Session State Machine ────────────────────────────────────────── export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' @@ -63,6 +63,7 @@ export type CreateOrAttachRequest = { rows: number cwd?: string env?: Record + envToDelete?: string[] command?: string /** Explicit Windows shell override selected by the user (e.g. 'wsl.exe'). * The daemon forwards this to its subprocess spawner so each tab honors @@ -165,6 +166,11 @@ export type PingRequest = { type: 'ping' } +export type SystemResolverHealthRequest = { + id: string + type: 'systemResolverHealth' +} + export type GetSnapshotRequest = { id: string type: 'getSnapshot' @@ -186,6 +192,7 @@ export type DaemonRequest = | ClearScrollbackRequest | ShutdownRequest | PingRequest + | SystemResolverHealthRequest | GetSnapshotRequest // ─── RPC Responses (Daemon → Client, on control socket) ──────────── @@ -219,6 +226,12 @@ export type ListSessionsResult = { sessions: SessionInfo[] } +export type SystemResolverHealth = 'healthy' | 'unhealthy' | 'unknown' + +export type SystemResolverHealthResult = { + health: SystemResolverHealth +} + export type SessionInfo = { sessionId: string state: SessionState diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts new file mode 100644 index 00000000000..7490a1b8434 --- /dev/null +++ b/src/main/gemini/hook-service.test.ts @@ -0,0 +1,152 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import type * as osModule from 'os' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +import { GeminiHookService } from './hook-service' + +describe('GeminiHookService', () => { + let homeDir: string + let userDataDir: string + + beforeAll(() => { + homeDir = mkdtempSync(join(tmpdir(), 'orca-gemini-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-gemini-userdata-')) + homedirMock.mockReturnValue(homeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected getPath(${name})`) + }) + }) + + afterAll(() => { + rmSync(homeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('removes stale PreToolUse hooks when reinstalling managed Gemini hooks', () => { + const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh' + const staleManagedHookPath = + process.platform === 'win32' + ? `C:\\Users\\ramzi\\.orca\\agent-hooks\\${managedHookFileName}` + : `/Users/ramzi/.orca/agent-hooks/${managedHookFileName}` + const staleManagedCommand = + process.platform === 'win32' + ? staleManagedHookPath + : `if [ -x '${staleManagedHookPath}' ]; then /bin/sh '${staleManagedHookPath}'; fi` + const managedHookPath = join(homeDir, '.orca', 'agent-hooks', managedHookFileName) + const configDir = join(homeDir, '.gemini') + mkdirSync(configDir, { recursive: true }) + writeFileSync( + join(configDir, 'settings.json'), + JSON.stringify( + { + hooks: { + BeforeAgent: [ + { + hooks: [{ type: 'command', command: 'echo user-before-agent' }] + } + ], + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: staleManagedCommand + } + ] + } + ] + } + }, + null, + 2 + ) + ) + + const service = new GeminiHookService() + const status = service.install() + const config = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8')) + + expect(status.state).toBe('installed') + expect(Object.keys(config.hooks).sort()).toEqual([ + 'AfterAgent', + 'AfterTool', + 'BeforeAgent', + 'BeforeTool' + ]) + expect(config.hooks.PreToolUse).toBeUndefined() + expect(config.hooks.BeforeAgent).toHaveLength(2) + expect(config.hooks.BeforeAgent[0].hooks[0].command).toBe('echo user-before-agent') + expect(config.hooks.BeforeAgent[1].hooks[0].command).toContain(managedHookPath) + expect(config.hooks.AfterAgent[0].hooks[0].command).toContain(managedHookPath) + expect(config.hooks.AfterTool[0].hooks[0].command).toContain(managedHookPath) + expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookPath) + }) + + it('preserves user-authored PreToolUse hooks while sweeping stale managed Gemini hooks', () => { + const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh' + const staleManagedHookPath = + process.platform === 'win32' + ? `C:\\Users\\ramzi\\.orca\\agent-hooks\\${managedHookFileName}` + : `/Users/ramzi/.orca/agent-hooks/${managedHookFileName}` + const staleManagedCommand = + process.platform === 'win32' + ? staleManagedHookPath + : `if [ -x '${staleManagedHookPath}' ]; then /bin/sh '${staleManagedHookPath}'; fi` + const configDir = join(homeDir, '.gemini') + mkdirSync(configDir, { recursive: true }) + writeFileSync( + join(configDir, 'settings.json'), + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + hooks: [{ type: 'command', command: staleManagedCommand }] + }, + { + hooks: [{ type: 'command', command: 'echo user-authored' }] + } + ] + } + }, + null, + 2 + ) + ) + + const status = new GeminiHookService().install() + const config = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8')) + const preToolCommands = config.hooks.PreToolUse.flatMap( + (definition: { hooks?: { command: string }[] }) => + (definition.hooks ?? []).map((hook) => hook.command) + ) + + expect(status.state).toBe('installed') + expect(preToolCommands).toEqual(['echo user-authored']) + expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookFileName) + }) +}) diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 8247ca43365..4ff71a10eec 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -25,12 +25,10 @@ import { // (approvals flow through inline UI), so Orca cannot surface a waiting state // for Gemini — that is an upstream limitation, not an Orca bug. // -// PreToolUse surfaces the current tool name + input preview (e.g. -// `read_file: src/foo.ts`) so long-running tool calls aren't a silent gap -// between BeforeAgent and AfterAgent. PostToolUse is intentionally omitted — -// AfterTool already signals "back to working" and the tool name from -// PreToolUse is what we show; PostToolUse would be a redundant fire. -const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse'] as const +// Gemini's native pre-tool event is BeforeTool, not Claude/Codex's PreToolUse. +// Keep installing the pre-tool status hook, but sweep stale PreToolUse entries +// below so current Gemini CLI no longer warns about an invalid event bucket. +const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool'] as const function getConfigPath(): string { return join(homedir(), '.gemini', 'settings.json') @@ -175,6 +173,27 @@ export class GeminiHookService { // accumulate duplicate hook entries pointing at defunct scripts. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const managedEvents = new Set(GEMINI_EVENTS) + + // Why: when Orca stops subscribing to an event, install() must sweep the + // old managed entry out of any leftover event bucket. Otherwise a stale + // hook such as PreToolUse survives forever in ~/.gemini/settings.json and + // continues firing even though the current build no longer wants it. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName)) { + continue + } + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + for (const eventName of GEMINI_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) @@ -213,6 +232,24 @@ export class GeminiHookService { const command = wrapPosixHookCommand(remoteScriptPath) const nextHooks = { ...config.hooks } const isManagedCommand = createManagedCommandMatcher('gemini-hook.sh') + const managedEvents = new Set(GEMINI_EVENTS) + + // Why: remote installs must sweep legacy managed event buckets too. + // Otherwise stale PreToolUse entries keep warning in SSH Gemini sessions. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName)) { + continue + } + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } for (const eventName of GEMINI_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] diff --git a/src/main/git/branch-rename.test.ts b/src/main/git/branch-rename.test.ts new file mode 100644 index 00000000000..9b0917f7c9b --- /dev/null +++ b/src/main/git/branch-rename.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest' +import { + branchHasUpstream, + renameCurrentBranch, + resolveUniqueBranchName, + type GitExec +} from './branch-rename' + +const noUpstreamError = new Error( + "fatal: no upstream configured for branch 'feature'\n" + + 'To push the current branch and set the remote as upstream, use\n' + + ' git push --set-upstream origin feature' +) + +describe('branchHasUpstream', () => { + it('is true when @{u} resolves to a tracking ref', async () => { + const exec: GitExec = vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + return { stdout: 'origin/feature\n', stderr: '' } + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) + expect(await branchHasUpstream(exec)).toBe(true) + }) + + it('is false when there is no upstream', async () => { + const exec: GitExec = vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw noUpstreamError + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + throw new Error('not found') + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) + expect(await branchHasUpstream(exec)).toBe(false) + }) + + it('is true when a same-name origin tracking ref exists without configured upstream', async () => { + const exec: GitExec = vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw noUpstreamError + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + return { stdout: '', stderr: '' } + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) + expect(await branchHasUpstream(exec)).toBe(true) + }) + + it('is conservatively true on an unexpected failure', async () => { + const exec: GitExec = vi.fn().mockRejectedValue(new Error('fatal: not a git repository')) + expect(await branchHasUpstream(exec)).toBe(true) + }) +}) + +describe('resolveUniqueBranchName', () => { + const compute = (leaf: string): string => `you/${leaf}` + + it('returns the first candidate when no branch collides', async () => { + const exec: GitExec = vi.fn().mockRejectedValue(new Error('not found')) // show-ref misses + const result = await resolveUniqueBranchName(exec, 'fix-auth', compute, 'you/Nautilus') + expect(result).toBe('you/fix-auth') + }) + + it('suffixes when the first candidate already exists', async () => { + const exec: GitExec = vi.fn(async (args: string[]) => { + const ref = args.at(-1) + if (ref === 'refs/heads/you/fix-auth') { + return { stdout: '', stderr: '' } // exists + } + throw new Error('not found') + }) + const result = await resolveUniqueBranchName(exec, 'fix-auth', compute, 'you/Nautilus') + expect(result).toBe('you/fix-auth-2') + }) + + it('does not treat the branch being renamed away from as a collision', async () => { + // exec would report every ref as existing; only the currentBranch shortcut + // lets a candidate through. + const exec: GitExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + const result = await resolveUniqueBranchName(exec, 'octopus', compute, 'you/octopus') + expect(result).toBe('you/octopus') + }) +}) + +describe('renameCurrentBranch', () => { + it('runs git branch -m with the new name', async () => { + const exec: GitExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + await renameCurrentBranch(exec, 'you/fix-auth') + expect(exec).toHaveBeenCalledWith(['branch', '-m', 'you/fix-auth']) + }) +}) diff --git a/src/main/git/branch-rename.ts b/src/main/git/branch-rename.ts new file mode 100644 index 00000000000..061f585f3b7 --- /dev/null +++ b/src/main/git/branch-rename.ts @@ -0,0 +1,74 @@ +import { isNoUpstreamError } from '../../shared/git-remote-error' +import { + resolveEffectiveGitUpstream, + type GitCommandRunner +} from '../../shared/git-effective-upstream' + +/** + * Git runner so branch-rename logic works identically for local worktrees + * (`gitExecFileAsync`) and SSH worktrees (`provider.exec`). Same contract the + * shared upstream-status helpers use. + */ +export type GitExec = GitCommandRunner + +/** + * True when the branch has a configured upstream — i.e. it has been pushed or + * is tracking a remote. Auto-rename refuses to touch such a branch because + * `git branch -m` would orphan the remote branch and break any open PR. + */ +export async function branchHasUpstream(exec: GitExec): Promise { + try { + return (await resolveEffectiveGitUpstream(exec)) !== null + } catch (error) { + if (isNoUpstreamError(error)) { + return false + } + // Why: an unexpected failure (detached HEAD, corruption, transport error) + // is not proof there's no upstream. Stay conservative and report "has + // upstream" so the caller skips the rename rather than risk a published branch. + return true + } +} + +async function localBranchExists(exec: GitExec, branch: string): Promise { + try { + await exec(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]) + return true + } catch { + return false + } +} + +/** + * Resolve a branch name that doesn't collide with an existing local branch by + * appending `-2`, `-3`, … to the leaf — the same suffixing worktree creation + * uses. `compute` applies the configured prefix to a leaf. The branch currently + * being renamed away from is never treated as a collision. + */ +export async function resolveUniqueBranchName( + exec: GitExec, + leaf: string, + compute: (leaf: string) => string, + currentBranch: string, + maxAttempts = 100 +): Promise { + const isAvailable = async (candidate: string): Promise => + candidate === currentBranch || !(await localBranchExists(exec, candidate)) + + const first = compute(leaf) + if (await isAvailable(first)) { + return first + } + for (let suffix = 2; suffix <= maxAttempts; suffix += 1) { + const candidate = compute(`${leaf}-${suffix}`) + if (await isAvailable(candidate)) { + return candidate + } + } + return null +} + +/** Rename the currently checked-out branch (`git branch -m `). */ +export async function renameCurrentBranch(exec: GitExec, newBranch: string): Promise { + await exec(['branch', '-m', newBranch]) +} diff --git a/src/main/git/repo-clone-path.test.ts b/src/main/git/repo-clone-path.test.ts new file mode 100644 index 00000000000..e651e1d965f --- /dev/null +++ b/src/main/git/repo-clone-path.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { deriveValidatedClonePath, getClonePathComparisonKey } from './repo-clone-path' + +describe('repo clone path helpers', () => { + it('allows safe repository names that start with two dots', async () => { + const destination = await mkdtemp(join(tmpdir(), 'orca-clone-path-')) + try { + expect( + deriveValidatedClonePath({ + url: 'https://example.com/..repo.git', + destination + }) + ).toBe(join(destination, '..repo')) + } finally { + await rm(destination, { recursive: true, force: true }) + } + }) + + it('rejects Windows-looking destinations on non-Windows hosts', async () => { + if (process.platform === 'win32') { + return + } + expect(() => + deriveValidatedClonePath({ + url: 'https://example.com/orca.git', + destination: 'C:\\Users\\me\\src' + }) + ).toThrow('Clone destination must be an absolute path') + expect(() => + deriveValidatedClonePath({ + url: 'https://example.com/orca.git', + destination: '\\\\server\\share' + }) + ).toThrow('Clone destination must be an absolute path') + expect(() => + deriveValidatedClonePath({ + url: 'https://example.com/orca.git', + destination: '//server/share' + }) + ).toThrow('Clone destination must be an absolute path') + expect(() => + deriveValidatedClonePath({ + url: 'https://example.com/orca.git', + destination: '//wsl.localhost/Ubuntu/home/me' + }) + ).toThrow('Clone destination must be an absolute path') + }) + + it('canonicalizes WSL UNC server aliases without folding Linux path casing', () => { + expect(getClonePathComparisonKey('\\\\wsl.localhost\\Ubuntu\\home\\User\\repo')).toBe( + getClonePathComparisonKey('\\\\wsl$\\ubuntu\\home\\User\\repo') + ) + expect(getClonePathComparisonKey('\\\\wsl.localhost\\Ubuntu\\home\\User\\repo\\')).toBe( + getClonePathComparisonKey('\\\\wsl$\\ubuntu\\home\\User\\repo') + ) + expect(getClonePathComparisonKey('\\\\wsl.localhost\\Ubuntu\\home\\User\\repo')).not.toBe( + getClonePathComparisonKey('\\\\wsl$\\ubuntu\\home\\user\\repo') + ) + }) +}) diff --git a/src/main/git/repo-clone-path.ts b/src/main/git/repo-clone-path.ts new file mode 100644 index 00000000000..8c0c7df7576 --- /dev/null +++ b/src/main/git/repo-clone-path.ts @@ -0,0 +1,127 @@ +import { isAbsolute, join, posix, relative, resolve, sep, win32 } from 'path' +import type { Stats } from 'fs' +import { lstat, mkdir, rm } from 'fs/promises' +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison, + normalizeRuntimePathSeparators +} from '../../shared/cross-platform-path' + +export type ClaimedCloneTarget = { + canCleanup: boolean + ownedDirectoryIdentity: CloneDirectoryIdentity | null +} + +type CloneDirectoryIdentity = Pick + +export function deriveValidatedClonePath(args: { url: string; destination: string }): string { + if ( + !args.destination || + !isAbsolute(args.destination) || + (process.platform !== 'win32' && isWindowsAbsolutePathLike(args.destination)) + ) { + throw new Error('Clone destination must be an absolute path') + } + + // Why: direct callers can supply URLs whose default git clone folder would + // be "." or ".."; rejecting them prevents parent/destination deletion. + const source = args.url.replace(/\.git\/?$/, '') + const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\') + const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source) + if (!repoName || repoName === '.' || repoName === '..') { + throw new Error('Invalid repository name derived from URL') + } + if (repoName.includes('/') || repoName.includes('\\')) { + throw new Error('Invalid repository name derived from URL') + } + + const clonePath = join(args.destination, repoName) + const resolvedDestination = resolve(args.destination) + const resolvedClonePath = resolve(clonePath) + const pathFromDestination = relative(resolvedDestination, resolvedClonePath) + if ( + pathFromDestination === '' || + pathFromDestination === '..' || + pathFromDestination.startsWith(`..${sep}`) || + isAbsolute(pathFromDestination) + ) { + throw new Error('Clone path must be inside the destination directory') + } + + return clonePath +} + +export function getClonePathComparisonKey(clonePath: string): string { + const resolvedClonePath = isWindowsAbsolutePathLike(clonePath) ? clonePath : resolve(clonePath) + const normalized = normalizeRuntimePathSeparators(resolvedClonePath) + const wslUncMatch = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i) + if (wslUncMatch) { + // Why: WSL UNC paths cross into a case-sensitive Linux filesystem, so only + // the Windows UNC server alias and distro segment should be case-folded. + const linuxPath = (wslUncMatch[2] ?? '').replace(/\/+$/, '') + return `//wsl/${wslUncMatch[1].toLowerCase()}${linuxPath}` + } + return normalizeRuntimePathForComparison(resolvedClonePath) +} + +export async function claimCloneTarget(clonePath: string): Promise { + try { + await mkdir(clonePath, { recursive: false }) + return { + canCleanup: true, + ownedDirectoryIdentity: cloneDirectoryIdentity(await lstat(clonePath)) + } + } catch (error) { + if (isErrnoCode(error, 'EEXIST')) { + return { canCleanup: false, ownedDirectoryIdentity: null } + } + throw error + } +} + +export async function cleanupClaimedCloneTarget( + clonePath: string, + claimedTarget: ClaimedCloneTarget +): Promise { + if (!claimedTarget.canCleanup || !claimedTarget.ownedDirectoryIdentity) { + return + } + + try { + const currentStats = await lstat(clonePath) + if (!currentStats.isDirectory()) { + return + } + if ( + !isSameCloneDirectoryIdentity( + claimedTarget.ownedDirectoryIdentity, + cloneDirectoryIdentity(currentStats) + ) + ) { + return + } + } catch { + return + } + + await rm(clonePath, { recursive: true, force: true }).catch(() => { + // Best-effort cleanup - do not mask the original clone failure. + }) +} + +function cloneDirectoryIdentity(stats: Stats): CloneDirectoryIdentity { + // Why: fast remove/recreate cycles can reuse an inode; birthtime keeps us + // from treating a replacement directory as the clone target we created. + return { dev: stats.dev, ino: stats.ino, birthtimeMs: stats.birthtimeMs } +} + +function isSameCloneDirectoryIdentity( + a: CloneDirectoryIdentity, + b: CloneDirectoryIdentity +): boolean { + return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs +} + +function isErrnoCode(error: unknown, code: string): boolean { + return error instanceof Error && (error as NodeJS.ErrnoException).code === code +} diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index 2a8bced1111..8cdb1852b45 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -109,6 +109,43 @@ describe('searchBaseRefs (widened glob)', () => { expect(results).toContain('local-only') }) + // Why: a single-word query must match a term in ANY segment of a slashed + // branch name (`user/feature`), not just the leaf. fnmatch `*` does not + // cross `/`, so the glob must use `**` to span ref segments. + it('finds a local slashed branch when the query lands in a deep segment', async () => { + git(tmpDir, ['branch', 'feature/login']) + + const results = await searchBaseRefs(tmpDir, 'login') + + expect(results).toContain('feature/login') + }) + + it('finds a local slashed branch when the query lands in an ancestor segment', async () => { + git(tmpDir, ['branch', 'feature/login']) + + const results = await searchBaseRefs(tmpDir, 'feature') + + expect(results).toContain('feature/login') + }) + + it('finds a remote slashed branch when the query lands in a deep segment', async () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'origin/feature/login', sha) + + const results = await searchBaseRefs(tmpDir, 'login') + + expect(results).toContain('origin/feature/login') + }) + + it('finds a remote slashed branch when the query lands in an ancestor segment', async () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'origin/feature/login', sha) + + const results = await searchBaseRefs(tmpDir, 'feature') + + expect(results).toContain('origin/feature/login') + }) + it('returns the local branch name for a remote ref with slashes', async () => { const sha = getHeadSha(tmpDir) git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git']) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 020d3ec914c..ad23d8e777d 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -461,12 +461,11 @@ async function getDefaultBaseRefAsync(path: string): Promise { * `upstream/main`). The picker would otherwise structurally deny the * correct answer for fork contributors — see docs/upstream-base-ref-design.md. * - * Why two remote globs for a single-segment query: `git for-each-ref` - * uses fnmatch-style globs where `*` does NOT cross `/`. A single - * `refs/remotes/*\/**` pattern only matches when `` appears in the - * branch-name segment, so typing `upstream` (a remote name) would return - * nothing. The extra `refs/remotes/**\/*` glob matches when the query - * appears in the remote-name segment, making remote-name filtering work. + * Why paired leaf/ancestor globs for a single-segment query: `git for-each-ref` + * uses fnmatch-style globs where `*` does NOT cross `/`. Slash-named branch + * refs need an ancestor-segment glob for `user` in `user/feature`, a leaf glob + * for `feature`, and the same remote-side shape so typing a remote name like + * `upstream` keeps working. * * Why the multi-segment branch: the picker displays results as * `upstream/main`, so users naturally retype that format. With a single @@ -491,13 +490,18 @@ export function buildSearchBaseRefsArgv(normalizedQuery: string): string[] { const tokens = normalizedQuery.split('/').filter((t) => t.length > 0) if (tokens.length <= 1) { const q = tokens[0] ?? '' - // Why three globs for a single-segment query: `git for-each-ref` - // uses fnmatch-style globs where `*` does NOT cross `/`. A single - // `refs/remotes/*\/**` only matches when `` is in the branch - // segment, so typing `upstream` (a remote name) returns nothing. - // The extra `refs/remotes/**\/*` matches when the query lives in - // the remote segment, making remote-name filtering work. - return [...base, `refs/remotes/*${q}*/*`, `refs/remotes/*/*${q}*`, `refs/heads/*${q}*`] + // Why `**`, not `*`: git for-each-ref globs are fnmatch-style where a + // single `*` does NOT cross `/`. Slash-named branches (`user/feature`) + // are the norm, so match both leaf and ancestor branch-name segments. + // The remote ancestor glob also preserves remote-name queries like + // `upstream` while `**/` keeps flat names like `main` working. + return [ + ...base, + `refs/heads/**/*${q}*`, + `refs/heads/**/*${q}*/**`, + `refs/remotes/**/*${q}*`, + `refs/remotes/**/*${q}*/**` + ] } // Why: multi-token queries like `upstream/main` map one `*token*` per // ref segment, so each token is matched within a single git ref diff --git a/src/main/git/status-discard-symlink.test.ts b/src/main/git/status-discard-symlink.test.ts new file mode 100644 index 00000000000..0f0bfaad801 --- /dev/null +++ b/src/main/git/status-discard-symlink.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { execFileSync } from 'child_process' +import { mkdtemp, mkdir, rm, symlink, writeFile, access, readFile } from 'fs/promises' +import * as path from 'path' +import { tmpdir } from 'os' +import { bulkDiscardChanges, discardChanges } from './status' + +const tempRoots: string[] = [] + +async function createRepoWithOutsideDirectory(): Promise<{ + repo: string + outsideDir: string + outsideFile: string +}> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-discard-symlink-')) + tempRoots.push(root) + const repo = path.join(root, 'repo') + const outsideDir = path.join(root, 'outside') + const outsideFile = path.join(outsideDir, 'keep.txt') + + await mkdir(repo) + await mkdir(outsideDir) + execFileSync('git', ['init', '-q'], { cwd: repo }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo }) + await writeFile(path.join(repo, '.gitkeep'), '') + execFileSync('git', ['add', '.gitkeep'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: repo }) + await writeFile(outsideFile, 'outside') + + return { repo, outsideDir, outsideFile } +} + +async function createDirectoryLink(target: string, linkPath: string): Promise { + await symlink(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir') +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('discardChanges symlink safety', () => { + it('rejects an untracked child path through a symlinked parent', async () => { + const { repo, outsideDir, outsideFile } = await createRepoWithOutsideDirectory() + await createDirectoryLink(outsideDir, path.join(repo, 'link')) + + await expect(discardChanges(repo, 'link/keep.txt')).rejects.toThrow( + 'resolves outside the worktree' + ) + await expect(access(outsideFile)).resolves.toBeUndefined() + }) + + it('rejects bulk untracked child paths through symlinked parents before deleting anything', async () => { + const { repo, outsideDir, outsideFile } = await createRepoWithOutsideDirectory() + const untrackedFile = path.join(repo, 'new.txt') + const trackedFile = path.join(repo, '.gitkeep') + await writeFile(trackedFile, 'modified') + await writeFile(untrackedFile, 'untracked') + await createDirectoryLink(outsideDir, path.join(repo, 'link')) + + await expect( + bulkDiscardChanges(repo, ['.gitkeep', 'new.txt', 'link/keep.txt']) + ).rejects.toThrow('resolves outside the worktree') + await expect(access(outsideFile)).resolves.toBeUndefined() + await expect(access(untrackedFile)).resolves.toBeUndefined() + await expect(readFile(trackedFile, 'utf8')).resolves.toBe('modified') + }) + + it('removes an untracked symlink leaf without deleting its target', async () => { + const { repo, outsideDir, outsideFile } = await createRepoWithOutsideDirectory() + const linkPath = path.join(repo, 'outside-link') + await createDirectoryLink(outsideDir, linkPath) + + await discardChanges(repo, 'outside-link') + + await expect(access(linkPath)).rejects.toThrow() + await expect(access(outsideFile)).resolves.toBeUndefined() + }) + + it('removes ignored paths selected for discard', async () => { + const { repo } = await createRepoWithOutsideDirectory() + await writeFile(path.join(repo, '.gitignore'), 'ignored/\n') + execFileSync('git', ['add', '.gitignore'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'ignore ignored dir'], { cwd: repo }) + const ignoredFile = path.join(repo, 'ignored', 'file.txt') + await mkdir(path.dirname(ignoredFile)) + await writeFile(ignoredFile, 'ignored') + + await discardChanges(repo, 'ignored') + + await expect(access(path.join(repo, 'ignored'))).rejects.toThrow() + }) + + it('removes untracked nested git repos selected for discard', async () => { + const { repo } = await createRepoWithOutsideDirectory() + const nestedRepo = path.join(repo, 'nested') + await mkdir(nestedRepo) + execFileSync('git', ['init', '-q'], { cwd: nestedRepo }) + await writeFile(path.join(nestedRepo, 'file.txt'), 'nested') + + await discardChanges(repo, 'nested') + + await expect(access(nestedRepo)).rejects.toThrow() + }) +}) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index cb53a14361d..079f1d7e625 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -2,14 +2,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import path from 'path' -const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, readFileMock, rmMock, existsSyncMock } = - vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileAsyncBufferMock: vi.fn(), - readFileMock: vi.fn(), - rmMock: vi.fn(), - existsSyncMock: vi.fn() - })) +const { + gitExecFileAsyncMock, + gitExecFileAsyncBufferMock, + lstatMock, + realpathMock, + readFileMock, + rmMock, + existsSyncMock +} = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileAsyncBufferMock: vi.fn(), + lstatMock: vi.fn(), + realpathMock: vi.fn(), + readFileMock: vi.fn(), + rmMock: vi.fn(), + existsSyncMock: vi.fn() +})) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -21,6 +30,8 @@ vi.mock('./runner', () => ({ })) vi.mock('fs/promises', () => ({ + lstat: lstatMock, + realpath: realpathMock, readFile: readFileMock, rm: rmMock })) @@ -30,6 +41,7 @@ vi.mock('fs', () => ({ })) import { + abortMerge, bulkStageFiles, bulkDiscardChanges, bulkUnstageFiles, @@ -46,8 +58,12 @@ describe('discardChanges', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() + lstatMock.mockReset() + realpathMock.mockReset() readFileMock.mockReset() rmMock.mockReset() + lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + realpathMock.mockImplementation(async (targetPath: string) => path.resolve(targetPath)) }) it('restores tracked files from HEAD', async () => { @@ -78,13 +94,15 @@ describe('discardChanges', () => { await discardChanges('/repo', 'src/new-file.ts') - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - // Why: discardChanges uses path.resolve(worktreePath, filePath) to build - // the absolute rm target, which on Windows prepends a drive letter. - expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'src', 'new-file.ts'), { - force: true, - recursive: true - }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['clean', '-ffdx', '--', 'src/new-file.ts'], + { + cwd: '/repo' + } + ) + expect(rmMock).not.toHaveBeenCalled() }) it('rejects paths that traverse outside the worktree', async () => { @@ -104,7 +122,11 @@ describe('discardChanges', () => { describe('bulk git helpers', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() + lstatMock.mockReset() + realpathMock.mockReset() rmMock.mockReset() + lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + realpathMock.mockImplementation(async (targetPath: string) => path.resolve(targetPath)) }) it('chunks bulk stage requests to avoid oversized argv payloads', async () => { @@ -169,14 +191,14 @@ describe('bulk git helpers', () => { cwd: '/repo' } ) - expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'src', 'new-file.ts'), { - force: true, - recursive: true - }) - expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'scratch'), { - force: true, - recursive: true - }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['clean', '-ffdx', '--', 'src/new-file.ts', 'scratch'], + { + cwd: '/repo' + } + ) + expect(rmMock).not.toHaveBeenCalled() }) it('rejects bulk discard paths that traverse outside the worktree', async () => { @@ -193,6 +215,7 @@ describe('getDiff', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() + lstatMock.mockReset() readFileMock.mockReset() existsSyncMock.mockReset() }) @@ -283,8 +306,14 @@ describe('getStatus', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() + lstatMock.mockReset() readFileMock.mockReset() existsSyncMock.mockReset() + // Why: after the status call, getStatus may issue `git diff --numstat` + // calls to attach per-entry line counts. Tests that don't care about counts + // set only a `mockResolvedValueOnce` for the status output; this default + // keeps those follow-up numstat calls from returning undefined. + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) }) it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => { @@ -518,6 +547,132 @@ describe('getStatus', () => { expect(result.ignoredPaths).toEqual(['dist/', '.env', 'coverage/']) expect(result.entries).toEqual([]) }) + + it('attaches per-area line counts from staged and unstaged numstat', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + '1 M. N... 100644 100644 100644 aaaa aaaa src/staged.ts\n' + + '1 .M N... 100644 100644 100644 bbbb bbbb src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ + stdout: args.includes('--cached') ? '10\t0\tsrc/staged.ts\n' : '3\t4\tsrc/unstaged.ts\n' + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const result = await getStatus('/repo') + + expect(result.entries).toEqual([ + { path: 'src/staged.ts', status: 'modified', area: 'staged', added: 10, removed: 0 }, + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } + ]) + }) + + it('attaches staged rename counts to the new path', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: '2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '2\t1\tsrc/old name.ts => src/new name.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + const result = await getStatus('/repo') + + expect(result.entries).toEqual([ + { + path: 'src/new name.ts', + oldPath: 'src/old name.ts', + status: 'renamed', + area: 'staged', + added: 2, + removed: 1 + } + ]) + }) + + it('counts untracked file contents as additions', async () => { + existsSyncMock.mockReturnValue(false) + lstatMock.mockResolvedValue({ + size: 14, + mtimeMs: 1, + ctimeMs: 1, + isFile: () => true, + isSymbolicLink: () => false + }) + readFileMock.mockImplementation((target: string) => + String(target).endsWith('.git') + ? Promise.resolve('gitdir: /repo/.git/worktrees/feature\n') + : Promise.resolve(Buffer.from('one\ntwo\nthree\n')) + ) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '? src/brand-new.ts\n' }) + + const result = await getStatus('/repo') + + expect(result.entries).toEqual([ + { path: 'src/brand-new.ts', status: 'untracked', area: 'untracked', added: 3 } + ]) + }) + + it('leaves binary working-tree changes without counts', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: '1 .M N... 100644 100644 100644 cccc cccc assets/logo.png\n' + }) + } + // git reports binary files as '-' in both numstat columns. + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '-\t-\tassets/logo.png\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + const result = await getStatus('/repo') + + expect(result.entries).toEqual([ + { path: 'assets/logo.png', status: 'modified', area: 'unstaged' } + ]) + }) + + it('skips numstat entirely for a clean working tree', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + await getStatus('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('abortMerge', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('runs git merge --abort in the worktree', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + await abortMerge('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['merge', '--abort'], { cwd: '/repo' }) + }) }) describe('getStagedCommitContext', () => { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index a72dd3832bf..02c2ea08063 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import { existsSync } from 'fs' -import { readFile, rm } from 'fs/promises' +import { readFile } from 'fs/promises' import * as path from 'path' import type { GitBranchChangeEntry, @@ -21,7 +21,18 @@ import { getEffectiveGitUpstreamStatus, splitRemoteBranchName } from '../../shared/git-effective-upstream' +import { isBinaryBuffer } from '../../shared/binary-buffer' +import { + applyLineStats, + collectUntrackedAdditions, + parseNumstat, + type GitLineStats +} from '../../shared/git-uncommitted-line-stats' import { gitExecFileAsync, gitExecFileAsyncBuffer, gitOptionalLocksDisabledEnv } from './runner' +import { + removeSafeUntrackedDiscardTarget, + removeSafeUntrackedDiscardTargets +} from '../../shared/git-discard-path-safety' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES @@ -115,10 +126,12 @@ export async function getStatus( const worktreeStatus = xy[1] if (line.startsWith('2 ')) { - // Rename entry - tab separated at the end + // Why: porcelain v2 type-2 records put the new path after 9 fixed + // space-delimited fields and the old path after the tab. Preserving + // spaces here keeps row actions and numstat counts keyed correctly. const tabParts = line.split('\t') - const path = tabParts[1] - const oldPath = tabParts[2] + const path = tabParts[0].split(' ').slice(9).join(' ') + const oldPath = tabParts.slice(1).join('\t') if (indexStatus !== '.') { entries.push({ path, status: parseStatusChar(indexStatus), area: 'staged', oldPath }) } @@ -170,6 +183,13 @@ export async function getStatus( // Not a git repo or git not available } + // Why: attach per-area line counts for the sidebar. Diffs run after status + // (we need the entry list first) and only for areas that have entries, so a + // clean tree costs zero extra git calls. Staged and unstaged are diffed + // separately so each row reflects only its own staging area; untracked files + // have no baseline and count their full contents as additions. + await attachLineStats(worktreePath, entries) + return { entries, conflictOperation, @@ -193,6 +213,50 @@ export async function getStatus( } } +async function runNumstat( + worktreePath: string, + cached: boolean +): Promise> { + try { + const { stdout } = await gitExecFileAsync( + ['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'], + { cwd: worktreePath, env: gitOptionalLocksDisabledEnv() } + ) + return parseNumstat(stdout) + } catch { + // Why: a numstat failure (e.g. transient lock) should leave rows without + // counts rather than break the whole status refresh. + return new Map() + } +} + +async function attachLineStats(worktreePath: string, entries: GitStatusEntry[]): Promise { + if (entries.length === 0) { + return + } + const hasStaged = entries.some((entry) => entry.area === 'staged') + const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') + const untrackedPaths = entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path) + const emptyStats = new Map() + const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ + hasStaged ? runNumstat(worktreePath, true) : Promise.resolve(emptyStats), + hasUnstaged ? runNumstat(worktreePath, false) : Promise.resolve(emptyStats), + collectUntrackedAdditions(worktreePath, untrackedPaths) + ]) + for (const entry of entries) { + applyLineStats( + entry, + entry.area === 'staged' + ? stagedStats.get(entry.path) + : entry.area === 'unstaged' + ? unstagedStats.get(entry.path) + : untrackedStats.get(entry.path) + ) + } +} + function getShortBranchName(branch: string | undefined): string | null { const prefix = 'refs/heads/' return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null @@ -399,6 +463,10 @@ export async function detectConflictOperation(worktreePath: string): Promise { + await gitExecFileAsync(['merge', '--abort'], { cwd: worktreePath }) +} + export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') @@ -678,7 +746,7 @@ async function loadBranchChanges( gitOptions ) ]) - const statsByPath = parseBranchChangeNumstat(numstat) + const statsByPath = parseNumstat(numstat) const entries: GitBranchChangeEntry[] = [] // [Fix]: Split by /\r?\n/ instead of '\n' to handle Git CRLF output on Windows, @@ -737,7 +805,7 @@ async function loadCommitChanges( gitExecFileAsync(args, gitOptions), gitExecFileAsync(numstatArgs, gitOptions) ]) - const statsByPath = parseBranchChangeNumstat(numstat) + const statsByPath = parseNumstat(numstat) const entries: GitBranchChangeEntry[] = [] for (const line of stdout.split(/\r?\n/)) { @@ -752,45 +820,6 @@ async function loadCommitChanges( return entries } -function parseBranchChangeCount(value: string): number | undefined { - if (value === '-') { - return undefined - } - const count = Number.parseInt(value, 10) - return Number.isFinite(count) ? count : undefined -} - -function normalizeBranchNumstatPath(path: string): string { - const bracedRename = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(path) - if (bracedRename) { - return `${bracedRename[1]}${bracedRename[3]}${bracedRename[4]}` - } - const renameMarker = ' => ' - const markerIndex = path.lastIndexOf(renameMarker) - return markerIndex === -1 ? path : path.slice(markerIndex + renameMarker.length) -} - -function parseBranchChangeNumstat( - stdout: string -): Map> { - const stats = new Map>() - for (const line of stdout.split(/\r?\n/)) { - if (!line) { - continue - } - const parts = line.split('\t') - const rawPath = parts.slice(2).join('\t') - if (!rawPath) { - continue - } - stats.set(normalizeBranchNumstatPath(rawPath), { - added: parseBranchChangeCount(parts[0] ?? ''), - removed: parseBranchChangeCount(parts[1] ?? '') - }) - } - return stats -} - function parseBranchChangeLine(line: string): GitBranchChangeEntry | null { const parts = line.split('\t') const rawStatus = parts[0] ?? '' @@ -932,16 +961,6 @@ function bufferToBlob(buffer: Buffer, filePath?: string): GitBlobReadResult { } } -function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, 8192) - for (let i = 0; i < len; i += 1) { - if (buffer[i] === 0) { - return true - } - } - return false -} - function buildDiffResult( originalContent: string, modifiedContent: string, @@ -1090,11 +1109,16 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr // File is not tracked by git } - await (tracked - ? gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', filePath], { - cwd: worktreePath - }) - : rm(resolvedTarget, { force: true, recursive: true })) + if (tracked) { + await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', filePath], { + cwd: worktreePath + }) + return + } + + await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) => + cleanUntrackedPaths(worktreePath, [targetPath]) + ) } function normalizeGitPathForCompare(filePath: string): string { @@ -1124,6 +1148,19 @@ async function listTrackedPathSpecs( return trackedPaths } +async function cleanUntrackedPaths( + worktreePath: string, + filePaths: readonly string[] +): Promise { + for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) + if (chunk.length > 0) { + // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. + await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk], { cwd: worktreePath }) + } + } +} + /** * Discard working tree changes for many paths in a small number of subprocesses. */ @@ -1145,18 +1182,18 @@ export async function bulkDiscardChanges(worktreePath: string, filePaths: string const untrackedPaths = filePaths.filter( (filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs) ) - - for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { - const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', ...chunk], { - cwd: worktreePath - }) - } - - await Promise.all( - untrackedPaths.map((filePath) => - rm(path.resolve(worktreePath, filePath), { force: true, recursive: true }) - ) + await removeSafeUntrackedDiscardTargets( + worktreePath, + untrackedPaths, + (targetPaths) => cleanUntrackedPaths(worktreePath, targetPaths), + async () => { + for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { + const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) + await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', ...chunk], { + cwd: worktreePath + }) + } + } ) } diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 1b4d53bacad..3c9d885a81b 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -18,7 +18,7 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -import { addWorktree, parseWorktreeList } from './worktree' +import { addSparseWorktree, addWorktree, parseWorktreeList } from './worktree' describe('parseWorktreeList', () => { it('parses regular and bare worktree blocks from porcelain output', () => { @@ -204,6 +204,10 @@ describe('addWorktree', () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} } + const resolveCreationBaseConfigWrite = () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + } + beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() @@ -213,6 +217,7 @@ describe('addWorktree', () => { it('creates the worktree without touching the local base ref by default', async () => { resolveRemoteBase() gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote @@ -232,6 +237,16 @@ describe('addWorktree', () => { ], { cwd: '/repo' } ], + [ + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], + { cwd: '/repo-feature' } + ], [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }], [['config', '--local', 'push.autoSetupRemote', 'true'], { cwd: '/repo-feature' }] ]) @@ -249,16 +264,57 @@ describe('addWorktree', () => { ]) }) + it('does not write branch base config when no base branch is provided', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set + + await addWorktree('/repo', '/repo-feature', 'feature/no-base') + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [ + ['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'], + { cwd: '/repo' } + ], + [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }] + ]) + }) + + it('warns and unsets stale branch base config when persisting the base fails', async () => { + resolveRemoteBase() + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) // config --local --replace-all branch..base + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --unset-all branch..base + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect( + addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') + ).resolves.toEqual({}) + + expect(warnSpy).toHaveBeenCalledWith( + 'addWorktree: failed to set branch.feature/test.base for /repo-feature', + expect.any(Error) + ) + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ + 'config', + '--local', + '--unset-all', + 'branch.feature/test.base' + ]) + warnSpy.mockRestore() + }) + it('warns but does not throw when push.autoSetupRemote config fails', async () => { resolveRemoteBase() gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get (unset, expected) gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) // config --local set fails const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect( addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') - ).resolves.toBeUndefined() + ).resolves.toEqual({}) expect(warnSpy).toHaveBeenCalledWith( 'addWorktree: failed to set push.autoSetupRemote for /repo-feature', @@ -274,12 +330,13 @@ describe('addWorktree', () => { // value the user actually has. resolveRemoteBase() gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get fails non-unset const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect( addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') - ).resolves.toBeUndefined() + ).resolves.toEqual({}) expect(warnSpy).toHaveBeenCalledWith( 'addWorktree: failed to set push.autoSetupRemote for /repo-feature', @@ -300,6 +357,16 @@ describe('addWorktree', () => { ], { cwd: '/repo' } ], + [ + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], + { cwd: '/repo-feature' } + ], [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }] ]) warnSpy.mockRestore() @@ -308,6 +375,7 @@ describe('addWorktree', () => { it('preserves existing push.autoSetupRemote value (does not overwrite user-set false)', async () => { resolveRemoteBase() gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'false\n' }) // config --get returns existing value await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') @@ -327,6 +395,16 @@ describe('addWorktree', () => { ], { cwd: '/repo' } ], + [ + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], + { cwd: '/repo-feature' } + ], [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }] ]) }) @@ -337,6 +415,7 @@ describe('addWorktree', () => { // must not fall through to `--local set true` and overwrite that. resolveRemoteBase() gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --get succeeds with empty value await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') @@ -355,6 +434,16 @@ describe('addWorktree', () => { ], { cwd: '/repo' } ], + [ + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], + { cwd: '/repo-feature' } + ], [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }] ]) }) @@ -392,23 +481,24 @@ describe('addWorktree', () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n' gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} .mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo) .mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo) - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) expect(gitExecFileAsyncMock.mock.calls).toEqual([ - [['merge-base', '--is-ancestor', 'main', 'origin/main'], { cwd: '/repo' }], + [['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], + [['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'], { cwd: '/repo' }], [['worktree', 'list', '--porcelain'], { cwd: '/repo' }], [['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }], - [['reset', '--hard', 'origin/main'], { cwd: '/repo' }], - [['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], + [['reset', '--hard', 'refs/remotes/origin/main'], { cwd: '/repo' }], [ [ 'worktree', @@ -421,6 +511,16 @@ describe('addWorktree', () => { ], { cwd: '/repo' } ], + [ + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], + { cwd: '/repo-feature' } + ], [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }], [['config', '--local', 'push.autoSetupRemote', 'true'], { cwd: '/repo-feature' }] ]) @@ -430,23 +530,24 @@ describe('addWorktree', () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n' gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} .mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt) .mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt) - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) - expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([ + expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([ ['status', '--porcelain', '--untracked-files=no'], expect.objectContaining({ cwd: '/repo-main-wt' }) ]) - expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([ - ['reset', '--hard', 'origin/main'], + expect(gitExecFileAsyncMock.mock.calls[4]).toEqual([ + ['reset', '--hard', 'refs/remotes/origin/main'], expect.objectContaining({ cwd: '/repo-main-wt' }) ]) }) @@ -454,18 +555,19 @@ describe('addWorktree', () => { it('uses update-ref when localBranch is not checked out in any worktree', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n' gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} .mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // update-ref - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) - expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([ - ['update-ref', 'refs/heads/main', 'origin/main'], + expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([ + ['update-ref', 'refs/heads/main', 'refs/remotes/origin/main'], expect.objectContaining({ cwd: '/repo' }) ]) }) @@ -473,19 +575,27 @@ describe('addWorktree', () => { it('skips update when the owning worktree is dirty', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} .mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty) - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote - await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) - // No reset --hard or update-ref — just merge-base, worktree list, status, worktree add, config --get, config --local set - expect(gitExecFileAsyncMock.mock.calls).toHaveLength(7) - expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual([ + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_dirty_worktree', + baseRef: 'origin/main', + localBranch: 'main', + ownerWorktreePath: '/repo' + }) + + // No reset --hard or update-ref — just base resolution, merge-base, worktree list, status, worktree add, base config, config --get, config --local set + expect(gitExecFileAsyncMock.mock.calls).toHaveLength(8) + expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([ 'rev-parse', '--verify', '--quiet', @@ -501,11 +611,18 @@ describe('addWorktree', () => { 'refs/remotes/origin/main' ]) expect(gitExecFileAsyncMock.mock.calls[5]?.[0]).toEqual([ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ]) + expect(gitExecFileAsyncMock.mock.calls[6]?.[0]).toEqual([ 'config', '--get', 'push.autoSetupRemote' ]) - expect(gitExecFileAsyncMock.mock.calls[6]?.[0]).toEqual([ + expect(gitExecFileAsyncMock.mock.calls[7]?.[0]).toEqual([ 'config', '--local', 'push.autoSetupRemote', @@ -514,9 +631,10 @@ describe('addWorktree', () => { }) it('skips updating the local branch when it has diverged', async () => { - gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward')) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward')) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote @@ -524,11 +642,11 @@ describe('addWorktree', () => { expect(gitExecFileAsyncMock.mock.calls).toEqual([ [ - ['merge-base', '--is-ancestor', 'main', 'origin/main'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], expect.objectContaining({ cwd: '/repo' }) ], [ - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], + ['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'], expect.objectContaining({ cwd: '/repo' }) ], [ @@ -543,6 +661,16 @@ describe('addWorktree', () => { ], expect.objectContaining({ cwd: '/repo' }) ], + [ + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], + expect.objectContaining({ cwd: '/repo-feature' }) + ], [ ['config', '--get', 'push.autoSetupRemote'], expect.objectContaining({ cwd: '/repo-feature' }) @@ -560,6 +688,7 @@ describe('addWorktree', () => { // Qualifying as refs/heads/main tells git exactly which object to use. gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/heads/main^{commit} gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote @@ -587,6 +716,7 @@ describe('addWorktree', () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit} gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/heads/release/main^{commit} gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote @@ -604,6 +734,54 @@ describe('addWorktree', () => { '/repo-feature', 'refs/heads/release/main' ], + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/release.base', + 'refs/heads/release/main' + ], + ['config', '--get', 'push.autoSetupRemote'], + ['config', '--local', 'push.autoSetupRemote', 'true'] + ]) + }) + + it('does not report a local base refresh for slash-containing local branch names', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit} + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/heads/release/main^{commit} + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() + gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree( + '/repo', + '/repo-feature', + 'feature/release', + 'release/main', + true + ) + + expect(result).toEqual({}) + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([ + ['rev-parse', '--verify', '--quiet', 'refs/remotes/release/main^{commit}'], + ['rev-parse', '--verify', '--quiet', 'refs/heads/release/main^{commit}'], + [ + 'worktree', + 'add', + '--no-track', + '-b', + 'feature/release', + '/repo-feature', + 'refs/heads/release/main' + ], + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/release.base', + 'refs/heads/release/main' + ], ['config', '--get', 'push.autoSetupRemote'], ['config', '--local', 'push.autoSetupRemote', 'true'] ]) @@ -612,23 +790,61 @@ describe('addWorktree', () => { it('uses the remote name from the base ref instead of hardcoding origin', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/upstream/main^{commit} .mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain .mockResolvedValueOnce({ stdout: '' }) // reset --hard - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/upstream/main^{commit} .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true) - expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([ + expect(gitExecFileAsyncMock.mock.calls[1]?.[0]).toEqual([ 'merge-base', '--is-ancestor', 'main', - 'upstream/main' + 'refs/remotes/upstream/main' + ]) + expect(gitExecFileAsyncMock.mock.calls[4]?.[0]).toEqual([ + 'reset', + '--hard', + 'refs/remotes/upstream/main' + ]) + }) + + it('unsets branch base config during sparse setup cleanup after creation succeeds', async () => { + const beforeRemoval = + 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n' + const afterPrune = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' + resolveRemoteBase() + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('sparse setup failed')) // sparse-checkout init + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --unset-all branch..base + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // worktree list before remove + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree prune + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: afterPrune }) // worktree list after prune + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -D + + await expect( + addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['src'], 'origin/main') + ).rejects.toThrow('sparse setup failed') + + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ + 'config', + '--local', + '--unset-all', + 'branch.feature/test.base' + ]) + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ + 'branch', + '-D', + 'feature/test' ]) - expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual(['reset', '--hard', 'upstream/main']) }) }) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index ed8a9e963f0..318584e976c 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -2,11 +2,15 @@ import { stat } from 'fs/promises' import { join, posix, win32 } from 'path' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' -import type { GitWorktreeInfo } from '../../shared/types' +import type { GitWorktreeInfo, LocalBaseRefRefreshResult } from '../../shared/types' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir } from './status' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +export type AddWorktreeResult = { + localBaseRefRefresh?: LocalBaseRefRefreshResult +} + type SparseWorktreeCreateError = Error & { cleanupFailed?: boolean } @@ -39,6 +43,44 @@ function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } +async function persistWorktreeCreationBase( + worktreePath: string, + branch: string, + effectiveBase: string +): Promise { + const configKey = `branch.${branch}.base` + try { + await gitExecFileAsync(['config', '--local', '--replace-all', configKey, effectiveBase], { + cwd: worktreePath + }) + } catch (error) { + console.warn(`addWorktree: failed to set ${configKey} for ${worktreePath}`, error) + try { + // Why: reused branch names may carry stale base metadata; if replacement + // fails, remove the old value so consumers do not trust outdated lineage. + await gitExecFileAsync(['config', '--local', '--unset-all', configKey], { + cwd: worktreePath + }) + } catch (unsetError) { + console.warn( + `addWorktree: failed to unset stale ${configKey} for ${worktreePath}`, + unsetError + ) + } + } +} + +async function unsetWorktreeCreationBase(worktreePath: string, branch: string): Promise { + try { + await gitExecFileAsync(['config', '--local', '--unset-all', `branch.${branch}.base`], { + cwd: worktreePath + }) + } catch { + // Best-effort cleanup; missing keys and locked config both leave the + // original sparse setup error as the actionable failure. + } +} + function areWorktreePathsEqual( leftPath: string, rightPath: string, @@ -154,15 +196,94 @@ export async function listWorktrees(repoPath: string): Promise { + const remoteRefPrefix = 'refs/remotes/' + if (!remoteTrackingRef.startsWith(remoteRefPrefix)) { + return undefined + } + + // Why: Only refs proven to be remote-tracking refs get refresh status. + // Local branches can contain slashes (e.g. release/2026) and must not + // produce a fake "Local 2026 was not refreshed" warning. + const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length) + const slashIndex = shortRemoteRef.indexOf('/') + if (slashIndex <= 0) { + return undefined + } + + const localBranch = shortRemoteRef.slice(slashIndex + 1) + const fullRef = `refs/heads/${localBranch}` + const resultBase = { baseRef: baseBranch, localBranch } + + try { + // Why: We only fast-forward the local branch pointer. A force-move (`branch -f`) + // would silently destroy unpushed local commits if the branch has diverged from + // remote. `merge-base --is-ancestor` returns exit 0 when localBranch is an + // ancestor of baseBranch — i.e. the update is a safe fast-forward. + await gitExecFileAsync(['merge-base', '--is-ancestor', localBranch, remoteTrackingRef], { + cwd: repoPath + }) + } catch { + // merge-base fails if the local branch doesn't exist or has diverged. + return { ...resultBase, status: 'skipped_not_fast_forward' } + } + + try { + // Why: We need to find which worktree (if any) has localBranch checked + // out, because moving the ref without updating that worktree's files would + // leave it looking massively dirty. A sibling worktree we don't control is + // just as vulnerable as the primary one. + const { stdout: worktreeListOutput } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain'], + { cwd: repoPath } + ) + const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath)) + const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef) + + if (ownerWorktree) { + // Why: localBranch is checked out in a worktree. We can only safely + // update if that worktree is clean, and we must use `reset --hard` + // (run inside that worktree) so the files move with the ref. + const { stdout: status } = await gitExecFileAsync( + ['status', '--porcelain', '--untracked-files=no'], + { cwd: ownerWorktree.path } + ) + if (status.trim()) { + return { + ...resultBase, + status: 'skipped_dirty_worktree', + ownerWorktreePath: ownerWorktree.path + } + } + await gitExecFileAsync(['reset', '--hard', remoteTrackingRef], { cwd: ownerWorktree.path }) + return { ...resultBase, status: 'updated', ownerWorktreePath: ownerWorktree.path } + } + + // Why: localBranch is not checked out anywhere, so there is no working + // tree to desync. `update-ref` is safe here. + await gitExecFileAsync(['update-ref', fullRef, remoteTrackingRef], { cwd: repoPath }) + return { ...resultBase, status: 'updated' } + } catch { + // update-ref/reset can fail on locked refs, filesystem errors, or unusual + // worktree states. Worktree creation should still proceed. + return { ...resultBase, status: 'skipped_error' } + } +} + /** * Create a new worktree. * @param repoPath - Path to the main repo (or bare repo) * @param worktreePath - Absolute path where the worktree will be created * @param branch - Branch name for the new worktree * @param baseBranch - Optional base branch to create from (defaults to HEAD) - * @remarks Side effect: passes `--no-track` and may write `push.autoSetupRemote=true` - * to the repo's shared config (best-effort, warn-only on failure; preserves any - * user-set value at any scope). See body comment below for the full rationale. + * @remarks Side effect: passes `--no-track`, writes `branch..base` + * for new-branch worktrees with a base ref, and may write + * `push.autoSetupRemote=true` to the repo's shared config. Config writes are + * best-effort and warn-only. See body comments below for the full rationale. */ export async function addWorktree( repoPath: string, @@ -172,62 +293,10 @@ export async function addWorktree( refreshLocalBaseRef = false, noCheckout = false, options: { checkoutExistingBranch?: boolean } = {} -): Promise { - // Why: Some users want Orca-created worktrees to make plain commands like - // `git diff main...HEAD` work out of the box, while others do not want - // worktree creation to mutate their local main/master ref at all. Keep this - // behavior behind an explicit setting so the default stays conservative. - if (baseBranch && refreshLocalBaseRef && !options.checkoutExistingBranch) { - // Why: We split on '/' instead of matching a hardcoded 'origin/' prefix because - // callers may pass arbitrary remotes (e.g. 'upstream/main'), not just 'origin'. - const slashIndex = baseBranch.indexOf('/') - if (slashIndex > 0) { - const localBranch = baseBranch.slice(slashIndex + 1) - try { - // Why: We only fast-forward the local branch pointer. A force-move (`branch -f`) - // would silently destroy unpushed local commits if the branch has diverged from - // remote. `merge-base --is-ancestor` returns exit 0 when localBranch is an - // ancestor of baseBranch — i.e. the update is a safe fast-forward. - await gitExecFileAsync(['merge-base', '--is-ancestor', localBranch, baseBranch], { - cwd: repoPath - }) - // Why: We need to find which worktree (if any) has localBranch checked - // out, because moving the ref without updating that worktree's files would - // leave it looking massively dirty. A sibling worktree we don't control is - // just as vulnerable as the primary one. - const { stdout: worktreeListOutput } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain'], - { cwd: repoPath } - ) - const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath)) - const fullRef = `refs/heads/${localBranch}` - const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef) - - if (ownerWorktree) { - // Why: localBranch is checked out in a worktree. We can only safely - // update if that worktree is clean, and we must use `reset --hard` - // (run inside that worktree) so the files move with the ref. - const { stdout: status } = await gitExecFileAsync( - ['status', '--porcelain', '--untracked-files=no'], - { cwd: ownerWorktree.path } - ) - if (!status.trim()) { - await gitExecFileAsync(['reset', '--hard', baseBranch], { cwd: ownerWorktree.path }) - } - } else { - // Why: localBranch is not checked out anywhere, so there is no working - // tree to desync. `update-ref` is safe here. - await gitExecFileAsync(['update-ref', fullRef, baseBranch], { cwd: repoPath }) - } - } catch { - // merge-base fails if the local branch doesn't exist or has diverged; - // update-ref fails on locked/corrupted refs or filesystem errors. - // Both cases are non-fatal — skip the update silently. - } - } - } - +): Promise { + let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined const args = ['worktree', 'add'] + let effectiveBase: string | undefined if (noCheckout) { args.push('--no-checkout') } @@ -242,19 +311,34 @@ export async function addWorktree( // below for the terminal ergonomics. args.push('--no-track', '-b', branch, worktreePath) if (baseBranch) { - const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => + effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef) ) + // Why: resolving the creation base first distinguishes real + // remote-tracking refs from slash-containing local branch names. + // The mutation stays behind the explicit setting so the default + // remains conservative. + if (refreshLocalBaseRef) { + localBaseRefRefresh = await refreshLocalBaseRefForWorktreeCreate( + repoPath, + baseBranch, + effectiveBase + ) + } args.push(effectiveBase) } } await gitExecFileAsync(args, { cwd: repoPath }) if (options.checkoutExistingBranch) { - return + return localBaseRefRefresh ? { localBaseRefRefresh } : {} } - // SSH parity: src/relay/git-handler.ts addWorktree mirrors this exact + if (effectiveBase) { + await persistWorktreeCreationBase(worktreePath, branch, effectiveBase) + } + + // SSH parity: src/relay/git-handler-worktree-ops.ts addWorktreeOp mirrors this exact // probe-and-write state machine. If you change the logic here, update // the relay handler in lockstep so local and SSH paths stay aligned. // @@ -310,6 +394,7 @@ export async function addWorktree( } catch (error) { console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error) } + return localBaseRefRefresh ? { localBaseRefRefresh } : {} } export async function addSparseWorktree( @@ -320,10 +405,11 @@ export async function addSparseWorktree( baseBranch?: string, refreshLocalBaseRef = false, options: { checkoutExistingBranch?: boolean } = {} -): Promise { +): Promise { let created = false + let addResult: AddWorktreeResult = {} try { - await addWorktree( + addResult = await addWorktree( repoPath, worktreePath, branch, @@ -336,10 +422,14 @@ export async function addSparseWorktree( await gitExecFileAsync(['sparse-checkout', 'init', '--cone'], { cwd: worktreePath }) await gitExecFileAsync(['sparse-checkout', 'set', '--', ...directories], { cwd: worktreePath }) await gitExecFileAsync(['checkout', branch], { cwd: worktreePath }) + return addResult } catch (error) { const wrapped: SparseWorktreeCreateError = error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error)) if (created) { + if (!options.checkoutExistingBranch) { + await unsetWorktreeCreationBase(worktreePath, branch) + } try { await removeWorktree(repoPath, worktreePath, true, { deleteBranch: !options.checkoutExistingBranch diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 453a6a17493..8051163c10c 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -1,19 +1,33 @@ +/* eslint-disable max-lines -- Why: create-PR tests share gh/SSH mocks across + template, CLI, and error-path cases; keeping them together prevents drift. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { readFile } from 'fs/promises' -const { ghExecFileAsyncMock, getOwnerRepoMock, extractExecErrorMock, acquireMock, releaseMock } = - vi.hoisted(() => ({ - ghExecFileAsyncMock: vi.fn(), - getOwnerRepoMock: vi.fn(), - extractExecErrorMock: vi.fn((error: unknown) => { - const value = error as { stderr?: string; stdout?: string; message?: string } - return { - stderr: value?.stderr ?? value?.message ?? '', - stdout: value?.stdout ?? '' - } - }), - acquireMock: vi.fn(), - releaseMock: vi.fn() - })) +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + extractExecErrorMock, + acquireMock, + releaseMock, + getSshFilesystemProviderMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + extractExecErrorMock: vi.fn((error: unknown) => { + const value = error as { stderr?: string; stdout?: string; message?: string } + return { + stderr: value?.stderr ?? value?.message ?? '', + stdout: value?.stdout ?? '' + } + }), + acquireMock: vi.fn(), + releaseMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) vi.mock('./gh-utils', () => ({ execFileAsync: vi.fn(), @@ -49,6 +63,7 @@ describe('createGitHubPullRequest', () => { extractExecErrorMock.mockClear() acquireMock.mockReset() releaseMock.mockReset() + getSshFilesystemProviderMock.mockReset() acquireMock.mockResolvedValue(undefined) }) @@ -149,6 +164,76 @@ describe('createGitHubPullRequest', () => { expect(options).not.toHaveProperty('cwd') }) + it.each([ + ['.github/pull_request_template.md', ['.github/pull_request_template.md']], + [ + '.github/PULL_REQUEST_TEMPLATE.md', + ['.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md'] + ], + [ + 'docs/PULL_REQUEST_TEMPLATE.md', + [ + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + 'pull_request_template.md', + 'PULL_REQUEST_TEMPLATE.md', + 'docs/pull_request_template.md', + 'docs/PULL_REQUEST_TEMPLATE.md' + ] + ] + ] as [string, string[]][])( + 'reads PR templates from the SSH filesystem provider at %s', + async (relativeTemplatePath, expectedRelativeLookups) => { + const templateBody = `Remote template body from ${relativeTemplatePath}` + const readRemoteFile = vi.fn(async (path: string) => { + if (path === `/remote/repo-root/${relativeTemplatePath}`) { + return { + content: templateBody, + isBinary: false + } + } + throw new Error('missing template') + }) + getSshFilesystemProviderMock.mockReturnValue({ readFile: readRemoteFile }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + const writtenBodies: string[] = [] + ghExecFileAsyncMock.mockImplementationOnce(async (args: string[]) => { + const bodyPath = args[args.indexOf('--body-file') + 1] + writtenBodies.push(await readFile(bodyPath, 'utf8')) + return { + stdout: JSON.stringify({ + number: 46, + url: 'https://github.com/acme/widgets/pull/46' + }) + } + }) + + await expect( + createGitHubPullRequest( + '/remote/repo-root', + { + provider: 'github', + base: 'main', + head: 'feature/ssh-template', + title: 'SSH Template PR', + body: '', + useTemplate: true + }, + 'ssh-1' + ) + ).resolves.toEqual({ + ok: true, + number: 46, + url: 'https://github.com/acme/widgets/pull/46' + }) + + expect(readRemoteFile.mock.calls.map(([path]) => path)).toEqual( + expectedRelativeLookups.map((relativeLookup) => `/remote/repo-root/${relativeLookup}`) + ) + expect(writtenBodies).toEqual([templateBody]) + } + ) + it('falls back to parsing the PR URL for older gh output', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6e3b2a61ecb..c6edafcdd32 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -29,10 +29,12 @@ import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE, sortWorkItemsByUpdatedAt } from '../../shared/work-items' -import { mkdtemp, rm, writeFile } from 'fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' import { getPRConflictSummary } from './conflict-summary' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { execFileAsync, ghExecFileAsync, @@ -1391,6 +1393,41 @@ async function findOpenPRByHeadBase(args: { return { number: list[0].number, url: list[0].url } } +async function readPullRequestTemplate( + repoPath: string, + connectionId?: string | null +): Promise { + const relativeCandidates = [ + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + 'pull_request_template.md', + 'PULL_REQUEST_TEMPLATE.md', + 'docs/pull_request_template.md', + 'docs/PULL_REQUEST_TEMPLATE.md' + ] + const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined + if (connectionId && !remoteProvider) { + return '' + } + for (const relativeCandidate of relativeCandidates) { + try { + if (remoteProvider) { + const result = await remoteProvider.readFile( + joinWorktreeRelativePath(repoPath, relativeCandidate) + ) + if (result.isBinary) { + continue + } + return result.content + } + return await readFile(join(repoPath, relativeCandidate), 'utf8') + } catch { + // Try the next conventional PR template path. + } + } + return '' +} + export async function createGitHubPullRequest( repoPath: string, input: CreateHostedReviewInput, @@ -1435,7 +1472,11 @@ export async function createGitHubPullRequest( await acquire() const bodyPath = join(tempDir, 'body.md') try { - await writeFile(bodyPath, input.body ?? '', 'utf8') + const body = + input.useTemplate && !input.body?.trim() + ? await readPullRequestTemplate(repoPath, connectionId) + : (input.body ?? '') + await writeFile(bodyPath, body, 'utf8') const createArgs = [ 'pr', 'create', diff --git a/src/main/index.ts b/src/main/index.ts index 25a89d705b6..5b2b654bbb2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -24,6 +24,11 @@ import { startSpan } from './observability/tracer' import { registerMobileHandlers } from './ipc/mobile' import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client' import { runManagedHookInstallers } from './agent-hooks/install-telemetry' +import { + isAgentStatusHooksEnabled, + MANAGED_AGENT_HOOK_INSTALLERS, + removeManagedAgentHooks +} from './agent-hooks/managed-agent-hook-controls' import { initCohortClassifier } from './telemetry/cohort-classifier' import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier' import { resolveConsent } from './telemetry/consent' @@ -44,7 +49,8 @@ import { installDevParentDisconnectQuit, installDevParentWatchdog, installUncaughtPipeErrorGuard, - patchPackagedProcessPath + patchPackagedProcessPath, + shouldInstallManagedHooks } from './startup/configure-process' import { startFirstWindowStartupServices } from './startup/first-window-startup-services' import { getDevInstanceIdentity } from './startup/dev-instance-identity' @@ -55,20 +61,13 @@ import { attachMainWindowServices } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' +import { codexHookService } from './codex/hook-service' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { StarNagService } from './star-nag/service' import { agentHookServer } from './agent-hooks/server' +import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' -import { claudeHookService } from './claude/hook-service' -import { codexHookService } from './codex/hook-service' -import { geminiHookService } from './gemini/hook-service' -import { antigravityHookService } from './antigravity/hook-service' -import { cursorHookService } from './cursor/hook-service' -import { droidHookService } from './droid/hook-service' -import { grokHookService } from './grok/hook-service' -import { copilotHookService } from './copilot/hook-service' -import { hermesHookService } from './hermes/hook-service' import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, @@ -125,6 +124,51 @@ let automations: AutomationService | null = null let keybindings: KeybindingService | null = null let expectedRendererReload: { webContentsId: number; until: number } | null = null const isServeMode = process.argv.includes('--serve') + +// Why: the store/runtime singletons live here in index.ts; injecting them keeps +// the rename orchestrator free of module-level state and unit-testable. +function maybeAutoRenameBranchOnFirstWorkFromHook(event: { + paneKey: string + tabId: string | undefined + worktreeId: string | undefined + payload: { state: string; prompt?: string; lastAssistantMessage?: string } + isReplay: boolean | undefined +}): void { + const currentStore = store + const currentRuntime = runtime + if (!currentStore || !currentRuntime) { + return + } + void maybeAutoRenameBranchOnFirstWork( + { + paneKey: event.paneKey, + tabId: event.tabId, + worktreeId: event.worktreeId, + state: event.payload.state, + prompt: event.payload.prompt, + assistantMessage: event.payload.lastAssistantMessage, + isReplay: event.isReplay + }, + { + getSettings: () => currentStore.getSettings(), + getRepo: (repoId) => currentStore.getRepo(repoId), + getAgentEnvResolvers: () => currentRuntime.getCommitMessageAgentEnvironmentResolvers(), + getCurrentDisplayName: (worktreeId) => currentStore.getWorktreeMeta(worktreeId)?.displayName, + canRenameOrcaCreatedBranch: (worktreeId) => { + const meta = currentStore.getWorktreeMeta(worktreeId) + // Why: a user/imported branch can coincidentally be named after a creature. + // Only worktrees Orca stamped at creation are safe to auto-rename. + return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true + }, + setDisplayName: (worktreeId, displayName) => { + currentStore.setWorktreeMeta(worktreeId, { displayName }) + }, + resolveWorktreeIdForTab: (tabId) => currentStore.getWorktreeIdForTab(tabId), + onRenamed: (repoId) => currentRuntime.notifyBranchRenamed(repoId) + } + ) +} + const devInstanceIdentity = getDevInstanceIdentity(is.dev) const devAgentHookEndpointNamespace = devInstanceIdentity.isDev ? devInstanceIdentity.appUserModelId @@ -153,6 +197,9 @@ if (app.isPackaged && process.platform !== 'win32') { }) } configureDevUserDataPath(is.dev) +// Why: CLI-shared Codex helpers cannot import Electron. Seed the resolved +// app userData path once Electron has applied dev/e2e overrides. +process.env.ORCA_USER_DATA_PATH ??= app.getPath('userData') function focusExistingWindow(): void { // Why: the second-instance event fires on the *primary* Electron process @@ -260,6 +307,36 @@ if (hasSingleInstanceLock) { enableMainProcessGpuFeatures() } +function prepareCodexRuntimeHomeForLaunch(): string { + const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch() + const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) + try { + // Why: launch prep is reachable after startup via PTY/runtime paths; honor + // the persisted off switch so those launches cannot reinstall removed hooks. + const status = hooksEnabled + ? codexHookService.install() + : codexHookService.refreshRuntimeUserHooks() + if (status.state === 'error') { + console.warn( + `[codex-hook-service] failed to ${ + hooksEnabled ? 'refresh' : 'refresh user' + } runtime hooks before launch`, + status.detail + ) + } + } catch (error) { + // Why: hook install/removal is best-effort launch prep. A malformed hooks file + // should not block the Codex process from starting with its prepared auth. + console.warn( + `[codex-hook-service] failed to ${ + hooksEnabled ? 'refresh' : 'refresh user' + } runtime hooks before launch`, + error + ) + } + return runtimeHomePath +} + function openMainWindow(): BrowserWindow { if (!store) { throw new Error('Store must be initialized before opening the main window') @@ -337,6 +414,7 @@ function openMainWindow(): BrowserWindow { shouldRecordRendererCrash: (details, webContentsId) => shouldRecordProcessGoneCrash({ source: 'renderer', + processType: 'renderer', reason: details.reason, exitCode: details.exitCode ?? null, expectedTeardown: getExpectedTeardownScope(webContentsId) @@ -390,15 +468,17 @@ function openMainWindow(): BrowserWindow { rendererWebContentsId, automations, { - prepareForCodexLaunch: () => - store!.getSettings().activeCodexManagedAccountId - ? codexRuntimeHome!.prepareForCodexLaunch() - : null, + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch() }, agentAwakeService ?? undefined, crashReports ?? undefined, - keybindings + keybindings, + { + onBeforeRelaunch: () => { + isQuitting = true + } + } ) automations.setWebContents(window.webContents) automations.start() @@ -406,10 +486,7 @@ function openMainWindow(): BrowserWindow { window, store, runtime, - () => - store!.getSettings().activeCodexManagedAccountId - ? codexRuntimeHome!.prepareForCodexLaunch() - : null, + prepareCodexRuntimeHomeForLaunch, () => claudeRuntimeAuth!.prepareForClaudeLaunch(), { onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { @@ -447,10 +524,20 @@ function openMainWindow(): BrowserWindow { window.on('hide', stopSyntheticTitleSpinnerTimer) window.on('minimize', stopSyntheticTitleSpinnerTimer) agentHookServer.setListener( - ({ paneKey, tabId, worktreeId, connectionId, payload, receivedAt, stateStartedAt }) => { + ({ + paneKey, + tabId, + worktreeId, + connectionId, + payload, + receivedAt, + stateStartedAt, + isReplay + }) => { if (mainWindow?.isDestroyed()) { return } + maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay }) const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) mainWindow?.webContents.send('agentStatus:set', { ...payload, @@ -524,6 +611,8 @@ function recordProcessGoneCrash( if ( !shouldRecordProcessGoneCrash({ source, + processType, + serviceName: typeof details.serviceName === 'string' ? details.serviceName : undefined, reason, exitCode, expectedTeardown: getExpectedTeardownScope(webContentsId) @@ -992,10 +1081,10 @@ app.whenReady().then(async () => { runtimeService.setAutomationService(automations) runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) runtimeService.setCommitMessageAgentEnvironmentResolvers({ - prepareForCodexLaunch: () => - store!.getSettings().activeCodexManagedAccountId - ? codexRuntimeHome!.prepareForCodexLaunch() - : null, + // Why: local Codex hooks and auth now live in Orca's managed runtime home + // even for the system-default path, so every Orca-launched Codex process + // must resolve CODEX_HOME through the runtime-home service. + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch() }) starNag = new StarNagService(store, stats) @@ -1007,23 +1096,15 @@ app.whenReady().then(async () => { }) ) nativeTheme.themeSource = store.getSettings().theme ?? 'system' - // Why: managed hook installation mutates user-global agent config. Each - // installer runs inside its own try/catch so a malformed local config - // (e.g. corrupted ~/.claude/settings.json) cannot brick Orca startup. - // The agent label travels with each installer so the catch can attribute - // the failure in the `agent_hook_install_failed` telemetry event. - const managedHookInstallers = [ - ['claude', () => claudeHookService.install()], - ['codex', () => codexHookService.install()], - ['gemini', () => geminiHookService.install()], - ['antigravity', () => antigravityHookService.install()], - ['cursor', () => cursorHookService.install()], - ['droid', () => droidHookService.install()], - ['grok', () => grokHookService.install()], - ['copilot', () => copilotHookService.install()], - ['hermes', () => hermesHookService.install()] - ] as const - runManagedHookInstallers(managedHookInstallers) + if (shouldInstallManagedHooks(is.dev)) { + // Why: the persisted off switch must run before any auto-install path so + // users who removed Orca-managed hooks do not see them silently reappear on launch. + if (isAgentStatusHooksEnabled(store.getSettings())) { + runManagedHookInstallers(MANAGED_AGENT_HOOK_INSTALLERS) + } else { + removeManagedAgentHooks() + } + } app.on('child-process-gone', (_event, details) => { recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { @@ -1170,7 +1251,7 @@ app.whenReady().then(async () => { if (serveOptions) { registerHeadlessPtyRuntime( runtime, - () => codexRuntimeHome!.prepareForCodexLaunch(), + prepareCodexRuntimeHomeForLaunch, () => store!.getSettings(), () => claudeRuntimeAuth!.prepareForClaudeLaunch(), store diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 61c2cc073a1..679fe8d5650 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -61,9 +61,15 @@ vi.mock('../cursor/hook-service', () => ({ vi.mock('../droid/hook-service', () => ({ droidHookService: { getStatus: vi.fn(() => ({ agent: 'droid', state: 'absent' })) } })) +vi.mock('../command-code/hook-service', () => ({ + commandCodeHookService: { getStatus: vi.fn(() => ({ agent: 'command-code', state: 'absent' })) } +})) vi.mock('../grok/hook-service', () => ({ grokHookService: { getStatus: vi.fn(() => ({ agent: 'grok', state: 'absent' })) } })) +vi.mock('../copilot/hook-service', () => ({ + copilotHookService: { getStatus: vi.fn(() => ({ agent: 'copilot', state: 'absent' })) } +})) vi.mock('../hermes/hook-service', () => ({ hermesHookService: { getStatus: vi.fn(() => ({ agent: 'hermes', state: 'absent' })) } })) @@ -115,6 +121,17 @@ describe('agentHooks:antigravityStatus IPC', () => { }) }) +describe('agentHooks:commandCodeStatus IPC', () => { + it('returns Command Code hook installation status', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = handleHandlers.get('agentHooks:commandCodeStatus') + expect(handler).toBeDefined() + expect(handler!({})).toEqual({ agent: 'command-code', state: 'absent' }) + }) +}) + describe('agentStatus:inferInterrupt IPC', () => { it('forwards valid inference requests to the hook server', async () => { inferInterrupt.mockReturnValue(true) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 79246f38b53..11e05618e6e 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -16,6 +16,7 @@ import { geminiHookService } from '../gemini/hook-service' import { antigravityHookService } from '../antigravity/hook-service' import { cursorHookService } from '../cursor/hook-service' import { droidHookService } from '../droid/hook-service' +import { commandCodeHookService } from '../command-code/hook-service' import { grokHookService } from '../grok/hook-service' import { copilotHookService } from '../copilot/hook-service' import { hermesHookService } from '../hermes/hook-service' @@ -37,6 +38,7 @@ export function registerAgentHookHandlers(): void { ipcMain.removeHandler('agentHooks:antigravityStatus') ipcMain.removeHandler('agentHooks:cursorStatus') ipcMain.removeHandler('agentHooks:droidStatus') + ipcMain.removeHandler('agentHooks:commandCodeStatus') ipcMain.removeHandler('agentHooks:grokStatus') ipcMain.removeHandler('agentHooks:copilotStatus') ipcMain.removeHandler('agentHooks:hermesStatus') @@ -162,6 +164,19 @@ export function registerAgentHookHandlers(): void { } } }) + ipcMain.handle('agentHooks:commandCodeStatus', (): AgentHookInstallStatus => { + try { + return commandCodeHookService.getStatus() + } catch (err) { + return { + agent: 'command-code', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) ipcMain.handle('agentHooks:grokStatus', (): AgentHookInstallStatus => { try { return grokHookService.getStatus() diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts new file mode 100644 index 00000000000..1abd90b560e --- /dev/null +++ b/src/main/ipc/app.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, appExitMock, appQuitMock, appRelaunchMock } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + appExitMock: vi.fn(), + appQuitMock: vi.fn(), + appRelaunchMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + exit: appExitMock, + getAppPath: vi.fn(() => '/test/app'), + isPackaged: false, + quit: appQuitMock, + relaunch: appRelaunchMock + }, + BrowserWindow: { + fromWebContents: vi.fn(() => null) + }, + dialog: { + showOpenDialog: vi.fn() + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('@electron-toolkit/utils', () => ({ + is: { dev: true } +})) + +import { registerAppHandlers } from './app' + +describe('registerAppHandlers', () => { + beforeEach(() => { + vi.useFakeTimers() + handlers.clear() + appExitMock.mockReset() + appQuitMock.mockReset() + appRelaunchMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('marks relaunch as expected shutdown before exiting', () => { + const onBeforeRelaunch = vi.fn() + registerAppHandlers({} as never, { onBeforeRelaunch }) + + handlers.get('app:relaunch')?.(null) + + expect(onBeforeRelaunch).toHaveBeenCalledTimes(1) + expect(appRelaunchMock).not.toHaveBeenCalled() + expect(appExitMock).not.toHaveBeenCalled() + + vi.advanceTimersByTime(150) + + expect(appRelaunchMock).toHaveBeenCalledTimes(1) + expect(appExitMock).toHaveBeenCalledWith(0) + }) + + it('marks restart as expected shutdown before quitting through the normal pipeline', () => { + const onBeforeRelaunch = vi.fn() + registerAppHandlers({} as never, { onBeforeRelaunch }) + + handlers.get('app:restart')?.(null) + + expect(onBeforeRelaunch).toHaveBeenCalledTimes(1) + expect(appRelaunchMock).not.toHaveBeenCalled() + expect(appQuitMock).not.toHaveBeenCalled() + expect(appExitMock).not.toHaveBeenCalled() + + vi.advanceTimersByTime(150) + + expect(appRelaunchMock).toHaveBeenCalledTimes(1) + expect(appQuitMock).toHaveBeenCalledTimes(1) + expect(appExitMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 7fae681d1e8..2fb289e3ece 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -22,6 +22,10 @@ import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown const execFileAsync = promisify(execFile) +type RegisterAppHandlersOptions = { + onBeforeRelaunch?: () => void +} + async function pickFloatingMarkdownDocument( event: IpcMainInvokeEvent ): Promise { @@ -96,7 +100,7 @@ function resolveDevFeatureWallAssetDir(): string { return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] } -export function registerAppHandlers(store: Store): void { +export function registerAppHandlers(store: Store, options: RegisterAppHandlersOptions = {}): void { ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl()) ipcMain.handle('app:getIdentity', (): AppIdentity => { @@ -166,12 +170,25 @@ export function registerAppHandlers(store: Store): void { // UI state before the window tears down. `app.relaunch()` schedules a // spawn; `app.exit(0)` triggers the actual quit without invoking // before-quit handlers that could block on confirmation dialogs. + // Mark shutdown first because app.exit() can bypass the usual quit latch. + options.onBeforeRelaunch?.() setTimeout(() => { app.relaunch() app.exit(0) }, 150) }) + ipcMain.handle('app:restart', () => { + // Why: the hidden admin restart should mirror the update relaunch path: + // schedule a new Orca process, then use the normal quit pipeline so daemon + // checkpoints, runtime metadata, and telemetry flush before exit. + options.onBeforeRelaunch?.() + setTimeout(() => { + app.relaunch() + app.quit() + }, 150) + }) + ipcMain.handle('app:setUnreadDockBadgeCount', (_event, count: number) => { setUnreadDockBadgeCount(Number.isFinite(count) ? count : 0) }) diff --git a/src/main/ipc/computer-use-permissions.ts b/src/main/ipc/computer-use-permissions.ts index 8665fa9a096..af6d19d68b3 100644 --- a/src/main/ipc/computer-use-permissions.ts +++ b/src/main/ipc/computer-use-permissions.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import type { ComputerUsePermissionId, + ComputerUsePermissionResetResult, ComputerUsePermissionSetupResult, ComputerUsePermissionStatusResult } from '../../shared/computer-use-permissions-types' @@ -25,4 +26,12 @@ export function registerComputerUsePermissionHandlers(): void { return getComputerUsePermissionStatus() } ) + ipcMain.handle( + 'computerUsePermissions:reset', + async (): Promise => { + const { resetComputerUsePermissions } = + await import('../computer/macos-computer-use-permissions') + return resetComputerUsePermissions() + } + ) } diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 459fd8ac55f..92e1f6d8943 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -94,7 +94,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { // destructive or read/write operation, so the security boundary remains // enforced where it matters. const repos = getLocalRepos(store) - const perRepoResults = await Promise.all( + const perProjectResults = await Promise.all( repos.map(async (repo) => { const roots: string[] = [] try { @@ -117,7 +117,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { registeredWorktreeRoots.clear() registeredWorktreeRootsByRepo.clear() registeredWorktreeRootRepoIds.clear() - for (const { repoId, roots } of perRepoResults) { + for (const { repoId, roots } of perProjectResults) { const normalizedRoots = new Set() for (const root of roots) { normalizedRoots.add(root) diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 13c4a648acc..df3fa559554 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -58,6 +58,10 @@ function mockRealpath(mapping: Record) { }) } +function mockStats(dev: number, ino: number) { + return { dev, ino, isDirectory: () => false } +} + describe('registerFilesystemMutationHandlers', () => { beforeEach(() => { handlers.clear() @@ -162,18 +166,22 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) - it('rejects rename when destination already exists', async () => { + it('rejects rename when destination already exists as a true collision', async () => { + const oldPath = path.resolve('/workspace/repo/old.ts') const resolvedNewPath = path.resolve('/workspace/repo/new.ts') lstatMock.mockImplementation(async (p: string) => { + if (p === oldPath) { + return mockStats(1, 10) + } if (p === resolvedNewPath) { - return { isDirectory: () => false } + return mockStats(1, 11) } throw enoent() }) await expect( handlers.get('fs:rename')!(null, { - oldPath: path.resolve('/workspace/repo/old.ts'), + oldPath, newPath: resolvedNewPath }) ).rejects.toThrow("A file or folder named 'new.ts' already exists in this location") @@ -181,6 +189,55 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).not.toHaveBeenCalled() }) + it('allows case-only rename when destination is the same entry in the same parent', async () => { + const oldPath = path.resolve('/workspace/repo/README.md') + const newPath = path.resolve('/workspace/repo/readme.md') + lstatMock.mockImplementation(async (p: string) => { + if (p === oldPath || p === newPath) { + return mockStats(2, 20) + } + throw enoent() + }) + + await handlers.get('fs:rename')!(null, { oldPath, newPath }) + + expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) + }) + + it('rejects hard-link alias rename collisions even when dev and ino match', async () => { + const oldPath = path.resolve('/workspace/repo/README.md') + const newPath = path.resolve('/workspace/repo/README-hardlink.md') + lstatMock.mockImplementation(async (p: string) => { + if (p === oldPath || p === newPath) { + return mockStats(3, 30) + } + throw enoent() + }) + + await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toThrow( + "A file or folder named 'README-hardlink.md' already exists in this location" + ) + + expect(renameMock).not.toHaveBeenCalled() + }) + + it('rejects cross-parent case-only rename collisions even when dev and ino match', async () => { + const oldPath = path.resolve('/workspace/repo/src/README.md') + const newPath = path.resolve('/workspace/repo/docs/readme.md') + lstatMock.mockImplementation(async (p: string) => { + if (p === oldPath || p === newPath) { + return mockStats(4, 40) + } + throw enoent() + }) + + await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toThrow( + "A file or folder named 'readme.md' already exists in this location" + ) + + expect(renameMock).not.toHaveBeenCalled() + }) + it('rejects rename when parent directory escapes allowed roots', async () => { // Why: the parent is still canonicalized (preserveSymlink only preserves // the leaf). A symlinked ancestor that points outside allowed roots must @@ -216,6 +273,43 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) + it('routes rename through the SSH no-clobber filesystem provider when a connection is present', async () => { + const renameNoClobber = vi.fn().mockResolvedValue(undefined) + registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) + + try { + await handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-1' + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(renameNoClobber).toHaveBeenCalledWith('/home/me/repo/old.ts', '/home/me/repo/new.ts') + expect(renameMock).not.toHaveBeenCalled() + }) + + it('propagates SSH no-clobber rename failures', async () => { + const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists')) + registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) + + try { + await expect( + handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-1' + }) + ).rejects.toThrow('destination exists') + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(renameMock).not.toHaveBeenCalled() + }) + // ── fs:copy ──────────────────────────────────────────────────── it('copies a file without overwriting an existing destination', async () => { diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index cecb1568d7c..38c9d798105 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -20,6 +20,7 @@ import type { Store } from '../persistence' import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { importExternalPathsSsh } from './filesystem-import-ssh' +import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' /** * Re-throw filesystem errors with user-friendly messages. @@ -108,7 +109,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { ): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) - return provider.rename(args.oldPath, args.newPath) + return provider.renameNoClobber(args.oldPath, args.newPath) } // Why: rename() operates on directory entries, not file contents. If // oldPath is a symlink, we must rename the link itself rather than @@ -118,7 +119,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { // accidentally write into a symlinked destination name. const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true }) const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true }) - await assertNotExists(newPath) + await assertNoClobberRenameDestinationAvailable(oldPath, newPath) await rename(oldPath, newPath) } ) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 3615d8d43af..3915cbe5a04 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -15,6 +15,7 @@ const { lstatMock, commitChangesMock, getStatusMock, + abortMergeMock, getDiffMock, getBranchCompareMock, getBranchDiffMock, @@ -46,6 +47,7 @@ const { lstatMock: vi.fn(), commitChangesMock: vi.fn(), getStatusMock: vi.fn(), + abortMergeMock: vi.fn(), getDiffMock: vi.fn(), getBranchCompareMock: vi.fn(), getBranchDiffMock: vi.fn(), @@ -89,6 +91,7 @@ vi.mock('fs/promises', () => ({ vi.mock('../git/status', () => ({ commitChanges: commitChangesMock, getStatus: getStatusMock, + abortMerge: abortMergeMock, getDiff: getDiffMock, getBranchCompare: getBranchCompareMock, getBranchDiff: getBranchDiffMock, @@ -579,6 +582,26 @@ describe('registerFilesystemHandlers', () => { ]) }) + it('routes abort merge through local and SSH git providers', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + abortMergeMock.mockResolvedValue(undefined) + const sshProvider = { + abortMerge: vi.fn().mockResolvedValue(undefined) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:abortMerge')!(null, { worktreePath: WORKTREE_FEATURE_PATH }) + await handlers.get('git:abortMerge')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1' + }) + + expect(abortMergeMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) + expect(sshProvider.abortMerge).toHaveBeenCalledWith('/remote/repo') + }) + it('rejects git file paths that escape the selected worktree', async () => { registerFilesystemHandlers(store as never) @@ -907,9 +930,7 @@ describe('registerFilesystemHandlers', () => { ) }) - it('preserves the inherited Codex environment when no managed account is selected', async () => { - const previousCodexHome = process.env.CODEX_HOME - process.env.CODEX_HOME = '/system/codex-home' + it('prepares the Orca-managed Codex home for the default system selection', async () => { const context = { branch: 'feature/ai', stagedSummary: 'M\tREADME.md', @@ -923,26 +944,23 @@ describe('registerFilesystemHandlers', () => { message: 'Update README' }) - try { - registerFilesystemHandlers(store as never, { - prepareForCodexLaunch: () => null - }) + registerFilesystemHandlers(store as never, { + prepareForCodexLaunch: () => '/orca-managed/codex-home' + }) - await handlers.get('git:generateCommitMessage')!(null, { - worktreePath: WORKTREE_FEATURE_PATH - }) + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) - expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(context, params, { + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + context, + params, + expect.objectContaining({ kind: 'local', - cwd: WORKTREE_FEATURE_PATH + cwd: WORKTREE_FEATURE_PATH, + env: expect.objectContaining({ CODEX_HOME: '/orca-managed/codex-home' }) }) - } finally { - if (previousCodexHome === undefined) { - delete process.env.CODEX_HOME - } else { - process.env.CODEX_HOME = previousCodexHome - } - } + ) }) it('returns a sanitized error when local agent account preparation fails', async () => { diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index b1c1d1a8c59..e33c2ce3928 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import { ipcMain, shell } from 'electron' import { readdir, readFile, writeFile, stat, lstat, open } from 'fs/promises' -import { extname, join } from 'path' +import { extname, join, resolve } from 'path' import type { ChildProcess } from 'child_process' import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' @@ -18,6 +18,7 @@ import type { MarkdownDocument, SearchOptions, SearchResult, + Repo, TuiAgent } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' @@ -31,6 +32,7 @@ import { } from '../../shared/text-search' import { getStatus, + abortMerge, detectConflictOperation, getDiff, commitChanges, @@ -91,6 +93,8 @@ import { prepareLocalCommitMessageAgentEnv, type CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' +import { listRepoWorktrees } from '../repo-worktrees' +import { splitWorktreeId } from '../../shared/worktree-id' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. @@ -117,6 +121,133 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.pdf': 'application/pdf' } +function comparableLocalPath(value: string): string { + const normalized = resolve(value) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function comparableRemotePath(value: string): string { + return value.replace(/[/\\]+$/g, '') +} + +function getCandidateLocalWorktreePaths( + worktreePath: string, + resolvedWorktreePath: string +): Set { + return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) +} + +function hasRegisteredWorktreeMetaForRepo( + store: Store, + repoId: string, + candidatePaths: Set +): boolean { + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { + return true + } + } + return false +} + +function hasRegisteredRemoteWorktreeMetaForRepo( + store: Store, + repoId: string, + worktreePath: string +): boolean { + const comparableWorktreePath = comparableRemotePath(worktreePath) + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if ( + parsed?.repoId === repoId && + comparableRemotePath(parsed.worktreePath) === comparableWorktreePath + ) { + return true + } + } + return false +} + +async function localRepoOwnsWorktree( + store: Store, + repo: Repo, + worktreePath: string +): Promise { + let resolvedWorktreePath: string + try { + resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) + } catch { + return false + } + const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) + if (candidatePaths.has(comparableLocalPath(repo.path))) { + return true + } + if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) { + return true + } + try { + const worktrees = await listRepoWorktrees(repo) + return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) + } catch { + return false + } +} + +async function remoteRepoOwnsWorktree( + store: Store, + repo: Repo, + worktreePath: string, + connectionId: string +): Promise { + const comparableWorktreePath = comparableRemotePath(worktreePath) + if (comparableRemotePath(repo.path) === comparableWorktreePath) { + return true + } + const provider = getSshGitProvider(connectionId) + if (!provider) { + return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) + } + try { + const worktrees = await provider.listWorktrees(repo.path) + return worktrees.some( + (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath + ) + } catch { + return false + } +} + +async function getRepoForSourceControlAi( + store: Store, + args: { repoId?: string; worktreePath: string; connectionId?: string } +): Promise { + if (!args.repoId) { + return null + } + const repo = store.getRepo(args.repoId) + if (!repo) { + return null + } + if (args.connectionId) { + if (repo.connectionId !== args.connectionId) { + return null + } + // Why: a single SSH connection can host several repos; repo-scoped AI + // overrides only apply when the requested worktree belongs to that repo. + return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) + ? repo + : null + } + if (repo.connectionId) { + return null + } + // Why: renderer-supplied repoId is advisory; only apply repo overrides when + // the requested local worktree is known to belong to that repo. + return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null +} + function validateFullGitObjectId(value: string, label: string): string { if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { throw new Error(`${label} must be a full git object id`) @@ -567,6 +698,21 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:abortMerge', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.abortMerge(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await abortMerge(worktreePath) + } + ) + ipcMain.handle( 'git:diff', async ( @@ -625,11 +771,17 @@ export function registerFilesystemHandlers( _event, args: { worktreePath: string + repoId?: string connectionId?: string } ): Promise => { const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) - const resolvedSettings = resolveCommitMessageSettings(store.getSettings(), discoveryHostKey) + const resolvedSettings = resolveCommitMessageSettings( + store.getSettings(), + discoveryHostKey, + 'commitMessage', + await getRepoForSourceControlAi(store, args) + ) if (!resolvedSettings.ok) { return { success: false, error: resolvedSettings.error } } @@ -751,6 +903,7 @@ export function registerFilesystemHandlers( _event, args: { worktreePath: string + repoId?: string base: string title: string body: string @@ -759,7 +912,12 @@ export function registerFilesystemHandlers( } ): Promise => { const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) - const resolvedSettings = resolveCommitMessageSettings(store.getSettings(), discoveryHostKey) + const resolvedSettings = resolveCommitMessageSettings( + store.getSettings(), + discoveryHostKey, + 'pullRequest', + await getRepoForSourceControlAi(store, args) + ) if (!resolvedSettings.ok) { return { success: false, error: resolvedSettings.error } } diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index a26560ce768..fe23909ab5c 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -119,7 +119,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector head: args.head, title: args.title, body: args.body, - draft: args.draft + draft: args.draft, + useTemplate: args.useTemplate }, repo.connectionId ?? null ) diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index f3df09c8433..c0b4b02c2c3 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -98,6 +98,10 @@ export function registerLinearHandlers(): void { workspaceId?: string parentIssueId?: string projectId?: string | null + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] } ) => { if (typeof args?.teamId !== 'string' || !args.teamId.trim()) { @@ -106,6 +110,19 @@ export function registerLinearHandlers(): void { if (typeof args?.title !== 'string' || !args.title.trim()) { return { ok: false, error: 'Title is required' } } + if ( + args.priority !== undefined && + (!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4) + ) { + return { ok: false, error: 'Invalid priority' } + } + if ( + args.labelIds !== undefined && + (!Array.isArray(args.labelIds) || + !args.labelIds.every((id) => typeof id === 'string' && id.trim())) + ) { + return { ok: false, error: 'Invalid label IDs' } + } return createIssue( args.teamId.trim(), args.title.trim(), @@ -113,7 +130,11 @@ export function registerLinearHandlers(): void { normalizeWorkspaceId(args.workspaceId), { parentId: typeof args.parentIssueId === 'string' ? args.parentIssueId.trim() : undefined, - projectId: typeof args.projectId === 'string' ? args.projectId.trim() : null + projectId: typeof args.projectId === 'string' ? args.projectId.trim() : null, + stateId: typeof args.stateId === 'string' ? args.stateId.trim() : undefined, + priority: typeof args.priority === 'number' ? args.priority : undefined, + assigneeId: typeof args.assigneeId === 'string' ? args.assigneeId.trim() : null, + labelIds: Array.isArray(args.labelIds) ? args.labelIds.map((id) => id.trim()) : undefined } ) } @@ -142,6 +163,12 @@ export function registerLinearHandlers(): void { if (u.stateId !== undefined && (typeof u.stateId !== 'string' || !u.stateId.trim())) { return { ok: false, error: 'Invalid state ID' } } + if (u.title !== undefined && (typeof u.title !== 'string' || !u.title.trim())) { + return { ok: false, error: 'Title is required' } + } + if (u.description !== undefined && typeof u.description !== 'string') { + return { ok: false, error: 'Description must be a string' } + } if ( u.priority !== undefined && (!Number.isInteger(u.priority) || u.priority < 0 || u.priority > 4) diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index 23dbcf7ce8c..d92179a8222 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -13,6 +13,7 @@ const AGENT_TYPE_LABELS: Readonly> = { cursor: 'Cursor', aider: 'Aider', pi: 'Pi', + omp: 'OMP', droid: 'Droid', grok: 'Grok', hermes: 'Hermes' diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index a2272a2adfd..3c959c60155 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -652,7 +652,7 @@ describe('registerNotificationHandlers', () => { ) }) - it('uses rich formatter output for mobile notifications before desktop guards', () => { + it('uses rich formatter output for mobile notifications before the native support guard', () => { notificationIsSupportedMock.mockReturnValue(false) const dispatchMobileNotification = vi.fn() registerNotificationHandlers( @@ -694,6 +694,145 @@ describe('registerNotificationHandlers', () => { expect(notificationCtorMock).not.toHaveBeenCalled() }) + it('does not dispatch mobile notifications when notifications are disabled', () => { + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: false, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ + delivered: false, + reason: 'disabled' + }) + + expect(dispatchMobileNotification).not.toHaveBeenCalled() + }) + + it('does not dispatch mobile notifications when the source is disabled', () => { + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ + delivered: false, + reason: 'source-disabled' + }) + + expect(dispatchMobileNotification).not.toHaveBeenCalled() + }) + + it('does not dispatch mobile notifications for focused active-worktree notifications', () => { + getAllWindowsMock.mockReturnValue([ + { + isDestroyed: () => false, + isFocused: () => true + } as never + ]) + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: true + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect( + handler( + {}, + { source: 'agent-task-complete', worktreeId: 'repo::wt1', isActiveWorktree: true } + ) + ).toEqual({ + delivered: false, + reason: 'suppressed-focus' + }) + + expect(dispatchMobileNotification).not.toHaveBeenCalled() + }) + + it('does not dispatch mobile notifications for cooldown-suppressed bursts', () => { + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ + delivered: true + }) + expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ + delivered: false, + reason: 'cooldown' + }) + + expect(dispatchMobileNotification).toHaveBeenCalledTimes(1) + expect(dispatchMobileNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' }) + ) + }) + + it('does not forward explicit desktop test notifications to mobile clients', () => { + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) + + expect(dispatchMobileNotification).not.toHaveBeenCalled() + }) + it('silences the native notification when a custom sound is configured', () => { registerNotificationHandlers({ getSettings: () => ({ diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 0159d046759..2cbbe63504c 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -157,25 +157,6 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime _event, args: NotificationDispatchRequest ): NotificationDispatchResult | Promise => { - // Why: mobile push is independent of desktop notification guards. - // The user's phone should receive the notification even when the desktop - // window is focused (suppressWhenFocused), Electron notifications aren't - // supported, or the desktop is in cooldown. The mobile client decides - // independently whether to show based on its own app state. - if (runtime) { - const opts = buildNotificationOptions(args) - runtime.dispatchMobileNotification({ - source: args.source, - title: opts.title, - body: opts.body, - worktreeId: args.worktreeId - }) - } - - if (!Notification.isSupported()) { - return { delivered: false, reason: 'not-supported' } - } - const settings = store.getSettings().notifications if (!settings.enabled) { return { delivered: false, reason: 'disabled' } @@ -223,6 +204,23 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime } const notificationOptions = buildNotificationOptions(args) + + // Why: paired mobile clients should follow the same user-facing + // notification gates as desktop delivery, while still working on hosts + // where Electron native notifications are unavailable. + if (runtime && args.source !== 'test') { + runtime.dispatchMobileNotification({ + source: args.source, + title: notificationOptions.title, + body: notificationOptions.body, + worktreeId: args.worktreeId + }) + } + + if (!Notification.isSupported()) { + return { delivered: false, reason: 'not-supported' } + } + if (getEffectiveNotificationSoundId(settings) !== 'system') { notificationOptions.silent = true } else if (process.platform === 'darwin') { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 1f8d43c0210..3fc08a71d38 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -187,15 +187,25 @@ describe('registerPtyHandlers', () => { const savedPiAgentDir = process.env.PI_CODING_AGENT_DIR const savedOrcaPiAgentDir = process.env.ORCA_PI_CODING_AGENT_DIR const savedOrcaPiSourceAgentDir = process.env.ORCA_PI_SOURCE_AGENT_DIR + const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME + const savedOrcaOmpAgentDir = process.env.ORCA_OMP_CODING_AGENT_DIR + const savedOrcaOmpSourceAgentDir = process.env.ORCA_OMP_SOURCE_AGENT_DIR + const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION + const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS beforeEach(() => { delete process.env.OPENCODE_CONFIG_DIR delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR delete process.env.ORCA_OPENCODE_CONFIG_DIR delete process.env.ORCA_AGENT_HOOK_ENDPOINT + delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS delete process.env.PI_CODING_AGENT_DIR delete process.env.ORCA_PI_SOURCE_AGENT_DIR delete process.env.ORCA_PI_CODING_AGENT_DIR + delete process.env.ORCA_CODEX_HOME + delete process.env.ORCA_OMP_SOURCE_AGENT_DIR + delete process.env.ORCA_OMP_CODING_AGENT_DIR + delete process.env.ORCA_OMP_STATUS_EXTENSION handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -248,11 +258,13 @@ describe('registerPtyHandlers', () => { ORCA_AGENT_HOOK_PORT: '5678', ORCA_AGENT_HOOK_TOKEN: 'agent-token' }) - piBuildPtyEnvMock.mockImplementation((_ptyId: string, existingAgentDir?: string) => ({ - PI_CODING_AGENT_DIR: existingAgentDir - ? '/tmp/orca-pi-agent-overlay' - : '/tmp/orca-pi-agent-overlay' - })) + piBuildPtyEnvMock.mockImplementation( + (_ptyId: string, existingAgentDir?: string, _kind?: string) => ({ + PI_CODING_AGENT_DIR: existingAgentDir + ? '/tmp/orca-pi-agent-overlay' + : '/tmp/orca-pi-agent-overlay' + }) + ) isPwshAvailableMock.mockReturnValue(false) spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), @@ -298,6 +310,31 @@ describe('registerPtyHandlers', () => { } else { process.env.ORCA_PI_SOURCE_AGENT_DIR = savedOrcaPiSourceAgentDir } + if (savedOrcaCodexHome === undefined) { + delete process.env.ORCA_CODEX_HOME + } else { + process.env.ORCA_CODEX_HOME = savedOrcaCodexHome + } + if (savedOrcaOmpAgentDir !== undefined) { + process.env.ORCA_OMP_CODING_AGENT_DIR = savedOrcaOmpAgentDir + } else { + delete process.env.ORCA_OMP_CODING_AGENT_DIR + } + if (savedOrcaOmpSourceAgentDir !== undefined) { + process.env.ORCA_OMP_SOURCE_AGENT_DIR = savedOrcaOmpSourceAgentDir + } else { + delete process.env.ORCA_OMP_SOURCE_AGENT_DIR + } + if (savedOrcaOmpStatusExtension !== undefined) { + process.env.ORCA_OMP_STATUS_EXTENSION = savedOrcaOmpStatusExtension + } else { + delete process.env.ORCA_OMP_STATUS_EXTENSION + } + if (savedOrcaClaudeAgentStatusSettings === undefined) { + delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS + } else { + process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS = savedOrcaClaudeAgentStatusSettings + } }) function createMockProc() { @@ -340,7 +377,12 @@ describe('registerPtyHandlers', () => { argsEnv?: Record, processEnvOverrides?: Record, getSelectedCodexHomePath?: () => string | null, - getSettings?: () => { enableGitHubAttribution: boolean } + getSettings?: () => { enableGitHubAttribution?: boolean; agentStatusHooksEnabled?: boolean }, + // Why: PR #2662 finding 2 — the threading from IPC `args.command` through + // buildPtyHostEnv to piTitlebarExtensionService.buildPtyEnv was untested + // for the OMP case because this helper never forwarded a command. Accept + // an optional `command` so callers can exercise OMP overlay resolution. + command?: string ): Promise> { const savedEnv: Record = {} if (processEnvOverrides) { @@ -367,7 +409,8 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - ...(argsEnv ? { env: argsEnv } : {}) + ...(argsEnv ? { env: argsEnv } : {}), + ...(command ? { command } : {}) }) const spawnCall = spawnMock.mock.calls.at(-1)! return spawnCall[2].env as Record @@ -470,6 +513,7 @@ describe('registerPtyHandlers', () => { it('injects the selected Codex home into Orca terminal PTYs', async () => { const env = await spawnAndGetEnv(undefined, undefined, () => '/tmp/orca-codex-home') expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') + expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') }) it('injects the OpenCode hook env into Orca terminal PTYs', async () => { @@ -498,6 +542,41 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/tmp/user-opencode-config') }) + it('restores user OpenCode config when agent status hooks are disabled in a nested Orca shell', async () => { + const env = await spawnAndGetEnv( + { + OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', + ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', + ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config' + }, + undefined, + undefined, + () => ({ agentStatusHooksEnabled: false }) + ) + + expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled() + expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/user-opencode-config') + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + }) + + it('strips inherited OpenCode overlay env when agent status hooks are disabled without a source dir', async () => { + const env = await spawnAndGetEnv( + { + OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', + ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay' + }, + undefined, + undefined, + () => ({ agentStatusHooksEnabled: false }) + ) + + expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled() + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + }) + it('reproduces issue #1534: GUI-launched Orca mirrors zshrc-only OpenCode config', async () => { // Why: the reporter's app process did not inherit OPENCODE_CONFIG_DIR; // their interactive zsh startup later exported a company config repo. @@ -531,10 +610,44 @@ describe('registerPtyHandlers', () => { it('injects the Pi agent overlay env into Orca terminal PTYs', async () => { const env = await spawnAndGetEnv(undefined, { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent', 'pi') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp') expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') + expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( + '/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts' + ) + }) + + it('threads command: "omp" through to piBuildPtyEnv and emits ORCA_OMP_* shadow vars', async () => { + // Why: OMP launches must emit OMP-named Orca shadow vars (ORCA_OMP_*), + // not Pi-named ones. The PI_CODING_AGENT_DIR binary var is unavoidable + // (OMP's own binary reads it — see C:\tmp\pr-workspace\oh-my-pi + // packages/utils/src/dirs.ts), but every other Orca-owned env name + // stays kind-scoped so an OMP PTY never accumulates Pi shadow state. + const env = await spawnAndGetEnv( + undefined, + { PI_CODING_AGENT_DIR: '/tmp/user-omp-agent' }, + undefined, + undefined, + 'omp' + ) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/tmp/user-omp-agent', + 'omp' + ) + expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( + '/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts' + ) + expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/tmp/user-omp-agent') + // CRITICAL: a Pi-named shadow MUST NOT leak into an OMP PTY env. + expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) it('mirrors the original Pi source dir when launched from an Orca overlay shell', async () => { @@ -542,12 +655,71 @@ describe('registerPtyHandlers', () => { PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent' }) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent', 'pi') expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') }) + it('does not use an inherited Pi overlay source for an OMP launch', async () => { + const env = await spawnAndGetEnv( + { + PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', + ORCA_PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', + ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent' + }, + undefined, + undefined, + undefined, + 'omp' + ) + + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp') + expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + + it('does not use an inherited OMP overlay source for an explicit Pi launch', async () => { + const env = await spawnAndGetEnv( + { + PI_CODING_AGENT_DIR: '/tmp/parent-orca-omp-overlay', + ORCA_OMP_CODING_AGENT_DIR: '/tmp/parent-orca-omp-overlay', + ORCA_OMP_SOURCE_AGENT_DIR: '/tmp/user-omp-agent' + }, + undefined, + undefined, + undefined, + 'pi' + ) + + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'pi') + expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined() + expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBeUndefined() + expect(env.ORCA_OMP_STATUS_EXTENSION).toBeUndefined() + }) + + it('restores user Pi config when agent status hooks are disabled in a nested Orca shell', async () => { + const env = await spawnAndGetEnv( + { + PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', + ORCA_PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', + ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent' + }, + undefined, + undefined, + () => ({ agentStatusHooksEnabled: false }) + ) + + expect(piBuildPtyEnvMock).not.toHaveBeenCalled() + expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/user-pi-agent') + expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + it('mirrors Pi config exported only by shell startup files', async () => { readFileSyncMock.mockImplementation((path: string) => path.endsWith('.zshrc') ? 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n' : '' @@ -561,14 +733,15 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), - '/home/tester/.config/pi-agent' + '/home/tester/.config/pi-agent', + 'pi' ) expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/home/tester/.config/pi-agent') }) - it('injects the Claude/Codex hook receiver env into Orca terminal PTYs', async () => { + it('injects the agent hook receiver env into Orca terminal PTYs', async () => { const env = await spawnAndGetEnv() // Why: after the daemon-parity refactor, buildAgentHookEnv runs exactly // once for a local spawn — inside the shared buildPtyHostEnv helper, @@ -586,7 +759,8 @@ describe('registerPtyHandlers', () => { ORCA_AGENT_HOOK_TOKEN: 'stale-token', ORCA_AGENT_HOOK_ENV: 'production', ORCA_AGENT_HOOK_VERSION: 'stale-version', - ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env' + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env', + ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/orca/agent-hooks/claude-agent-status-settings.json' }) expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') @@ -594,6 +768,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined() expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined() expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined() }) it('does not leak inherited hook receiver env if the hook server is unavailable', async () => { @@ -604,7 +779,8 @@ describe('registerPtyHandlers', () => { ORCA_AGENT_HOOK_TOKEN: 'stale-token', ORCA_AGENT_HOOK_ENV: 'production', ORCA_AGENT_HOOK_VERSION: 'stale-version', - ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env' + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env', + ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/orca/agent-hooks/claude-agent-status-settings.json' }) expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() @@ -612,6 +788,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined() expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined() expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined() }) it('prepends local git/gh attribution shims when attribution is enabled', async () => { @@ -667,13 +844,14 @@ describe('registerPtyHandlers', () => { expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix') }) - it('leaves ambient CODEX_HOME untouched when system default is selected', async () => { + it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => { const env = await spawnAndGetEnv( undefined, { CODEX_HOME: '/tmp/system-codex-home' }, - () => null + () => '/tmp/orca-codex-home' ) - expect(env.CODEX_HOME).toBe('/tmp/system-codex-home') + expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') + expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') }) describe('daemon-active provider (parity with LocalPtyProvider)', () => { @@ -687,7 +865,11 @@ describe('registerPtyHandlers', () => { function setupDaemonAdapter() { const daemonSpawn = vi.fn( - async (options: { env: Record; sessionId?: string }) => ({ + async (options: { + env: Record + sessionId?: string + isNewSession?: boolean + }) => ({ id: options.sessionId ?? 'daemon-pty' }) ) @@ -705,12 +887,21 @@ describe('registerPtyHandlers', () => { return daemonSpawn } - async function daemonSpawnAndGetEnv( + type DaemonSpawnCall = { + env: Record + envToDelete?: string[] + isNewSession?: boolean + } + + async function daemonSpawnAndGetOptions( argsEnv?: Record, getSelectedCodexHomePath?: () => string | null, getSettings?: () => { enableGitHubAttribution: boolean }, - processEnvOverrides?: Record - ): Promise> { + processEnvOverrides?: Record, + // Why: daemon spawn tests need to exercise both WSL launch metadata + // from main and PR #2662 command threading for OMP overlay selection. + spawnArgs?: { cwd?: string; shellOverride?: string; command?: string } + ): Promise { const daemonSpawn = setupDaemonAdapter() const savedEnv: Record = {} if (processEnvOverrides) { @@ -734,9 +925,10 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, + ...spawnArgs, ...(argsEnv ? { env: argsEnv } : {}) }) - return daemonSpawn.mock.calls.at(-1)![0].env + return daemonSpawn.mock.calls.at(-1)![0] as DaemonSpawnCall } finally { for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) { @@ -748,6 +940,24 @@ describe('registerPtyHandlers', () => { } } + async function daemonSpawnAndGetEnv( + argsEnv?: Record, + getSelectedCodexHomePath?: () => string | null, + getSettings?: () => { enableGitHubAttribution: boolean }, + processEnvOverrides?: Record, + spawnArgs?: { cwd?: string; shellOverride?: string; command?: string } + ): Promise> { + return ( + await daemonSpawnAndGetOptions( + argsEnv, + getSelectedCodexHomePath, + getSettings, + processEnvOverrides, + spawnArgs + ) + ).env + } + it('injects OpenCode plugin env (OPENCODE_CONFIG_DIR) on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, undefined, undefined, { OPENCODE_CONFIG_DIR: undefined @@ -789,15 +999,108 @@ describe('registerPtyHandlers', () => { // Why: asserts the overlay key was passed through — the id is the // daemon-assigned sessionId minted in pty.ts, and the mock returns // the fixed overlay path from the shared setup. - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/user/.pi/agent') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/user/.pi/agent', 'pi') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp') expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/user/.pi/agent') + expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( + '/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts' + ) + }) + + it('threads command: "omp" through to piBuildPtyEnv on the daemon path with OMP shadow vars', async () => { + // Why: mirror of the local-spawn OMP threading assertion. The + // daemon path's `command` forwarding could silently regress and + // Pi-only tests would still pass. + const env = await daemonSpawnAndGetEnv( + { PI_CODING_AGENT_DIR: '/user/.omp/agent' }, + undefined, + undefined, + undefined, + { command: 'omp' } + ) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/user/.omp/agent', + 'omp' + ) + expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( + '/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts' + ) + expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/user/.omp/agent') + expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) it('injects the selected Codex home on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, () => '/tmp/orca-codex-home') expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') + expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') + }) + + it('skips host Codex home when a daemon-backed Windows spawn targets a WSL cwd', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + try { + const spawnOptions = await daemonSpawnAndGetOptions( + {}, + () => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', + undefined, + { + CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', + ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' + }, + { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo' } + ) + const { env } = spawnOptions + expect(env.CODEX_HOME).toBeUndefined() + expect(env.ORCA_CODEX_HOME).toBeUndefined() + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME']) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('skips host Codex home when a daemon-backed Windows spawn uses a WSL shell override', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + try { + const spawnOptions = await daemonSpawnAndGetOptions( + {}, + () => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', + undefined, + { + CODEX_HOME: 'C:\\Users\\test\\.codex', + ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' + }, + { shellOverride: 'wsl.exe' } + ) + expect(spawnOptions.env.CODEX_HOME).toBeUndefined() + expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined() + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME']) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } }) it('injects the agent-hook receiver env on the daemon path', async () => { @@ -806,6 +1109,53 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('deletes stale Claude scoped settings env from daemon-hosted PTYs', async () => { + const spawnOptions = await daemonSpawnAndGetOptions({}, undefined, undefined, { + ORCA_CLAUDE_AGENT_STATUS_SETTINGS: + '/tmp/orca/agent-hooks/claude-agent-status-settings.json' + }) + expect(spawnOptions.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined() + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining(['ORCA_CLAUDE_AGENT_STATUS_SETTINGS']) + ) + expect(spawnOptions.env.ORCA_AGENT_HOOK_PORT).toBe('5678') + expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') + }) + + it('deletes stale Claude scoped settings env from runtime-created daemon PTYs', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS = + '/tmp/orca/agent-hooks/claude-agent-status-settings.json' + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} }) + + const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall + expect(spawnOptions.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined() + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining(['ORCA_CLAUDE_AGENT_STATUS_SETTINGS']) + ) + expect(spawnOptions.env.ORCA_AGENT_HOOK_PORT).toBe('5678') + expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') + }) + it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => { const { app } = await import('electron') const mockedApp = app as unknown as { isPackaged: boolean } @@ -878,7 +1228,8 @@ describe('registerPtyHandlers', () => { const sessionId = spawnOpts.sessionId expect(sessionId).toEqual(expect.any(String)) expect((sessionId ?? '').length).toBeGreaterThan(0) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined) + expect(spawnOpts.isNewSession).toBe(true) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi') }) it('respects a caller-provided sessionId instead of minting a new one', async () => { @@ -892,7 +1243,8 @@ describe('registerPtyHandlers', () => { sessionId: 'user-session-42' }) expect(daemonSpawn.mock.calls.at(-1)![0].sessionId).toBe('user-session-42') - expect(piBuildPtyEnvMock).toHaveBeenCalledWith('user-session-42', undefined) + expect(daemonSpawn.mock.calls.at(-1)![0].isNewSession).toBeUndefined() + expect(piBuildPtyEnvMock).toHaveBeenCalledWith('user-session-42', undefined, 'pi') }) it('prefixes a minted sessionId with the worktreeId when provided', async () => { @@ -911,7 +1263,7 @@ describe('registerPtyHandlers', () => { }) const sessionId = daemonSpawn.mock.calls.at(-1)![0].sessionId ?? '' expect(sessionId).toMatch(/^wt-alpha@@[0-9a-f]{8}$/) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi') }) it('falls back to process.env.PI_CODING_AGENT_DIR when baseEnv lacks it on the daemon path', async () => { @@ -923,7 +1275,11 @@ describe('registerPtyHandlers', () => { const env = await daemonSpawnAndGetEnv({}, undefined, undefined, { PI_CODING_AGENT_DIR: '/ambient/pi/agent' }) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/ambient/pi/agent') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/ambient/pi/agent', + 'pi' + ) expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') }) @@ -2199,7 +2555,7 @@ describe('registerPtyHandlers', () => { registerPtyHandlers( mainWindow as never, undefined, - undefined, + () => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', () => ({ terminalWindowsShell: 'wsl.exe', @@ -2208,7 +2564,10 @@ describe('registerPtyHandlers', () => { ) handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record } expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object)) + expect(spawnOptions.env.CODEX_HOME).toBeUndefined() + expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined() }) it('keeps shellOverride priority for one-off tabs', () => { @@ -2218,7 +2577,7 @@ describe('registerPtyHandlers', () => { registerPtyHandlers( mainWindow as never, undefined, - undefined, + () => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', () => ({ terminalWindowsShell: 'powershell.exe', @@ -2231,7 +2590,10 @@ describe('registerPtyHandlers', () => { shellOverride: 'wsl.exe' }) + const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record } expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object)) + expect(spawnOptions.env.CODEX_HOME).toBeUndefined() + expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined() }) }) @@ -2626,6 +2988,55 @@ describe('registerPtyHandlers', () => { } }) + it('drains large batched PTY output in bounded slices', async () => { + vi.useFakeTimers() + const firstProc = createMockProc() + const secondProc = createMockProc() + spawnMock.mockReturnValueOnce(firstProc.proc).mockReturnValueOnce(secondProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const firstSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const secondSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + const firstChunk = 'x'.repeat(16 * 1024) + const firstRemainder = 'tail' + secondProc.emitData('second-terminal-output') + firstProc.emitData(`${firstChunk}${firstRemainder}`) + + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(1, 'pty:data', { + id: secondSpawn.id, + data: 'second-terminal-output' + }) + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(2, 'pty:data', { + id: firstSpawn.id, + data: firstChunk + }) + + vi.advanceTimersByTime(1) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(3) + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(3, 'pty:data', { + id: firstSpawn.id, + data: firstRemainder + }) + } finally { + vi.useRealTimers() + } + }) + it('batches stale PTY output after the interactive window expires', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -3323,4 +3734,30 @@ describe('registerPtyHandlers', () => { await expect(pending).resolves.toBeNull() }) }) + + describe('main buffer snapshot dispatch', () => { + it('returns a sequenced main-owned terminal snapshot with clamped scrollback', async () => { + const runtime = { + setPtyController: vi.fn(), + serializeMainTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot\r\n', + cols: 120, + rows: 40, + seq: 42 + }) + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + const result = await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: 'pty-1', + opts: { scrollbackRows: 999_999 } + }) + + expect(runtime.serializeMainTerminalBuffer).toHaveBeenCalledWith('pty-1', { + scrollbackRows: 50_000 + }) + expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, rows: 40, seq: 42 }) + }) + }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 30a88442085..c48a5ff8ede 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -12,7 +12,10 @@ import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' import { openCodeHookService } from '../opencode/hook-service' import { agentHookServer } from '../agent-hooks/server' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' +import { ORCA_PI_AGENT_STATUS_EXTENSION_FILE } from '../pi/agent-status-extension-source' +import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind' import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' @@ -51,6 +54,8 @@ import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' +import { parseWslPath } from '../wsl' +import { mergePersistedWindowsPath } from '../pty/windows-environment-path' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -87,7 +92,10 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_AGENT_HOOK_ENV', 'ORCA_AGENT_HOOK_VERSION', - 'ORCA_AGENT_HOOK_ENDPOINT' + 'ORCA_AGENT_HOOK_ENDPOINT', + // Why: PR 2778 briefly exported this scoped Claude settings path. Keep + // deleting stale inherited values so older PTYs cannot leak the reverted path. + 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' ] as const export function getPtyIdForPaneKey(paneKey: string): string | undefined { @@ -249,11 +257,164 @@ export type BuildPtyHostEnvOptions = { isPackaged: boolean userDataPath: string selectedCodexHomePath: string | null + skipCodexHomeEnv?: boolean githubAttributionEnabled: boolean + /** The launch command the renderer chose for this PTY (e.g. 'pi', 'omp', + * 'claude'). Used to resolve the per-agent overlay source dir for Pi / + * OMP - both consume `PI_CODING_AGENT_DIR` but default to different + * `~/./agent` paths. Undefined for bare-shell spawns; defaults + * resolve to Pi for back-compat. NEVER infer from disk presence; that's + * the bug this option fixes (cross-agent shadowing when both dirs exist). */ + launchCommand?: string + agentStatusHooksEnabled: boolean } function readInheritedPath(baseEnv: Record): string { - return baseEnv.PATH ?? process.env.PATH ?? process.env.Path ?? '' + return baseEnv.PATH ?? baseEnv.Path ?? process.env.PATH ?? process.env.Path ?? '' +} + +function isWslShellName(shellPath: string | undefined): boolean { + const shellName = shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase() + return shellName === 'wsl.exe' || shellName === 'wsl' +} + +function shouldSkipCodexHomeEnvForWindowsShell( + shellPath: string | undefined, + cwd: string | undefined +): boolean { + return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) +} + +const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const + +function readEnvWithProcessFallback( + baseEnv: Record, + key: string +): string | undefined { + return baseEnv[key] ?? process.env[key] +} + +function resolvePiAgentSourceDir( + baseEnv: Record, + kind: PiAgentKind +): string | undefined { + const sourceKey = kind === 'omp' ? 'ORCA_OMP_SOURCE_AGENT_DIR' : 'ORCA_PI_SOURCE_AGENT_DIR' + const overlayKey = kind === 'omp' ? 'ORCA_OMP_CODING_AGENT_DIR' : 'ORCA_PI_CODING_AGENT_DIR' + const otherOverlayKey = kind === 'omp' ? 'ORCA_PI_CODING_AGENT_DIR' : 'ORCA_OMP_CODING_AGENT_DIR' + + const sourceDir = readEnvWithProcessFallback(baseEnv, sourceKey) + if (sourceDir) { + return sourceDir + } + + const publicDir = readEnvWithProcessFallback(baseEnv, 'PI_CODING_AGENT_DIR') + const ownOverlayDir = readEnvWithProcessFallback(baseEnv, overlayKey) + const otherOverlayDir = readEnvWithProcessFallback(baseEnv, otherOverlayKey) + // Why: if PI_CODING_AGENT_DIR is just a restored Orca overlay from either + // kind and the matching source shadow is absent, remirroring it would leak + // another agent's overlay tree into this launch. Fall through to defaults. + if (publicDir && publicDir !== ownOverlayDir && publicDir !== otherOverlayDir) { + return publicDir + } + + return readShellStartupEnvVar( + 'PI_CODING_AGENT_DIR', + baseEnv.HOME ?? process.env.HOME, + baseEnv.SHELL ?? process.env.SHELL + ) +} + +function resolveScopedPiAgentSourceDir( + baseEnv: Record, + kind: PiAgentKind +): string | undefined { + const sourceKey = kind === 'omp' ? 'ORCA_OMP_SOURCE_AGENT_DIR' : 'ORCA_PI_SOURCE_AGENT_DIR' + return readEnvWithProcessFallback(baseEnv, sourceKey) +} + +function getPiAgentStatusExtensionPath(agentDir: string): string { + return join(agentDir, 'extensions', ORCA_PI_AGENT_STATUS_EXTENSION_FILE) +} + +function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKind): void { + if (kind === 'omp') { + delete baseEnv.ORCA_OMP_CODING_AGENT_DIR + delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + return + } + delete baseEnv.ORCA_PI_CODING_AGENT_DIR + delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR +} + +function exposePiAgentOverlayEnv( + baseEnv: Record, + kind: PiAgentKind, + overlayDir: string, + sourceDir: string | undefined +): void { + if (kind === 'omp') { + baseEnv.ORCA_OMP_CODING_AGENT_DIR = overlayDir + baseEnv.ORCA_OMP_STATUS_EXTENSION = getPiAgentStatusExtensionPath(overlayDir) + if (sourceDir) { + // Why: preserve the original OMP root across nested Orca terminals; the + // public env var is intentionally restored to the current PTY overlay. + baseEnv.ORCA_OMP_SOURCE_AGENT_DIR = sourceDir + } else { + delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR + } + return + } + baseEnv.ORCA_PI_CODING_AGENT_DIR = overlayDir + if (sourceDir) { + // Why: preserve the original Pi root across nested Orca terminals; the + // public env var is intentionally restored to the current PTY overlay. + baseEnv.ORCA_PI_SOURCE_AGENT_DIR = sourceDir + } else { + delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR + } +} + +function mergePtyEnvDeletions( + existingKeys: string[] | undefined, + additionalKeys: readonly string[] +): string[] | undefined { + if (!existingKeys && additionalKeys.length === 0) { + return undefined + } + return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys])) +} + +function getInheritedAgentHookEnvKeysToDelete( + spawnEnv: Record | undefined +): string[] { + const env = spawnEnv ?? {} + // Why: daemon/local providers merge process.env after main-process cleanup. + // Delete reverted or unavailable hook env keys there without dropping fresh + // receiver coordinates that buildPtyHostEnv intentionally set. + return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) +} + +// Why: when agent status is disabled, a nested Orca terminal can still pass +// through a prior PTY's OpenCode/Pi overlay env. Restore the user's original +// source dir when Orca recorded one, otherwise strip only values known to be ours. +function restoreOrStripOverlayEnv( + baseEnv: Record, + keys: { + primary: string + overlay: string + source: string + } +): void { + const sourceValue = baseEnv[keys.source] ?? process.env[keys.source] + const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay] + if (sourceValue) { + baseEnv[keys.primary] = sourceValue + } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { + delete baseEnv[keys.primary] + } + delete baseEnv[keys.overlay] + delete baseEnv[keys.source] } /** @@ -272,6 +433,8 @@ export function buildPtyHostEnv( baseEnv: Record, opts: BuildPtyHostEnvOptions ): Record { + mergePersistedWindowsPath(baseEnv) + // Why: the Local path passes a baseEnv that already includes process.env // (LocalPtyProvider.spawn merges it before calling buildSpawnEnv). The // daemon path passes only args.env since process.env propagates to the @@ -289,34 +452,43 @@ export function buildPtyHostEnv( baseEnv.HOME ?? process.env.HOME, baseEnv.SHELL ?? process.env.SHELL ) - const preexistingPiAgentDir = - baseEnv.ORCA_PI_SOURCE_AGENT_DIR ?? - process.env.ORCA_PI_SOURCE_AGENT_DIR ?? - baseEnv.PI_CODING_AGENT_DIR ?? - process.env.PI_CODING_AGENT_DIR ?? - readShellStartupEnvVar( - 'PI_CODING_AGENT_DIR', - baseEnv.HOME ?? process.env.HOME, - baseEnv.SHELL ?? process.env.SHELL - ) + const piAgentKind = detectPiAgentKindFromCommand(opts.launchCommand) + const hasLaunchCommand = + typeof opts.launchCommand === 'string' && opts.launchCommand.trim().length > 0 + const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand + // Why: source shadows are agent-scoped. Trusting the other kind's source + // would reintroduce the exact Pi/OMP extension-state shadowing this PR fixes. + const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') + const preexistingOmpAgentDir = + piAgentKind === 'omp' + ? resolvePiAgentSourceDir(baseEnv, 'omp') + : resolveScopedPiAgentSourceDir(baseEnv, 'omp') - // Why: OPENCODE_CONFIG_DIR is a singular path, not a colon-list, so a user - // value cannot coexist with an Orca-only injection. Hand the user's value - // (when present) to the hook service and let it materialize a per-PTY - // mirror overlay that lets the user's plugins and Orca's status plugin - // load together — same pattern Pi uses below for PI_CODING_AGENT_DIR. See - // docs/opencode-config-dir-collision.md. - Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready - // wrappers restore this PTY-scoped value after user startup files run. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: terminals launched from another Orca terminal inherit the overlay - // as OPENCODE_CONFIG_DIR; keep the original source so overlays do not - // mirror overlays and drop the user's real config. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir + if (opts.agentStatusHooksEnabled) { + // Why: OPENCODE_CONFIG_DIR is a singular path, not a colon-list, so a user + // value cannot coexist with an Orca-only injection. Hand the user's value + // (when present) to the hook service and let it materialize a per-PTY + // mirror overlay that lets the user's plugins and Orca's status plugin + // load together — same pattern Pi uses below for PI_CODING_AGENT_DIR. See + // docs/opencode-config-dir-collision.md. + Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + if (baseEnv.OPENCODE_CONFIG_DIR) { + // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready + // wrappers restore this PTY-scoped value after user startup files run. + baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR + if (preexistingOpenCodeConfigDir) { + // Why: terminals launched from another Orca terminal inherit the overlay + // as OPENCODE_CONFIG_DIR; keep the original source so overlays do not + // mirror overlays and drop the user's real config. + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir + } } + } else { + restoreOrStripOverlayEnv(baseEnv, { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + }) } // Why: Claude/Codex native hooks run inside the shell process, so Orca @@ -329,35 +501,73 @@ export function buildPtyHostEnv( for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { delete baseEnv[key] } - Object.assign(baseEnv, agentHookServer.buildPtyEnv()) - - // Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a - // PTY-scoped overlay from the caller's chosen root so Pi sessions keep - // their user state without sharing a mutable overlay across terminals. - // Under the daemon path, `id` is the daemon sessionId — the overlay - // survives daemon cold restore because the sessionId is stable across - // restarts by design. A future reader should NOT "simplify" id allocation - // back to a fresh UUID per spawn; that would discard user Pi state on - // every daemon reconnect. - Object.assign(baseEnv, piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir)) - if (baseEnv.PI_CODING_AGENT_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready - // wrappers restore this PTY-scoped value after user startup files run. - baseEnv.ORCA_PI_CODING_AGENT_DIR = baseEnv.PI_CODING_AGENT_DIR - if (preexistingPiAgentDir) { - // Why: preserve the original Pi root across nested Orca terminals; the - // public env var is intentionally restored to the current PTY overlay. - baseEnv.ORCA_PI_SOURCE_AGENT_DIR = preexistingPiAgentDir - } + if (opts.agentStatusHooksEnabled) { + Object.assign(baseEnv, agentHookServer.buildPtyEnv()) } - // Why: Codex account switching now materializes auth into one shared - // runtime home (~/.codex), and Codex launched inside Orca terminals must - // use that same prepared home as quota fetches and other entry points. - // Keep the override PTY-scoped so Orca does not mutate the app process - // environment or the user's unrelated external shells. - if (opts.selectedCodexHomePath) { + // Why: PI_CODING_AGENT_DIR owns Pi's / OMP's full config/session root (OMP + // inherits the env var name from Pi by design; its CHANGELOG documents the + // OMP_CODING_AGENT_DIR -> PI_CODING_AGENT_DIR rename. Build a PTY-scoped + // overlay from the caller's chosen root so sessions keep their user state + // without sharing a mutable overlay across terminals. Under the daemon path, + // `id` is the daemon sessionId — the overlay survives daemon cold restore + // because the sessionId is stable across restarts by design. A future reader + // should NOT "simplify" id allocation back to a fresh UUID per spawn; that + // would discard user state on every daemon reconnect. + if (opts.agentStatusHooksEnabled) { + clearPiAgentShadowEnv(baseEnv, 'pi') + clearPiAgentShadowEnv(baseEnv, 'omp') + if (piAgentKind === 'pi') { + const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi') + Object.assign(baseEnv, piEnv) + if (piEnv.PI_CODING_AGENT_DIR) { + // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready + // wrappers restore this PTY-scoped value after user startup files run. + baseEnv.PI_CODING_AGENT_DIR = piEnv.PI_CODING_AGENT_DIR + exposePiAgentOverlayEnv(baseEnv, 'pi', piEnv.PI_CODING_AGENT_DIR, preexistingPiAgentDir) + } + } + + if (shouldPrepareOmpShadow) { + const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp') + if (ompEnv.PI_CODING_AGENT_DIR) { + if (piAgentKind === 'omp') { + // Why: an OMP-launched PTY should default the binary-facing env var to + // OMP. Bare shells keep the Pi primary and use the `omp` shell wrapper + // to switch only while OMP is running. + baseEnv.PI_CODING_AGENT_DIR = ompEnv.PI_CODING_AGENT_DIR + } + exposePiAgentOverlayEnv(baseEnv, 'omp', ompEnv.PI_CODING_AGENT_DIR, preexistingOmpAgentDir) + } + } + } else { + // Why: when agent status is disabled we must strip BOTH kinds' shadow vars + // so a nested PTY does not inherit a stale overlay from either agent. + restoreOrStripOverlayEnv(baseEnv, { + primary: 'PI_CODING_AGENT_DIR', + overlay: 'ORCA_PI_CODING_AGENT_DIR', + source: 'ORCA_PI_SOURCE_AGENT_DIR' + }) + restoreOrStripOverlayEnv(baseEnv, { + primary: 'PI_CODING_AGENT_DIR', + overlay: 'ORCA_OMP_CODING_AGENT_DIR', + source: 'ORCA_OMP_SOURCE_AGENT_DIR' + }) + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + } + + // Why: Codex account switching now materializes auth into an Orca-scoped + // runtime home, and Codex launched inside Orca terminals must use that same + // prepared home as quota fetches and other entry points. Keep the override + // PTY-scoped so dev/prod Orcas do not share hooks through ~/.codex. + if (opts.skipCodexHomeEnv) { + delete baseEnv.CODEX_HOME + delete baseEnv.ORCA_CODEX_HOME + } else if (opts.selectedCodexHomePath) { baseEnv.CODEX_HOME = opts.selectedCodexHomePath + // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers + // restore this runtime home before Codex can be launched from the prompt. + baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath } // Why: in dev mode the `orca` CLI defaults to the production userData @@ -601,6 +811,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:declarePendingPaneSerializer') ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') + ipcMain.removeHandler('pty:getMainBufferSnapshot') ipcMain.removeHandler('pty:writeAccepted') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') @@ -620,12 +831,17 @@ export function registerPtyHandlers( ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined, pwshAvailable: () => isPwshAvailable(), - buildSpawnEnv: (id, baseEnv) => { + buildSpawnEnv: (id, baseEnv, ctx) => { const env = buildPtyHostEnv(id, baseEnv, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, - githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false + // Why: WSL's inner shell cannot use a Windows userData CODEX_HOME. + // Leave Linux Codex on its native ~/.codex until we own a WSL home. + skipCodexHomeEnv: ctx?.isWsl === true, + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, + launchCommand: ctx?.command, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) }) // Why: agents need their own terminal handle at process start so they // can self-identify in orchestration messages without an extra RPC. @@ -657,25 +873,96 @@ export function registerPtyHandlers( // reduces IPC round-trips from hundreds/sec to ~120/sec under high // throughput. Keystroke echo/redraws bypass this below because agent TUIs // already spend tens of ms producing their redraw. - const pendingData = new Map() + type PendingPtyData = { + data: string + startSeq?: number + } + + const pendingData = new Map() const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null const PTY_BATCH_INTERVAL_MS = 8 + const PTY_BATCH_DRAIN_CONTINUE_MS = 1 + const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 + const PTY_BATCH_FLUSH_MAX_WRITES = 2 // Why: keep the immediate path bounded to keystroke-sized TUI redraws; // large output and non-interactive output must still use the batcher. const INTERACTIVE_OUTPUT_WINDOW_MS = 100 const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 - const flushPendingData = (): void => { + function getChunkStartSeq(endSeq: number | undefined, data: string): number | undefined { + return typeof endSeq === 'number' ? Math.max(0, endSeq - data.length) : undefined + } + + function makePtyDataPayload( + id: string, + data: string, + startSeq: number | undefined + ): { id: string; data: string; seq?: number; rawLength?: number } { + const payload: { id: string; data: string; seq?: number; rawLength?: number } = { id, data } + if (typeof startSeq === 'number') { + payload.seq = startSeq + data.length + payload.rawLength = data.length + } + return payload + } + + function appendPendingPtyData( + existing: PendingPtyData | undefined, + data: string, + startSeq: number | undefined + ): PendingPtyData { + if (!existing) { + return typeof startSeq === 'number' ? { data, startSeq } : { data } + } + const next: PendingPtyData = { data: existing.data + data } + if (typeof existing.startSeq === 'number') { + next.startSeq = existing.startSeq + } else if (typeof startSeq === 'number') { + next.startSeq = startSeq + } + return next + } + + function schedulePendingDataFlush(delayMs: number): void { + if (flushTimer) { + return + } + flushTimer = setTimeout(flushPendingData, delayMs) + } + + function flushPendingData(): void { flushTimer = null if (mainWindow.isDestroyed()) { pendingData.clear() return } - for (const [id, data] of pendingData) { - mainWindow.webContents.send('pty:data', { id, data }) + let writes = 0 + while (pendingData.size > 0 && writes < PTY_BATCH_FLUSH_MAX_WRITES) { + const next = pendingData.entries().next().value + if (!next) { + break + } + const [id, pending] = next + pendingData.delete(id) + const { data } = pending + const chunk = data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) + const remaining = data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) + if (remaining) { + const nextPending: PendingPtyData = { data: remaining } + if (typeof pending.startSeq === 'number') { + nextPending.startSeq = pending.startSeq + chunk.length + } + pendingData.set(id, nextPending) + } + mainWindow.webContents.send('pty:data', makePtyDataPayload(id, chunk, pending.startSeq)) + writes++ + } + if (pendingData.size > 0) { + // Why: a background terminal can dump megabytes at once. Yield between + // small IPC slices so keystroke writes are not stuck behind one flush. + schedulePendingDataFlush(PTY_BATCH_DRAIN_CONTINUE_MS) } - pendingData.clear() } const clearFlushTimerIfIdle = (): void => { @@ -703,9 +990,10 @@ export function registerPtyHandlers( const isLocalProvider = localProvider instanceof LocalPtyProvider localDataUnsub = localProvider.onData((payload) => { - if (!isLocalProvider) { - runtime?.onPtyData(payload.id, payload.data, Date.now()) - } + const outputSeq = isLocalProvider + ? runtime?.getPtyOutputSequence(payload.id) + : runtime?.onPtyData(payload.id, payload.data, Date.now()) + const startSeq = getChunkStartSeq(outputSeq, payload.data) if (mainWindow.isDestroyed()) { // Why: clear the pending flush timer so it doesn't fire after the window // is gone. Without this, macOS app re-activation leaks orphaned timers @@ -718,7 +1006,8 @@ export function registerPtyHandlers( return } const existing = pendingData.get(payload.id) - const nextData = existing ? existing + payload.data : payload.data + const pending = appendPendingPtyData(existing, payload.data, startSeq) + const nextData = pending.data const lastInputAt = lastInputAtByPty.get(payload.id) const isInteractiveOutput = nextData.length <= INTERACTIVE_OUTPUT_MAX_CHARS && @@ -731,13 +1020,16 @@ export function registerPtyHandlers( // Waiting for the throughput batch timer adds visible input latency. mainWindow.webContents.send('pty:data', { id: payload.id, - data: nextData + data: nextData, + ...(typeof pending.startSeq === 'number' + ? { seq: pending.startSeq + nextData.length, rawLength: nextData.length } + : {}) }) return } - pendingData.set(payload.id, nextData) + pendingData.set(payload.id, pending) if (!flushTimer) { - flushTimer = setTimeout(flushPendingData, PTY_BATCH_INTERVAL_MS) + schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) } }) localExitUnsub = localProvider.onExit((payload) => { @@ -753,7 +1045,10 @@ export function registerPtyHandlers( // tears down the terminal on pty:exit before the batch timer fires. const remaining = pendingData.get(payload.id) if (remaining) { - mainWindow.webContents.send('pty:data', { id: payload.id, data: remaining }) + mainWindow.webContents.send( + 'pty:data', + makePtyDataPayload(payload.id, remaining.data, remaining.startSeq) + ) pendingData.delete(payload.id) } lastInputAtByPty.delete(payload.id) @@ -900,6 +1195,12 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } + const daemonShellOverride = + process.platform === 'win32' && !args.connectionId + ? getSettings?.()?.terminalWindowsShell + : undefined + const skipCodexHomeEnv = + isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd) if (isDaemonHostSpawn && sessionId) { if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { throw new Error('Invalid PTY session id') @@ -908,7 +1209,10 @@ export function registerPtyHandlers( isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, - githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false + skipCodexHomeEnv, + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, + launchCommand: args.command, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) }) } @@ -916,10 +1220,20 @@ export function registerPtyHandlers( cols: args.cols, rows: args.rows, cwd: args.cwd, - env + env, + ...(isDaemonHostSpawn ? { isNewSession: true } : {}) } - if (claudeAuth?.stripAuthEnv) { - spawnOptions.envToDelete = [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + spawnOptions.envToDelete = mergePtyEnvDeletions( + claudeAuth?.stripAuthEnv + ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + : undefined, + args.connectionId ? [] : getInheritedAgentHookEnvKeysToDelete(env) + ) + if (skipCodexHomeEnv) { + spawnOptions.envToDelete = mergePtyEnvDeletions( + spawnOptions.envToDelete, + CODEX_HOME_ENV_KEYS + ) } if (args.command !== undefined) { spawnOptions.command = args.command @@ -1095,6 +1409,31 @@ export function registerPtyHandlers( // ─── IPC Handlers (thin dispatch layer) ───────────────────────── + function normalizeSnapshotScrollbackRows(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.max(0, Math.min(50_000, Math.floor(value))) + } + + ipcMain.handle( + 'pty:getMainBufferSnapshot', + async ( + _event, + args: { id?: unknown; opts?: { scrollbackRows?: unknown } } + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) + try { + return await runtime.serializeMainTerminalBuffer(args.id, { scrollbackRows }) + } catch { + return null + } + } + ) + ipcMain.handle( 'pty:spawn', async ( @@ -1258,6 +1597,13 @@ export function registerPtyHandlers( runtime && !(provider instanceof LocalPtyProvider) ? runtime.createPreAllocatedTerminalHandle() : null + const effectiveShellOverride = + args.shellOverride ?? + (process.platform === 'win32' && !args.connectionId + ? getSettings?.()?.terminalWindowsShell + : undefined) + const skipCodexHomeEnv = + isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd) if (isDaemonHostSpawn) { if (effectiveSessionId === undefined) { // Should be unreachable: the expression above returns a string when @@ -1282,7 +1628,10 @@ export function registerPtyHandlers( isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, - githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false + skipCodexHomeEnv, + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, + launchCommand: args.command, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) }) } catch (err) { // Why: buildPtyHostEnv has filesystem side-effects (Pi overlay @@ -1305,14 +1654,22 @@ export function registerPtyHandlers( const envToDelete = claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined + const combinedEnvToDelete = mergePtyEnvDeletions( + mergePtyEnvDeletions( + envToDelete, + args.connectionId ? [] : getInheritedAgentHookEnvKeysToDelete(spawnEnv) + ), + skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] + ) const spawnOptions: PtySpawnOptions = { cols: args.cols, rows: args.rows, cwd: args.cwd, - env: spawnEnv + env: spawnEnv, + ...(isMintedSessionId ? { isNewSession: true } : {}) } - if (envToDelete) { - spawnOptions.envToDelete = envToDelete + if (combinedEnvToDelete) { + spawnOptions.envToDelete = combinedEnvToDelete } if (args.command !== undefined) { spawnOptions.command = args.command @@ -1330,11 +1687,6 @@ export function registerPtyHandlers( // or falls back to PowerShell. The LocalPtyProvider already consults // getWindowsShell(); this mirrors that on the daemon path so users who // set WSL as default actually get WSL when pressing Ctrl+T. - const effectiveShellOverride = - args.shellOverride ?? - (process.platform === 'win32' && !args.connectionId - ? getSettings?.()?.terminalWindowsShell - : undefined) if (effectiveShellOverride !== undefined) { spawnOptions.shellOverride = effectiveShellOverride } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index cce6f3e6510..0a0fb44cc04 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -331,6 +331,7 @@ describe('registerCoreHandlers', () => { const claudeAccounts = { marker: 'claudeAccounts' } const rateLimits = { marker: 'rateLimits' } const agentAwakeService = { marker: 'agentAwakeService' } + const onBeforeRelaunch = vi.fn() registerCoreHandlers( store as never, @@ -345,13 +346,16 @@ describe('registerCoreHandlers', () => { null, undefined, undefined, - agentAwakeService as never + agentAwakeService as never, + undefined, + undefined, + { onBeforeRelaunch } ) expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) - expect(registerAppHandlersMock).toHaveBeenCalledWith(store) + expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) expect(registerAgentHookHandlersMock).toHaveBeenCalled() expect(registerPetHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 025787579ec..53e676f6106 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -63,6 +63,10 @@ import type { KeybindingService } from '../keybindings/keybinding-service' let registered = false +type CoreHandlerLifecycleOptions = { + onBeforeRelaunch?: () => void +} + export function registerCoreHandlers( store: Store, runtime: OrcaRuntimeService, @@ -78,7 +82,8 @@ export function registerCoreHandlers( commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers, agentAwakeService?: AgentAwakeService, crashReports?: CrashReportStore, - keybindings?: KeybindingService + keybindings?: KeybindingService, + lifecycleOptions: CoreHandlerLifecycleOptions = {} ): void { // Why: on macOS the app can stay alive after all windows close, then // openMainWindow() is called again on 'activate'. ipcMain.handle() throws @@ -91,7 +96,7 @@ export function registerCoreHandlers( } registered = true - registerAppHandlers(store) + registerAppHandlers(store, { onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch }) registerCliHandlers() registerPreflightHandlers() registerClaudeUsageHandlers(claudeUsage) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index dca66f5d39b..c5a330461d2 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -28,7 +28,7 @@ const { mockStore: { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeRepo: vi.fn(), + removeProject: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn() }, diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 746cc55842a..db1ece856c4 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -3,34 +3,51 @@ tests (addRemote, getBaseRefDefault envelope, searchBaseRefs SSH relay) so fixture setup and mock plumbing can be shared. Splitting by line count would duplicate the hoisted mocks and the `../git/repo` partial-real/partial-stub setup. */ -import { describe, expect, it, vi, beforeEach } from 'vitest' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { EventEmitter } from 'events' +import { existsSync } from 'fs' +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' import type * as RepoModule from '../git/repo' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' -const { handleMock, mockStore, mockGitProvider, mockMultiplexer, gitSpawnMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - mockStore: { - getRepos: vi.fn().mockReturnValue([]), - addRepo: vi.fn(), - removeRepo: vi.fn(), - getRepo: vi.fn(), - updateRepo: vi.fn(), - getSshTarget: vi.fn() - }, - mockGitProvider: { - isGitRepo: vi.fn().mockReturnValue(true), - isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - }, - mockMultiplexer: { - request: vi.fn(), - notify: vi.fn() - }, - gitSpawnMock: vi.fn() - }) -) +const { + handleMock, + mockStore, + mockGitProvider, + mockFilesystemProvider, + mockMultiplexer, + gitSpawnMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeProject: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn(), + getProjectGroups: vi.fn().mockReturnValue([]), + createProjectGroup: vi.fn(), + updateProjectGroup: vi.fn(), + deleteProjectGroup: vi.fn(), + moveProjectToGroup: vi.fn(), + getSshTarget: vi.fn() + }, + mockGitProvider: { + isGitRepo: vi.fn().mockReturnValue(true), + isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + }, + mockFilesystemProvider: { + readDir: vi.fn().mockResolvedValue([]) + }, + mockMultiplexer: { + request: vi.fn(), + notify: vi.fn() + }, + gitSpawnMock: vi.fn() +})) vi.mock('electron', () => ({ dialog: { showOpenDialog: vi.fn() }, @@ -76,6 +93,15 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ }) })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn().mockImplementation((id: string) => { + if (id === 'conn-1') { + return mockFilesystemProvider + } + return undefined + }) +})) + vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn().mockImplementation((id: string) => { if (id === 'conn-1') { @@ -87,6 +113,164 @@ vi.mock('./ssh', () => ({ import { registerRepoHandlers } from './repos' +describe('projectGroups IPC validation', () => { + const handlers = new Map unknown>() + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() } + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + mockWindow.webContents.send.mockReset() + mockStore.createProjectGroup.mockReset() + mockStore.updateProjectGroup.mockReset() + mockStore.deleteProjectGroup.mockReset() + mockStore.moveProjectToGroup.mockReset() + mockStore.getRepos.mockReset() + mockStore.getRepos.mockReturnValue([]) + mockFilesystemProvider.readDir.mockReset() + mockFilesystemProvider.readDir.mockResolvedValue([]) + mockGitProvider.isGitRepoAsync.mockReset() + mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null }) + mockMultiplexer.notify.mockReset() + mockMultiplexer.request.mockReset() + + registerRepoHandlers(mockWindow as never, mockStore as never) + }) + + it('rejects malformed local project group create arguments before persistence', () => { + expect(() => + handlers.get('projectGroups:create')!(null, { name: 123, createdFrom: 'unexpected' }) + ).toThrow('invalid_project_group_create_args') + + expect(mockStore.createProjectGroup).not.toHaveBeenCalled() + }) + + it('rejects malformed local project group update arguments before persistence', () => { + expect(() => + handlers.get('projectGroups:update')!(null, { + groupId: 'group-1', + updates: { isCollapsed: 'yes' } + }) + ).toThrow('invalid_project_group_update_args') + + expect(mockStore.updateProjectGroup).not.toHaveBeenCalled() + }) + + it('scans nested repositories over a connected SSH filesystem', async () => { + mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ + isRepo: path === '/srv/platform/api', + rootPath: null + })) + mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => + dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : [] + ) + + const result = await handlers.get('projectGroups:scanNested')!(null, { + path: '/srv/platform', + connectionId: 'conn-1' + }) + + expect(result).toMatchObject({ + selectedPath: '/srv/platform', + selectedPathKind: 'non_git_folder', + repos: [{ path: '/srv/platform/api', displayName: 'api' }] + }) + }) + + it('rejects local nested scans with relative paths', async () => { + await expect( + handlers.get('projectGroups:scanNested')!(null, { + path: 'relative/project' + }) + ).rejects.toThrow('Repo path must be an absolute path') + }) + + it('imports nested SSH repositories with connection-scoped repo entries', async () => { + const group = { + id: 'group-1', + name: 'Platform', + parentPath: '/srv/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + mockStore.createProjectGroup.mockReturnValue(group) + mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ + isRepo: path === '/srv/platform/api', + rootPath: null + })) + mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => + dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : [] + ) + + const result = await handlers.get('projectGroups:importNested')!(null, { + parentPath: '/srv/platform', + groupName: 'Platform', + projectPaths: ['/srv/platform/api'], + connectionId: 'conn-1', + mode: 'group' + }) + + expect(result).toMatchObject({ importedCount: 1, failedCount: 0 }) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/srv/platform/api', + connectionId: 'conn-1', + projectGroupId: group.id + }) + ) + expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { + rootPath: '/srv/platform/api' + }) + }) + + it('sanitizes unexpected nested import errors before returning results', async () => { + const group = { + id: 'group-1', + name: 'Platform', + parentPath: '/srv/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + mockStore.createProjectGroup.mockReturnValue(group) + mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ + isRepo: path === '/srv/platform/api', + rootPath: null + })) + mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => + dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : [] + ) + mockStore.addRepo.mockImplementationOnce(() => { + throw new Error('secret backend path /srv/platform/api') + }) + + const result = (await handlers.get('projectGroups:importNested')!(null, { + parentPath: '/srv/platform', + groupName: 'Platform', + projectPaths: ['/srv/platform/api'], + connectionId: 'conn-1', + mode: 'group' + })) as { projects: { error?: string }[] } + + expect(result.projects[0].error).toBe('Repository could not be imported') + }) +}) + describe('repos:getGitUsername', () => { const handlers = new Map unknown>() const mockWindow = { @@ -171,6 +355,8 @@ describe('repos:addRemote', () => { mockStore.addRepo.mockReset() mockStore.getSshTarget.mockReset() mockStore.updateRepo.mockReset() + mockGitProvider.isGitRepoAsync.mockReset() + mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null }) mockMultiplexer.request.mockReset() mockMultiplexer.notify.mockReset() gitSpawnMock.mockReset() @@ -382,12 +568,46 @@ describe('repos:addRemote', () => { }) }) +type MockCloneProcess = EventEmitter & { + stderr: EventEmitter + kill: ReturnType +} + +function createMockCloneProcess(): MockCloneProcess { + const proc = new EventEmitter() as MockCloneProcess + proc.stderr = new EventEmitter() + proc.kill = vi.fn().mockReturnValue(true) + return proc +} + +async function waitForAssertion(assertion: () => void): Promise { + const deadline = Date.now() + 2_000 + let lastError: unknown + while (Date.now() < deadline) { + try { + assertion() + return + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 10)) + } + } + throw lastError +} + describe('repos:add + repos:clone', () => { const handlers = new Map unknown>() const mockWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } + const tempRoots: string[] = [] + + const createTempRoot = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'orca-repos-clone-')) + tempRoots.push(root) + return root + } beforeEach(() => { handlers.clear() @@ -401,8 +621,7 @@ describe('repos:add + repos:clone', () => { mockWindow.webContents.send.mockReset() gitSpawnMock.mockReset() gitSpawnMock.mockImplementation(() => { - const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } - proc.stderr = new EventEmitter() + const proc = createMockCloneProcess() queueMicrotask(() => proc.emit('close', 0, null)) return proc }) @@ -410,6 +629,10 @@ describe('repos:add + repos:clone', () => { registerRepoHandlers(mockWindow as never, mockStore as never) }) + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + it('defaults repos:add badgeColor to DEFAULT_REPO_BADGE_COLOR for folder repos', async () => { const result = await handlers.get('repos:add')!(null, { path: '/tmp/from-add', kind: 'folder' }) @@ -456,14 +679,16 @@ describe('repos:add + repos:clone', () => { }) it('defaults repos:clone badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const destination = await createTempRoot() + const result = await handlers.get('repos:clone')!(null, { url: 'https://example.com/orca.git', - destination: '/tmp' + destination }) expect(mockStore.addRepo).toHaveBeenCalledWith( expect.objectContaining({ - path: '/tmp/orca', + path: join(destination, 'orca'), badgeColor: DEFAULT_REPO_BADGE_COLOR, kind: 'git', externalWorktreeVisibility: 'hide', @@ -475,9 +700,11 @@ describe('repos:add + repos:clone', () => { }) it('preserves existing badgeColor when repos:clone upgrades folder->git after dedupe', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') const existing = { id: 'folder-repo', - path: '/tmp/orca', + path: clonePath, displayName: 'orca', badgeColor: '#8b5cf6', addedAt: 1, @@ -489,7 +716,7 @@ describe('repos:add + repos:clone', () => { const result = await handlers.get('repos:clone')!(null, { url: 'https://example.com/orca.git', - destination: '/tmp' + destination }) expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, { kind: 'git' }) @@ -497,6 +724,330 @@ describe('repos:add + repos:clone', () => { expect(result).toHaveProperty('badgeColor', '#8b5cf6') expect(mockStore.addRepo).not.toHaveBeenCalled() }) + + it('rejects a dot-segment URL before creating the destination or spawning git', async () => { + const tempRoot = await createTempRoot() + const destination = join(tempRoot, 'destination') + + await expect( + handlers.get('repos:clone')!(null, { + url: 'file:///tmp/source/.', + destination + }) + ).rejects.toThrow('Invalid repository name derived from URL') + + expect(existsSync(destination)).toBe(false) + expect(gitSpawnMock).not.toHaveBeenCalled() + }) + + it('rejects a parent-segment URL before creating the destination or spawning git', async () => { + const tempRoot = await createTempRoot() + const destination = join(tempRoot, 'destination') + + await expect( + handlers.get('repos:clone')!(null, { + url: 'file:///tmp/source/..', + destination + }) + ).rejects.toThrow('Invalid repository name derived from URL') + + expect(existsSync(destination)).toBe(false) + expect(gitSpawnMock).not.toHaveBeenCalled() + }) + + it('rejects a relative destination before creating directories or spawning git', async () => { + const destination = `relative-clone-destination-${Date.now()}` + + await expect( + handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + ).rejects.toThrow('Clone destination must be an absolute path') + + expect(existsSync(destination)).toBe(false) + expect(gitSpawnMock).not.toHaveBeenCalled() + }) + + it('rejects URL-derived names containing Windows separators before spawning git', async () => { + const destination = await createTempRoot() + + await expect( + handlers.get('repos:clone')!(null, { + url: 'https://example.com/team\\orca.git', + destination + }) + ).rejects.toThrow('Invalid repository name derived from URL') + + expect(gitSpawnMock).not.toHaveBeenCalled() + }) + + it('accepts Windows local-path clone sources while validating the final segment', async () => { + const destination = await createTempRoot() + + const result = await handlers.get('repos:clone')!(null, { + url: 'C:\\src\\orca.git', + destination + }) + + expect(gitSpawnMock).toHaveBeenCalledWith( + ['clone', '--progress', '--', 'C:\\src\\orca.git', join(destination, 'orca')], + expect.objectContaining({ cwd: destination }) + ) + expect(result).toHaveProperty('path', join(destination, 'orca')) + }) + + it('treats cloneAbort with no active clone as a no-op', async () => { + await expect(handlers.get('repos:cloneAbort')!(null, undefined)).resolves.toBeUndefined() + }) + + it('does not remove an existing target directory when aborting a pending clone', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + await mkdir(clonePath) + await writeFile(join(clonePath, 'user-file.txt'), 'keep me') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + proc.emit('close', null, 'SIGTERM') + await expect(clonePromise).rejects.toThrow('Clone aborted') + + expect(existsSync(clonePath)).toBe(true) + expect(existsSync(join(clonePath, 'user-file.txt'))).toBe(true) + }) + + it('does not remove an existing target file when aborting a pending clone', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + await writeFile(clonePath, 'existing file') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + proc.emit('close', null, 'SIGTERM') + await expect(clonePromise).rejects.toThrow('Clone aborted') + + expect(existsSync(clonePath)).toBe(true) + }) + + it('removes a fresh clone target only after the aborted process closes unsuccessfully', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + expect(existsSync(clonePath)).toBe(true) + + proc.emit('close', null, 'SIGTERM') + await expect(clonePromise).rejects.toThrow('Clone aborted') + expect(existsSync(clonePath)).toBe(false) + }) + + it('removes an owned fresh clone target when git exits unsuccessfully', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const partialFile = join(clonePath, 'partial.txt') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + await writeFile(partialFile, 'git wrote this before failing') + + proc.stderr.emit('data', Buffer.from('fatal: repository not found\n')) + proc.emit('close', 128, null) + await expect(clonePromise).rejects.toThrow('Clone failed: fatal: repository not found') + + expect(existsSync(clonePath)).toBe(false) + }) + + it('removes an owned fresh clone target when git spawn emits an error', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const partialFile = join(clonePath, 'partial.txt') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + await writeFile(partialFile, 'git wrote this before spawn failure') + + proc.emit('error', new Error('spawn failed')) + await expect(clonePromise).rejects.toThrow('Clone failed: spawn failed') + + expect(existsSync(clonePath)).toBe(false) + }) + + it('keeps a fresh clone target when abort races with a successful close', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + proc.emit('close', 0, null) + await expect(clonePromise).resolves.toMatchObject({ + path: clonePath, + kind: 'git' + }) + + expect(existsSync(clonePath)).toBe(true) + }) + + it('dedupes retry when abort races with a successful clone close', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const repos: unknown[] = [] + mockStore.getRepos.mockImplementation(() => repos) + mockStore.addRepo.mockImplementation((repo: unknown) => { + repos.push(repo) + }) + const firstProc = createMockCloneProcess() + const secondProc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(firstProc).mockReturnValueOnce(secondProc) + + const firstClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + const secondClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(gitSpawnMock).toHaveBeenCalledTimes(1) + + firstProc.emit('close', 0, null) + await expect(firstClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' }) + await expect(secondClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' }) + + expect(gitSpawnMock).toHaveBeenCalledTimes(1) + expect(secondProc.kill).not.toHaveBeenCalled() + }) + + it('serializes concurrent clones for the same target', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const repos: unknown[] = [] + mockStore.getRepos.mockImplementation(() => repos) + mockStore.addRepo.mockImplementation((repo: unknown) => { + repos.push(repo) + }) + const firstProc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(firstProc) + + const firstClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + const secondClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + firstProc.emit('close', 0, null) + await expect(firstClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' }) + await expect(secondClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' }) + + expect(gitSpawnMock).toHaveBeenCalledTimes(1) + }) + + it('waits for pending abort cleanup before retrying the same clone target', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const partialFile = join(clonePath, 'partial.txt') + const firstProc = createMockCloneProcess() + const secondProc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(firstProc).mockReturnValueOnce(secondProc) + + const firstClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + await writeFile(partialFile, 'first clone wrote this before abort') + await handlers.get('repos:cloneAbort')!(null, undefined) + + const secondClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(gitSpawnMock).toHaveBeenCalledTimes(1) + expect(existsSync(partialFile)).toBe(true) + + firstProc.emit('close', null, 'SIGTERM') + await expect(firstClonePromise).rejects.toThrow('Clone aborted') + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(2)) + expect(existsSync(partialFile)).toBe(false) + + secondProc.emit('close', 0, null) + await expect(secondClonePromise).resolves.toMatchObject({ + path: clonePath, + kind: 'git' + }) + expect(existsSync(clonePath)).toBe(true) + }) + + it('skips abort cleanup when the claimed target is replaced before close', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const replacementFile = join(clonePath, 'replacement.txt') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + await rm(clonePath, { recursive: true, force: true }) + await mkdir(clonePath) + await writeFile(replacementFile, 'new owner') + + proc.emit('close', null, 'SIGTERM') + await expect(clonePromise).rejects.toThrow('Clone aborted') + + expect(existsSync(replacementFile)).toBe(true) + }) }) describe('repos:getBaseRefDefault envelope', () => { @@ -763,10 +1314,11 @@ describe('repos:searchBaseRefs SSH relay', () => { expect(mockGitProvider.exec).not.toHaveBeenCalled() }) - it('sends the widened argv (refs/remotes/*/*) so upstream branches are discoverable', async () => { + it('sends the widened `**` argv so all remotes and slash-named branches are discoverable', async () => { // Why: this is the core issue-624 behavior — the SSH path must glob all - // remotes, not just origin. If this ever regresses to refs/remotes/origin/*, - // SSH fork users go back to being structurally blocked. + // remotes, not just origin. The `**` globs additionally span ref segments + // so slash-named branches (`user/feature`) are found by a single-word + // query; a single `*` would not cross `/`. mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) mockStore.getRepo.mockReturnValue({ @@ -782,9 +1334,10 @@ describe('repos:searchBaseRefs SSH relay', () => { const [argv, path] = mockGitProvider.exec.mock.calls[0] expect(path).toBe('/remote/repo') expect(argv[0]).toBe('for-each-ref') - expect(argv).toContain('refs/remotes/*upstream*/*') - expect(argv).toContain('refs/remotes/*/*upstream*') - expect(argv).toContain('refs/heads/*upstream*') + expect(argv).toContain('refs/heads/**/*upstream*') + expect(argv).toContain('refs/heads/**/*upstream*/**') + expect(argv).toContain('refs/remotes/**/*upstream*') + expect(argv).toContain('refs/remotes/**/*upstream*/**') // Guard against regression to the old origin-only glob. expect(argv).not.toContain('refs/remotes/origin/*upstream*') expect(mockGitProvider.exec.mock.calls[1]).toEqual([['remote'], '/remote/repo']) diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index cab50873817..336d2748251 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -8,7 +8,7 @@ const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({ mockStore: { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeRepo: vi.fn(), + removeProject: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn(), getSparsePresets: vi.fn(), diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 395ece5e187..f51de2d2535 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -4,21 +4,40 @@ boundary. Splitting by line count would scatter tightly coupled repo behavior. * import type { BrowserWindow } from 'electron' import { dialog, ipcMain } from 'electron' import { randomUUID } from 'crypto' +import { z } from 'zod' import type { Store } from '../persistence' import type { BaseRefSearchResult, Repo, + ProjectGroup, + ProjectGroupImportResult, + NestedRepoScanResult, BaseRefDefaultResult, SparsePreset } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { sanitizeRepoIcon } from '../../shared/repo-icon' +import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai' import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'child_process' import { access, mkdir, readdir, rm } from 'fs/promises' import { gitExecFileAsync, gitSpawn } from '../git/runner' -import { basename, isAbsolute, join } from 'path' +import { isAbsolute, join, posix } from 'path' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + cleanupClaimedCloneTarget, + claimCloneTarget, + deriveValidatedClonePath, + getClonePathComparisonKey +} from '../git/repo-clone-path' +import type { ClaimedCloneTarget } from '../git/repo-clone-path' +import { getNextProjectGroupOrder } from '../../shared/project-groups' +import { scanNestedRepos } from '../project-groups/nested-repo-discovery' +import { + createNestedProjectGroupResolver, + resolveNestedRepoSelection +} from '../project-groups/nested-repo-import' import { isGitRepo, getGitUsername, @@ -33,12 +52,14 @@ import { searchBaseRefDetails } from '../git/repo' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitUsername } from '../git/git-username' import { getActiveMultiplexer } from './ssh' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import type { RepoMethod } from '../../shared/telemetry-events' +import { detectRepoIcon } from '../repo-icon-autodetect' // Why: `method` answers "which entry point did the user take?", not "what did // they add?" — so the IPC the renderer invoked IS the method. We never send @@ -61,11 +82,216 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void { track('repo_added', { method, ...getCohortAtEmit() }) } +type ActiveCloneMetadata = { + path: string + pathKey: string + claimedTarget: ClaimedCloneTarget + process: ChildProcess + abortRequested: boolean + generation: number + pendingAbortCleanup: Promise | null + resolvePendingAbortCleanup: (() => void) | null +} + // Why: module-scoped so the abort handle survives window re-creation on macOS. // registerRepoHandlers is called again when a new BrowserWindow is created, // and a function-scoped variable would lose the reference to an in-flight clone. -let activeCloneProc: ChildProcess | null = null -let activeClonePath: string | null = null +let activeClone: ActiveCloneMetadata | null = null +let nextCloneGeneration = 1 +const latestCloneGenerationByPath = new Map() +const pendingAbortCleanupByPath = new Map>() +const cloneInFlightByPath = new Map>() + +const ProjectGroupCreateArgs = z.object({ + name: z.string().min(1), + parentPath: z.string().nullable().optional(), + parentGroupId: z.string().nullable().optional(), + createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional() +}) + +const ProjectGroupUpdateArgs = z.object({ + groupId: z.string().min(1), + updates: z.object({ + name: z.string().optional(), + isCollapsed: z.boolean().optional(), + tabOrder: z.number().finite().optional(), + color: z.string().nullable().optional() + }) +}) + +const ProjectGroupSelectorArgs = z.object({ + groupId: z.string().min(1) +}) + +const ProjectGroupMoveProjectArgs = z.object({ + projectId: z.string().min(1), + groupId: z.string().nullable(), + order: z.number().finite().optional() +}) + +const ProjectGroupScanNestedArgs = z.object({ + path: z.string().min(1), + connectionId: z.string().min(1).optional(), + options: z.unknown().optional() +}) + +const ProjectGroupImportNestedArgs = z.discriminatedUnion('mode', [ + z.object({ + parentPath: z.string().min(1), + groupName: z.string().min(1), + projectPaths: z.array(z.string()), + connectionId: z.string().min(1).optional(), + mode: z.literal('group') + }), + z.object({ + parentPath: z.string().min(1), + groupName: z.string().optional().default(''), + projectPaths: z.array(z.string()), + connectionId: z.string().min(1).optional(), + mode: z.literal('separate') + }) +]) + +function parseProjectGroupIpcArgs(schema: z.ZodType, value: unknown, errorCode: string): T { + const result = schema.safeParse(value) + if (result.success) { + return result.data + } + throw new Error(errorCode) +} + +function validateNestedRepoScanRoot(path: string, connectionId?: string): void { + if (connectionId) { + return + } + if (!isAbsolute(path)) { + throw new Error('Repo path must be an absolute path') + } +} + +async function cleanupOwnedCloneTarget(metadata: ActiveCloneMetadata): Promise { + if (!metadata.claimedTarget.canCleanup || !metadata.claimedTarget.ownedDirectoryIdentity) { + return + } + if (latestCloneGenerationByPath.get(metadata.pathKey) !== metadata.generation) { + return + } + // Why: an immediate retry can attach a newer process to the same target + // before the aborted process closes; the old close handler must not delete it. + if ( + activeClone && + activeClone.process !== metadata.process && + activeClone.pathKey === metadata.pathKey + ) { + return + } + + if (latestCloneGenerationByPath.get(metadata.pathKey) !== metadata.generation) { + return + } + await cleanupClaimedCloneTarget(metadata.path, metadata.claimedTarget) +} + +function markCloneAbortCleanupPending(metadata: ActiveCloneMetadata): void { + if (metadata.resolvePendingAbortCleanup) { + return + } + metadata.pendingAbortCleanup = new Promise((resolve) => { + metadata.resolvePendingAbortCleanup = resolve + }) + pendingAbortCleanupByPath.set(metadata.pathKey, metadata.pendingAbortCleanup) +} + +function settleCloneAbortCleanup(metadata: ActiveCloneMetadata): void { + if (pendingAbortCleanupByPath.get(metadata.pathKey) === metadata.pendingAbortCleanup) { + pendingAbortCleanupByPath.delete(metadata.pathKey) + } + metadata.resolvePendingAbortCleanup?.() + metadata.pendingAbortCleanup = null + metadata.resolvePendingAbortCleanup = null +} + +async function runWithClonePathLock(clonePathKey: string, task: () => Promise): Promise { + const previous = cloneInFlightByPath.get(clonePathKey) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.then( + () => current, + () => current + ) + cloneInFlightByPath.set(clonePathKey, tail) + + try { + await previous + return await task() + } finally { + release() + if (cloneInFlightByPath.get(clonePathKey) === tail) { + cloneInFlightByPath.delete(clonePathKey) + } + } +} + +function sanitizeNestedRepoImportError(context: string, error: unknown): string { + console.warn(`[project-groups] ${context}`, error) + return 'Repository could not be imported' +} + +async function resolveSshProjectGroupPath(connectionId: string, path: string): Promise { + if (path === '~' || path === '~/' || path.startsWith('~/')) { + const mux = getActiveMultiplexer(connectionId) + if (mux) { + try { + const result = (await mux.request('session.resolveHome', { path })) as { + resolvedPath: string + } + return result.resolvedPath + } catch { + return path + } + } + } + return path +} + +async function scanNestedReposForIpc(args: { + path: string + connectionId?: string + options?: unknown +}): Promise { + validateNestedRepoScanRoot(args.path, args.connectionId) + if (!args.connectionId) { + return scanNestedRepos({ path: args.path, options: args.options }) + } + const gitProvider = getSshGitProvider(args.connectionId) + const fsProvider = getSshFilesystemProvider(args.connectionId) + if (!gitProvider || !fsProvider) { + throw new Error('ssh_connection_unavailable') + } + const resolvedPath = await resolveSshProjectGroupPath(args.connectionId, args.path) + return scanNestedRepos({ + path: resolvedPath, + options: args.options, + filesystem: { + readDirectory: async (dirPath) => + (await fsProvider.readDir(dirPath)).map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory + })), + joinPath: (parentPath, childName) => posix.join(parentPath, childName), + basename: (path) => posix.basename(path), + isGitRepoPath: async (path) => { + try { + return (await gitProvider.isGitRepoAsync(path)).isRepo + } catch { + return false + } + } + } + }) +} export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void { // Remove any previously registered handlers so we can re-register them @@ -75,6 +301,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:remove') ipcMain.removeHandler('repos:reorder') ipcMain.removeHandler('repos:update') + ipcMain.removeHandler('projectGroups:list') + ipcMain.removeHandler('projectGroups:create') + ipcMain.removeHandler('projectGroups:update') + ipcMain.removeHandler('projectGroups:delete') + ipcMain.removeHandler('projectGroups:moveProject') + ipcMain.removeHandler('projectGroups:scanNested') + ipcMain.removeHandler('projectGroups:importNested') ipcMain.removeHandler('repos:pickFolder') ipcMain.removeHandler('repos:pickDirectory') ipcMain.removeHandler('repos:clone') @@ -93,6 +326,190 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return store.getRepos() }) + ipcMain.handle('projectGroups:list', () => store.getProjectGroups()) + + ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => { + const args = parseProjectGroupIpcArgs( + ProjectGroupCreateArgs, + rawArgs, + 'invalid_project_group_create_args' + ) + const group = store.createProjectGroup({ + name: args.name, + parentPath: args.parentPath ?? null, + parentGroupId: args.parentGroupId ?? null, + createdFrom: args.createdFrom ?? 'manual' + }) + notifyReposChanged(mainWindow) + return group + }) + + ipcMain.handle('projectGroups:update', (_event, rawArgs: unknown): ProjectGroup | null => { + const args = parseProjectGroupIpcArgs( + ProjectGroupUpdateArgs, + rawArgs, + 'invalid_project_group_update_args' + ) + const updated = store.updateProjectGroup(args.groupId, args.updates) + if (updated) { + notifyReposChanged(mainWindow) + } + return updated + }) + + ipcMain.handle('projectGroups:delete', (_event, rawArgs: unknown): boolean => { + const args = parseProjectGroupIpcArgs( + ProjectGroupSelectorArgs, + rawArgs, + 'invalid_project_group_delete_args' + ) + const deleted = store.deleteProjectGroup(args.groupId) + if (deleted) { + notifyReposChanged(mainWindow) + } + return deleted + }) + + ipcMain.handle('projectGroups:moveProject', (_event, rawArgs: unknown): Repo | null => { + const args = parseProjectGroupIpcArgs( + ProjectGroupMoveProjectArgs, + rawArgs, + 'invalid_project_group_move_repo_args' + ) + const moved = store.moveProjectToGroup(args.projectId, args.groupId, args.order) + if (moved) { + notifyReposChanged(mainWindow) + } + return moved + }) + + ipcMain.handle( + 'projectGroups:scanNested', + async (_event, rawArgs: unknown): Promise => { + const args = parseProjectGroupIpcArgs( + ProjectGroupScanNestedArgs, + rawArgs, + 'invalid_project_group_scan_nested_args' + ) + return scanNestedReposForIpc(args) + } + ) + + ipcMain.handle( + 'projectGroups:importNested', + async (_event, rawArgs: unknown): Promise => { + const args = parseProjectGroupIpcArgs( + ProjectGroupImportNestedArgs, + rawArgs, + 'invalid_project_group_import_nested_args' + ) + const requestedPaths = args.projectPaths + const scan = await scanNestedReposForIpc({ + path: args.parentPath, + connectionId: args.connectionId + }) + const selection = resolveNestedRepoSelection({ scan, projectPaths: requestedPaths }) + const groupResolver = createNestedProjectGroupResolver({ + parentPath: scan.selectedPath, + groupName: args.groupName ?? '', + mode: args.mode, + createGroup: (input) => store.createProjectGroup(input) + }) + const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map( + (repoPath) => ({ + path: repoPath, + status: 'failed', + error: 'Repository was not found in the nested repo scan result' + }) + ) + + for (const repoPath of selection.selectedPaths) { + try { + if (args.connectionId) { + const gitProvider = getSshGitProvider(args.connectionId) + if (!gitProvider || !(await gitProvider.isGitRepoAsync(repoPath)).isRepo) { + results.push({ + path: repoPath, + status: 'failed', + error: 'Not a valid git repository' + }) + continue + } + } else if (!isGitRepo(repoPath)) { + results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) + continue + } + const existing = store + .getRepos() + .find( + (repo) => + (repo.connectionId ?? null) === (args.connectionId ?? null) && + normalizeRuntimePathForComparison(repo.path) === + normalizeRuntimePathForComparison(repoPath) + ) + const group = groupResolver.getGroupForRepo(repoPath) + if (existing) { + if (group) { + store.moveProjectToGroup(existing.id, group.id) + } + results.push({ path: repoPath, projectId: existing.id, status: 'already-known' }) + continue + } + const repo: Repo = { + id: randomUUID(), + path: repoPath, + displayName: getRepoName(repoPath), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + addedAt: Date.now(), + kind: 'git', + ...(args.connectionId ? { connectionId: args.connectionId } : {}), + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: false, + ...(group + ? { + projectGroupId: group.id, + projectGroupOrder: getNextProjectGroupOrder(store.getRepos(), group.id) + } + : {}) + } + store.addRepo(repo) + if (args.connectionId) { + getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { + rootPath: repoPath + }) + } + results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) + emitRepoAdded('folder_picker', false) + } catch (error) { + results.push({ + path: repoPath, + status: 'failed', + error: sanitizeNestedRepoImportError('Failed to import nested repository', error) + }) + } + } + + const importedCount = results.filter((entry) => entry.status === 'imported').length + const alreadyKnownCount = results.filter((entry) => entry.status === 'already-known').length + const failedCount = results.filter((entry) => entry.status === 'failed').length + if (importedCount + alreadyKnownCount === 0) { + for (const group of groupResolver.getCreatedGroups().reverse()) { + store.deleteProjectGroup(group.id) + } + } + invalidateAuthorizedRootsCache() + notifyReposChanged(mainWindow) + const rootGroup = groupResolver.getRootGroup() + return { + ...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}), + projects: results, + importedCount, + alreadyKnownCount, + failedCount + } + } + ) + ipcMain.handle( 'repos:add', async ( @@ -111,11 +528,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return { repo: existing } } + const repoIcon = await detectRepoIcon({ repoPath: args.path, kind: repoKind }) const repo: Repo = { id: randomUUID(), path: args.path, displayName: getRepoName(args.path), badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(repoIcon ? { repoIcon } : {}), addedAt: Date.now(), kind: repoKind, ...(repoKind === 'git' @@ -216,11 +635,17 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } } + const repoIcon = await detectRepoIcon({ + repoPath: resolvedPath, + kind: repoKind, + connectionId: args.connectionId + }) const repo: Repo = { id: randomUUID(), path: resolvedPath, displayName, badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(repoIcon ? { repoIcon } : {}), addedAt: Date.now(), kind: repoKind, connectionId: args.connectionId, @@ -421,11 +846,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return { repo: raceWinner } } + const repoIcon = await detectRepoIcon({ repoPath: targetPath, kind: repoKind }) const repo: Repo = { id: randomUUID(), path: targetPath, displayName: name, badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(repoIcon ? { repoIcon } : {}), addedAt: Date.now(), kind: repoKind, ...(repoKind === 'git' @@ -461,7 +888,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { - store.removeRepo(args.repoId) + store.removeProject(args.repoId) invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) }) @@ -485,6 +912,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'issueSourcePreference' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'projectGroupId' + | 'projectGroupOrder' + | 'sourceControlAi' > > } @@ -541,6 +971,16 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) { delete updates.externalWorktreeVisibilityPromptDismissedAt } + if ('sourceControlAi' in updates && updates.sourceControlAi !== undefined) { + const normalizedSourceControlAi = normalizeRepoSourceControlAiOverrides( + updates.sourceControlAi + ) + if (normalizedSourceControlAi === undefined) { + delete updates.sourceControlAi + } else { + updates.sourceControlAi = normalizedSourceControlAi + } + } const updated = store.updateRepo(args.repoId, updates) if (updated) { notifyReposChanged(mainWindow) @@ -622,19 +1062,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v }) ipcMain.handle('repos:cloneAbort', async () => { - if (activeCloneProc) { - const pathToClean = activeClonePath - activeCloneProc.kill() - activeCloneProc = null - activeClonePath = null - // Why: git clone creates the target directory before it finishes. - // Without cleanup, retrying the same URL/destination fails with - // "destination path already exists and is not an empty directory". - if (pathToClean) { - await rm(pathToClean, { recursive: true, force: true }).catch(() => { - // Best-effort cleanup — don't fail the abort if removal fails - }) - } + if (activeClone) { + const clone = activeClone + clone.abortRequested = true + markCloneAbortCleanupPending(clone) + clone.process.kill() + activeClone = null } }) @@ -645,112 +1078,183 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // the repo folder name from the URL (e.g. "orca" from .../orca.git). // This matches the default git clone behavior where the last path segment // of the URL becomes the directory name. - const repoName = basename(args.url.replace(/\.git\/?$/, '')) - if (!repoName) { - throw new Error('Could not determine repository name from URL') - } - // Why: gitSpawn uses args.destination as cwd, so it must exist before - // spawn — fresh installs may have a defaulted parent dir that does not - // exist yet (e.g. ~/orca). recursive: true is a no-op when present. - await mkdir(args.destination, { recursive: true }) - const clonePath = join(args.destination, repoName) + const clonePath = deriveValidatedClonePath(args) + const clonePathKey = getClonePathComparisonKey(clonePath) + return runWithClonePathLock(clonePathKey, async () => { + await pendingAbortCleanupByPath.get(clonePathKey) + const existingAfterPendingClone = store + .getRepos() + .find((r) => getClonePathComparisonKey(r.path) === clonePathKey) + if (existingAfterPendingClone && !isFolderRepo(existingAfterPendingClone)) { + emitRepoAdded('clone_url', true) + return existingAfterPendingClone + } + // Why: gitSpawn uses args.destination as cwd, so it must exist before + // spawn — fresh installs may have a defaulted parent dir that does not + // exist yet (e.g. ~/orca). recursive: true is a no-op when present. + await mkdir(args.destination, { recursive: true }) + const claimedTarget = await claimCloneTarget(clonePath) - // Why: use spawn instead of execFile so there is no maxBuffer limit. - // git clone writes progress to stderr which can exceed Node's default - // 1 MB buffer on large or submodule-heavy repos. We only keep the tail - // of stderr for error reporting and discard stdout entirely. - // Why: use --progress to force git to emit progress even when stderr - // is not a TTY. Without it, git suppresses progress output when piped. - await new Promise((resolve, reject) => { - // Why: clone destination may be a WSL path (e.g. user picks a WSL - // directory). Use the parent destination as the cwd so the runner - // detects WSL and routes through wsl.exe. - // Why: use the '--' separator to isolate the URL argument and prevent - // malicious URLs from being interpreted as git flags (command injection). - const proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], { - cwd: args.destination, - stdio: ['ignore', 'ignore', 'pipe'] - }) - activeCloneProc = proc - activeClonePath = clonePath + // Why: use spawn instead of execFile so there is no maxBuffer limit. + // git clone writes progress to stderr which can exceed Node's default + // 1 MB buffer on large or submodule-heavy repos. We only keep the tail + // of stderr for error reporting and discard stdout entirely. + // Why: use --progress to force git to emit progress even when stderr + // is not a TTY. Without it, git suppresses progress output when piped. + const cloneMetadataRef: { current: ActiveCloneMetadata | null } = { current: null } + await new Promise((resolve, reject) => { + // Why: clone destination may be a WSL path (e.g. user picks a WSL + // directory). Use the parent destination as the cwd so the runner + // detects WSL and routes through wsl.exe. + // Why: use the '--' separator to isolate the URL argument and prevent + // malicious URLs from being interpreted as git flags (command injection). + let proc: ReturnType + try { + proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], { + cwd: args.destination, + stdio: ['ignore', 'ignore', 'pipe'] + }) + } catch (err) { + void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => { + const message = err instanceof Error ? err.message : String(err) + reject(new Error(`Clone failed: ${message}`)) + }) + return + } + const generation = nextCloneGeneration++ + latestCloneGenerationByPath.set(clonePathKey, generation) + const metadata: ActiveCloneMetadata = { + path: clonePath, + pathKey: clonePathKey, + claimedTarget, + process: proc, + abortRequested: false, + generation, + pendingAbortCleanup: null, + resolvePendingAbortCleanup: null + } + cloneMetadataRef.current = metadata + activeClone = metadata - let stderrTail = '' - proc.stderr!.on('data', (chunk: Buffer) => { - const text = chunk.toString() - stderrTail = (stderrTail + text).slice(-4096) + let stderrTail = '' + let settled = false + proc.stderr!.on('data', (chunk: Buffer) => { + const text = chunk.toString() + stderrTail = (stderrTail + text).slice(-4096) - // Why: git progress lines use \r to overwrite in-place. Split on - // both \r and \n to find the latest progress fragment, then extract - // the phase name and percentage for the renderer. - const lines = text.split(/[\r\n]+/) - for (const line of lines) { - const match = line.match(/^([\w\s]+):\s+(\d+)%/) - if (match && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('repos:clone-progress', { - phase: match[1].trim(), - percent: parseInt(match[2], 10) - }) + // Why: git progress lines use \r to overwrite in-place. Split on + // both \r and \n to find the latest progress fragment, then extract + // the phase name and percentage for the renderer. + const lines = text.split(/[\r\n]+/) + for (const line of lines) { + const match = line.match(/^([\w\s]+):\s+(\d+)%/) + if (match && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('repos:clone-progress', { + phase: match[1].trim(), + percent: parseInt(match[2], 10) + }) + } + } + }) + + const finishClone = async ( + code: number | null, + signal: NodeJS.Signals | null, + err?: Error + ) => { + if (settled) { + return + } + settled = true + // Why: only clear the ref if it still points to this process. + // A quick abort-and-retry can reassign activeClone to a new + // spawn before this handler fires, and nulling it would make the + // new clone unabortable. + if (activeClone?.process === proc) { + activeClone = null + } + + const cloneSucceeded = !err && code === 0 && !signal + if (!cloneSucceeded) { + // Why: only the process that created this target may remove it, + // and only after git reports the clone did not complete. + await cleanupOwnedCloneTarget(metadata) + } + if (metadata.abortRequested && !cloneSucceeded) { + settleCloneAbortCleanup(metadata) + } + if (latestCloneGenerationByPath.get(metadata.pathKey) === metadata.generation) { + latestCloneGenerationByPath.delete(metadata.pathKey) + } + + if (err) { + reject(new Error(`Clone failed: ${err.message}`)) + } else if (signal === 'SIGTERM') { + reject(new Error('Clone aborted')) + } else if (code === 0) { + resolve() + } else { + const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' + reject(new Error(`Clone failed: ${lastLine}`)) } } + + proc.on('error', (err) => { + void finishClone(null, null, err) + }) + + proc.on('close', (code, signal) => { + void finishClone(code, signal) + }) }) - proc.on('error', (err) => reject(new Error(`Clone failed: ${err.message}`))) - - proc.on('close', (code, signal) => { - // Why: only clear the ref if it still points to this process. - // A quick abort-and-retry can reassign activeCloneProc to a new - // spawn before this handler fires, and nulling it would make the - // new clone unabortable. - if (activeCloneProc === proc) { - activeCloneProc = null - activeClonePath = null + try { + // Why: check after clone (not before) because the path didn't exist + // before cloning. But if the user somehow had a folder repo at this path + // that git clone succeeded into (empty dir), reuse that entry and upgrade + // its kind to 'git' instead of creating a duplicate. + const existing = store + .getRepos() + .find((r) => getClonePathComparisonKey(r.path) === clonePathKey) + if (existing) { + if (isFolderRepo(existing)) { + const updated = store.updateRepo(existing.id, { kind: 'git' }) + if (updated) { + notifyReposChanged(mainWindow) + // Why: folder→git upgrade is a real new git repo provisioning event. + emitRepoAdded('clone_url', false) + return updated + } + } + emitRepoAdded('clone_url', true) + return existing } - if (signal === 'SIGTERM') { - reject(new Error('Clone aborted')) - } else if (code === 0) { - resolve() - } else { - const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' - reject(new Error(`Clone failed: ${lastLine}`)) - } - }) - }) - // Why: check after clone (not before) because the path didn't exist - // before cloning. But if the user somehow had a folder repo at this path - // that git clone succeeded into (empty dir), reuse that entry and upgrade - // its kind to 'git' instead of creating a duplicate. - const existing = store.getRepos().find((r) => r.path === clonePath) - if (existing) { - if (isFolderRepo(existing)) { - const updated = store.updateRepo(existing.id, { kind: 'git' }) - if (updated) { - notifyReposChanged(mainWindow) - // Why: folder→git upgrade is a real new git repo provisioning event. - emitRepoAdded('clone_url', false) - return updated + const repoIcon = await detectRepoIcon({ repoPath: clonePath, kind: 'git' }) + const repo: Repo = { + id: randomUUID(), + path: clonePath, + displayName: getRepoName(clonePath), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(repoIcon ? { repoIcon } : {}), + addedAt: Date.now(), + kind: 'git', + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: false + } + + store.addRepo(repo) + invalidateAuthorizedRootsCache() + notifyReposChanged(mainWindow) + emitRepoAdded('clone_url', false) + return repo + } finally { + const metadata = cloneMetadataRef.current + if (metadata?.abortRequested) { + settleCloneAbortCleanup(metadata) } } - emitRepoAdded('clone_url', true) - return existing - } - - const repo: Repo = { - id: randomUUID(), - path: clonePath, - displayName: getRepoName(clonePath), - badgeColor: DEFAULT_REPO_BADGE_COLOR, - addedAt: Date.now(), - kind: 'git', - externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false - } - - store.addRepo(repo) - invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) - emitRepoAdded('clone_url', false) - return repo + }) } ) diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index 68a570e28cd..f951091c986 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -76,4 +76,27 @@ describe('registerRuntimeHandlers', () => { _meta: { runtimeId: 'runtime-1' } }) }) + + it('registers project group runtime RPC methods for local desktop callers', async () => { + const runtime = { + syncWindowGraph: vi.fn(), + getStatus: vi.fn(), + getRuntimeId: vi.fn().mockReturnValue('runtime-1'), + listProjectGroups: vi.fn().mockReturnValue([{ id: 'group-1', name: 'Platform' }]) + } + + registerRuntimeHandlers(runtime as never) + + const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call') + expect(callRegistration).toBeTruthy() + + const handler = callRegistration![1] + const result = await handler({ sender: {} }, { method: 'projectGroup.list' }) + + expect(result).toMatchObject({ + ok: true, + result: { groups: [{ id: 'group-1', name: 'Platform' }] }, + _meta: { runtimeId: 'runtime-1' } + }) + }) }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index c8bc4103d78..209a1e2c1d4 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -8,6 +8,7 @@ import { track } from '../telemetry/client' import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../shared/telemetry-events' import type { AgentAwakeService } from '../agent-awake-service' import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory' +import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -55,6 +56,16 @@ export function registerSettingsHandlers( if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun) } + if ( + 'agentStatusHooksEnabled' in sanitizedArgs && + before.agentStatusHooksEnabled !== result.agentStatusHooksEnabled + ) { + try { + applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled) + } catch (error) { + console.warn('[settings] failed to apply agentStatusHooksEnabled:', error) + } + } if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) { rebuildAppMenu() } diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index e526005b372..b3ac052224f 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' const { handleMock, + powerMonitorOffMock, + powerMonitorOnMock, mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay, @@ -13,9 +15,14 @@ const { mockPtyProvider, mockFsProvider, mockGitProvider, - mockPortForwardManager + mockPortForwardManager, + mockPortScannerCallbacks, + mockNextConnectionManagers, + mockNextPortForwardManagers } = vi.hoisted(() => ({ handleMock: vi.fn(), + powerMonitorOffMock: vi.fn(), + powerMonitorOnMock: vi.fn(), mockSshStore: { listTargets: vi.fn().mockReturnValue([]), getTarget: vi.fn(), @@ -27,9 +34,12 @@ const { mockConnectionManager: { connect: vi.fn(), disconnect: vi.fn(), + reconnect: vi.fn(), getConnection: vi.fn(), getState: vi.fn(), - disconnectAll: vi.fn() + disconnectAll: vi.fn(), + setCallbacks: vi.fn(), + callbacksRef: { current: null as unknown } }, mockDeployAndLaunchRelay: vi.fn(), mockForceStopRelayForTarget: vi.fn(), @@ -52,11 +62,15 @@ const { mockGitProvider: {}, mockPortForwardManager: { addForward: vi.fn(), + updateForward: vi.fn(), removeForward: vi.fn(), listForwards: vi.fn().mockReturnValue([]), removeAllForwards: vi.fn(), dispose: vi.fn() - } + }, + mockPortScannerCallbacks: new Map(), + mockNextConnectionManagers: [] as unknown[], + mockNextPortForwardManagers: [] as unknown[] })) vi.mock('electron', () => ({ @@ -66,6 +80,10 @@ vi.mock('electron', () => ({ once: vi.fn(), removeHandler: vi.fn(), removeAllListeners: vi.fn() + }, + powerMonitor: { + on: powerMonitorOnMock, + off: powerMonitorOffMock } })) @@ -79,8 +97,14 @@ vi.mock('../ssh/ssh-connection-store', () => ({ vi.mock('../ssh/ssh-connection', () => ({ SshConnectionManager: class MockSshConnectionManager { - constructor() { - return mockConnectionManager + constructor(callbacks: unknown) { + const manager = (mockNextConnectionManagers.shift() ?? + mockConnectionManager) as typeof mockConnectionManager + manager.callbacksRef.current = callbacks + manager.setCallbacks.mockImplementation((nextCallbacks: unknown) => { + manager.callbacksRef.current = nextCallbacks + }) + return manager } } })) @@ -152,13 +176,27 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ vi.mock('../ssh/ssh-port-forward', () => ({ SshPortForwardManager: class MockPortForwardManager { constructor() { - return mockPortForwardManager + return mockNextPortForwardManagers.shift() ?? mockPortForwardManager } } })) -import { registerSshHandlers } from './ssh' -import type { SshTarget } from '../../shared/ssh-types' +vi.mock('../ssh/ssh-port-scanner', () => ({ + PortScanner: class MockPortScanner { + startScanning(targetId: string, _mux: unknown, onChanged: unknown) { + mockPortScannerCallbacks.set(targetId, onChanged) + } + getDetectedPorts() { + return [] + } + stopScanning(targetId: string) { + mockPortScannerCallbacks.delete(targetId) + } + } +})) + +import { getSshConnectionManager, registerSshHandlers, resetSshHandlerStateForTests } from './ssh' +import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, type SshTarget } from '../../shared/ssh-types' import { clearProviderPtyState, deletePtyOwnership, @@ -179,9 +217,35 @@ describe('SSH IPC handlers', () => { isDestroyed: () => false, webContents: { send: vi.fn() } } + const createMockWindow = () => ({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + const createConnectionManagerMock = () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + reconnect: vi.fn(), + getConnection: vi.fn(), + getState: vi.fn(), + disconnectAll: vi.fn(), + setCallbacks: vi.fn(), + callbacksRef: { current: null as unknown } + }) + const createPortForwardManagerMock = () => ({ + addForward: vi.fn(), + updateForward: vi.fn(), + removeForward: vi.fn(), + listForwards: vi.fn().mockReturnValue([]), + removeAllForwards: vi.fn(), + dispose: vi.fn() + }) - beforeEach(() => { + beforeEach(async () => { + await resetSshHandlerStateForTests() handlers.clear() + mockNextConnectionManagers.length = 0 + mockNextPortForwardManagers.length = 0 + mockPortScannerCallbacks.clear() handleMock.mockReset() handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { handlers.set(channel, handler) @@ -200,9 +264,12 @@ describe('SSH IPC handlers', () => { mockConnectionManager.connect.mockReset() mockConnectionManager.disconnect.mockReset() + mockConnectionManager.reconnect.mockReset() mockConnectionManager.getConnection.mockReset() mockConnectionManager.getState.mockReset() mockConnectionManager.disconnectAll.mockReset() + mockConnectionManager.setCallbacks.mockReset() + mockConnectionManager.callbacksRef.current = null mockForceStopRelayForTarget.mockReset().mockResolvedValue(undefined) mockDeployAndLaunchRelay.mockReset().mockResolvedValue({ @@ -218,10 +285,13 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onReplay.mockReset() mockPtyProvider.shutdown.mockReset() mockPortForwardManager.addForward.mockReset() + mockPortForwardManager.updateForward.mockReset() mockPortForwardManager.removeForward.mockReset() mockPortForwardManager.listForwards.mockReset().mockReturnValue([]) mockPortForwardManager.removeAllForwards.mockReset() mockPortForwardManager.dispose.mockReset() + powerMonitorOnMock.mockReset() + powerMonitorOffMock.mockReset() vi.mocked(getSshPtyProvider).mockReset() vi.mocked(getPtyIdsForConnection).mockReset().mockReturnValue([]) vi.mocked(clearProviderPtyState).mockReset() @@ -425,6 +495,64 @@ describe('SSH IPC handlers', () => { } }) + it('rebuilds instead of reusing a ready session while relay loss is pending', async () => { + vi.useFakeTimers() + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + try { + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + const onDispose = mockMux.onDispose.mock.calls[0]?.[0] as + | ((reason: 'shutdown' | 'connection_lost') => void) + | undefined + + onDispose?.('connection_lost') + + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + targetId: 'ssh-1', + status: 'reconnecting', + error: 'Relay channel lost. Reconnecting...', + reconnectAttempt: 1 + }) + + mockDeployAndLaunchRelay.mockClear() + mockPortForwardManager.removeAllForwards.mockClear() + + await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + expect(mockDeployAndLaunchRelay).toHaveBeenCalled() + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + } finally { + vi.useRealTimers() + } + }) + it('forwards remote PTY events into the runtime', async () => { const runtime = { onPtyData: vi.fn(), @@ -462,6 +590,258 @@ describe('SSH IPC handlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7) }) + it('preserves active port forwards and live connections across handler re-registration', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + const forward = { + id: 'pf-1', + connectionId: 'ssh-1', + localPort: 4100, + remoteHost: '127.0.0.1', + remotePort: 3000, + label: 'app' + } + const updatedForward = { ...forward, remotePort: 3001 } + const newForward = { ...forward, id: 'pf-2', localPort: 4101 } + const connectedState = { + targetId: 'ssh-1', + status: 'connected' as const, + error: null, + reconnectAttempt: 0 + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue(connectedState) + mockPortForwardManager.addForward + .mockResolvedValueOnce(forward) + .mockResolvedValueOnce(newForward) + mockPortForwardManager.updateForward.mockResolvedValue(updatedForward) + mockPortForwardManager.removeForward.mockReturnValue(updatedForward) + mockPortForwardManager.listForwards.mockReturnValue([forward]) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + await handlers.get('ssh:addPortForward')!(null, { + targetId: 'ssh-1', + localPort: 4100, + remoteHost: '127.0.0.1', + remotePort: 3000, + label: 'app' + }) + const replacementConnectionManager = createConnectionManagerMock() + const replacementPortForwardManager = createPortForwardManagerMock() + mockNextConnectionManagers.push(replacementConnectionManager) + mockNextPortForwardManagers.push(replacementPortForwardManager) + + registerSshHandlers(mockStore as never, () => createMockWindow() as never) + + expect(getSshConnectionManager()).toBe(mockConnectionManager) + expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ + forward + ]) + mockDeployAndLaunchRelay.mockClear() + mockPortForwardManager.removeAllForwards.mockClear() + + await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual( + connectedState + ) + expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() + expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() + expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ + forward + ]) + + await handlers.get('ssh:updatePortForward')!(null, { + id: 'pf-1', + targetId: 'ssh-1', + localPort: 4100, + remoteHost: '127.0.0.1', + remotePort: 3001, + label: 'app' + }) + expect(mockPortForwardManager.updateForward).toHaveBeenCalledWith( + 'pf-1', + conn, + 4100, + '127.0.0.1', + 3001, + 'app' + ) + + expect(await handlers.get('ssh:removePortForward')!(null, { id: 'pf-1' })).toEqual( + updatedForward + ) + await handlers.get('ssh:addPortForward')!(null, { + targetId: 'ssh-1', + localPort: 4101, + remoteHost: '127.0.0.1', + remotePort: 3000, + label: 'app' + }) + expect(mockPortForwardManager.addForward).toHaveBeenLastCalledWith( + 'ssh-1', + conn, + 4101, + '127.0.0.1', + 3000, + 'app' + ) + expect(replacementConnectionManager.getConnection).not.toHaveBeenCalled() + expect(replacementPortForwardManager.listForwards).not.toHaveBeenCalled() + }) + + it('disconnects the original session and releases original forwards after re-registration', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockPortForwardManager.removeAllForwards.mockClear() + mockConnectionManager.disconnect.mockClear().mockResolvedValue(undefined) + const replacementConnectionManager = createConnectionManagerMock() + const replacementPortForwardManager = createPortForwardManagerMock() + mockNextConnectionManagers.push(replacementConnectionManager) + mockNextPortForwardManagers.push(replacementPortForwardManager) + + registerSshHandlers(mockStore as never, () => createMockWindow() as never) + await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) + + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + expect(replacementPortForwardManager.removeAllForwards).not.toHaveBeenCalled() + expect(replacementConnectionManager.disconnect).not.toHaveBeenCalled() + }) + + it('refreshes live session callbacks to the newest window, store, and runtime', async () => { + const firstWindow = createMockWindow() + const secondWindow = createMockWindow() + const firstRuntime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn() + } + const secondRuntime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn() + } + registerSshHandlers(mockStore as never, () => firstWindow as never, firstRuntime as never) + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as + | ((payload: { id: string; data: string }) => void) + | undefined + const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as + | ((payload: { id: string; code: number }) => void) + | undefined + const onDetectedPorts = mockPortScannerCallbacks.get('ssh-1') as + | ((targetId: string, ports: unknown[], platform: string) => void) + | undefined + firstWindow.webContents.send.mockClear() + secondWindow.webContents.send.mockClear() + + registerSshHandlers(mockStore as never, () => secondWindow as never, secondRuntime as never) + const callbacks = mockConnectionManager.callbacksRef.current as { + onStateChange: (targetId: string, state: unknown) => void + } + + callbacks.onStateChange('ssh-1', { + targetId: 'ssh-1', + status: 'error', + error: 'network down', + reconnectAttempt: 0 + }) + onData?.({ id: 'remote-pty', data: 'hello' }) + onExit?.({ id: 'remote-pty', code: 9 }) + onDetectedPorts?.( + 'ssh-1', + [{ host: '127.0.0.1', port: 3000, pid: 12, processName: 'node' }], + 'linux-x64' + ) + + expect(firstWindow.webContents.send).not.toHaveBeenCalled() + expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'error', + error: 'network down', + reconnectAttempt: 0 + } + }) + expect(secondWindow.webContents.send).toHaveBeenCalledWith( + 'pty:data', + expect.objectContaining({ id: 'remote-pty', data: 'hello' }) + ) + expect(secondWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: 'remote-pty', + code: 9 + }) + expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:detected-ports-changed', { + targetId: 'ssh-1', + ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })]) + }) + expect(secondRuntime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number)) + expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9) + expect(firstRuntime.onPtyData).not.toHaveBeenCalled() + expect(firstRuntime.onPtyExit).not.toHaveBeenCalled() + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'remote-pty', + 'terminated' + ) + }) + + it('re-registers without replacing managers when no targets are connected', () => { + const replacementConnectionManager = createConnectionManagerMock() + const replacementPortForwardManager = createPortForwardManagerMock() + mockNextConnectionManagers.push(replacementConnectionManager) + mockNextPortForwardManagers.push(replacementPortForwardManager) + + const result = registerSshHandlers(mockStore as never, () => createMockWindow() as never) + + expect(result.connectionManager).toBe(mockConnectionManager) + expect(replacementConnectionManager.setCallbacks).not.toHaveBeenCalled() + expect(replacementPortForwardManager.dispose).not.toHaveBeenCalled() + expect(mockNextConnectionManagers).toHaveLength(1) + expect(mockNextPortForwardManagers).toHaveLength(1) + }) + it('ssh:disconnect calls connection manager', async () => { mockConnectionManager.disconnect.mockResolvedValue(undefined) @@ -752,6 +1132,69 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + it('forces active SSH sessions to reconnect when the system resumes from sleep', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] + expect(resumeListener).toBeTypeOf('function') + + resumeListener() + + expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1') + }) + + it('extends active relay grace while the system is suspending', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockMux.notify.mockClear() + + const suspendListener = powerMonitorOnMock.mock.calls.find( + ([event]) => event === 'suspend' + )?.[1] + expect(suspendListener).toBeTypeOf('function') + + suspendListener() + + expect(mockMux.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { + graceTimeSeconds: 0 + }) + }) + it('ssh:resetRelay expires active-session leases instead of marking them terminated', async () => { const target: SshTarget = { id: 'ssh-1', diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 4e2f0303771..3497fd9f99f 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -1,6 +1,6 @@ /* oxlint-disable max-lines -- Why: co-locates SSH IPC handlers, port-forward broadcasting, and session lifecycle in one file to keep the data flow obvious. */ -import { ipcMain, type BrowserWindow } from 'electron' +import { ipcMain, powerMonitor, type BrowserWindow } from 'electron' import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection' @@ -44,6 +44,38 @@ let registeredConnectSshTarget: ((targetId: string) => Promise SshConnectionState | undefined) | null = null let persistedStore: Store | null = null let advertisedUrlWatcherUnsubscribe: (() => void) | null = null +let powerMonitorUnsubscribe: (() => void) | null = null +let currentGetMainWindow: () => BrowserWindow | null = () => null +let currentRuntime: OrcaRuntimeService | undefined + +const SSH_IPC_CHANNELS = [ + 'ssh:listTargets', + 'ssh:addTarget', + 'ssh:updateTarget', + 'ssh:removeTarget', + 'ssh:importConfig', + 'ssh:connect', + 'ssh:disconnect', + 'ssh:terminateSessions', + 'ssh:resetRelay', + 'ssh:getState', + 'ssh:needsPassphrasePrompt', + 'ssh:testConnection', + 'ssh:addPortForward', + 'ssh:updatePortForward', + 'ssh:removePortForward', + 'ssh:listPortForwards', + 'ssh:listDetectedPorts' +] as const + +// Why: connection callbacks are process-lifetime; keeping this set outside +// registerSshHandlers prevents in-flight connects from splitting credential +// tracking when a BrowserWindow is recreated. +const credentialRequestedForTarget = new Set() + +function getCurrentMainWindow(): BrowserWindow | null { + return currentGetMainWindow() +} export async function connectRegisteredSshTarget(targetId: string): Promise { if (!registeredConnectSshTarget) { @@ -308,51 +340,42 @@ function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null) }) } -export function registerSshHandlers( - store: Store, - getMainWindow: () => BrowserWindow | null, - runtime?: OrcaRuntimeService -): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } { - // Why: on macOS, app re-activation creates a new BrowserWindow and re-calls - // this function. ipcMain.handle() throws if a handler is already registered, - // so we must remove any prior handlers before re-registering. - for (const ch of [ - 'ssh:listTargets', - 'ssh:addTarget', - 'ssh:updateTarget', - 'ssh:removeTarget', - 'ssh:importConfig', - 'ssh:connect', - 'ssh:disconnect', - 'ssh:terminateSessions', - 'ssh:resetRelay', - 'ssh:getState', - 'ssh:needsPassphrasePrompt', - 'ssh:testConnection', - 'ssh:addPortForward', - 'ssh:updatePortForward', - 'ssh:removePortForward', - 'ssh:listPortForwards', - 'ssh:listDetectedPorts' - ]) { - ipcMain.removeHandler(ch) +function registerPowerMonitorReconnect(): void { + powerMonitorUnsubscribe?.() + const onSuspend = (): void => { + for (const session of activeSessions.values()) { + session.prepareForHostSleep() + } } + const onResume = (): void => { + for (const targetId of activeSessions.keys()) { + const conn = connectionManager?.getConnection(targetId) + if (!conn) { + continue + } + const reconnect = connectionManager?.reconnect(targetId) + void reconnect?.catch((err) => { + console.warn( + `[ssh] Failed to reconnect ${targetId} after system resume: ${ + err instanceof Error ? err.message : String(err) + }` + ) + }) + } + } + powerMonitor.on('suspend', onSuspend) + powerMonitor.on('resume', onResume) + powerMonitorUnsubscribe = () => { + powerMonitor.off('suspend', onSuspend) + powerMonitor.off('resume', onResume) + } +} - sshStore = new SshConnectionStore(store) - persistedStore = store - registerAdvertisedUrlRefresh(getMainWindow) - - registerCredentialHandler(getMainWindow) - - // Why: tracks whether a credential prompt was triggered during the current - // ssh:connect call. Used to set lastRequiredPassphrase on the target so - // startup reconnect can defer passphrase-protected targets to tab focus. - const credentialRequestedForTarget = new Set() - - const callbacks: SshConnectionCallbacks = { +function createSshConnectionCallbacks(): SshConnectionCallbacks { + return { onCredentialRequest: (targetId, kind, detail) => { credentialRequestedForTarget.add(targetId) - return requestCredential(getMainWindow, targetId, kind, detail) + return requestCredential(getCurrentMainWindow, targetId, kind, detail) }, onStateChange: (targetId: string, state: SshConnectionState) => { if (testingTargets.has(targetId)) { @@ -375,7 +398,7 @@ export function registerSshHandlers( // Why: SSH is connected before the relay providers are rebuilt. Keep // renderer actions gated until SshRelaySession reaches ready again. publishRelayOverride( - getMainWindow, + getCurrentMainWindow, targetId, 'reconnecting', 'Relay channel reconnecting...', @@ -383,7 +406,7 @@ export function registerSshHandlers( ) } else { clearRelayStateOverride(targetId) - broadcastSshState(getMainWindow, targetId, state) + broadcastSshState(getCurrentMainWindow, targetId, state) } if (!session) { @@ -402,9 +425,160 @@ export function registerSshHandlers( } } } +} - connectionManager = new SshConnectionManager(callbacks) - portForwardManager = new SshPortForwardManager() +function broadcastDetectedPortsFromCurrentWindow( + targetId: string, + ports: DetectedPort[], + _platform: string +): void { + broadcastDetectedPorts(getCurrentMainWindow, targetId, ports) +} + +function configureRelaySessionCallbacks(session: SshRelaySession): void { + session.setOnTerminalRelayError((tid, err) => { + clearRelayLostBackoff(tid) + console.warn( + `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` + ) + publishRelayOverride(getCurrentMainWindow, tid, 'error', err.message, 0) + }) + + session.setOnRelayLost((tid) => { + const s = activeSessions.get(tid) + if (!s) { + return + } + const c = connectionManager?.getConnection(tid) + if (!c) { + return + } + const t = sshStore?.getTarget(tid) + + // Why: bounded exponential backoff. Without this, a remote-side bug + // that closes every fresh --connect channel turns into an infinite + // tight loop spawning relay deploys until the user force-quits. + const state = relayLostBackoff.get(tid) ?? { + attempts: 0, + lastAttemptStartedAt: 0, + pendingTimer: null + } + if (state.pendingTimer) { + return + } + if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { + console.warn( + `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` + ) + relayLostBackoff.delete(tid) + // Why: surface the failure so the renderer can prompt the user. + // A still-live SSH connection with a dead relay is otherwise an + // invisible failure — typing in remote terminals just stops working. + publishRelayOverride( + getCurrentMainWindow, + tid, + 'error', + 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', + 0 + ) + return + } + const delay = Math.min(RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, RELAY_LOST_MAX_DELAY_MS) + state.attempts += 1 + publishRelayOverride( + getCurrentMainWindow, + tid, + 'reconnecting', + 'Relay channel lost. Reconnecting...', + state.attempts + ) + state.pendingTimer = setTimeout(() => { + state.pendingTimer = null + state.lastAttemptStartedAt = Date.now() + relayLostBackoff.set(tid, state) + const liveConn = connectionManager?.getConnection(tid) + if (!liveConn || !activeSessions.has(tid)) { + return + } + void s.reconnect(liveConn, relayGracePeriodForTarget(t)) + }, delay) + relayLostBackoff.set(tid, state) + console.warn( + `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` + ) + }) + + // Why: fires after both establish() and reconnect() reach 'ready'. + // Re-creates persisted port forwards so they survive app restarts + // and network blips without manual re-configuration. + session.setOnReady((tid) => { + const state = relayLostBackoff.get(tid) + if (state) { + const stabilized = + state.lastAttemptStartedAt === 0 || + Date.now() - state.lastAttemptStartedAt >= RELAY_LOST_STABILIZED_MS + if (stabilized) { + relayLostBackoff.delete(tid) + } + } + clearRelayStateOverride(tid) + if (!testingTargets.has(tid)) { + broadcastSshState(getCurrentMainWindow, tid, { + targetId: tid, + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + } + void restorePortForwards(tid, getCurrentMainWindow) + }) +} + +function refreshActiveRelaySessions(): void { + if (!persistedStore || !portForwardManager) { + return + } + for (const session of activeSessions.values()) { + session.refreshEnvironment( + getCurrentMainWindow, + persistedStore, + portForwardManager, + currentRuntime, + broadcastDetectedPortsFromCurrentWindow + ) + configureRelaySessionCallbacks(session) + } +} + +export function registerSshHandlers( + store: Store, + getMainWindow: () => BrowserWindow | null, + runtime?: OrcaRuntimeService +): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } { + // Why: on macOS, app re-activation creates a new BrowserWindow and re-calls + // this function. ipcMain.handle() throws if a handler is already registered, + // so we must remove any prior handlers before re-registering. + for (const ch of SSH_IPC_CHANNELS) { + ipcMain.removeHandler(ch) + } + + currentGetMainWindow = getMainWindow + currentRuntime = runtime + sshStore = new SshConnectionStore(store) + persistedStore = store + registerAdvertisedUrlRefresh(getCurrentMainWindow) + + registerCredentialHandler(getCurrentMainWindow) + + const callbacks = createSshConnectionCallbacks() + if (connectionManager) { + connectionManager.setCallbacks(callbacks) + } else { + connectionManager = new SshConnectionManager(callbacks) + } + portForwardManager ??= new SshPortForwardManager() + refreshActiveRelaySessions() + registerPowerMonitorReconnect() registerSshBrowseHandler(() => connectionManager) // ── Target CRUD ──────────────────────────────────────────────────── @@ -442,7 +616,7 @@ export function registerSshHandlers( `[ssh] Failed to disconnect removed target ${args.id}: ${err instanceof Error ? err.message : String(err)}` ) } - store.removeSshRemotePtyLeases(args.id) + persistedStore!.removeSshRemotePtyLeases(args.id) sshStore!.removeTarget(args.id) }) @@ -483,17 +657,35 @@ export function registerSshHandlers( }) async function doConnect(targetId: string): Promise { - clearRelayStateOverride(targetId) const target = sshStore!.getTarget(targetId) if (!target) { throw new Error(`SSH target "${targetId}" not found`) } + const existingSession = activeSessions.get(targetId) + const existingState = connectionManager!.getState(targetId) + const existingMux = existingSession?.getMux() + if ( + existingSession?.getState() === 'ready' && + existingState?.status === 'connected' && + connectionManager!.getConnection(targetId) && + existingMux && + !existingMux.isDisposed() && + !relayStateOverrides.has(targetId) && + !relayLostBackoff.has(targetId) + ) { + // Why: BrowserWindow reactivation reruns renderer startup, which calls + // ssh:connect for already-live targets. Treat that as a refresh instead + // of tearing down the relay and stranding active port forwards. + broadcastSshState(getCurrentMainWindow, targetId, existingState) + return existingState + } + + clearRelayStateOverride(targetId) let conn // Why: dispose any existing session to avoid leaking the old multiplexer, // providers, and timers. This handles double-connect (user clicks connect // while already connected) and reconnect-after-error. - const existingSession = activeSessions.get(targetId) if (existingSession) { // Why: await port teardown before disposing so the OS fully releases // local ports. Without this, restorePortForwards in the new session @@ -509,14 +701,13 @@ export function registerSshHandlers( // state and knows not to trigger reconnect logic. const session = new SshRelaySession( targetId, - getMainWindow, - store, + getCurrentMainWindow, + persistedStore!, portForwardManager!, - runtime, - (tid, ports, _platform) => { - broadcastDetectedPorts(getMainWindow, tid, ports) - } + currentRuntime, + broadcastDetectedPortsFromCurrentWindow ) + configureRelaySessionCallbacks(session) activeSessions.set(targetId, session) try { @@ -536,7 +727,7 @@ export function registerSshHandlers( activeSessions.delete(targetId) clearRelayLostBackoff(targetId) clearRelayStateOverride(targetId) - broadcastSshState(getMainWindow, targetId, { + broadcastSshState(getCurrentMainWindow, targetId, { targetId, status, error: errObj.message, @@ -554,129 +745,13 @@ export function registerSshHandlers( reconnectAttempt: 0 }) - // Why: the relay exec channel can close independently of the SSH - // connection (e.g. --connect bridge exits, relay process crashes). - // When that happens, the mux is disposed but onStateChange never - // fires because the SSH connection is still alive. This callback - // triggers session.reconnect() using the live SSH connection. - // Set before establish() so the callback is in place if the relay - // dies during the deploy/connect sequence. - // Why: a wire-handshake mismatch (typed RelayVersionMismatchError) means - // the local client and remote daemon are at different code versions — - // no amount of backoff will reconcile them. Skip the relay-lost loop - // entirely and surface a terminal "please reconnect manually" error. - session.setOnTerminalRelayError((tid, err) => { - clearRelayLostBackoff(tid) - console.warn( - `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` - ) - publishRelayOverride(getMainWindow, tid, 'error', err.message, 0) - }) - - session.setOnRelayLost((tid) => { - const s = activeSessions.get(tid) - if (!s) { - return - } - const c = connectionManager?.getConnection(tid) - if (!c) { - return - } - const t = sshStore?.getTarget(tid) - - // Why: bounded exponential backoff. Without this, a remote-side bug - // that closes every fresh --connect channel turns into an infinite - // tight loop spawning relay deploys until the user force-quits. - const state = relayLostBackoff.get(tid) ?? { - attempts: 0, - lastAttemptStartedAt: 0, - pendingTimer: null - } - if (state.pendingTimer) { - // A retry is already scheduled — coalesce this burst. - return - } - if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { - console.warn( - `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` - ) - relayLostBackoff.delete(tid) - // Why: surface the failure so the renderer can prompt the user. - // A still-live SSH connection with a dead relay is otherwise an - // invisible failure — typing in remote terminals just stops working. - publishRelayOverride( - getMainWindow, - tid, - 'error', - 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', - 0 - ) - return - } - const delay = Math.min( - RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, - RELAY_LOST_MAX_DELAY_MS - ) - state.attempts += 1 - publishRelayOverride( - getMainWindow, - tid, - 'reconnecting', - 'Relay channel lost. Reconnecting...', - state.attempts - ) - state.pendingTimer = setTimeout(() => { - state.pendingTimer = null - state.lastAttemptStartedAt = Date.now() - relayLostBackoff.set(tid, state) - const liveConn = connectionManager?.getConnection(tid) - if (!liveConn || !activeSessions.has(tid)) { - return - } - void s.reconnect(liveConn, relayGracePeriodForTarget(t)) - }, delay) - relayLostBackoff.set(tid, state) - console.warn( - `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` - ) - }) - - // Why: fires after both establish() and reconnect() reach 'ready'. - // Re-creates persisted port forwards so they survive app restarts - // and network blips without manual re-configuration. We also clear - // the relay-lost backoff state so a subsequent genuine drop starts - // from a fresh attempt counter — but only if the session had a chance - // to stabilize, otherwise rapid `ready → lost → ready → lost` flaps - // would silently keep retrying forever. - session.setOnReady((tid) => { - const state = relayLostBackoff.get(tid) - if (state) { - const stabilized = - state.lastAttemptStartedAt === 0 || - Date.now() - state.lastAttemptStartedAt >= RELAY_LOST_STABILIZED_MS - if (stabilized) { - relayLostBackoff.delete(tid) - } - } - clearRelayStateOverride(tid) - if (!testingTargets.has(tid)) { - broadcastSshState(getMainWindow, tid, { - targetId: tid, - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - } - void restorePortForwards(tid, getMainWindow) - }) - await session.establish(conn, relayGracePeriodForTarget(target)) // Why: we manually pushed `deploying-relay` above, so the renderer's // state is stuck there. Send `connected` directly to the renderer // instead of going through callbacks.onStateChange, which would // trigger the reconnection logic. - const win = getMainWindow() + const win = getCurrentMainWindow() if (win && !win.isDestroyed()) { clearRelayStateOverride(targetId) win.webContents.send('ssh:state-changed', { @@ -726,7 +801,7 @@ export function registerSshHandlers( ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => { const session = activeSessions.get(args.targetId) const provider = getSshPtyProvider(args.targetId) - const leasedIds = store + const leasedIds = persistedStore! .getSshRemotePtyLeases(args.targetId) .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') .map((lease) => lease.ptyId) @@ -770,7 +845,7 @@ export function registerSshHandlers( } clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) - store.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') + persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') } if (shutdownFailures.length > 0) { // Why: a failed relay shutdown can leave the remote process alive in the @@ -815,10 +890,10 @@ export function registerSshHandlers( await forceStopRelayForTarget(conn, targetId) } finally { const ptyIds = new Set(getPtyIdsForConnection(targetId)) - for (const lease of store.getSshRemotePtyLeases(targetId)) { + for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { if (lease.state !== 'terminated' && lease.state !== 'expired') { ptyIds.add(lease.ptyId) - store.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') } } // Why: reset force-kills the remote relay daemon, so every local PTY @@ -965,7 +1040,7 @@ export function registerSshHandlers( args.label ) persistPortForwards(args.targetId) - broadcastPortForwards(getMainWindow, args.targetId) + broadcastPortForwards(getCurrentMainWindow, args.targetId) return entry } ) @@ -997,14 +1072,14 @@ export function registerSshHandlers( args.label ) persistPortForwards(entry.connectionId) - broadcastPortForwards(getMainWindow, entry.connectionId) + broadcastPortForwards(getCurrentMainWindow, entry.connectionId) return entry } catch (err) { // Why: if the edit failed (and rollback may also have failed), // sync the renderer with the actual runtime state so it doesn't // show a forward that no longer exists. persistPortForwards(args.targetId) - broadcastPortForwards(getMainWindow, args.targetId) + broadcastPortForwards(getCurrentMainWindow, args.targetId) throw err } } @@ -1014,7 +1089,7 @@ export function registerSshHandlers( const removed = portForwardManager!.removeForward(args.id) if (removed) { persistPortForwards(removed.connectionId) - broadcastPortForwards(getMainWindow, removed.connectionId) + broadcastPortForwards(getCurrentMainWindow, removed.connectionId) } return removed }) @@ -1043,6 +1118,41 @@ export function getSshConnectionManager(): SshConnectionManager | null { return connectionManager } +export async function resetSshHandlerStateForTests(): Promise { + advertisedUrlWatcherUnsubscribe?.() + advertisedUrlWatcherUnsubscribe = null + powerMonitorUnsubscribe?.() + powerMonitorUnsubscribe = null + for (const ch of SSH_IPC_CHANNELS) { + ipcMain.removeHandler(ch) + } + ipcMain.removeHandler('ssh:submitCredential') + + for (const session of activeSessions.values()) { + session.dispose() + } + activeSessions.clear() + for (const targetId of relayLostBackoff.keys()) { + clearRelayLostBackoff(targetId) + } + relayStateOverrides.clear() + connectInFlight.clear() + resetRelayInFlight.clear() + testingTargets.clear() + credentialRequestedForTarget.clear() + + await connectionManager?.disconnectAll() + portForwardManager?.dispose() + connectionManager = null + portForwardManager = null + sshStore = null + persistedStore = null + registeredConnectSshTarget = null + registeredGetSshState = null + currentGetMainWindow = () => null + currentRuntime = undefined +} + export function getSshConnectionStore(): SshConnectionStore | null { return sshStore } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 90fc715ffee..08166162e91 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -16,11 +16,13 @@ import type { CreateWorktreeArgs, CreateWorktreeResult, GitPushTarget, + LocalBaseRefRefreshResult, Repo, WorktreeMeta } from '../../shared/types' import { getPRForBranch } from '../github/client' -import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' +import { listWorktrees, addWorktree, addSparseWorktree, parseWorktreeList } from '../git/worktree' +import type { AddWorktreeResult } from '../git/worktree' import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' import { validateGitPushTarget } from '../git/push-target-validation' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' @@ -63,6 +65,22 @@ import { normalizeSparseDirectories } from './sparse-checkout-directories' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import type { IFilesystemProvider } from '../providers/types' +async function unsetRemoteWorktreeCreationBase( + provider: SshGitProvider, + worktreePath: string, + branchName: string +): Promise { + try { + await provider.exec( + ['config', '--local', '--unset-all', `branch.${branchName}.base`], + worktreePath + ) + } catch { + // Best-effort SSH sparse cleanup; keep the sparse setup error as the + // actionable failure and let removeWorktree handle the partial checkout. + } +} + async function findRemoteForUrl(repoPath: string, remoteUrl: string): Promise { const target = parseGitHubOwnerRepo(remoteUrl) try { @@ -619,6 +637,57 @@ async function resolveRemoteTrackingBaseSsh( } } +async function refreshLocalBaseRefForRemoteWorktreeCreate( + provider: SshGitProvider, + repoPath: string, + remoteTrackingBase: RemoteTrackingBase +): Promise { + const resultBase = { + baseRef: remoteTrackingBase.base, + localBranch: remoteTrackingBase.branch + } + const fullRef = `refs/heads/${remoteTrackingBase.branch}` + + try { + await provider.exec( + ['merge-base', '--is-ancestor', remoteTrackingBase.branch, remoteTrackingBase.ref], + repoPath + ) + } catch { + return { ...resultBase, status: 'skipped_not_fast_forward' } + } + + try { + const { stdout: worktreeListOutput } = await provider.exec( + ['worktree', 'list', '--porcelain'], + repoPath + ) + const worktrees = parseWorktreeList(worktreeListOutput) + const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef) + + if (ownerWorktree) { + const { stdout: status } = await provider.exec( + ['status', '--porcelain', '--untracked-files=no'], + ownerWorktree.path + ) + if (status.trim()) { + return { + ...resultBase, + status: 'skipped_dirty_worktree', + ownerWorktreePath: ownerWorktree.path + } + } + await provider.exec(['reset', '--hard', remoteTrackingBase.ref], ownerWorktree.path) + return { ...resultBase, status: 'updated', ownerWorktreePath: ownerWorktree.path } + } + + await provider.exec(['update-ref', fullRef, remoteTrackingBase.ref], repoPath) + return { ...resultBase, status: 'updated' } + } catch { + return { ...resultBase, status: 'skipped_error' } + } +} + export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('worktrees:changed', { repoId }) @@ -763,6 +832,10 @@ export async function createRemoteWorktree( /* best-effort */ } } + const localBaseRefRefresh = + settings.refreshLocalBaseRefOnWorktreeCreate && !checkoutExistingBranch && remoteTrackingBase + ? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase) + : undefined const fsProvider = getSshFilesystemProvider(repo.connectionId!) if (fsProvider) { @@ -846,6 +919,9 @@ export async function createRemoteWorktree( await provider.exec(['sparse-checkout', 'set', '--', ...sparseDirectories], remotePath) await provider.exec(['checkout', branchName], remotePath) } catch (err) { + if (!checkoutExistingBranch) { + await unsetRemoteWorktreeCreationBase(provider, remotePath, branchName) + } await provider.removeWorktree(remotePath, true).catch(() => undefined) throw err } @@ -956,7 +1032,8 @@ export async function createRemoteWorktree( notifyWorktreesChanged(mainWindow, repo.id) return { worktree, - ...(setup ? { setup } : {}) + ...(setup ? { setup } : {}), + ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}) } } @@ -1200,44 +1277,43 @@ export async function createLocalWorktree( } const existingBranchOption = { checkoutExistingBranch } - if (sparseDirectories.length > 0) { - await (checkoutExistingBranch - ? addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - existingBranchOption - ) - : addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - )) - } else { - await (checkoutExistingBranch - ? addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - false, - existingBranchOption - ) - : addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - )) - } + const addResult: AddWorktreeResult = + (await (sparseDirectories.length > 0 + ? checkoutExistingBranch + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + existingBranchOption + ) + : addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + : checkoutExistingBranch + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + existingBranchOption + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ))) ?? {} let configuredPushTarget: GitPushTarget | undefined if (preparedPushTarget) { @@ -1364,6 +1440,7 @@ export async function createLocalWorktree( notifyWorktreesChanged(mainWindow, repo.id) return { worktree, - ...(setup ? { setup } : {}) + ...(setup ? { setup } : {}), + ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } : {}) } } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index efc6b9ae5bc..473af7a9e58 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -1,10 +1,14 @@ /* eslint-disable max-lines */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' const { handleMock, removeHandlerMock, listWorktreesMock, + parseWorktreeListMock, assertWorktreeCleanForRemovalMock, addWorktreeMock, addSparseWorktreeMock, @@ -38,6 +42,18 @@ const { handleMock: vi.fn(), removeHandlerMock: vi.fn(), listWorktreesMock: vi.fn(), + parseWorktreeListMock: vi.fn((output: string) => + output + .trim() + .split(/\n\s*\n/) + .filter(Boolean) + .map((block, index) => { + const lines = block.split(/\r?\n/) + const path = lines.find((line) => line.startsWith('worktree '))?.slice(9) ?? '' + const branch = lines.find((line) => line.startsWith('branch '))?.slice(7) ?? '' + return { path, branch, head: String(index), isBare: false, isMainWorktree: index === 0 } + }) + ), assertWorktreeCleanForRemovalMock: vi.fn(), addWorktreeMock: vi.fn(), addSparseWorktreeMock: vi.fn(), @@ -78,6 +94,7 @@ vi.mock('electron', () => ({ vi.mock('../git/worktree', () => ({ listWorktrees: listWorktreesMock, + parseWorktreeList: parseWorktreeListMock, assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock, addWorktree: addWorktreeMock, addSparseWorktree: addSparseWorktreeMock, @@ -380,10 +397,13 @@ describe('registerWorktreeHandlers', () => { expect(handlers['worktrees:resolveMrBase']).toBeDefined() }) - function mockKnownFeatureWorktree(path = '/workspace/feature-wt'): void { + function mockKnownFeatureWorktree( + path = '/workspace/feature-wt', + repoPath = '/workspace/repo' + ): void { listWorktreesMock.mockResolvedValue([ { - path: '/workspace/repo', + path: repoPath, head: 'main', branch: 'main', isBare: false, @@ -415,6 +435,27 @@ describe('registerWorktreeHandlers', () => { } } + it('strips Orca provenance fields from renderer metadata updates', () => { + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const result = handlers['worktrees:updateMeta'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + updates: { + comment: 'keep me', + isPinned: true, + orcaCreatedAt: 123, + orcaCreationSource: 'desktop', + orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false } + } + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/feature-wt', { + comment: 'keep me', + isPinned: true + }) + expect(result).toEqual({ comment: 'keep me', isPinned: true }) + }) + it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => { // Why: new-workspace flow should silently try improve-dashboard-2, -3, ... // rather than failing and forcing the user back to the name picker. @@ -1110,6 +1151,90 @@ describe('registerWorktreeHandlers', () => { }) }) + it('returns SSH local base refresh skip status when the owning worktree is dirty', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { + stdout: 'worktree /remote/repo\nHEAD abc123\nbranch refs/heads/main\n', + stderr: '' + } + } + if (args[0] === 'status') { + return { stdout: ' M package.json\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: true, + workspaceDir: '/workspace' + }) + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + }) + + expect(provider.exec).toHaveBeenCalledWith( + ['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'], + '/remote/repo' + ) + expect(provider.exec).toHaveBeenCalledWith(['worktree', 'list', '--porcelain'], '/remote/repo') + expect(provider.exec).toHaveBeenCalledWith( + ['status', '--porcelain', '--untracked-files=no'], + '/remote/repo' + ) + expect(provider.exec).not.toHaveBeenCalledWith( + ['reset', '--hard', 'refs/remotes/origin/main'], + expect.any(String) + ) + expect(result).toEqual( + expect.objectContaining({ + localBaseRefRefresh: { + status: 'skipped_dirty_worktree', + baseRef: 'origin/main', + localBranch: 'main', + ownerWorktreePath: '/remote/repo' + } + }) + ) + }) + it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => { const repo = { id: 'repo-ssh', @@ -1295,6 +1420,59 @@ describe('registerWorktreeHandlers', () => { }) }) + it('unsets SSH branch base config before removing a sparse worktree after setup failure', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const setupError = new Error('sparse init failed') + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'sparse-checkout' && args[1] === 'init') { + throw setupError + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn() + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getSparsePresets.mockReturnValue([]) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'sparse-dashboard', + sparseCheckout: { + directories: ['apps/mobile'] + } + }) + ).rejects.toThrow('sparse init failed') + + expect(provider.exec).toHaveBeenCalledWith( + ['config', '--local', '--unset-all', 'branch.sparse-dashboard.base'], + '/remote/repo/../sparse-dashboard' + ) + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/repo/../sparse-dashboard', true) + }) + it('does not create an SSH worktree when remote-tracking base refresh fails', async () => { const repo = { id: 'repo-ssh', @@ -2601,6 +2779,10 @@ describe('registerWorktreeHandlers', () => { removeWorktree: vi.fn().mockImplementation(async () => { callOrder.push('remove') }), + worktreeIsClean: vi.fn().mockImplementation(async () => { + callOrder.push('preflight') + return { clean: true } + }), execNonInteractive: vi.fn().mockImplementation(async () => { callOrder.push('archive') return { stdout: '', stderr: '', exitCode: 0, timedOut: false } @@ -2635,10 +2817,130 @@ describe('registerWorktreeHandlers', () => { }) ) expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - expect(callOrder).toEqual(['archive', 'remove']) + expect(callOrder).toEqual(['archive', 'preflight', 'remove']) expect(runHookMock).not.toHaveBeenCalled() }) + it('runs SSH archive hooks before failing dirty non-force removal', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const callOrder: string[] = [] + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockImplementation(async () => { + callOrder.push('remove') + }), + worktreeIsClean: vi.fn().mockImplementation(async () => { + callOrder.push('preflight') + return { clean: false, stdout: ' M src/file.ts\n?? scratch.txt\n' } + }), + execNonInteractive: vi.fn().mockImplementation(async () => { + callOrder.push('archive') + return { stdout: '', stderr: '', exitCode: 0, timedOut: false } + }) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n archive: echo archived\n', + isBinary: false + }) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } }) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-ssh::/remote/feature-wt' + }) + ).rejects.toThrow('Worktree has uncommitted or untracked changes.') + + expect(callOrder).toEqual(['archive', 'preflight']) + expect(provider.removeWorktree).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('skips SSH dirty preflight for force removal', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn(), + execNonInteractive: vi.fn().mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false + }) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n archive: echo archived\n', + isBinary: false + }) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-ssh::/remote/feature-wt', + force: true + }) + + expect(provider.worktreeIsClean).not.toHaveBeenCalled() + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', true) + }) + it('continues SSH worktree removal when the archive hook fails', async () => { const repo = { id: 'repo-ssh', @@ -2668,6 +2970,7 @@ describe('registerWorktreeHandlers', () => { } ]), removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), execNonInteractive: vi.fn().mockResolvedValue({ stdout: '', stderr: 'cleanup failed', @@ -2730,6 +3033,7 @@ describe('registerWorktreeHandlers', () => { } ]), removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), execNonInteractive: vi.fn().mockRejectedValue(new Error('relay disconnected')) } const fsProvider = { @@ -2786,6 +3090,7 @@ describe('registerWorktreeHandlers', () => { } ]), removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), execNonInteractive: vi.fn().mockResolvedValue({ stdout: '', stderr: '', @@ -2851,6 +3156,7 @@ describe('registerWorktreeHandlers', () => { } ]), removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), execNonInteractive: vi.fn() } const fsProvider = { @@ -3034,6 +3340,177 @@ describe('registerWorktreeHandlers', () => { }) }) + it('force-removes a legacy Orca-created orphaned worktree directory after Git tracking is gone', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-orphan-')) + const repoPath = join(parentDir, 'repo') + const orphanPath = join(parentDir, 'orphan') + const adminWorktreePath = join(repoPath, '.git', 'worktrees', 'orphan') + const worktreeId = `repo-1::${orphanPath}` + await mkdir(orphanPath, { recursive: true }) + await mkdir(adminWorktreePath, { recursive: true }) + await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`) + await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`) + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + }) + mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ createdAt: Date.now() })) + + try { + await handlers['worktrees:remove'](null, { + worktreeId, + force: true + }) + + await expect(lstat(orphanPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(runHookMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + } finally { + await rm(parentDir, { recursive: true, force: true }) + } + }) + + it('prompts for force before removing an Orca-created orphaned worktree directory', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-orphan-')) + const repoPath = join(parentDir, 'repo') + const orphanPath = join(parentDir, 'orphan') + const adminWorktreePath = join(repoPath, '.git', 'worktrees', 'orphan') + await mkdir(orphanPath, { recursive: true }) + await mkdir(adminWorktreePath, { recursive: true }) + await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`) + await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`) + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + }) + mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath) + store.getWorktreeMeta.mockReturnValue( + makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' }) + ) + + try { + await expect( + handlers['worktrees:remove'](null, { + worktreeId: `repo-1::${orphanPath}` + }) + ).rejects.toThrow('Worktree is no longer registered with Git but its directory remains.') + + await expect(lstat(orphanPath)).resolves.toBeTruthy() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + } finally { + await rm(parentDir, { recursive: true, force: true }) + } + }) + + it('does not inspect or delete a local path when SSH orphan cleanup has no filesystem provider', async () => { + const localPath = await mkdtemp(join(tmpdir(), 'orca-ipc-ssh-missing-fs-')) + const repo = { + id: 'repo-ssh-missing-fs', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-missing-fs' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(repo) + store.getWorktreeMeta.mockReturnValue( + makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'ssh' }) + ) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(undefined) + + try { + await expect( + handlers['worktrees:remove'](null, { + worktreeId: `${repo.id}::${localPath}`, + force: true + }) + ).rejects.toThrow('SSH filesystem provider unavailable') + + await expect(lstat(localPath)).resolves.toBeTruthy() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + } finally { + await rm(localPath, { recursive: true, force: true }) + } + }) + + it('refuses SSH orphan cleanup when remote .git is a symlink', async () => { + const repo = { + id: 'repo-ssh-symlink-git', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-symlink-git' + } + const worktreePath = '/remote/orphan' + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + const fsProvider = { + lstat: vi.fn().mockResolvedValue({ type: 'symlink' }), + stat: vi.fn().mockResolvedValue({ type: 'directory' }), + readFile: vi.fn(), + deletePath: vi.fn() + } + store.getRepo.mockReturnValue(repo) + store.getWorktreeMeta.mockReturnValue( + makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'ssh' }) + ) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: `${repo.id}::${worktreePath}`, + force: true + }) + ).rejects.toThrow(`Refusing to delete unregistered worktree path: ${worktreePath}`) + + expect(fsProvider.lstat).toHaveBeenCalledWith(`${worktreePath}/.git`) + expect(fsProvider.readFile).not.toHaveBeenCalled() + expect(fsProvider.deletePath).not.toHaveBeenCalled() + }) + it('coalesces concurrent deletes for the same worktree id', async () => { mockKnownFeatureWorktree() deleteWorktreeHistoryDirMock.mockClear() diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 9293c346bf9..f8f9abdcc36 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -79,9 +79,13 @@ import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' import { + assertWorktreeDoesNotContainRegisteredWorktree, + canCleanupUnregisteredOrcaWorktreeDirectory, canSafelyRemoveOrphanedWorktreeDirectory, findRegisteredDeletableWorktree, - isWorktreePathMissing + isWorktreePathMissing, + ORPHANED_WORKTREE_DIRECTORY_MESSAGE, + stripOrcaProvenanceMetaUpdates } from '../worktree-removal-safety' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' @@ -947,6 +951,69 @@ export function registerWorktreeHandlers( registeredWorktrees ) if (!registeredWorktree) { + const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null + let canCleanOrphanedDirectory = false + const knownOrcaLayouts = repo.connectionId + ? [] + : buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) + if ( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: removedMeta, + worktreePath, + repo, + knownOrcaLayouts + }) + ) { + if (repo.connectionId) { + if (!fsProvider) { + throw new Error('SSH filesystem provider unavailable') + } + if (!fsProvider.lstat) { + throw new Error('SSH filesystem provider lstat unavailable') + } + canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( + worktreePath, + repo.path, + (path) => fsProvider.lstat!(path), + (path) => fsProvider.readFile(path) + ) + } else { + canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( + worktreePath, + repo.path + ) + } + } + if (canCleanOrphanedDirectory) { + assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) + if (!args.force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + if (repo.connectionId) { + await fsProvider!.deletePath(worktreePath, true) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + } else { + await rm(worktreePath, { recursive: true, force: true }) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + invalidateAuthorizedRootsCache() + } + runtime.clearOptimisticReconcileToken(args.worktreeId) + store.removeWorktreeMeta(args.worktreeId) + deleteWorktreeHistoryDir(args.worktreeId) + notifyWorktreesChanged(mainWindow, repoId) + return + } if (args.force && (await isAlreadyRemovedWorktreePath(repo, worktreePath))) { // Why: Force-delete can be retried from stale UI after a prior delete // already removed the directory and Git registration. Treat that as @@ -979,6 +1046,8 @@ export function registerWorktreeHandlers( const canonicalWorktreePath = registeredWorktree.path const deleteBranch = removedMeta?.preserveBranchOnDelete !== true + let shouldTearDownPtys = true + // Run archive hook before removal so teardown scripts still see the worktree directory. const hooks = await getArchiveHooksForRemoval(repo) if (hooks?.scripts.archive && !args.skipArchive) { @@ -994,6 +1063,17 @@ export function registerWorktreeHandlers( } if (repo.connectionId) { + // Why: SSH deletion mirrors the local flow: hooks run while the + // directory is intact, then the clean check guards destructive removal. + if (!args.force) { + const { clean, stdout } = await provider!.worktreeIsClean(canonicalWorktreePath) + if (!clean) { + const error = new Error('Worktree has uncommitted or untracked changes.') + ;(error as Error & { stdout?: string }).stdout = stdout + throw error + } + } + await (deleteBranch ? provider!.removeWorktree(canonicalWorktreePath, args.force) : provider!.removeWorktree(canonicalWorktreePath, args.force, { deleteBranch })) @@ -1020,7 +1100,6 @@ export function registerWorktreeHandlers( await removeWorktreeSymlinks(canonicalWorktreePath, repo.symlinkPaths) } - let shouldTearDownPtys = true try { await assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false) } catch (error) { @@ -1122,7 +1201,10 @@ export function registerWorktreeHandlers( ipcMain.handle( 'worktrees:updateMeta', (_event, args: { worktreeId: string; updates: Partial }) => { - const meta = store.setWorktreeMeta(args.worktreeId, args.updates) + const meta = store.setWorktreeMeta( + args.worktreeId, + stripOrcaProvenanceMetaUpdates(args.updates) + ) // Do NOT call notifyWorktreesChanged here. The renderer applies meta // updates optimistically before calling this IPC, so a notification // would trigger a redundant fetchWorktrees round-trip that bumps diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index 1c10d51fafd..1392b90b997 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -366,7 +366,14 @@ export async function createIssue( title: string, description?: string, workspaceId?: string | null, - options?: { parentId?: string; projectId?: string | null } + options?: { + parentId?: string + projectId?: string | null + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] + } ): Promise< | { ok: true; id: string; identifier: string; title: string; url: string } | { ok: false; error: string } @@ -383,7 +390,11 @@ export async function createIssue( title, ...(description ? { description } : {}), ...(options?.parentId ? { parentId: options.parentId } : {}), - ...(options?.projectId ? { projectId: options.projectId } : {}) + ...(options?.projectId ? { projectId: options.projectId } : {}), + ...(options?.stateId ? { stateId: options.stateId } : {}), + ...(options?.priority !== undefined ? { priority: options.priority } : {}), + ...(options?.assigneeId ? { assigneeId: options.assigneeId } : {}), + ...(options?.labelIds ? { labelIds: options.labelIds } : {}) }) if (!result.success) { return { ok: false, error: 'Linear create failed' } @@ -436,6 +447,9 @@ export async function updateIssue( if (updates.title !== undefined) { payload.title = updates.title } + if (updates.description !== undefined) { + payload.description = updates.description + } if (updates.assigneeId !== undefined) { payload.assigneeId = updates.assigneeId } diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index 142895e84ec..f5b9f7d80a5 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -17,9 +17,6 @@ export async function listProjects( workspaceId?: LinearWorkspaceSelection | null ): Promise { const trimmed = query?.trim() - if (!trimmed) { - return [] - } const entries = getClients(workspaceId) if (entries.length === 0) { @@ -30,6 +27,10 @@ export async function listProjects( entries.map(async (entry) => { await acquire() try { + if (!trimmed) { + const connection = await entry.client.projects({ first: limit }) + return connection.nodes.map(mapLinearProject) + } const connection = await entry.client.searchProjects(trimmed, { first: limit }) return connection.nodes.map(mapLinearProject) } catch (error) { diff --git a/src/main/linear/teams.ts b/src/main/linear/teams.ts index 4e0c7b012fd..73913dec734 100644 --- a/src/main/linear/teams.ts +++ b/src/main/linear/teams.ts @@ -5,6 +5,7 @@ import type { LinearMember, LinearWorkspaceSelection } from '../../shared/types' +import { buildLinearTeamUrl } from '../../shared/linear-links' import { acquire, release, getClients, isAuthError, clearToken } from './client' export async function listTeams( @@ -25,7 +26,12 @@ export async function listTeams( workspaceId: entry.workspace.id, workspaceName: entry.workspace.organizationName, name: t.name, - key: t.key + key: t.key, + url: + buildLinearTeamUrl({ + organizationUrlKey: entry.workspace.organizationUrlKey, + teamKey: t.key + }) ?? undefined })) } catch (error) { if (isAuthError(error)) { diff --git a/src/main/network/macos-system-resolver-health.test.ts b/src/main/network/macos-system-resolver-health.test.ts new file mode 100644 index 00000000000..508f28c4c2e --- /dev/null +++ b/src/main/network/macos-system-resolver-health.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { classifyMacSystemResolverHealth } from './macos-system-resolver-health' + +describe('classifyMacSystemResolverHealth', () => { + it('treats the macOS no-resolver output as unhealthy', () => { + expect(classifyMacSystemResolverHealth('No DNS configuration available\n')).toBe('unhealthy') + }) + + it('treats scutil DNS output with nameservers as healthy', () => { + expect( + classifyMacSystemResolverHealth(` +DNS configuration + +resolver #1 + nameserver[0] : 1.1.1.1 + flags : Request A records +`) + ).toBe('healthy') + }) + + it('fails open when the resolver output is inconclusive', () => { + expect(classifyMacSystemResolverHealth('')).toBe('unknown') + }) +}) diff --git a/src/main/network/macos-system-resolver-health.ts b/src/main/network/macos-system-resolver-health.ts new file mode 100644 index 00000000000..846eb1f3e0c --- /dev/null +++ b/src/main/network/macos-system-resolver-health.ts @@ -0,0 +1,33 @@ +import { spawnSync } from 'child_process' +import type { SystemResolverHealth } from '../daemon/types' + +const MAC_RESOLVER_CHECK_TIMEOUT_MS = 1_500 +const MAC_NO_DNS_CONFIGURATION_RE = /\bNo DNS configuration available\b/i +const MAC_DNS_CONFIGURATION_RE = /^DNS configuration\b/m +const MAC_NAMESERVER_RE = /nameserver\[\d+\]\s*:/m + +export function classifyMacSystemResolverHealth(scutilOutput: string): SystemResolverHealth { + if (MAC_NO_DNS_CONFIGURATION_RE.test(scutilOutput)) { + return 'unhealthy' + } + if (MAC_DNS_CONFIGURATION_RE.test(scutilOutput) && MAC_NAMESERVER_RE.test(scutilOutput)) { + return 'healthy' + } + return 'unknown' +} + +export function readCurrentProcessMacSystemResolverHealth(): SystemResolverHealth { + if (process.platform !== 'darwin') { + return 'unknown' + } + + const result = spawnSync('/usr/sbin/scutil', ['--dns'], { + encoding: 'utf8', + timeout: MAC_RESOLVER_CHECK_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'pipe'] + }) + const output = `${typeof result.stdout === 'string' ? result.stdout : ''}\n${ + typeof result.stderr === 'string' ? result.stderr : '' + }` + return classifyMacSystemResolverHealth(output) +} diff --git a/src/main/network/macos-tailscale-dns-diagnostic.test.ts b/src/main/network/macos-tailscale-dns-diagnostic.test.ts new file mode 100644 index 00000000000..5ea93e259e1 --- /dev/null +++ b/src/main/network/macos-tailscale-dns-diagnostic.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { + parseMacTailscaleDnsDiagnostic, + withMacTailscaleDnsHintForDiagnostic +} from './macos-tailscale-dns-diagnostic' + +const MAGIC_DNS_ONLY_SCUTIL = ` +DNS configuration + +resolver #1 + nameserver[0] : 100.100.100.100 + flags : Request A records + reach : 0x00000002 (Reachable) + order : 5000 + +DNS configuration (for scoped queries) + +resolver #1 + nameserver[0] : 192.168.1.1 + if_index : 14 (en0) +` + +describe('parseMacTailscaleDnsDiagnostic', () => { + it('detects Tailscale MagicDNS as the only global resolver', () => { + const diagnostic = parseMacTailscaleDnsDiagnostic(MAGIC_DNS_ONLY_SCUTIL) + + expect(diagnostic).toEqual({ globalNameservers: ['100.100.100.100'] }) + }) + + it('ignores configurations with non-Tailscale global resolvers', () => { + const diagnostic = parseMacTailscaleDnsDiagnostic(` +DNS configuration + +resolver #1 + nameserver[0] : 100.100.100.100 + nameserver[1] : 1.1.1.1 + order : 5000 +`) + + expect(diagnostic).toBeNull() + }) + + it('does not treat scoped-only Tailscale resolvers as the global failure shape', () => { + const diagnostic = parseMacTailscaleDnsDiagnostic(` +DNS configuration + +resolver #1 + nameserver[0] : 192.168.1.1 + order : 5000 + +DNS configuration (for scoped queries) + +resolver #1 + nameserver[0] : 100.100.100.100 + if_index : 19 (utun7) +`) + + expect(diagnostic).toBeNull() + }) +}) + +describe('withMacTailscaleDnsHintForDiagnostic', () => { + it('adds a Tailscale DNS hint for the Codex lookup failure from the issue', () => { + const diagnostic = parseMacTailscaleDnsDiagnostic(MAGIC_DNS_ONLY_SCUTIL) + const result = withMacTailscaleDnsHintForDiagnostic( + 'Codex failed. Check the agent CLI configuration and try again.', + 'stream disconnected before completion: failed to lookup address information: nodename nor servname provided, or not known', + diagnostic + ) + + expect(result).toContain('Tailscale MagicDNS (100.100.100.100)') + expect(result).toContain('add an upstream DNS server') + }) + + it('leaves unrelated failures unchanged even under MagicDNS-only DNS', () => { + const diagnostic = parseMacTailscaleDnsDiagnostic(MAGIC_DNS_ONLY_SCUTIL) + const message = 'Codex failed. Check the agent CLI configuration and try again.' + + expect(withMacTailscaleDnsHintForDiagnostic(message, 'permission denied', diagnostic)).toBe( + message + ) + }) +}) diff --git a/src/main/network/macos-tailscale-dns-diagnostic.ts b/src/main/network/macos-tailscale-dns-diagnostic.ts new file mode 100644 index 00000000000..6ea30576756 --- /dev/null +++ b/src/main/network/macos-tailscale-dns-diagnostic.ts @@ -0,0 +1,93 @@ +import { execFileSync } from 'node:child_process' + +const TAILSCALE_MAGIC_DNS = '100.100.100.100' +const CACHE_TTL_MS = 5 * 60 * 1000 + +type DnsDiagnostic = { + globalNameservers: string[] +} + +type CacheEntry = { + expiresAt: number + diagnostic: DnsDiagnostic | null +} + +let cache: CacheEntry | null = null + +const NETWORK_LOOKUP_FAILURE_RE = + /\b(?:ENOTFOUND|EAI_AGAIN|ESERVFAIL|ERR_NAME_NOT_RESOLVED)\b|lookup address|nodename nor servname|name resolution|dns|websocket|connection refused/i + +function globalDnsSection(scutilOutput: string): string { + const scopedStart = scutilOutput.indexOf('\nDNS configuration (for scoped queries)') + return scopedStart >= 0 ? scutilOutput.slice(0, scopedStart) : scutilOutput +} + +export function parseMacTailscaleDnsDiagnostic(scutilOutput: string): DnsDiagnostic | null { + const globalSection = globalDnsSection(scutilOutput) + const nameservers = [ + ...new Set( + Array.from(globalSection.matchAll(/nameserver\[\d+\]\s*:\s*([^\s]+)/g), (match) => + match[1].trim() + ) + ) + ] + + if (nameservers.length === 0) { + return null + } + if (!nameservers.every((nameserver) => nameserver === TAILSCALE_MAGIC_DNS)) { + return null + } + + return { globalNameservers: nameservers } +} + +function readMacTailscaleDnsDiagnostic(now = Date.now()): DnsDiagnostic | null { + if (process.platform !== 'darwin') { + return null + } + if (cache && cache.expiresAt > now) { + return cache.diagnostic + } + + let diagnostic: DnsDiagnostic | null = null + try { + const output = execFileSync('scutil', ['--dns'], { + encoding: 'utf8', + timeout: 1500, + stdio: ['ignore', 'pipe', 'ignore'] + }) + diagnostic = parseMacTailscaleDnsDiagnostic(output) + } catch { + diagnostic = null + } + + cache = { diagnostic, expiresAt: now + CACHE_TTL_MS } + return diagnostic +} + +export function withMacTailscaleDnsHintForDiagnostic( + message: string, + detail: string | null | undefined, + diagnostic: DnsDiagnostic | null +): string { + const probeText = `${message}\n${detail ?? ''}` + if (!NETWORK_LOOKUP_FAILURE_RE.test(probeText)) { + return message + } + if (!diagnostic) { + return message + } + + // Why: Claude/Codex own the failing API transports, so Orca can only point + // users at the macOS resolver configuration that makes those transports fail. + return `${message} macOS is using Tailscale MagicDNS (100.100.100.100) as the only global DNS resolver; add an upstream DNS server to the active network service or configure Tailscale global nameservers, then retry.` +} + +export function withMacTailscaleDnsHint(message: string, detail?: string | null): string { + return withMacTailscaleDnsHintForDiagnostic(message, detail, readMacTailscaleDnsDiagnostic()) +} + +export function __resetMacTailscaleDnsDiagnosticCacheForTests(): void { + cache = null +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 97e3a10c337..d5bf1b61543 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -14,7 +14,13 @@ import { } from 'fs' import { join } from 'path' import { tmpdir } from 'os' -import type { Repo, TerminalTab, WorktreeLineage, WorkspaceSessionState } from '../shared/types' +import type { + PersistedState, + Repo, + TerminalTab, + WorktreeLineage, + WorkspaceSessionState +} from '../shared/types' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' @@ -247,6 +253,7 @@ describe('Store', () => { expect(settings.openInApplications).toEqual([]) expect(settings.experimentalActivity).toBe(false) expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true) + expect(settings.experimentalTerminalAttention).toBe(false) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) expect(settings.notifications.customSoundPath).toBeNull() @@ -257,6 +264,8 @@ describe('Store', () => { const store = await createStore() const ui = store.getUI() expect(ui.sidebarWidth).toBe(280) + expect(ui.rightSidebarOpen).toBe(true) + expect(ui.rightSidebarTab).toBe('explorer') expect(ui.groupBy).toBe('repo') expect(ui.lastActiveRepoId).toBeNull() expect(ui.dismissedUpdateVersion).toBeNull() @@ -613,6 +622,35 @@ describe('Store', () => { expect(second.title).toBe('Nightly run 2') }) + it('records feature interactions when automations are created or manually queued', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + + store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime(), 'scheduled') + store.createAutomationRun(automation, new Date('2026-05-14T09:00:00Z').getTime(), 'manual') + + expect(store.getUI().featureInteractions?.['automation-created']?.interactionCount).toBe(1) + expect(store.getUI().featureInteractions?.['automation-run']?.interactionCount).toBe(1) + const persisted = readDataFile() as PersistedState + expect(persisted.ui?.featureInteractions?.['automation-created']).toMatchObject({ + interactionCount: 1 + }) + expect(persisted.ui?.featureInteractions?.['automation-run']).toMatchObject({ + interactionCount: 1 + }) + }) + it('snapshots automation run workspace names for deleted-workspace history', async () => { const store = await createStore() store.addRepo(makeRepo()) @@ -732,6 +770,8 @@ describe('Store', () => { // ui should have defaults const ui = store.getUI() expect(ui.sidebarWidth).toBe(280) + expect(ui.rightSidebarOpen).toBe(true) + expect(ui.rightSidebarTab).toBe('explorer') // settings should preserve the overridden value expect(store.getSettings().theme).toBe('dark') // new fields get defaults when missing from persisted data @@ -746,11 +786,149 @@ describe('Store', () => { expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear']) expect(store.getSettings().experimentalActivity).toBe(false) expect(store.getSettings().experimentalActivityDefaultedOffForAllUsers).toBe(true) + expect(store.getSettings().experimentalTerminalAttention).toBe(false) expect(store.getSettings().notifications.customSoundPath).toBeNull() // repos should be loaded expect(store.getRepos()).toHaveLength(1) }) + it('migrates legacy commit-message AI settings to source-control AI on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + commitMessageAi: { + enabled: true, + agentId: 'cursor', + selectedModelByAgent: { cursor: 'gpt-5.2' }, + selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-model' } }, + discoveredModelsByAgent: { + cursor: [{ id: 'gpt-5.2', label: 'GPT 5.2' }] + }, + discoveredModelsByAgentByHost: { + 'ssh:conn-1': { + cursor: [{ id: 'remote-model', label: 'Remote Model' }] + } + }, + selectedThinkingByModel: { 'gpt-5.2': 'high' }, + customPrompt: 'Use Conventional Commits.', + customAgentCommand: 'cursor-agent' + } + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + const sourceControlAi = store.getSettings().sourceControlAi + + expect(sourceControlAi).toMatchObject({ + enabled: true, + agentId: 'cursor', + selectedModelByAgent: { cursor: 'gpt-5.2' }, + selectedThinkingByModel: { 'gpt-5.2': 'high' }, + customAgentCommand: 'cursor-agent', + instructionsByOperation: { + commitMessage: 'Use Conventional Commits.', + pullRequest: '', + branchName: 'Use Conventional Commits.' + } + }) + expect(sourceControlAi?.selectedModelByAgentByHost?.['ssh:conn-1']?.cursor).toBe('remote-model') + expect(sourceControlAi?.discoveredModelsByAgent?.cursor?.[0]?.id).toBe('gpt-5.2') + expect(sourceControlAi?.discoveredModelsByAgentByHost?.['ssh:conn-1']?.cursor?.[0]?.id).toBe( + 'remote-model' + ) + expect(store.getSettings().commitMessageAi?.customPrompt).toBe('Use Conventional Commits.') + }) + + it('merges rollback commit-message AI writes into existing source-control AI on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + sourceControlAi: { + enabled: true, + agentId: 'codex', + selectedModelByAgent: { codex: 'source-model' }, + selectedModelByAgentByHost: {}, + discoveredModelsByAgent: {}, + discoveredModelsByAgentByHost: {}, + selectedThinkingByModel: { 'source-model': 'medium' }, + customAgentCommand: 'codex', + instructionsByOperation: { + commitMessage: 'Source commit prompt', + pullRequest: 'Preserve PR prompt' + }, + modelOverridesByOperation: { + pullRequest: { + selectedModelByAgent: { claude: 'pr-model' }, + selectedThinkingByModel: { 'pr-model': 'high' } + } + }, + prCreationDefaults: { + draft: true, + openAfterCreate: true + } + }, + commitMessageAi: { + enabled: false, + agentId: 'claude', + selectedModelByAgent: { claude: 'legacy-model' }, + selectedModelByAgentByHost: { 'ssh:conn-1': { claude: 'remote-legacy-model' } }, + discoveredModelsByAgent: {}, + discoveredModelsByAgentByHost: {}, + selectedThinkingByModel: { 'legacy-model': 'high' }, + customPrompt: 'Rollback commit prompt', + customAgentCommand: 'claude' + } + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + const sourceControlAi = store.getSettings().sourceControlAi + + expect(sourceControlAi).toMatchObject({ + enabled: false, + agentId: 'claude', + selectedModelByAgent: { codex: 'source-model' }, + selectedThinkingByModel: { 'source-model': 'medium' }, + customAgentCommand: 'claude', + instructionsByOperation: { + commitMessage: 'Rollback commit prompt', + pullRequest: 'Preserve PR prompt', + branchName: 'Rollback commit prompt' + }, + prCreationDefaults: { + draft: true, + openAfterCreate: true + } + }) + expect(sourceControlAi?.selectedModelByAgentByHost?.['ssh:conn-1']).toBeUndefined() + expect(sourceControlAi?.modelOverridesByOperation?.commitMessage).toEqual({ + selectedModelByAgent: { claude: 'legacy-model' }, + selectedModelByAgentByHost: { 'ssh:conn-1': { claude: 'remote-legacy-model' } }, + selectedThinkingByModel: { 'legacy-model': 'high' } + }) + expect(sourceControlAi?.modelOverridesByOperation?.pullRequest).toEqual({ + selectedModelByAgent: { claude: 'pr-model' }, + selectedThinkingByModel: { 'pr-model': 'high' } + }) + expect(store.getSettings().commitMessageAi).toMatchObject({ + enabled: false, + agentId: 'claude', + selectedModelByAgent: { claude: 'legacy-model' }, + customPrompt: 'Rollback commit prompt', + customAgentCommand: 'claude' + }) + }) + it('normalizes malformed visible task providers on load', async () => { writeDataFile({ schemaVersion: 1, @@ -1263,7 +1441,7 @@ describe('Store', () => { expect(store.getSettings().editorAutoSave).toBe(true) }) - it('preserves rightSidebarOpenByDefault when set to true in persisted data', async () => { + it('keeps legacy rightSidebarOpenByDefault readable from persisted data', async () => { writeDataFile({ schemaVersion: 1, repos: [], @@ -1305,14 +1483,49 @@ describe('Store', () => { expect(fetched!.gitUsername).toBe('testuser') }) + it('deleteProjectGroup ungroups repos from the deleted group subtree', async () => { + const store = await createStore() + const root = store.createProjectGroup({ name: 'Platform', createdFrom: 'folder-scan' }) + const child = store.createProjectGroup({ + name: 'Services', + parentGroupId: root.id, + createdFrom: 'folder-scan' + }) + const sibling = store.createProjectGroup({ name: 'Tools', createdFrom: 'manual' }) + store.addRepo(makeRepo({ id: 'direct', path: '/direct', projectGroupId: root.id })) + store.addRepo(makeRepo({ id: 'nested', path: '/nested', projectGroupId: child.id })) + store.addRepo(makeRepo({ id: 'sibling', path: '/sibling', projectGroupId: sibling.id })) + + expect(store.deleteProjectGroup(root.id)).toBe(true) + + expect(store.getProjectGroups().map((group) => group.id)).toEqual([sibling.id]) + expect(store.getRepo('direct')?.projectGroupId).toBeNull() + expect(store.getRepo('nested')?.projectGroupId).toBeNull() + expect(store.getRepo('sibling')?.projectGroupId).toBe(sibling.id) + }) + + it('sanitizes invalid project group updates before persisting a repo', async () => { + const store = await createStore() + const group = store.createProjectGroup({ name: 'Platform', createdFrom: 'manual' }) + store.addRepo(makeRepo({ id: 'r1', projectGroupId: group.id, projectGroupOrder: 1 })) + + const updated = store.updateRepo('r1', { + projectGroupId: '', + projectGroupOrder: Number.POSITIVE_INFINITY + } as never) + + expect(updated?.projectGroupId).toBeNull() + expect(updated?.projectGroupOrder).toBe(1) + }) + it('getRepo returns undefined for nonexistent id', async () => { const store = await createStore() expect(store.getRepo('nonexistent')).toBeUndefined() }) - // ── 6. removeRepo cleans up worktree meta ────────────────────────── + // ── 6. removeProject cleans up worktree meta ────────────────────────── - it('removeRepo deletes the repo and its worktree meta', async () => { + it('removeProject deletes the repo and its worktree meta', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) @@ -1321,7 +1534,7 @@ describe('Store', () => { store.setWorktreeMeta('r1::/path/wt2', { displayName: 'wt2' }) store.setWorktreeMeta('r2::/other', { displayName: 'other' }) - store.removeRepo('r1') + store.removeProject('r1') expect(store.getRepo('r1')).toBeUndefined() expect(store.getWorktreeMeta('r1::/path/wt1')).toBeUndefined() @@ -1330,7 +1543,7 @@ describe('Store', () => { expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other') }) - it('removeRepo deletes child and parent lineage for the repo', async () => { + it('removeProject deletes child and parent lineage for the repo', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) @@ -1357,7 +1570,7 @@ describe('Store', () => { }) ) - store.removeRepo('r1') + store.removeProject('r1') expect(store.getWorktreeLineage('r1::/path/child')).toBeUndefined() expect(store.getWorktreeLineage('r2::/other-child')).toBeUndefined() @@ -1458,6 +1671,94 @@ describe('Store', () => { expect(updated!.externalWorktreeVisibilityLegacy).toBe(true) }) + it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => { + const store = await createStore() + store.addRepo( + makeRepo({ + issueSourcePreference: 'origin', + sourceControlAi: { + instructionsByOperation: { commitMessage: 'Repo style' }, + prCreationDefaults: { draft: true } + } + }) + ) + + store.updateRepo('r1', { + issueSourcePreference: undefined, + sourceControlAi: undefined + }) + + expect(store.getRepo('r1')!.issueSourcePreference).toBeUndefined() + expect(store.getRepo('r1')!.sourceControlAi).toBeUndefined() + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')!.issueSourcePreference).toBeUndefined() + expect(reloaded.getRepo('r1')!.sourceControlAi).toBeUndefined() + }) + + it('updateRepo normalizes source-control AI overrides before storing', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const updated = store.updateRepo('r1', { + sourceControlAi: { + instructionsByOperation: { + commitMessage: 'Repo style', + pullRequest: 42, + unknown: 'ignored' + }, + prCreationDefaults: { + draft: true, + useTemplate: null, + openAfterCreate: 'yes' + }, + modelOverridesByOperation: { + commitMessage: { + selectedModelByAgent: { codex: 'gpt-5.4', claude: false }, + selectedThinkingByModel: { 'gpt-5.4': 'high', bad: true } + }, + unknown: { + selectedModelByAgent: { codex: 'ignored' } + } + } + } as never + }) + + expect(updated!.sourceControlAi).toEqual({ + instructionsByOperation: { + commitMessage: 'Repo style' + }, + prCreationDefaults: { + draft: true, + useTemplate: null + }, + modelOverridesByOperation: { + commitMessage: { + selectedModelByAgent: { codex: 'gpt-5.4' }, + selectedThinkingByModel: { 'gpt-5.4': 'high' } + } + } + }) + }) + + it('updateRepo ignores malformed source-control AI overrides without clearing existing overrides', async () => { + const store = await createStore() + store.addRepo( + makeRepo({ + sourceControlAi: { + instructionsByOperation: { commitMessage: 'Keep me' } + } + }) + ) + + const updated = store.updateRepo('r1', { sourceControlAi: 'bad' as never }) + + expect(updated!.sourceControlAi).toEqual({ + instructionsByOperation: { commitMessage: 'Keep me' } + }) + }) + // ── 8. setWorktreeMeta and getWorktreeMeta ───────────────────────── it('setWorktreeMeta creates meta with defaults for missing fields', async () => { @@ -1505,6 +1806,68 @@ describe('Store', () => { expect(updated.branchPrefix).toBe('git-username') }) + it('updateSettings keeps the legacy commit-message AI projection in sync', async () => { + const store = await createStore() + const current = store.getSettings().sourceControlAi! + + const updated = store.updateSettings({ + sourceControlAi: { + ...current, + enabled: true, + agentId: 'codex', + selectedModelByAgent: { codex: 'gpt-5.4' }, + selectedThinkingByModel: { 'gpt-5.4': 'high' }, + instructionsByOperation: { + commitMessage: 'Write concise commit messages.', + pullRequest: 'Write release-note-ready PR details.' + }, + customAgentCommand: '' + } + }) + + expect(updated.commitMessageAi).toMatchObject({ + enabled: true, + agentId: 'codex', + selectedModelByAgent: { codex: 'gpt-5.4' }, + selectedThinkingByModel: { 'gpt-5.4': 'high' }, + customPrompt: 'Write concise commit messages.', + customAgentCommand: '' + }) + }) + + it('updateSettings keeps source-control AI in sync for legacy commit-message updates', async () => { + const store = await createStore() + const current = store.getSettings().commitMessageAi! + + const updated = store.updateSettings({ + commitMessageAi: { + ...current, + enabled: false, + agentId: 'claude', + selectedModelByAgent: { claude: 'sonnet' }, + selectedThinkingByModel: { sonnet: 'medium' }, + customPrompt: 'Legacy settings update', + customAgentCommand: 'claude' + } + }) + + expect(updated.sourceControlAi).toMatchObject({ + enabled: false, + agentId: 'claude', + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customAgentCommand: 'claude', + instructionsByOperation: { + commitMessage: 'Legacy settings update', + branchName: 'Legacy settings update' + } + }) + expect(updated.sourceControlAi?.modelOverridesByOperation?.commitMessage).toEqual({ + selectedModelByAgent: { claude: 'sonnet' }, + selectedThinkingByModel: { sonnet: 'medium' } + }) + }) + it('updateSettings normalizes open-in applications', async () => { const store = await createStore() const updated = store.updateSettings({ @@ -1544,7 +1907,7 @@ describe('Store', () => { expect(store.getSettings().editorAutoSave).toBe(false) }) - it('updateSettings toggles rightSidebarOpenByDefault', async () => { + it('keeps legacy rightSidebarOpenByDefault writable for backward compatibility', async () => { const store = await createStore() expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) @@ -1703,6 +2066,81 @@ describe('Store', () => { expect(ui.dismissedUpdateVersion).toBeNull() }) + it('migrates missing rightSidebarOpen from the legacy default setting', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { rightSidebarOpenByDefault: false }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().rightSidebarOpen).toBe(false) + }) + + it('migrates missing rightSidebarOpen to open when the legacy default was open', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { rightSidebarOpenByDefault: true }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().rightSidebarOpen).toBe(true) + }) + + it('keeps explicit rightSidebarOpen authoritative over the legacy default setting', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { rightSidebarOpenByDefault: true }, + ui: { rightSidebarOpen: false }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().rightSidebarOpen).toBe(false) + }) + + it('preserves explicit rightSidebarTab in persisted UI', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { rightSidebarTab: 'checks' }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().rightSidebarTab).toBe('checks') + }) + + it('normalizes invalid rightSidebarTab in persisted UI', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { rightSidebarTab: 'bogus' }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().rightSidebarTab).toBe('explorer') + }) + it('updateUI merges feature interactions instead of replacing stale snapshots', async () => { const store = await createStore() @@ -1723,6 +2161,44 @@ describe('Store', () => { }) }) + it('normalizes malformed persisted feature discovery state on read', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { + featureTipsSeenIds: ['voice-dictation', 'unknown-tip', 'voice-dictation'], + featureInteractions: { + tasks: { firstInteractedAt: 100 }, + automations: { firstInteractedAt: 150, interactionCount: 4 }, + browser: { firstInteractedAt: Number.NaN }, + unknown: { firstInteractedAt: 200 } + } + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + + expect(store.getUI().featureTipsSeenIds).toEqual(['voice-dictation']) + expect(store.getUI().featureInteractions).toEqual({ + tasks: { firstInteractedAt: 100, interactionCount: 1 }, + automations: { firstInteractedAt: 150, interactionCount: 4 } + }) + }) + + it('normalizes feature tip ids from direct UI writes', async () => { + const store = await createStore() + + store.updateUI({ + featureTipsSeenIds: ['voice-dictation', 'unknown-tip', 'voice-dictation'] as never + }) + + expect(store.getUI().featureTipsSeenIds).toEqual(['voice-dictation']) + }) + it('recordFeatureInteraction increments from the current persisted UI state', async () => { const store = await createStore() @@ -4519,7 +4995,6 @@ describe('Store', () => { const first = await createStore() first.addRepo(makeRepo({ id: 'r1' })) first.flush() - expect((readDataFile() as { repos: Repo[] }).repos[0].id).toBe('r1') expect(readBackup(0).repos.map((r) => r.id)).toEqual(['r1']) vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000)) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 4d0b97a8e09..947cbeab4b7 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -33,6 +33,7 @@ import { import type { PersistedState, Repo, + ProjectGroup, SparsePreset, WorktreeMeta, WorktreeLineage, @@ -88,6 +89,7 @@ import { normalizeFeatureInteractions, type FeatureInteractionId } from '../shared/feature-interactions' +import { normalizeFeatureTipIds } from '../shared/feature-tips' import { DEFAULT_WORKSPACE_STATUS_ID, clampWorkspaceBoardColumnWidth, @@ -98,6 +100,21 @@ import { } from '../shared/workspace-statuses' import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership' import { sanitizeRepoIcon } from '../shared/repo-icon' +import { + clearMissingProjectGroupMemberships, + createProjectGroup, + getNextProjectGroupOrder, + getProjectGroupSubtreeIds, + normalizeProjectGroupName, + normalizeProjectGroups +} from '../shared/project-groups' +import { + mergeLegacyCommitMessageAiIntoSourceControlAi, + normalizeRepoSourceControlAiOverrides, + normalizeSourceControlAiSettings, + projectSourceControlAiToLegacyCommitMessageAi, + sourceControlAiSettingsFromLegacy +} from '../shared/source-control-ai' function encrypt(plaintext: string): string { if (!plaintext || !safeStorage.isEncryptionAvailable()) { @@ -260,6 +277,19 @@ function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] { return getDefaultUIState().sortBy } +function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { + if ( + tab === 'explorer' || + tab === 'search' || + tab === 'source-control' || + tab === 'checks' || + tab === 'ports' + ) { + return tab + } + return getDefaultUIState().rightSidebarTab +} + function normalizeNotificationSettings(value: unknown): NotificationSettings { const defaults = getDefaultNotificationSettings() const candidate = @@ -1417,6 +1447,19 @@ export class Store { // Merge with defaults in case new fields were added const homeDir = homedir() const defaults = getDefaultPersistedState(homeDir) + const rawSourceControlAiMissing = parsed.settings?.sourceControlAi === undefined + if (rawSourceControlAiMissing) { + this.loadNeedsSave = true + } + const legacyCommitMessageAi = parsed.settings?.commitMessageAi + const migratedSourceControlAi = rawSourceControlAiMissing + ? sourceControlAiSettingsFromLegacy( + legacyCommitMessageAi ?? defaults.settings.commitMessageAi + ) + : mergeLegacyCommitMessageAiIntoSourceControlAi( + parsed.settings?.sourceControlAi, + legacyCommitMessageAi + ) // Why: before the layout-aware 'auto' mode shipped (issue #903), // terminalMacOptionAsAlt defaulted to 'true' globally. That silently // broke Option-layer characters (@ on Turkish via Option+Q, @ on @@ -1521,6 +1564,7 @@ export class Store { result = { ...defaults, ...parsed, + projectGroups: normalizeProjectGroups(parsed.projectGroups), worktreeLineageById: parsed.worktreeLineageById ?? {}, settings: { ...defaults.settings, @@ -1560,6 +1604,14 @@ export class Store { ), openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications), notifications: normalizeNotificationSettings(parsed.settings?.notifications), + sourceControlAi: migratedSourceControlAi, + // Why: new builds read sourceControlAi, but rollback builds still + // write commitMessageAi; after merging those writes, refresh the + // legacy projection for continued rollback compatibility. + commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( + migratedSourceControlAi, + parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi + ), voice: { ...getDefaultVoiceSettings(), ...parsed.settings?.voice @@ -1575,6 +1627,15 @@ export class Store { const rawSort = parsed.ui?.sortBy const sort = normalizeSortBy(rawSort) const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' + const rightSidebarOpen = + typeof parsed.ui?.rightSidebarOpen === 'boolean' + ? parsed.ui.rightSidebarOpen + : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' + ? parsed.settings.rightSidebarOpenByDefault + : defaults.ui.rightSidebarOpen + if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { + this.loadNeedsSave = true + } const workspaceStatusesDefaultOrderMigrated = parsed.ui?._workspaceStatusesDefaultOrderMigrated === true // Why: the default workflow changed to Done -> Review -> Progress -> Todo. @@ -1681,6 +1742,10 @@ export class Store { return { ...defaults.ui, ...parsed.ui, + // Why: migrate once from the retired Appearance setting only + // when no explicit persisted chrome preference exists yet. + rightSidebarOpen, + rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), sortBy: migrate ? ('smart' as const) : sort, workspaceStatuses, _workspaceStatusesDefaultOrderMigrated: true, @@ -1794,6 +1859,7 @@ export class Store { result = { ...result, + repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []), workspaceSession: pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) ) @@ -2024,6 +2090,95 @@ export class Store { return repo ? this.hydrateRepo(repo) : undefined } + getProjectGroups(): ProjectGroup[] { + return [...(this.state.projectGroups ?? [])].sort( + (left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name) + ) + } + + createProjectGroup(input: { + name: string + parentPath?: string | null + parentGroupId?: string | null + createdFrom: ProjectGroup['createdFrom'] + }): ProjectGroup { + const maxOrder = Math.max( + -1, + ...(this.state.projectGroups ?? []).map((group) => group.tabOrder) + ) + const group = createProjectGroup({ + ...input, + tabOrder: maxOrder + 1 + }) + this.state.projectGroups = [...(this.state.projectGroups ?? []), group] + this.scheduleSave() + return group + } + + updateProjectGroup( + groupId: string, + updates: Partial> + ): ProjectGroup | null { + const group = (this.state.projectGroups ?? []).find((entry) => entry.id === groupId) + if (!group) { + return null + } + if (updates.name !== undefined) { + group.name = normalizeProjectGroupName(updates.name, group.name) + } + if (updates.isCollapsed !== undefined) { + group.isCollapsed = updates.isCollapsed + } + if (updates.tabOrder !== undefined && Number.isFinite(updates.tabOrder)) { + group.tabOrder = updates.tabOrder + } + if (updates.color !== undefined) { + group.color = typeof updates.color === 'string' ? updates.color : null + } + group.updatedAt = Date.now() + this.scheduleSave() + return group + } + + deleteProjectGroup(groupId: string): boolean { + const before = this.state.projectGroups?.length ?? 0 + const deletedGroupIds = getProjectGroupSubtreeIds(this.state.projectGroups ?? [], groupId) + this.state.projectGroups = (this.state.projectGroups ?? []).filter( + (group) => !deletedGroupIds.has(group.id) + ) + if ((this.state.projectGroups?.length ?? 0) === before) { + return false + } + // Why: groups are sidebar organization only. Deleting one must not delete + // repos or worktrees, so contained repos from the full subtree are ungrouped. + this.state.repos = this.state.repos.map((repo) => + repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId) + ? { ...repo, projectGroupId: null } + : repo + ) + this.scheduleSave() + return true + } + + moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { + const repo = this.state.repos.find((entry) => entry.id === repoId) + if (!repo) { + return null + } + const normalizedGroupId = + groupId && (this.state.projectGroups ?? []).some((group) => group.id === groupId) + ? groupId + : null + const siblingRepos = this.state.repos.filter((entry) => entry.id !== repoId) + repo.projectGroupId = normalizedGroupId + repo.projectGroupOrder = + typeof order === 'number' && Number.isFinite(order) + ? order + : getNextProjectGroupOrder(siblingRepos, normalizedGroupId) + this.scheduleSave() + return this.hydrateRepo(repo) + } + addRepo(repo: Repo): void { this.state.repos.push(repo) this.scheduleSave() @@ -2061,7 +2216,7 @@ export class Store { return true } - removeRepo(id: string): void { + removeProject(id: string): void { this.state.repos = this.state.repos.filter((r) => r.id !== id) // Why: presets are repo-scoped, so removing the repo means the presets // can never be referenced again — drop them with the parent. @@ -2092,9 +2247,13 @@ export class Store { | 'hookSettings' | 'worktreeBaseRef' | 'kind' + | 'symlinkPaths' | 'issueSourcePreference' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'projectGroupId' + | 'projectGroupOrder' + | 'sourceControlAi' > > ): Repo | null { @@ -2103,25 +2262,36 @@ export class Store { return null } const sanitizedUpdates = sanitizeRepoUpdatesForPersistence(updates) + if ('projectGroupId' in sanitizedUpdates) { + const nextGroupId = sanitizedUpdates.projectGroupId + if ( + typeof nextGroupId !== 'string' || + nextGroupId.trim().length === 0 || + !this.state.projectGroups.some((group) => group.id === nextGroupId) + ) { + sanitizedUpdates.projectGroupId = null + } + } + if ( + 'projectGroupOrder' in sanitizedUpdates && + (typeof sanitizedUpdates.projectGroupOrder !== 'number' || + !Number.isFinite(sanitizedUpdates.projectGroupOrder)) + ) { + delete sanitizedUpdates.projectGroupOrder + } const externalWorktreeVisibilityLegacy = 'externalWorktreeVisibility' in sanitizedUpdates && repo.externalWorktreeVisibilityLegacy === undefined ? isLegacyRepoForExternalWorktreeVisibility(repo) : undefined - // Why: `issueSourcePreference === undefined` in the patch means "reset to - // auto" (and the persisted record should drop the key, not preserve a - // stale explicit value via Object.assign's skip-on-undefined behavior). - // Without this delete branch, toggling explicit → auto would silently - // leave the old preference in place on disk. + // Why: selected repo fields use `undefined` as an explicit clear signal, + // so delete them before assigning the rest of the patch. if ( 'issueSourcePreference' in sanitizedUpdates && sanitizedUpdates.issueSourcePreference === undefined ) { delete repo.issueSourcePreference - const { issueSourcePreference: _drop, ...rest } = sanitizedUpdates - Object.assign(repo, rest) - } else { - Object.assign(repo, sanitizedUpdates) + delete sanitizedUpdates.issueSourcePreference } if ( 'externalWorktreeVisibility' in sanitizedUpdates && @@ -2131,6 +2301,20 @@ export class Store { // time visibility changes so later hide/show choices keep legacy safety. repo.externalWorktreeVisibilityLegacy = externalWorktreeVisibilityLegacy } + if ('sourceControlAi' in sanitizedUpdates && sanitizedUpdates.sourceControlAi === undefined) { + delete repo.sourceControlAi + delete sanitizedUpdates.sourceControlAi + } else if ('sourceControlAi' in sanitizedUpdates) { + const normalizedSourceControlAi = normalizeRepoSourceControlAiOverrides( + sanitizedUpdates.sourceControlAi + ) + if (normalizedSourceControlAi === undefined) { + delete sanitizedUpdates.sourceControlAi + } else { + sanitizedUpdates.sourceControlAi = normalizedSourceControlAi + } + } + Object.assign(repo, sanitizedUpdates) this.scheduleSave() return this.hydrateRepo(repo) } @@ -2231,6 +2415,7 @@ export class Store { updatedAt: now } this.state.automations = [...(this.state.automations ?? []), automation] + this.recordFeatureInteraction('automation-created') this.flush() return automation } @@ -2329,6 +2514,9 @@ export class Store { createdAt: now } this.state.automationRuns = [...(this.state.automationRuns ?? []), run] + if (trigger === 'manual') { + this.recordFeatureInteraction('automation-run') + } this.flush() return run } @@ -2517,6 +2705,22 @@ export class Store { sanitizedUpdates.telemetry !== undefined ? { ...this.state.settings.telemetry, ...sanitizedUpdates.telemetry } : this.state.settings.telemetry + if ('sourceControlAi' in sanitizedUpdates) { + const normalizedSourceControlAi = normalizeSourceControlAiSettings( + sanitizedUpdates.sourceControlAi, + this.state.settings.commitMessageAi + ) + sanitizedUpdates.sourceControlAi = normalizedSourceControlAi + sanitizedUpdates.commitMessageAi = projectSourceControlAiToLegacyCommitMessageAi( + normalizedSourceControlAi, + this.state.settings.commitMessageAi + ) + } else if ('commitMessageAi' in sanitizedUpdates) { + sanitizedUpdates.sourceControlAi = mergeLegacyCommitMessageAiIntoSourceControlAi( + this.state.settings.sourceControlAi, + sanitizedUpdates.commitMessageAi + ) + } this.state.settings = { ...this.state.settings, ...sanitizedUpdates, @@ -2538,6 +2742,7 @@ export class Store { ...this.state.ui, groupBy: normalizeGroupBy(this.state.ui?.groupBy), sortBy: normalizeSortBy(this.state.ui?.sortBy), + rightSidebarTab: normalizeRightSidebarTab(this.state.ui?.rightSidebarTab), worktreeCardProperties: normalizeWorktreeCardProperties( this.state.ui?.worktreeCardProperties ), @@ -2546,7 +2751,9 @@ export class Store { workspaceBoardCompact: normalizeWorkspaceBoardCompact(this.state.ui?.workspaceBoardCompact), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( this.state.ui?.workspaceBoardColumnWidth - ) + ), + featureTipsSeenIds: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), + featureInteractions: normalizeFeatureInteractions(this.state.ui?.featureInteractions) } } @@ -2560,6 +2767,10 @@ export class Store { sortBy: updates.sortBy ? normalizeSortBy(updates.sortBy) : normalizeSortBy(this.state.ui?.sortBy), + rightSidebarTab: + updates.rightSidebarTab !== undefined + ? normalizeRightSidebarTab(updates.rightSidebarTab) + : normalizeRightSidebarTab(this.state.ui?.rightSidebarTab), worktreeCardProperties: updates.worktreeCardProperties !== undefined ? normalizeWorktreeCardProperties(updates.worktreeCardProperties) @@ -2577,6 +2788,10 @@ export class Store { workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth ), + featureTipsSeenIds: + updates.featureTipsSeenIds !== undefined + ? normalizeFeatureTipIds(updates.featureTipsSeenIds) + : normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), // Why: runtime RPCs and the renderer can both record education state. // Merge instead of replacing so a stale renderer snapshot cannot erase // runtime-only feature interactions. @@ -2655,6 +2870,12 @@ export class Store { return this.state.workspaceSession ?? getDefaultWorkspaceSession() } + /** Resolve the worktree a terminal tab belongs to, from the session's + * tab→worktree map. More reliable than agent-echoed hook fields. */ + getWorktreeIdForTab(tabId: string): string | undefined { + return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) + } + setWorkspaceSession(session: PersistedState['workspaceSession']): void { session = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(session, this.state.repos) diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index 0affcf089bf..38b12e98b5a 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -3,7 +3,7 @@ // etc.). To get pi panes into the unified agent-hooks pipeline alongside // Claude/Codex/Gemini/OpenCode/Cursor, we ship a bundled extension into // the per-PTY Pi overlay (PiTitlebarExtensionService) that POSTs to -// /hook/pi using the same ORCA_AGENT_HOOK_* + ORCA_PANE_KEY env that every +// /hook/ using the same ORCA_AGENT_HOOK_* + ORCA_PANE_KEY env that every // PTY already receives from ipc/pty.ts. // // The overlay is per-PTY, so each pi process boots with its own copy of @@ -11,16 +11,18 @@ // returned source is a string (loaded by jiti from disk inside the pi // process), so we keep the source body in plain JS without TS types and // avoid pulling pi or any Orca dep into the pi runtime. +import type { PiAgentKind } from '../../shared/pi-agent-kind' + export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' -export function getPiAgentStatusExtensionSource(): string { +export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): string { // Why: keep this string self-contained — it runs inside the pi process, // so it cannot import from Orca's main bundle. fs/http coords come from // the same endpoint file the OpenCode plugin reads (process.env is frozen // at PTY spawn, so on Orca restart we have to re-read it from disk). return [ - "import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'", - '', + '// Why: no package-specific type import here. Pi and OMP expose the same', + '// extension API, but publish their types under different package names.', '// Why: warn-once so a recurring parse error on a malformed endpoint', '// file does not spam stderr inside the pi TUI on every event.', 'let warnedBadEndpoint = false', @@ -79,11 +81,35 @@ export function getPiAgentStatusExtensionSource(): string { ' }', '}', '', + 'function processName(value: unknown): string {', + " return String(value || '').split(/[\\\\/]/).pop()?.toLowerCase() || ''", + '}', + '', + 'function resolveHookPath(): string {', + ` const configuredPath = '/hook/${kind}'`, + ' const executableNames = [', + ' processName(process.title),', + ' processName(process.env._),', + ' processName(process.argv[1]),', + ' processName(process.argv[0])', + ' ]', + ' const isOmpExecutable = executableNames.some((name) =>', + " ['omp', 'omp.js', 'omp.sh', 'omp.cmd', 'omp.exe', 'omp.bat'].includes(name)", + ' )', + ' // Why: a bare shell gets the Pi overlay at spawn time, but may later', + ' // launch OMP. Runtime executable detection keeps that status labeled', + ' // as OMP instead of silently reporting it as Pi.', + ' if (isOmpExecutable) {', + " return '/hook/omp'", + ' }', + ' return configuredPath', + '}', + '', 'async function post(hookEventName: string, extra: Record = {}): Promise {', ' const coords = resolveHookCoords()', ' const paneKey = process.env.ORCA_PANE_KEY', ' if (!coords.port || !coords.token || !paneKey) return', - ' const url = `http://127.0.0.1:${coords.port}/hook/pi`', + ' const url = `http://127.0.0.1:${coords.port}${resolveHookPath()}`', ' const body = JSON.stringify({', ' paneKey,', " tabId: process.env.ORCA_TAB_ID || '',", @@ -134,7 +160,7 @@ export function getPiAgentStatusExtensionSource(): string { '// etc.), so we forward the raw object verbatim under the same field', '// names Claude uses (tool_name / tool_input) and let the server pick the', '// preview. Keeps tool-name knowledge centralized on the receiver side.', - 'export default function (pi: ExtensionAPI): void {', + 'export default function (pi): void {', " pi.on('before_agent_start', async (event) => {", " await post('before_agent_start', { prompt: event.prompt ?? '' })", ' })', diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 92ae6da24d6..b5d9259baf1 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -10,12 +10,28 @@ import { writeFileSync } from 'fs' import { tmpdir } from 'os' +import type * as osModule from 'os' import { join } from 'path' // The service calls app.getPath('userData') for its overlay root. Point that // at a real tmp dir so we can exercise the filesystem behavior end-to-end. const userDataDir = mkdtempSync(join(tmpdir(), 'orca-pi-test-userdata-')) +// Why: getDefaultPiAgentDir() inside titlebar-extension-service reads +// homedir() from 'os'. To exercise the ~/.omp/agent fallback branch we +// route the homedir lookup through a mutable holder so a single test can +// point it at a controlled tmp dir without disturbing the eagerly-evaluated +// tmpdir()/mkdtempSync calls above. +const homedirOverride = vi.hoisted(() => ({ current: '' as string })) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: () => homedirOverride.current || actual.homedir() + } +}) + vi.mock('electron', () => ({ app: { getPath: (name: string) => { @@ -48,6 +64,7 @@ describe('PiTitlebarExtensionService', () => { afterEach(() => { rmSync(piHome, { recursive: true, force: true }) rmSync(join(userDataDir, 'pi-agent-overlays'), { recursive: true, force: true }) + rmSync(join(userDataDir, 'omp-agent-overlays'), { recursive: true, force: true }) }) function expectPiHomeIntact(): void { @@ -64,9 +81,9 @@ describe('PiTitlebarExtensionService', () => { expect(readFileSync(join(piHome, 'sessions', 'session-1.json'), 'utf-8')).toBe('{}') } - it('buildPtyEnv mirrors the user Pi dir into an overlay under userData', () => { + it('buildPtyEnv mirrors the user agent dir into an overlay under userData', () => { const svc = new PiTitlebarExtensionService() - const env = svc.buildPtyEnv('pty-1', piHome) + const env = svc.buildPtyEnv('pty-1', piHome, 'pi') expect(env.PI_CODING_AGENT_DIR).toBe(join(userDataDir, 'pi-agent-overlays', 'pty-1')) // Orca's titlebar extension is added alongside user extensions, not replacing them. @@ -77,6 +94,13 @@ describe('PiTitlebarExtensionService', () => { 'orca-titlebar-spinner.ts', 'user-ext' ]) + const statusExtensionSource = readFileSync( + join(env.PI_CODING_AGENT_DIR!, 'extensions', 'orca-agent-status.ts'), + 'utf-8' + ) + expect(statusExtensionSource).toContain('/hook/pi') + expect(statusExtensionSource).toContain('process.title') + expect(statusExtensionSource).toContain("return '/hook/omp'") // User's top-level resources are reachable via the overlay. expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'skills', 'my-skill', 'SKILL.md'))).toBe(true) expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'))).toBe(true) @@ -85,7 +109,7 @@ describe('PiTitlebarExtensionService', () => { it('clearPty removes the overlay without touching the user Pi dir (issue #1083)', () => { const svc = new PiTitlebarExtensionService() - svc.buildPtyEnv('pty-2', piHome) + svc.buildPtyEnv('pty-2', piHome, 'pi') svc.clearPty('pty-2') expect(existsSync(join(userDataDir, 'pi-agent-overlays', 'pty-2'))).toBe(false) @@ -97,9 +121,9 @@ describe('PiTitlebarExtensionService', () => { it('rebuilding an overlay for the same ptyId does not corrupt the user Pi dir', () => { const svc = new PiTitlebarExtensionService() - svc.buildPtyEnv('pty-3', piHome) - svc.buildPtyEnv('pty-3', piHome) - svc.buildPtyEnv('pty-3', piHome) + svc.buildPtyEnv('pty-3', piHome, 'pi') + svc.buildPtyEnv('pty-3', piHome, 'pi') + svc.buildPtyEnv('pty-3', piHome, 'pi') expectPiHomeIntact() }) @@ -118,7 +142,7 @@ describe('PiTitlebarExtensionService', () => { symlinkSync('/nonexistent-pi-target/auth.json', join(overlayDir, 'auth.json'), 'file') const svc = new PiTitlebarExtensionService() - const env = svc.buildPtyEnv('pty-4', piHome) + const env = svc.buildPtyEnv('pty-4', piHome, 'pi') expect(env.PI_CODING_AGENT_DIR).toBe(overlayDir) expect(existsSync(join(overlayDir, 'skills', 'my-skill', 'SKILL.md'))).toBe(true) @@ -126,6 +150,131 @@ describe('PiTitlebarExtensionService', () => { } ) + // Why: per-agent overlay source dir. Orca's user picks Pi or OMP per + // launch (the agent kind isn't a global install-time choice), so each + // build's source dir MUST be resolved from the agent kind, not from a + // disk-presence check that silently shadows the other agent's user + // extensions when both `~/.pi/agent` and `~/.omp/agent` exist. + describe('per-agent default source dir (no cross-agent fallback)', () => { + function seedAgentDir(home: string, dotDir: '.pi' | '.omp', tag: string): string { + const agentDir = join(home, dotDir, 'agent') + mkdirSync(join(agentDir, 'extensions', `${tag}-ext`), { recursive: true }) + writeFileSync(join(agentDir, 'extensions', `${tag}-ext`, 'ext.ts'), `${tag} user extension`) + writeFileSync(join(agentDir, 'auth.json'), `${tag} secret token`) + return agentDir + } + + it('launching pi with both ~/.pi/agent and ~/.omp/agent present mirrors ~/.pi/agent', () => { + const fakeHome = mkdtempSync(join(tmpdir(), 'orca-pi-both-')) + seedAgentDir(fakeHome, '.pi', 'pi') + seedAgentDir(fakeHome, '.omp', 'omp') + + homedirOverride.current = fakeHome + try { + const svc = new PiTitlebarExtensionService() + const env = svc.buildPtyEnv('pty-pi-both', undefined, 'pi') + + expect(env.PI_CODING_AGENT_DIR).toBe(join(userDataDir, 'pi-agent-overlays', 'pty-pi-both')) + // The Pi auth file must be the one mirrored (not OMP's). + expect(readFileSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'), 'utf-8')).toBe( + 'pi secret token' + ) + // The user extension dir must be Pi's, not OMP's. + const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort() + expect(overlayExtensions).toContain('pi-ext') + expect(overlayExtensions).not.toContain('omp-ext') + } finally { + homedirOverride.current = '' + rmSync(join(userDataDir, 'pi-agent-overlays', 'pty-pi-both'), { + recursive: true, + force: true + }) + rmSync(fakeHome, { recursive: true, force: true }) + } + }) + + it('launching omp with both ~/.pi/agent and ~/.omp/agent present mirrors ~/.omp/agent into omp-agent-overlays', () => { + const fakeHome = mkdtempSync(join(tmpdir(), 'orca-omp-both-')) + seedAgentDir(fakeHome, '.pi', 'pi') + seedAgentDir(fakeHome, '.omp', 'omp') + + homedirOverride.current = fakeHome + try { + const svc = new PiTitlebarExtensionService() + const env = svc.buildPtyEnv('pty-omp-both', undefined, 'omp') + + // Critical regression guard for "OMP is its own program with its own + // paths": OMP overlays live under userData/omp-agent-overlays, NEVER + // under userData/pi-agent-overlays. A future refactor that re-shares + // the Pi overlay root for OMP would re-introduce cross-agent state + // visibility this PR exists to prevent. + expect(env.PI_CODING_AGENT_DIR).toBe( + join(userDataDir, 'omp-agent-overlays', 'pty-omp-both') + ) + // CRITICAL regression guard: even though ~/.pi/agent exists, the OMP + // launch MUST resolve OMP's own source dir, not Pi's. + expect(readFileSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'), 'utf-8')).toBe( + 'omp secret token' + ) + const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort() + expect(overlayExtensions).toContain('omp-ext') + expect(overlayExtensions).not.toContain('pi-ext') + expect( + readFileSync( + join(env.PI_CODING_AGENT_DIR!, 'extensions', 'orca-agent-status.ts'), + 'utf-8' + ) + ).toContain('/hook/omp') + // Pi's overlay root MUST NOT have been touched by the OMP launch. + expect(existsSync(join(userDataDir, 'pi-agent-overlays', 'pty-omp-both'))).toBe(false) + } finally { + homedirOverride.current = '' + rmSync(join(userDataDir, 'omp-agent-overlays', 'pty-omp-both'), { + recursive: true, + force: true + }) + rmSync(fakeHome, { recursive: true, force: true }) + } + }) + + it('launching omp when only ~/.pi/agent exists does NOT mirror Pi state', () => { + // Why: missing source dir for the resolved kind must materialize the + // overlay from empty (Orca extensions only) — never cross-pollinate + // from the other agent's dir. + const fakeHome = mkdtempSync(join(tmpdir(), 'orca-omp-only-pi-')) + seedAgentDir(fakeHome, '.pi', 'pi') + expect(existsSync(join(fakeHome, '.omp'))).toBe(false) + + homedirOverride.current = fakeHome + try { + const svc = new PiTitlebarExtensionService() + const env = svc.buildPtyEnv('pty-omp-empty', undefined, 'omp') + + expect(env.PI_CODING_AGENT_DIR).toBe( + join(userDataDir, 'omp-agent-overlays', 'pty-omp-empty') + ) + // The Pi-only home must NOT leak into the OMP overlay; the auth + // token from ~/.pi/agent/auth.json must be absent. + expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'))).toBe(false) + // Only Orca's bundled extensions are present — no user extensions + // from the other agent's dir. + const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort() + expect(overlayExtensions).toEqual([ + 'orca-agent-status.ts', + 'orca-prefill.ts', + 'orca-titlebar-spinner.ts' + ]) + } finally { + homedirOverride.current = '' + rmSync(join(userDataDir, 'omp-agent-overlays', 'pty-omp-empty'), { + recursive: true, + force: true + }) + rmSync(fakeHome, { recursive: true, force: true }) + } + }) + }) + describe('isSafeDescendCandidate (Windows junction regression guard)', () => { // Why: the #1083 regression cannot reproduce on POSIX CI because // fs.rmSync({recursive:true}) handles symlinks correctly on macOS/Linux. @@ -163,14 +312,16 @@ describe('PiTitlebarExtensionService', () => { it('refuses to remove anything outside the overlay root', () => { // Why: hard guard against a misresolved overlay path (regression defense). - // The overlay root is userData/pi-agent-overlays; any path outside it - // must be a no-op, not a `rm -rf` on arbitrary filesystem locations. + // The overlay roots are userData/{pi,omp}-agent-overlays; any path outside + // either must be a no-op, not a `rm -rf` on arbitrary filesystem locations. const svc = new PiTitlebarExtensionService() as unknown as { - safeRemoveOverlay: (p: string) => void + safeRemoveOverlay: (p: string, kind: 'pi' | 'omp') => void } - svc.safeRemoveOverlay(piHome) - svc.safeRemoveOverlay('/') - svc.safeRemoveOverlay(join(userDataDir, 'pi-agent-overlays')) // root itself + svc.safeRemoveOverlay(piHome, 'pi') + svc.safeRemoveOverlay(piHome, 'omp') + svc.safeRemoveOverlay('/', 'pi') + svc.safeRemoveOverlay(join(userDataDir, 'pi-agent-overlays'), 'pi') // root itself + svc.safeRemoveOverlay(join(userDataDir, 'omp-agent-overlays'), 'omp') // OMP root itself expectPiHomeIntact() }) }) diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 32fb00fb27b..16282341a41 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -11,6 +11,7 @@ import { mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' +import type { PiAgentKind } from '../../shared/pi-agent-kind' // Why: the Pi test suite imports `isSafeDescendCandidate` from this module's // public surface to lock in the Windows-junction ordering invariant against @@ -20,34 +21,61 @@ export const isSafeDescendCandidate = sharedIsSafeDescendCandidate const ORCA_PI_EXTENSION_FILE = 'orca-titlebar-spinner.ts' const ORCA_PI_PREFILL_EXTENSION_FILE = 'orca-prefill.ts' -const PI_AGENT_DIR_NAME = '.pi' const PI_AGENT_SUBDIR = 'agent' -const PI_OVERLAY_DIR_NAME = 'pi-agent-overlays' -// Why: env-var name read by the prefill extension. Mirrors Claude's -// `--prefill ` semantics for pi — the editor mounts with the text -// already in its input box but unsubmitted, so the user can review/edit -// before sending. Used by the new-workspace draft-launch flow to drop a -// linked GitHub/Linear issue URL into pi without racing pi's lengthy -// startup output (banner + skills + extensions) against the bracketed- -// paste readiness detector. -export const ORCA_PI_PREFILL_ENV_VAR = 'ORCA_PI_PREFILL' +// Why: each agent owns its own overlay tree so OMP launches never touch +// Pi's overlay dir (and vice versa). Shadowing one inside the other would +// re-introduce the cross-agent state leak the per-kind PR exists to prevent. +const OVERLAY_ROOT_DIR_NAME: Record = { + pi: 'pi-agent-overlays', + omp: 'omp-agent-overlays' +} -// Why: pi exposes `pi.ui.setEditorText(text)` from inside an extension's -// session_start handler — that's the equivalent of Claude's `--prefill`. -// We can't use a CLI flag (pi has none) and bracketed-paste-after-ready -// races against pi's startup output, so we ship a tiny extension that -// reads ORCA_PI_PREFILL on session_start and types it into the editor. -// The env var is consumed (deleted from process.env) so /new in the same -// session doesn't re-prefill. -function getPiPrefillExtensionSource(): string { +// Why: the overlay source dir is chosen by which agent is being launched, NOT +// by which `~/./agent` dir happens to exist on disk first. A +// cross-agent fallback (Pi -> OMP or vice versa) silently shadows the other +// agent's user extensions when both are installed and the user picks the +// shadowed one in Orca's per-launch agent picker. +const AGENT_HOME_DIR_NAME: Record = { + pi: '.pi', + omp: '.omp' +} + +// Why: prefill-without-submit needs an env-var the bundled `orca-prefill.ts` +// extension can read on session_start. Each kind owns its own variable so an +// OMP PTY never honors a Pi draft (or vice versa) - the only state the two +// share is the binary-mandated `PI_CODING_AGENT_DIR` itself. Pi keeps its +// original name for back-compat with PTYs already in flight at upgrade time; +// OMP gets a parallel `ORCA_OMP_PREFILL`. +const PREFILL_ENV_VAR_BY_KIND: Record = { + pi: 'ORCA_PI_PREFILL', + omp: 'ORCA_OMP_PREFILL' +} + +/** Pi's prefill env var. Exported for callers that need the literal name + * (renderer draft-launch plan builder, tests). OMP callers should read + * `ORCA_OMP_PREFILL_ENV_VAR` instead. */ +export const ORCA_PI_PREFILL_ENV_VAR = PREFILL_ENV_VAR_BY_KIND.pi + +/** OMP's prefill env var. Mirrors `ORCA_PI_PREFILL_ENV_VAR` for OMP launches + * so renderer plans and shell-ready restore lines can stay agent-scoped. */ +export const ORCA_OMP_PREFILL_ENV_VAR = PREFILL_ENV_VAR_BY_KIND.omp + +// Why: pi/OMP both expose an `ExtensionAPI` to the default-exported factory. +// The extension reads its kind-specific env var on session_start and types +// the payload into the editor without submitting. The env var is consumed +// (deleted from process.env) so `/new` in the same session doesn't re-prefill. +// The extension API name is identical between pi and OMP because OMP keeps +// Pi's `@oh-my-pi/*` runtime packages. +function getPiPrefillExtensionSource(kind: PiAgentKind): string { + const envVar = PREFILL_ENV_VAR_BY_KIND[kind] return [ 'export default function (pi) {', " pi.on('session_start', async (event, ctx) => {", " if (event.reason !== 'startup') return", - ` const prefill = process.env.${ORCA_PI_PREFILL_ENV_VAR}`, + ` const prefill = process.env.${envVar}`, ' if (!prefill) return', - ` delete process.env.${ORCA_PI_PREFILL_ENV_VAR}`, + ` delete process.env.${envVar}`, ' try {', ' ctx.ui.setEditorText(prefill)', ' } catch {}', @@ -119,24 +147,24 @@ function getPiTitlebarExtensionSource(): string { ].join('\n') } -function getDefaultPiAgentDir(): string { - return join(homedir(), PI_AGENT_DIR_NAME, PI_AGENT_SUBDIR) +function getDefaultPiAgentDir(kind: PiAgentKind): string { + return join(homedir(), AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR) } export class PiTitlebarExtensionService { - private getOverlayRoot(): string { - return join(app.getPath('userData'), PI_OVERLAY_DIR_NAME) + private getOverlayRoot(kind: PiAgentKind): string { + return join(app.getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind]) } - private getOverlayDir(ptyId: string): string { - return join(this.getOverlayRoot(), ptyId) + private getOverlayDir(ptyId: string, kind: PiAgentKind): string { + return join(this.getOverlayRoot(kind), ptyId) } // Why: overlay teardown must use the shared safeRemoveOverlay so the // Windows-junction guard from issue #1083 stays in lock-step across all // overlay consumers (Pi here, OpenCode in src/main/opencode/hook-service.ts). - private safeRemoveOverlay(overlayDir: string): void { - safeRemoveOverlay(overlayDir, this.getOverlayRoot()) + private safeRemoveOverlay(overlayDir: string, kind: PiAgentKind): void { + safeRemoveOverlay(overlayDir, this.getOverlayRoot(kind)) } private mirrorAgentDir(sourceAgentDir: string, overlayDir: string): void { @@ -159,26 +187,31 @@ export class PiTitlebarExtensionService { continue } - // Why: PI_CODING_AGENT_DIR controls Pi's entire state tree, not just - // extension discovery. Mirror the user's top-level Pi resources into the - // overlay so enabling Orca's titlebar extension preserves auth, sessions, - // skills, prompts, themes, and any future files Pi stores there. + // Why: PI_CODING_AGENT_DIR controls Pi's / OMP's entire state tree, not + // just extension discovery. Mirror the user's top-level resources into + // the overlay so enabling Orca's titlebar extension preserves auth, + // sessions, skills, prompts, themes, and any future files stored there. mirrorEntry(sourcePath, join(overlayDir, basename(sourcePath))) } } - buildPtyEnv(ptyId: string, existingAgentDir: string | undefined): Record { - const sourceAgentDir = existingAgentDir || getDefaultPiAgentDir() - const overlayDir = this.getOverlayDir(ptyId) + buildPtyEnv( + ptyId: string, + existingAgentDir: string | undefined, + kind: PiAgentKind + ): Record { + const sourceAgentDir = existingAgentDir || getDefaultPiAgentDir(kind) + const overlayDir = this.getOverlayDir(ptyId, kind) try { - this.safeRemoveOverlay(overlayDir) + this.safeRemoveOverlay(overlayDir, kind) } catch { // Why: on Windows the overlay directory can be locked by another process // (e.g. antivirus, indexer, or a previous Orca session that didn't clean up). - // If we can't remove the stale overlay, fall back to the user's own Pi agent - // dir so the terminal still spawns — the titlebar spinner is not worth - // blocking the PTY. + // If we can't remove the stale overlay, fall back to the user's own + // agent dir (Pi or OMP - both consume PI_CODING_AGENT_DIR) so the + // terminal still spawns - the titlebar spinner is not worth blocking + // the PTY. return existingAgentDir ? { PI_CODING_AGENT_DIR: existingAgentDir } : {} } @@ -188,28 +221,31 @@ export class PiTitlebarExtensionService { const extensionsDir = join(overlayDir, 'extensions') mkdirSync(extensionsDir, { recursive: true }) - // Why: Pi auto-loads global extensions from PI_CODING_AGENT_DIR/extensions. - // Add Orca's titlebar extension alongside the user's existing extensions - // instead of replacing that directory, otherwise Orca terminals would - // silently disable the user's Pi customization inside Orca only. + // Why: Pi / OMP both auto-load global extensions from + // PI_CODING_AGENT_DIR/extensions. Add Orca's titlebar extension alongside + // the user's existing extensions instead of replacing that directory, + // otherwise Orca terminals would silently disable the user's + // customization inside Orca only. writeFileSync(join(extensionsDir, ORCA_PI_EXTENSION_FILE), getPiTitlebarExtensionSource()) writeFileSync( join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE), - getPiPrefillExtensionSource() + getPiPrefillExtensionSource(kind) ) - // Why: bundled status extension that bridges pi's in-process event API - // to the unified /hook/pi endpoint. Without this, pi panes would have - // no entry in agentStatusByPaneKey and the dashboard would fall back - // to terminal-title heuristics like any uninstrumented CLI. + // Why: bundled status extension that bridges the in-process event API + // (`pi.on('agent_start', ...)` etc., identical between Pi and OMP) to the + // unified /hook/ endpoint. Without this, panes would have no entry in + // agentStatusByPaneKey and the dashboard would fall back to terminal-title + // heuristics like any uninstrumented CLI. writeFileSync( join(extensionsDir, ORCA_PI_AGENT_STATUS_EXTENSION_FILE), - getPiAgentStatusExtensionSource() + getPiAgentStatusExtensionSource(kind) ) } catch { - // Why: overlay creation is best-effort — permission errors (EPERM/EACCES) + // Why: overlay creation is best-effort - permission errors (EPERM/EACCES) // on Windows can occur when the userData directory is restricted or when - // symlink/junction creation fails without developer mode. Fall back to the - // user's Pi agent dir so the terminal spawns without the Orca extension. + // symlink/junction creation fails without developer mode. Fall back to + // the user's own agent dir (Pi or OMP) so the terminal spawns without + // the Orca extension. this.clearPty(ptyId) return existingAgentDir ? { PI_CODING_AGENT_DIR: existingAgentDir } : {} } @@ -220,12 +256,19 @@ export class PiTitlebarExtensionService { } clearPty(ptyId: string): void { - try { - this.safeRemoveOverlay(this.getOverlayDir(ptyId)) - } catch { - // Why: on Windows the overlay dir can be locked (EPERM/EBUSY) by antivirus - // or indexers. Overlay cleanup is best-effort — a stale directory in userData - // is harmless and will be overwritten on the next PTY spawn attempt. + // Why: PTY teardown doesn't know which kind was launched (the daemon + // exit path discards the launch command). Sweep both overlay roots so + // either kind's overlay is cleaned up; the per-kind root scoping keeps + // each safeRemoveOverlay call bounded to its own tree. + for (const kind of Object.keys(OVERLAY_ROOT_DIR_NAME) as PiAgentKind[]) { + try { + this.safeRemoveOverlay(this.getOverlayDir(ptyId, kind), kind) + } catch { + // Why: on Windows the overlay dir can be locked (EPERM/EBUSY) by + // antivirus or indexers. Overlay cleanup is best-effort - a stale + // directory in userData is harmless and will be overwritten on the + // next PTY spawn attempt. + } } } } diff --git a/src/main/powershell-osc133-bootstrap.test.ts b/src/main/powershell-osc133-bootstrap.test.ts index 10d278088a8..4801418bcc7 100644 --- a/src/main/powershell-osc133-bootstrap.test.ts +++ b/src/main/powershell-osc133-bootstrap.test.ts @@ -11,6 +11,11 @@ describe('PowerShell OSC 133 bootstrap', () => { expect(script).toContain('[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()') expect(script).toContain('ORCA_OPENCODE_CONFIG_DIR') expect(script).toContain('ORCA_PI_CODING_AGENT_DIR') + expect(script).toContain('ORCA_OMP_CODING_AGENT_DIR') + expect(script).toContain('ORCA_OMP_STATUS_EXTENSION') + expect(script).toContain('function Global:omp') + expect(script).toContain('--extension $env:ORCA_OMP_STATUS_EXTENSION') + expect(script).toContain('ORCA_CODEX_HOME') expect(script).toContain('function Global:prompt') expect(script).toContain('function Global:PSConsoleHostReadLine') expect(script).toContain('Esc = [char]27') diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 8f87ed21d2d..32af1d823ce 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -1,3 +1,5 @@ +import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' + const POWERSHELL_OSC133_BOOTSTRAP = `# Orca OSC 133 shell integration for PowerShell. if ((Test-Path variable:global:__OrcaOsc133State) -and $null -ne $Global:__OrcaOsc133State.OriginalPrompt) { @@ -22,6 +24,11 @@ try { # Profiles can re-export user defaults after Orca's spawn env is set. if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR } if ($env:ORCA_PI_CODING_AGENT_DIR) { $env:PI_CODING_AGENT_DIR = $env:ORCA_PI_CODING_AGENT_DIR } +if (-not $env:ORCA_PI_CODING_AGENT_DIR -and $env:ORCA_OMP_CODING_AGENT_DIR) { + $env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_CODING_AGENT_DIR +} +${getPowerShellOmpShellWrapper()} +if ($env:ORCA_CODEX_HOME) { $env:CODEX_HOME = $env:ORCA_CODEX_HOME } $Global:__OrcaOsc133State = @{ OriginalPrompt = $function:prompt diff --git a/src/main/project-groups/nested-repo-discovery.test.ts b/src/main/project-groups/nested-repo-discovery.test.ts new file mode 100644 index 00000000000..c1ec7dd19d9 --- /dev/null +++ b/src/main/project-groups/nested-repo-discovery.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { afterEach, describe, expect, it } from 'vitest' +import { scanNestedRepos } from './nested-repo-discovery' + +let tempDirs: string[] = [] + +async function tempRoot(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'orca-nested-repos-')) + tempDirs.push(dir) + return dir +} + +async function makeGitRepo(path: string): Promise { + await mkdir(join(path, '.git'), { recursive: true }) +} + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] +}) + +describe('scanNestedRepos', () => { + it('returns child repos for a non-git parent', async () => { + const root = await tempRoot() + await mkdir(join(root, 'auth-service'), { recursive: true }) + await mkdir(join(root, 'billing-service'), { recursive: true }) + await makeGitRepo(join(root, 'auth-service')) + await makeGitRepo(join(root, 'billing-service')) + + const result = await scanNestedRepos({ path: root }) + + expect(result.selectedPathKind).toBe('non_git_folder') + expect(result.repos.map((repo) => repo.displayName)).toEqual([ + 'auth-service', + 'billing-service' + ]) + }) + + it('does not scan inside an already discovered repo', async () => { + const root = await tempRoot() + await mkdir(join(root, 'service', 'nested'), { recursive: true }) + await makeGitRepo(join(root, 'service')) + await makeGitRepo(join(root, 'service', 'nested')) + + const result = await scanNestedRepos({ path: root }) + + expect(result.repos.map((repo) => repo.displayName)).toEqual(['service']) + }) + + it('skips heavy directories and respects result caps', async () => { + const root = await tempRoot() + await mkdir(join(root, 'node_modules', 'ignored'), { recursive: true }) + await mkdir(join(root, 'one'), { recursive: true }) + await mkdir(join(root, 'two'), { recursive: true }) + await makeGitRepo(join(root, 'node_modules', 'ignored')) + await makeGitRepo(join(root, 'one')) + await makeGitRepo(join(root, 'two')) + + const result = await scanNestedRepos({ path: root, options: { maxRepos: 1 } }) + + expect(result.repos[0].displayName).toBe('one') + expect(result.truncated).toBe(true) + }) + + it('treats a selected git repo as the existing repo path', async () => { + const root = await tempRoot() + await makeGitRepo(root) + await mkdir(join(root, 'child'), { recursive: true }) + await makeGitRepo(join(root, 'child')) + await writeFile(join(root, 'README.md'), '') + + const result = await scanNestedRepos({ path: root }) + + expect(result.selectedPathKind).toBe('git_repo') + expect(result.repos).toEqual([]) + }) +}) diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts new file mode 100644 index 00000000000..07b65974a87 --- /dev/null +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -0,0 +1,172 @@ +import { readdir, stat } from 'fs/promises' +import { basename, join } from 'path' +import type { + NestedRepoCandidate, + NestedRepoScanOptions, + NestedRepoScanResult +} from '../../shared/types' +import { isGitRepo } from '../git/repo' + +type NestedRepoDirectoryEntry = { + name: string + isDirectory: boolean +} + +type NestedRepoScanFilesystem = { + readDirectory: (dirPath: string) => Promise + joinPath: (parentPath: string, childName: string) => string + basename: (path: string) => string + isGitRepoPath: (path: string) => Promise | boolean +} + +const DEFAULT_MAX_DEPTH = 3 +const DEFAULT_MAX_REPOS = 100 +const DEFAULT_TIMEOUT_MS = 8_000 + +const SKIPPED_DIRS = new Set([ + 'node_modules', + '.next', + 'dist', + 'build', + '.cache', + 'vendor', + '__pycache__', + '.turbo', + '.parcel-cache' +]) + +function normalizeScanOptions(options: unknown): Required { + const raw = options && typeof options === 'object' ? (options as NestedRepoScanOptions) : {} + return { + maxDepth: + typeof raw.maxDepth === 'number' && Number.isFinite(raw.maxDepth) + ? Math.max(1, Math.min(8, Math.floor(raw.maxDepth))) + : DEFAULT_MAX_DEPTH, + maxRepos: + typeof raw.maxRepos === 'number' && Number.isFinite(raw.maxRepos) + ? Math.max(1, Math.min(500, Math.floor(raw.maxRepos))) + : DEFAULT_MAX_REPOS, + timeoutMs: + typeof raw.timeoutMs === 'number' && Number.isFinite(raw.timeoutMs) + ? Math.max(500, Math.min(30_000, Math.floor(raw.timeoutMs))) + : DEFAULT_TIMEOUT_MS + } +} + +function shouldSkipDirectory(name: string, depth: number): boolean { + if (SKIPPED_DIRS.has(name)) { + return true + } + return depth > 0 && name.startsWith('.') +} + +async function hasGitMarker(dirPath: string): Promise { + try { + const marker = await stat(join(dirPath, '.git')) + return marker.isDirectory() || marker.isFile() + } catch { + return false + } +} + +async function readLocalDirectory(dirPath: string): Promise { + const entries = await readdir(dirPath) + const result: NestedRepoDirectoryEntry[] = [] + for (const name of entries) { + const childStat = await stat(join(dirPath, name)).catch(() => null) + result.push({ name, isDirectory: childStat?.isDirectory() === true }) + } + return result +} + +export async function scanNestedRepos(args: { + path: string + options?: unknown + filesystem?: NestedRepoScanFilesystem +}): Promise { + const startedAt = Date.now() + const options = normalizeScanOptions(args.options) + const repos: NestedRepoCandidate[] = [] + let truncated = false + let timedOut = false + const filesystem = args.filesystem ?? { + readDirectory: readLocalDirectory, + joinPath: join, + basename, + isGitRepoPath: async (path: string) => isGitRepo(path) || (await hasGitMarker(path)) + } + + if (await filesystem.isGitRepoPath(args.path)) { + return { + selectedPath: args.path, + selectedPathKind: 'git_repo', + repos: [], + truncated: false, + timedOut: false, + durationMs: Date.now() - startedAt, + maxDepth: options.maxDepth + } + } + + const visit = async (dirPath: string, depth: number): Promise => { + if (repos.length >= options.maxRepos) { + truncated = true + return + } + if (Date.now() - startedAt > options.timeoutMs) { + timedOut = true + return + } + if (depth > options.maxDepth) { + return + } + + let entries: NestedRepoDirectoryEntry[] + try { + entries = await filesystem.readDirectory(dirPath) + } catch { + return + } + + const dirs = entries + .filter((entry) => entry.isDirectory) + .sort((left, right) => left.name.localeCompare(right.name)) + for (const entry of dirs) { + const name = entry.name + if (repos.length >= options.maxRepos) { + truncated = true + return + } + if (Date.now() - startedAt > options.timeoutMs) { + timedOut = true + return + } + if (shouldSkipDirectory(name, depth)) { + continue + } + const childPath = filesystem.joinPath(dirPath, name) + if (await filesystem.isGitRepoPath(childPath)) { + repos.push({ + path: childPath, + displayName: filesystem.basename(childPath), + depth: depth + 1 + }) + // Project Groups organize sibling repos; nested repos stay hidden until a + // later UI can explain and select submodule-style layouts explicitly. + continue + } + await visit(childPath, depth + 1) + } + } + + await visit(args.path, 0) + return { + selectedPath: args.path, + selectedPathKind: 'non_git_folder', + repos, + truncated, + timedOut, + durationMs: Date.now() - startedAt, + maxDepth: options.maxDepth + } +} diff --git a/src/main/project-groups/nested-repo-import.test.ts b/src/main/project-groups/nested-repo-import.test.ts new file mode 100644 index 00000000000..dbbaaeec49c --- /dev/null +++ b/src/main/project-groups/nested-repo-import.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import { createNestedProjectGroupResolver, resolveNestedRepoSelection } from './nested-repo-import' +import type { ProjectGroup } from '../../shared/types' + +describe('createNestedProjectGroupResolver', () => { + it('creates a root group plus intermediate directory groups for nested repos', () => { + const groups: ProjectGroup[] = [] + const resolver = createNestedProjectGroupResolver({ + parentPath: '/workspace', + groupName: 'workspace', + mode: 'group', + createGroup: (input) => { + const group: ProjectGroup = { + id: `group-${groups.length}`, + name: input.name, + parentPath: input.parentPath ?? null, + parentGroupId: input.parentGroupId ?? null, + createdFrom: input.createdFrom, + tabOrder: groups.length, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + groups.push(group) + return group + } + }) + + const direct = resolver.getGroupForRepo('/workspace/gateway-api') + const nested = resolver.getGroupForRepo('/workspace/services/payments/api') + const sibling = resolver.getGroupForRepo('/workspace/services/payments/worker') + + expect(direct?.name).toBe('workspace') + expect(nested?.name).toBe('payments') + expect(sibling?.id).toBe(nested?.id) + expect(groups.map((group) => [group.name, group.parentGroupId])).toEqual([ + ['workspace', null], + ['services', 'group-0'], + ['payments', 'group-1'] + ]) + expect(resolver.getRootGroup()?.id).toBe('group-0') + }) + + it('does not create groups for separate imports', () => { + const resolver = createNestedProjectGroupResolver({ + parentPath: '/workspace', + groupName: 'workspace', + mode: 'separate', + createGroup: () => { + throw new Error('should not create a group') + } + }) + + expect(resolver.getGroupForRepo('/workspace/services/api')).toBeUndefined() + expect(resolver.getCreatedGroups()).toEqual([]) + }) + + it('preserves filesystem root parent paths when creating groups', () => { + const groups: ProjectGroup[] = [] + const resolver = createNestedProjectGroupResolver({ + parentPath: '/', + groupName: 'root', + mode: 'group', + createGroup: (input) => { + const group: ProjectGroup = { + id: `group-${groups.length}`, + name: input.name, + parentPath: input.parentPath ?? null, + parentGroupId: input.parentGroupId ?? null, + createdFrom: input.createdFrom, + tabOrder: groups.length, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + groups.push(group) + return group + } + }) + + resolver.getGroupForRepo('/api') + resolver.getGroupForRepo('/services/api') + + expect(groups.map((group) => group.parentPath)).toEqual(['/', '/services']) + }) + + it('preserves Windows drive roots when creating groups', () => { + const groups: ProjectGroup[] = [] + const resolver = createNestedProjectGroupResolver({ + parentPath: 'C:\\', + groupName: 'C', + mode: 'group', + createGroup: (input) => { + const group: ProjectGroup = { + id: `group-${groups.length}`, + name: input.name, + parentPath: input.parentPath ?? null, + parentGroupId: input.parentGroupId ?? null, + createdFrom: input.createdFrom, + tabOrder: groups.length, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + groups.push(group) + return group + } + }) + + resolver.getGroupForRepo('C:\\api') + resolver.getGroupForRepo('C:\\services\\api') + + expect(groups.map((group) => group.parentPath)).toEqual(['C:/', 'C:/services']) + }) + + it('resolves Windows-style repo paths back to canonical scan output', () => { + const selection = resolveNestedRepoSelection({ + scan: { + selectedPath: 'C:\\workspace', + selectedPathKind: 'non_git_folder', + repos: [ + { path: 'C:\\workspace\\Services\\API', displayName: 'API', depth: 2 }, + { path: 'C:\\workspace\\tools', displayName: 'tools', depth: 1 } + ], + truncated: false, + timedOut: false, + durationMs: 1, + maxDepth: 3 + }, + projectPaths: ['c:/workspace/services/api', 'C:/workspace/services/api', 'D:/other/repo'] + }) + + expect(selection.selectedPaths).toEqual(['C:\\workspace\\Services\\API']) + expect(selection.rejectedPaths).toEqual(['D:/other/repo']) + }) +}) diff --git a/src/main/project-groups/nested-repo-import.ts b/src/main/project-groups/nested-repo-import.ts new file mode 100644 index 00000000000..c7751a67db1 --- /dev/null +++ b/src/main/project-groups/nested-repo-import.ts @@ -0,0 +1,144 @@ +import type { NestedRepoScanResult, ProjectGroup, ProjectGroupImportMode } from '../../shared/types' +import { + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../shared/cross-platform-path' + +type CreateGroupInput = { + name: string + parentPath?: string | null + parentGroupId?: string | null + createdFrom: ProjectGroup['createdFrom'] +} + +type NestedProjectGroupResolver = { + getGroupForRepo: (repoPath: string) => ProjectGroup | undefined + getRootGroup: () => ProjectGroup | undefined + getCreatedGroups: () => ProjectGroup[] +} + +export type ResolvedNestedRepoSelection = { + selectedPaths: string[] + rejectedPaths: string[] +} + +function trimPathSeparators(path: string): string { + if (path === '/' || /^[A-Za-z]:[\\/]?$/.test(path)) { + return path.replace(/\\/g, '/') + } + if (/^\/\/[^/]+\/[^/]+\/?$/.test(path.replace(/\\/g, '/'))) { + return path.replace(/\\/g, '/').replace(/\/$/, '') + } + return path.replace(/[\\/]+$/g, '') +} + +function splitPath(path: string): string[] { + return trimPathSeparators(path) + .split(/[\\/]+/) + .filter(Boolean) +} + +function joinPath(parentPath: string, segments: readonly string[]): string { + const trimmedParent = trimPathSeparators(parentPath) + const separator = trimmedParent.includes('\\') && !trimmedParent.includes('/') ? '\\' : '/' + return segments.length === 0 + ? trimmedParent + : trimmedParent === '/' + ? `/${segments.join('/')}` + : trimmedParent.endsWith(separator) + ? `${trimmedParent}${segments.join(separator)}` + : `${trimmedParent}${separator}${segments.join(separator)}` +} + +function getRelativeSegments(parentPath: string, repoPath: string): string[] { + const relativePath = relativePathInsideRoot(parentPath, repoPath) + if (relativePath !== null) { + return splitPath(relativePath) + } + const normalizedParent = trimPathSeparators(parentPath) + const normalizedRepo = trimPathSeparators(repoPath) + const parentWithSeparator = `${normalizedParent}/` + const normalizedRepoForMatch = normalizedRepo.replace(/\\/g, '/') + const normalizedParentForMatch = normalizedParent.replace(/\\/g, '/') + const parentWithMatchSeparator = `${normalizedParentForMatch}/` + if (normalizedRepoForMatch.startsWith(parentWithMatchSeparator)) { + return splitPath(normalizedRepoForMatch.slice(parentWithMatchSeparator.length)) + } + if (normalizedRepo.startsWith(parentWithSeparator)) { + return splitPath(normalizedRepo.slice(parentWithSeparator.length)) + } + return splitPath(normalizedRepo).slice(-1) +} + +export function createNestedProjectGroupResolver(args: { + parentPath: string + groupName: string + mode: ProjectGroupImportMode + createGroup: (input: CreateGroupInput) => ProjectGroup +}): NestedProjectGroupResolver { + const createdGroups: ProjectGroup[] = [] + const groupsByRelativeDir = new Map() + + const ensureGroup = (relativeDirs: readonly string[]): ProjectGroup | undefined => { + if (args.mode !== 'group') { + return undefined + } + const key = relativeDirs.join('/') + const existing = groupsByRelativeDir.get(key) + if (existing) { + return existing + } + const parentDirs = relativeDirs.slice(0, -1) + const parentGroup = relativeDirs.length > 0 ? ensureGroup(parentDirs) : undefined + const group = args.createGroup({ + name: relativeDirs.length === 0 ? args.groupName : (relativeDirs.at(-1) ?? args.groupName), + parentPath: joinPath(args.parentPath, relativeDirs), + parentGroupId: parentGroup?.id ?? null, + createdFrom: 'folder-scan' + }) + groupsByRelativeDir.set(key, group) + createdGroups.push(group) + return group + } + + return { + getGroupForRepo: (repoPath) => { + const segments = getRelativeSegments(args.parentPath, repoPath) + // Why: direct child repos belong to the selected-folder group; nested repos + // belong to the deepest intermediate directory group. + return ensureGroup(segments.slice(0, -1)) + }, + getRootGroup: () => groupsByRelativeDir.get(''), + getCreatedGroups: () => [...createdGroups] + } +} + +export function resolveNestedRepoSelection(args: { + scan: NestedRepoScanResult + projectPaths: readonly string[] +}): ResolvedNestedRepoSelection { + const candidatesByPath = new Map( + args.scan.repos.map((repo) => [normalizeRuntimePathForComparison(repo.path), repo.path]) + ) + const selectedPaths: string[] = [] + const rejectedPaths: string[] = [] + const seen = new Set() + + for (const repoPath of args.projectPaths) { + const normalizedPath = normalizeRuntimePathForComparison(repoPath) + if (seen.has(normalizedPath)) { + continue + } + seen.add(normalizedPath) + const canonicalPath = candidatesByPath.get(normalizedPath) + if (canonicalPath) { + selectedPaths.push(canonicalPath) + } else { + // Why: imports are derived from a bounded scan of this parent folder; + // callers must not smuggle unrelated paths into the group hierarchy. + rejectedPaths.push(repoPath) + } + } + + return { selectedPaths, rejectedPaths } +} diff --git a/src/main/providers/__tests__/shell-ready-framework-example.test.ts b/src/main/providers/__tests__/shell-ready-framework-example.test.ts new file mode 100644 index 00000000000..4d4640330c3 --- /dev/null +++ b/src/main/providers/__tests__/shell-ready-framework-example.test.ts @@ -0,0 +1,93 @@ +/** + * Example test using the shell-script-literal framework. + * + * This demonstrates the pattern for future shell-ready tests. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, rmSync } from 'fs' +import { spawnSync } from 'child_process' +import { shellScriptTest } from './shell-ready-framework/shell-script-test' + +const { getUserDataPathMock } = vi.hoisted(() => ({ + getUserDataPathMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: (name: string) => { + if (name === 'userData') { + return getUserDataPathMock() + } + throw new Error(`unexpected app.getPath(${name})`) + } + } +})) + +const hasZsh = process.platform !== 'win32' && spawnSync('which', ['zsh']).status === 0 +const describeIfZsh = hasZsh ? describe : describe.skip + +describeIfZsh('shell-script-literal framework example', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'shell-test-userdata-')) + getUserDataPathMock.mockReturnValue(userDataPath) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('discovers ZDOTDIR when .zshenv sources another file', async () => { + const { stdout } = await shellScriptTest( + ` + # Setup multi-file config + mkdir -p ~/.config/zsh + cat > ~/.config/zsh/env <<'EOF' +export ZDOTDIR="$HOME/.config/zsh" +EOF + + cat > ~/.zshenv <<'EOF' +source "$HOME/.config/zsh/env" +EOF + + # Run: check discovered ZDOTDIR + zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort' + `, + { userDataPath } + ) + + expect(stdout).toMatchInlineSnapshot(` + "HOME= + ZDOTDIR= + " + `) + }) + + it('handles conditional ZDOTDIR based on SSH_CONNECTION', async () => { + const { stdout } = await shellScriptTest( + ` + mkdir -p ~/.config/zsh-local ~/.config/zsh-remote + cat > ~/.zshenv <<'EOF' +if [[ -n "$SSH_CONNECTION" ]]; then + export ZDOTDIR="$HOME/.config/zsh-remote" +else + export ZDOTDIR="$HOME/.config/zsh-local" +fi +EOF + + # Run with SSH_CONNECTION set + SSH_CONNECTION='192.168.1.100 52100 192.168.1.1 22' zsh -c 'env | grep ZDOTDIR | sort' + `, + { userDataPath } + ) + + expect(stdout).toMatchInlineSnapshot(` + "ORCA_ORIG_ZDOTDIR=/.config/zsh-remote + ZDOTDIR= + " + `) + }) +}) diff --git a/src/main/providers/__tests__/shell-ready-framework/README.md b/src/main/providers/__tests__/shell-ready-framework/README.md new file mode 100644 index 00000000000..a94aa6b2340 --- /dev/null +++ b/src/main/providers/__tests__/shell-ready-framework/README.md @@ -0,0 +1,111 @@ +# Shell-Script-Literal Test Framework + +Framework for writing shell-ready tests as literal shell scripts that can be copy-pasted into a terminal to replicate. + +## Usage + +```typescript +import { shellScriptTest } from '../__tests__/shell-ready-framework/shell-script-test' + +it('discovers ZDOTDIR from multi-file config', async () => { + const { stdout } = await shellScriptTest(` + # Setup multi-file config + mkdir -p ~/.config/zsh + cat > ~/.config/zsh/env <<'EOF' +export ZDOTDIR="$HOME/.config/zsh" +EOF + + cat > ~/.zshenv <<'EOF' +source "$HOME/.config/zsh/env" +EOF + + # Run: check discovered ZDOTDIR + zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort' + `) + + expect(stdout).toMatchInlineSnapshot(` + "HOME= + ORCA_ORIG_ZDOTDIR=/.config/zsh + ZDOTDIR= + " + `) +}) +``` + +## How it works + +1. **Creates temp directories** for `$HOME` and Orca's `userDataPath` + +2. **Splits the script** on the `# Run:` marker: + - Lines before the marker → setup commands + - Lines after the marker → run command to test + +3. **Gets Orca's wrapper config** by calling `getShellReadyLaunchConfig()` + +4. **Executes setup** (if present) with bash in temp HOME, using wrapper env + +5. **Executes run command** with the wrapper's shell + args + env + +6. **Normalizes output** by replacing temp paths with placeholders: + - Temp HOME → `` + - Wrapper dir → `` + - Actual user HOME → `` + +7. **Cleans up** temp directories + +8. **Returns** stdout/stderr/exitCode ready for snapshot testing + +## Supported shell syntax + +**All shell syntax is supported** because the script is executed directly by bash/zsh, not parsed: + +- Heredocs (any delimiter, quoted or unquoted) +- Pipes, redirects, command substitution +- Conditionals (`if`, `[[ ]]`, `&&`, `||`) +- Loops, functions, variables +- Any valid shell script + +## Manual replication + +To manually replicate a test scenario, copy the shell commands from the test: + +```bash +# Setup commands (before # Run: marker): +mkdir -p ~/.config/zsh +cat > ~/.config/zsh/env <<'EOF' +export ZDOTDIR="$HOME/.config/zsh" +EOF + +cat > ~/.zshenv <<'EOF' +source "$HOME/.config/zsh/env" +EOF + +# Run command (after # Run: marker): +zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort' +``` + +**Note**: The test framework applies Orca's wrapper configuration (sets `ZDOTDIR` to wrapper directory, etc.). When running manually, you'll see different output unless you also configure the wrapper environment. + +## Snapshot testing + +Use `toMatchInlineSnapshot()` to keep expected output visible in the test file: + +```typescript +expect(stdout).toMatchInlineSnapshot(` + "HOME= + ORCA_ORIG_ZDOTDIR=/.config/zsh + ZDOTDIR= + " +`) +``` + +Update snapshots with `vitest -u`. + +## When to use this framework + +Use this framework for **new shell-ready tests** where: +- You want the test to be easy to replicate manually +- The setup is shell-script-based (file creation, env vars) +- You want a declarative snapshot-driven style + +**Don't migrate existing tests** - this framework is opt-in for new tests only. diff --git a/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts new file mode 100644 index 00000000000..d3921cebd67 --- /dev/null +++ b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts @@ -0,0 +1,154 @@ +import { tmpdir } from 'os' +import { join, basename } from 'path' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { spawnSync } from 'child_process' +import { getShellReadyLaunchConfig } from '../../local-pty-shell-ready' +import { escapeRegex } from '../../../../shared/string-utils' + +const RUN_MARKER = /^[ \t]*#[ \t]*Run:.*$/m + +/** + * Shell-script-literal test framework for shell-ready tests. + * + * Takes shell scripts as string literals that can be literally copy-pasted + * into a terminal to replicate the test scenario. + * + * Example: + * ```typescript + * const { stdout } = await shellScriptTest(` + * mkdir -p ~/.config/zsh + * cat > ~/.zshenv <<'EOF' + * export ZDOTDIR="$HOME/.config/zsh" + * EOF + * + * zsh -c 'env | grep ZDOTDIR' + * `, { userDataPath }) + * expect(stdout).toMatchInlineSnapshot(...) + * ``` + */ + +export type ShellScriptTestResult = { + stdout: string + stderr: string + exitCode: number +} + +export type ShellScriptTestOptions = { + userDataPath?: string + shell?: string +} + +function detectShellFromCommand(command: string, fallback: string): string { + const shellMatch = command.match(/(?:^|\s)((?:\/[\w/-]+\/)?(?:zsh|bash|sh))\s/) + return shellMatch ? shellMatch[1] : fallback +} + +export async function shellScriptTest( + script: string, + options: ShellScriptTestOptions = {} +): Promise { + const testHome = mkdtempSync(join(tmpdir(), 'shell-test-home-')) + const userDataPath = options.userDataPath || mkdtempSync(join(tmpdir(), 'shell-test-userdata-')) + const cleanupUserDataPath = !options.userDataPath + + try { + const parts = script.split(RUN_MARKER) + const hasRunMarker = parts.length === 2 + const setupScript = hasRunMarker ? parts[0].trim() : '' + const runScript = hasRunMarker ? parts[1].trim() : script.trim() + + const wrapperShell = detectShellFromCommand(runScript, options.shell || '/bin/zsh') + const config = getShellReadyLaunchConfig(wrapperShell) + + const env: Record = { + ...config.env, + HOME: testHome + } + + const spawnOptions = { + env: env as NodeJS.ProcessEnv, + cwd: testHome, + encoding: 'utf8' as const + } + + if (setupScript) { + const setupPath = join(testHome, '.setup.sh') + writeFileSync(setupPath, setupScript, 'utf8') + const setupResult = spawnSync('/bin/bash', [setupPath], spawnOptions) + if (setupResult.status !== 0) { + throw new Error( + `Setup script failed with exit code ${setupResult.status}\nstderr: ${setupResult.stderr}` + ) + } + } + + const runPath = join(testHome, '.run.sh') + writeFileSync(runPath, runScript, 'utf8') + const shellArgs = config.args ? [...config.args, runPath] : [runPath] + const result = spawnSync(wrapperShell, shellArgs, spawnOptions) + + const normalizationContext = { + testHome, + userDataPath, + actualUserHome: process.env.HOME || '', + shellName: basename(wrapperShell).toLowerCase() + } + + return { + stdout: normalizeOutput(result.stdout || '', normalizationContext), + stderr: normalizeOutput(result.stderr || '', normalizationContext), + exitCode: result.status ?? -1 + } + } finally { + rmSync(testHome, { recursive: true, force: true }) + if (cleanupUserDataPath) { + rmSync(userDataPath, { recursive: true, force: true }) + } + } +} + +const TEMP_PATH_PATTERN = + /\/(?:var\/folders|tmp)\/[^\s]+?\/(?:shell-test|orca|shell-ready)-[a-z]+-[a-z0-9-]+/g +const PID_PATTERN = /\bpid:\s*\d+/gi + +function normalizeOutput( + output: string, + ctx: { + testHome: string + userDataPath: string + actualUserHome: string + shellName: string + } +): string { + if (!output) { + return output + } + + const wrapperDir = join(ctx.userDataPath, 'shell-ready', ctx.shellName) + + const paths: { path: string; placeholder: string }[] = [ + { path: wrapperDir, placeholder: '' }, + { path: ctx.testHome, placeholder: '' } + ] + + if (ctx.actualUserHome) { + paths.push({ path: ctx.actualUserHome, placeholder: '' }) + } + + const replacements = paths + .sort((a, b) => b.path.length - a.path.length) + .map(({ path, placeholder }) => ({ + pattern: new RegExp(escapeRegex(path), 'g'), + placeholder + })) + + let normalized = output + for (const { pattern, placeholder } of replacements) { + normalized = normalized.replace(pattern, placeholder) + } + + normalized = normalized.replace(TEMP_PATH_PATTERN, '') + normalized = normalized.replace(PID_PATTERN, 'pid: ') + + return normalized +} diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 3b0b38fe647..95bd66b6f5a 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -139,7 +139,15 @@ function safeKillAndClean(id: string, proc: pty.IPty): void { } export type LocalPtyProviderOptions = { - buildSpawnEnv?: (id: string, baseEnv: Record) => Record + /** Why: `ctx.command` carries the renderer-chosen launch command (e.g. `pi`, + * `omp`, `claude`). Pi vs OMP must drive overlay source-dir selection in + * `buildPtyHostEnv` — a cross-agent disk-presence fallback silently + * shadows the other agent's user extensions when both are installed. */ + buildSpawnEnv?: ( + id: string, + baseEnv: Record, + ctx?: { command?: string; isWsl?: boolean } + ) => Record /** Whether worktree-scoped shell history is enabled. When true (or absent) * and a worktreeId is provided, HISTFILE is scoped per-worktree. */ isHistoryEnabled?: () => boolean @@ -281,7 +289,10 @@ export class LocalPtyProvider implements IPtyProvider { spawnEnv.PYTHONUTF8 ??= '1' } - const finalEnv = this.opts.buildSpawnEnv ? this.opts.buildSpawnEnv(id, spawnEnv) : spawnEnv + const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + const finalEnv = this.opts.buildSpawnEnv + ? this.opts.buildSpawnEnv(id, spawnEnv, { command: args.command, isWsl: isWslShell }) + : spawnEnv if ( process.platform === 'win32' && pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' && @@ -297,7 +308,9 @@ export class LocalPtyProvider implements IPtyProvider { const needsNoMarkerWrapper = finalEnv.ORCA_ATTRIBUTION_SHIM_DIR || finalEnv.ORCA_OPENCODE_CONFIG_DIR || - finalEnv.ORCA_PI_CODING_AGENT_DIR + finalEnv.ORCA_PI_CODING_AGENT_DIR || + finalEnv.ORCA_OMP_CODING_AGENT_DIR || + finalEnv.ORCA_CODEX_HOME getFallbackShellReadyConfig = args.command ? (shell) => getShellReadyLaunchConfig(shell) : needsNoMarkerWrapper diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 06f3405b213..aa08fe36ff5 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -4,8 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'child_process' import { tmpdir } from 'os' -import { join } from 'path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { join, dirname } from 'path' +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'fs' import type * as pty from 'node-pty' import type * as LocalPtyShellReadyModule from './local-pty-shell-ready' import { writeStartupCommandWhenShellReady } from './local-pty-shell-ready' @@ -205,6 +205,7 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -230,6 +231,7 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -260,6 +262,7 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -285,12 +288,12 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('local _orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') - expect(zshenv).toContain('[[ -f "$_orca_user_zdotdir/.zshenv" ]]') - expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') + expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') + expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;') + expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') }) - it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => { + it('writes wrappers that restore agent config homes after user startup files', async () => { const { getBashShellReadyRcfileContent, getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() @@ -303,12 +306,26 @@ describePosix('local PTY shell-ready launch config', () => { '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' const piRestoreLine = '[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"' + const codexRestoreLine = + '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' + const ompRestoreLine = + 'if [[ -z "${ORCA_PI_CODING_AGENT_DIR:-}" && -n "${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then' + const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) expect(zshrc).toContain(piRestoreLine) expect(zlogin).toContain(piRestoreLine) expect(bashRc).toContain(piRestoreLine) + expect(zshrc).toContain(codexRestoreLine) + expect(zlogin).toContain(codexRestoreLine) + expect(bashRc).toContain(codexRestoreLine) + expect(zshrc).toContain(ompRestoreLine) + expect(zlogin).toContain(ompRestoreLine) + expect(bashRc).toContain(ompRestoreLine) + expect(zshrc).toContain(ompWrapperLine) + expect(zlogin).toContain(ompWrapperLine) + expect(bashRc).toContain(ompWrapperLine) }) // Why: regression guard for issue #2422. Without OSC 133 C/D markers in the @@ -386,6 +403,7 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice/.config/zsh') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -456,4 +474,1127 @@ describePosix('local PTY shell-ready launch config', () => { } } }) + + it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => { + // Why: PR #1737 sourced .zshenv inside a wrapper function, which broke + // common patterns like "typeset -U path". The fix must keep .zshenv at + // zsh top level while still capturing the ZDOTDIR it resolved. + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + + const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + + expect(zshenv).toContain('unset ZDOTDIR') + expect(zshenv).toContain('_orca_zshenv_source_dir="${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"') + expect(zshenv).toContain('source "${_orca_zshenv_path}"') + expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"') + expect(zshenv).toContain( + 'export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"' + ) + expect(zshenv).toContain('export ZDOTDIR=') + }) + + it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => { + // Why: if user .zshenv returns early or doesn't set ZDOTDIR, the wrapper + // should fall back to the spawn-env ORCA_ORIG_ZDOTDIR (if present), then HOME. + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + + const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + + // Save spawn-env value before sourcing user .zshenv + expect(zshenv).toContain('_orca_spawn_orig_zdotdir="${ORCA_ORIG_ZDOTDIR:-}"') + + // Fallback chain: discovered → normalized spawn-env path → HOME + expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') + }) +}) + +// Why: end-to-end validation that wrapper ZDOTDIR discovery preserves top-level +// zsh semantics. These tests spawn real zsh subprocesses, so they're gated on +// zsh availability and skipped on platforms where zsh is not found. +describePosix('live zsh subprocess tests', () => { + const hasZsh = (() => { + const result = spawnSync('which', ['zsh'], { encoding: 'utf8' }) + return result.status === 0 + })() + + const describeIfZsh = hasZsh ? describe : describe.skip + + describeIfZsh('ZDOTDIR discovery with real zsh', () => { + let testHome: string + let userDataPath: string + + beforeEach(async () => { + testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-test-home-')) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-test-userdata-')) + getUserDataPathMock.mockReturnValue(userDataPath) + }) + + afterEach(() => { + rmSync(testHome, { recursive: true, force: true }) + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('preserves typeset -U path scoping when user .zshrc uses it', async () => { + // Why: this was the breakage pattern in PR #1737. The function-wrapper + // approach made "typeset -U path" function-scoped. User rcfiles must + // still be sourced at the wrapper's top level, preserving scoping. + + // Create XDG-style config: .zshenv sets ZDOTDIR, .zshrc modifies PATH + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + `export ZDOTDIR="$HOME/.config/zsh" +` + ) + writeFileSync( + join(xdgZshDir, '.zshrc'), + `typeset -U path +path=(/custom/bin $path) +` + ) + + // Generate the Orca wrapper + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Spawn interactive zsh with the wrapper and verify: + // 1. Wrapper discovered XDG ZDOTDIR from .zshenv + // 2. User's .zshrc was sourced from discovered ZDOTDIR + // 3. typeset -U path modification persisted (proving top-level scoping) + // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + PATH: '/usr/bin:/bin' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir + + const result = spawnSync( + 'zsh', + [ + '-i', + '-c', + 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}" && echo "PATH_HAS_CUSTOM=${PATH%%:*}"' + ], + { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + } + ) + + expect(result.status).toBe(0) + const output = result.stdout + expect(output).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + expect(output).toContain('PATH_HAS_CUSTOM=/custom/bin') + }) + + it('preserves top-level .zshenv path and function side effects', async () => { + // Why: .zshenv is the normal place for always-on zsh env/path setup. + // Dropping those side effects regresses non-Orca zsh startup semantics. + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + `typeset -U path +path=(/env/bin $path) +export MY_VAR=from-zshenv +orca_zshenv_func() { echo "from-zshenv-function"; } +export ZDOTDIR="$HOME/.config/zsh" +` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + PATH: '/usr/bin:/bin' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + delete cleanEnv.MY_VAR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync( + 'zsh', + [ + '-c', + 'echo "PATH_HEAD=${PATH%%:*}" && echo "MY_VAR=${MY_VAR:-unset}" && orca_zshenv_func' + ], + { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + } + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('PATH_HEAD=/env/bin') + expect(result.stdout).toContain('MY_VAR=from-zshenv') + expect(result.stdout).toContain('from-zshenv-function') + }) + + it('survives early return in user .zshenv without crashing', async () => { + // Why: common pattern to skip non-interactive sourcing. A direct source + // at zsh top level must keep the wrapper running, matching normal zsh. + writeFileSync( + join(testHome, '.zshenv'), + `[[ -o interactive ]] || return 0 +export ZDOTDIR="$HOME/.config/zsh" +` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir + + const result = spawnSync( + 'zsh', + ['-c', 'echo "survived" && echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], + { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + } + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('survived') + // ZDOTDIR discovery yields nothing (early return before export), fallback to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('falls back to HOME when user .zshenv does not set ZDOTDIR', async () => { + // Why: vanilla zsh users don't set ZDOTDIR. The fallback chain should + // land on HOME after preserving the rest of .zshenv behavior. + writeFileSync( + join(testHome, '.zshenv'), + `# Vanilla zsh config, no ZDOTDIR +export MY_VAR=foo +` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + }) + + describeIfZsh('high-priority edge cases', () => { + let testHome: string + let userDataPath: string + + beforeEach(async () => { + testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-edge-')) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-userdata-')) + getUserDataPathMock.mockReturnValue(userDataPath) + }) + + afterEach(() => { + rmSync(testHome, { recursive: true, force: true }) + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('discovers ZDOTDIR when .zshenv sources another file that sets it', async () => { + // Multi-file sourcing pattern + const commonSh = join(testHome, '.config', 'shell', 'common.sh') + mkdirSync(dirname(commonSh), { recursive: true }) + writeFileSync(commonSh, 'export ZDOTDIR="$HOME/.config/zsh"\n') + writeFileSync(join(testHome, '.zshenv'), 'source ~/.config/shell/common.sh\n') + + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('preserves ZDOTDIR with spaces in path', async () => { + const spacePath = join(testHome, 'My Config', 'zsh') + mkdirSync(spacePath, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${spacePath}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${spacePath}`) + }) + + it('falls back when .zshenv has syntax error', async () => { + writeFileSync(join(testHome, '.zshenv'), 'syntax error {{{\nexport ZDOTDIR=broken\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Syntax error causes discovery to fail, falls back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('handles framework pattern with ${ZDOTDIR:-$HOME}', async () => { + writeFileSync( + join(testHome, '.zshenv'), + 'export ZDOTDIR="${ZDOTDIR:-$HOME}"\n# prezto-style pattern\n' + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Framework pattern defaults to HOME when ZDOTDIR unset + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('captures last ZDOTDIR value when set multiple times', async () => { + const firstPath = join(testHome, '.config', 'zsh') + const lastPath = join(testHome, '.local', 'zsh') + mkdirSync(firstPath, { recursive: true }) + mkdirSync(lastPath, { recursive: true }) + + writeFileSync( + join(testHome, '.zshenv'), + `export ZDOTDIR="${firstPath}"\nexport ZDOTDIR="${lastPath}"\n` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${lastPath}`) + }) + + it('handles conditional ZDOTDIR based on environment', async () => { + const localPath = join(testHome, '.config', 'zsh') + const remotePath = join(testHome, '.config', 'zsh-remote') + mkdirSync(localPath, { recursive: true }) + mkdirSync(remotePath, { recursive: true }) + + writeFileSync( + join(testHome, '.zshenv'), + `if [[ -n "$SSH_CONNECTION" ]]; then\n export ZDOTDIR="${remotePath}"\nelse\n export ZDOTDIR="${localPath}"\nfi\n` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Test without SSH_CONNECTION + let cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + delete cleanEnv.SSH_CONNECTION + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + let result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${localPath}`) + + // Test with SSH_CONNECTION + cleanEnv = { ...process.env, HOME: testHome, SSH_CONNECTION: '10.0.0.1 12345 10.0.0.2 22' } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${remotePath}`) + }) + + it('preserves explicit ZDOTDIR="$HOME" from user .zshenv', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME"\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('falls back when discovered ZDOTDIR does not exist', async () => { + const nonexistent = join(testHome, '.config', 'zsh-missing') + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${nonexistent}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Validation rejects non-existent path, falls back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('does not source /.zshenv when HOME is empty', async () => { + // Create /.zshenv to verify it's NOT sourced + // (can't actually create in test but we verify the wrapper logic) + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + getShellReadyLaunchConfig('/bin/zsh') + + const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + + // Verify wrapper checks the resolved source root is non-empty before sourcing + expect(zshenv).toContain('if [[ -n "${_orca_zshenv_source_dir:-}"') + }) + + it('handles ZDOTDIR with single quote in path', async () => { + const quotePath = join(testHome, "config'zsh") + mkdirSync(quotePath, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${quotePath}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${quotePath}`) + }) + + it('does not evaluate command substitution in ZDOTDIR', async () => { + const safePath = join(testHome, '.config', 'zsh') + mkdirSync(safePath, { recursive: true }) + // Attempt command substitution - should be treated as literal path component + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${safePath}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Should contain the safe path, not any command-substituted value + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${safePath}`) + }) + + it('handles whitespace-only ZDOTDIR (tabs and newlines)', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="\t\t\n\n"\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Whitespace-only should be normalized to empty, fall back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('handles ZDOTDIR with multiple trailing slashes', async () => { + const cleanPath = join(testHome, '.config', 'zsh') + mkdirSync(cleanPath, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${cleanPath}///"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Should normalize to path without trailing slashes + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${cleanPath}`) + }) + }) + + describeIfZsh('terminal emulator edge cases', () => { + let testHome: string + let userDataPath: string + + beforeEach(async () => { + testHome = mkdtempSync(join(tmpdir(), 'orca-term-')) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-term-userdata-')) + getUserDataPathMock.mockReturnValue(userDataPath) + }) + + afterEach(() => { + rmSync(testHome, { recursive: true, force: true }) + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('discovers ZDOTDIR when launched inside tmux', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${xdgZshDir}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + TMUX: '/tmp/tmux-501/default,12345,0', + TMUX_PANE: '%0' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('discovers ZDOTDIR when launched from SSH session', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${xdgZshDir}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + SSH_CONNECTION: '10.0.0.1 12345 10.0.0.2 22', + SSH_CLIENT: '10.0.0.1 12345 22', + LC_CTYPE: 'C.UTF-8' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('handles sudo -E where HOME and ZDOTDIR mismatch', async () => { + const userZdotdir = join('/home', 'alice', '.config', 'zsh') + + const previousZdotdir = process.env.ZDOTDIR + const previousHome = process.env.HOME + process.env.ZDOTDIR = userZdotdir + process.env.HOME = '/root' // sudo changed HOME + + try { + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Should preserve user's ZDOTDIR from spawn env, not fall back to /root + expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) + } finally { + if (previousZdotdir === undefined) { + delete process.env.ZDOTDIR + } else { + process.env.ZDOTDIR = previousZdotdir + } + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + + it('re-discovers ZDOTDIR despite stale ORCA_ORIG_ZDOTDIR from previous session', async () => { + const currentZdotdir = join(testHome, '.config', 'zsh-current') + mkdirSync(currentZdotdir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${currentZdotdir}"\n`) + + const previousOrcaZdotdir = process.env.ORCA_ORIG_ZDOTDIR + process.env.ORCA_ORIG_ZDOTDIR = '/opt/orca-old/shell-ready/zsh' // stale wrapper path + + try { + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + ORCA_ORIG_ZDOTDIR: '/opt/orca-old/shell-ready/zsh' + } + delete cleanEnv.ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Should discover fresh value from .zshenv, not use stale wrapper path + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${currentZdotdir}`) + } finally { + if (previousOrcaZdotdir === undefined) { + delete process.env.ORCA_ORIG_ZDOTDIR + } else { + process.env.ORCA_ORIG_ZDOTDIR = previousOrcaZdotdir + } + } + }) + + it('prioritizes fresh discovery over inherited ORCA_ORIG_ZDOTDIR', async () => { + const freshZdotdir = join(testHome, '.config', 'zsh-updated') + mkdirSync(freshZdotdir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${freshZdotdir}"\n`) + + const previousOrcaZdotdir = process.env.ORCA_ORIG_ZDOTDIR + const oldZdotdir = join(testHome, '.config', 'zsh-old') + process.env.ORCA_ORIG_ZDOTDIR = oldZdotdir + + try { + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + ORCA_ORIG_ZDOTDIR: oldZdotdir + } + delete cleanEnv.ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Should use fresh discovery (user updated .zshenv) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${freshZdotdir}`) + } finally { + if (previousOrcaZdotdir === undefined) { + delete process.env.ORCA_ORIG_ZDOTDIR + } else { + process.env.ORCA_ORIG_ZDOTDIR = previousOrcaZdotdir + } + } + }) + + it('sources launch-time ZDOTDIR .zshenv when it is explicitly inherited', async () => { + const homeZdotdir = join(testHome, '.config', 'zsh-home') + const inheritedZdotdir = join(testHome, '.config', 'zsh-inherited') + mkdirSync(homeZdotdir, { recursive: true }) + mkdirSync(inheritedZdotdir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + `export SOURCE_MARKER=home\nexport ZDOTDIR="${homeZdotdir}"\n` + ) + writeFileSync( + join(inheritedZdotdir, '.zshenv'), + `export SOURCE_MARKER=inherited\nexport ZDOTDIR="${inheritedZdotdir}"\n` + ) + + const previousZdotdir = process.env.ZDOTDIR + const previousHome = process.env.HOME + process.env.ZDOTDIR = inheritedZdotdir + process.env.HOME = testHome + + try { + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(inheritedZdotdir) + + const cleanEnv: Record = { + ...process.env, + ...config.env, + HOME: testHome + } + + const result = spawnSync( + 'zsh', + [ + '-c', + 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}" && echo "SOURCE_MARKER=${SOURCE_MARKER:-unset}"' + ], + { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + } + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${inheritedZdotdir}`) + expect(result.stdout).toContain('SOURCE_MARKER=inherited') + } finally { + if (previousZdotdir === undefined) { + delete process.env.ZDOTDIR + } else { + process.env.ZDOTDIR = previousZdotdir + } + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + }) + + describeIfZsh('automation and edge cases', () => { + let testHome: string + let userDataPath: string + + beforeEach(async () => { + testHome = mkdtempSync(join(tmpdir(), 'orca-auto-')) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-auto-userdata-')) + getUserDataPathMock.mockReturnValue(userDataPath) + }) + + afterEach(() => { + rmSync(testHome, { recursive: true, force: true }) + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('matches normal zsh when user .zshenv calls exit', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\nexit 42\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "survived"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(42) + expect(result.stdout).not.toContain('survived') + }) + + it('survives user .zshenv with set -e and failing command', async () => { + writeFileSync( + join(testHome, '.zshenv'), + 'set -e\nfalse\nexport ZDOTDIR="$HOME/.config/zsh"\n' + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // No ZDOTDIR was reached after the failing command, so we fall back. + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('survives user .zshenv with set -u before ZDOTDIR is set', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), 'set -u\nexport ZDOTDIR="$HOME/.config/zsh"\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Should work because wrapper uses ${ZDOTDIR:-} which is safe with set -u + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('survives user .zshenv with nullglob set', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + 'setopt nullglob\nexport ZDOTDIR="$HOME/.config/zsh"\n' + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('survives user .zshenv with extendedglob set', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + 'setopt extendedglob\nexport ZDOTDIR="$HOME/.config/zsh"\n' + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('preserves exported .zshenv environment changes in the wrapper shell', async () => { + writeFileSync( + join(testHome, '.zshenv'), + 'export MY_VAR=from-zshenv\nexport ZDOTDIR="$HOME/.config/zsh"\n' + ) + + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + delete cleanEnv.MY_VAR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "MY_VAR=${MY_VAR:-unset}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('MY_VAR=from-zshenv') + }) + + it('handles empty HOME gracefully', async () => { + // When HOME is empty, wrapper should not attempt to source /.zshenv + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { HOME: '' } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Empty HOME falls back to empty ORCA_ORIG_ZDOTDIR + expect(result.stdout).toContain('ORCA_ORIG_ZDOTDIR=\n') + }) + + it('handles unset HOME gracefully', async () => { + // When HOME is unset at spawn, zsh initializes it from /etc/passwd before + // running the wrapper, so the wrapper can discover ZDOTDIR normally. + // This verifies the wrapper doesn't crash when HOME is initially unset. + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = {} + delete cleanEnv.HOME + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // zsh initializes HOME from passwd, wrapper discovers ZDOTDIR normally + expect(result.stdout).toMatch(/ORCA_ORIG_ZDOTDIR=.+/) + }) + + it('handles ZDOTDIR containing only "/"', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="/"\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Single slash normalizes to empty after %/, falls back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('handles ZDOTDIR containing only slashes "///"', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="///"\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Multiple slashes normalize to "/" then to empty after %/, falls back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('handles user .zshenv that unsets HOME', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `unset HOME\nexport ZDOTDIR="${xdgZshDir}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Subshell unsets HOME but wrapper HOME is in parent scope + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('handles user .zshenv that sets ZDOTDIR to empty string', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR=""\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Empty string should be normalized away, fall back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('handles conditional unset of ZDOTDIR', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + `export ZDOTDIR="${xdgZshDir}"\nif [[ "\${TERM}" == "dumb" ]]; then\n unset ZDOTDIR\nfi\n` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Test with TERM=dumb + let cleanEnv: Record = { + ...process.env, + HOME: testHome, + TERM: 'dumb' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + let result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // ZDOTDIR unset conditionally, falls back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + + // Test with TERM=xterm + cleanEnv = { ...process.env, HOME: testHome, TERM: 'xterm-256color' } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // ZDOTDIR not unset, uses discovered value + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + }) }) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index f43eb3a1e2f..53f46f96e9f 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -12,7 +12,7 @@ */ import { tmpdir } from 'os' import { basename, win32 as pathWin32 } from 'path' -import { mkdirSync, writeFileSync, chmodSync } from 'fs' +import { mkdirSync, writeFileSync, chmodSync, existsSync } from 'fs' import { app } from 'electron' import type * as pty from 'node-pty' import { @@ -20,13 +20,11 @@ import { getPowerShellOsc133Bootstrap, isPowerShellExecutableName } from '../powershell-osc133-bootstrap' +import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { getZshEnvTemplate } from '../shell-templates' let didEnsureShellReadyWrappers = false -function quotePosixSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - const STARTUP_COMMAND_READY_MAX_WAIT_MS = 1500 const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready' const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' @@ -85,6 +83,20 @@ function getShellReadyWrapperRoot(): string { return `${userDataPath}/shell-ready` } +function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] { + return [ + `${root}/zsh/.zshenv`, + `${root}/zsh/.zprofile`, + `${root}/zsh/.zshrc`, + `${root}/zsh/.zlogin`, + `${root}/bash/rcfile` + ] +} + +function shellReadyWrappersExist(): boolean { + return getRequiredShellReadyWrapperPaths().every((path) => existsSync(path)) +} + // Why: if our own process inherited ZDOTDIR from a parent shell that was // itself an Orca PTY (e.g. the user launched `pn dev` from a terminal inside // a running Orca), that ZDOTDIR points at an Orca shell-ready wrapper dir. @@ -122,6 +134,10 @@ function resolveOriginalZdotdir(): string { ) } +function resolveOriginalZshenvSourceDir(): string { + return normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || process.env.HOME || '' +} + export function getBashShellReadyRcfileContent(): string { return `# Orca bash shell-ready wrapper [[ -f /etc/profile ]] && source /etc/profile @@ -146,8 +162,15 @@ __orca_restore_attribution_path # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the PTY-scoped overlay before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" -# Why: PI_CODING_AGENT_DIR is also a single-root env var users may re-export. +# Why: bare shells carry both Pi and OMP shadows so a later typed OMP can +# switch on demand. Keep Pi as the shell default unless this PTY is OMP-only. [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then + export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" +fi +${getPosixOmpShellWrapper()} +# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. +[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command (e.g. an interrupted Claude/Codex CLI) # exits — mirrors the zsh wrapper. Without this, bash users (default on most @@ -251,8 +274,15 @@ __orca_restore_attribution_path() { if [[ ! -o login ]]; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" - # Why: PI_CODING_AGENT_DIR must keep the same PTY-scoped overlay after rc files. + # Why: bare shells carry both Pi and OMP shadows; keep Pi as the default and + # let the OMP wrapper switch to OMP only for that command. [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" + if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then + export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" + fi + ${getPosixOmpShellWrapper()} + # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. + [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi __orca_osc133_precmd() { local exit_code=$? @@ -273,7 +303,10 @@ preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]}) } function ensureShellReadyWrappers(): void { - if (didEnsureShellReadyWrappers || process.platform === 'win32') { + if (process.platform === 'win32') { + return + } + if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) { return } didEnsureShellReadyWrappers = true @@ -282,42 +315,7 @@ function ensureShellReadyWrappers(): void { const zshDir = `${root}/zsh` const bashDir = `${root}/bash` - const zshEnv = `# Orca zsh shell-ready wrapper -_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}" -# Why: clearing ZDOTDIR lets user .zshenv use the canonical XDG idiom -# \`export ZDOTDIR="\${ZDOTDIR:-$XDG_CONFIG_HOME/zsh}"\` to compute its -# preferred dir; pre-setting it (even to HOME) defeats that default. -unset ZDOTDIR -# Why: function isolates user .zshenv \`return\` so it doesn't abort our wrapper. -# Trade-off: top-level \`setopt LOCAL_OPTIONS\`/\`LOCAL_TRAPS\`, \`TRAPEXIT\`, and -# bare \`local\`/\`typeset\` in user .zshenv become function-scoped; use \`typeset -g\` -# or \`export\` to escape. -__orca_source_user_zshenv() { - # Why: honor an externally-set ZDOTDIR (login manager, /etc/zshenv, parent - # shell) so users whose real .zshenv lives at $ZDOTDIR (not $HOME) still - # get PATH/aliases/exports loaded. Falls back to $HOME when no spawn-env - # ZDOTDIR was inherited. - local _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}" - [[ -f "$_orca_user_zdotdir/.zshenv" ]] && source "$_orca_user_zdotdir/.zshenv" -} -__orca_source_user_zshenv -unfunction __orca_source_user_zshenv -# Why: prefer the ZDOTDIR user .zshenv resolved (XDG case); else preserve -# the spawn-env value (an inherited resolution from a parent Orca PTY); -# else HOME. -export ORCA_ORIG_ZDOTDIR="\${ZDOTDIR:-\${_orca_spawn_orig_zdotdir:-$HOME}}" -unset _orca_spawn_orig_zdotdir -# Why: strip trailing slashes (matches Node-side normalizer) before the -# self-loop check, so a wrapper-shaped ZDOTDIR with one or more trailing -# slashes still gets normalized away from .zprofile/.zshrc/.zlogin. -while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do - ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}" -done -case "\${ORCA_ORIG_ZDOTDIR}" in - */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; -esac -export ZDOTDIR=${quotePosixSingle(zshDir)} -` + const zshEnv = getZshEnvTemplate(zshDir) const zshProfile = `# Orca zsh shell-ready wrapper _orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" case "\${_orca_home%/}" in @@ -345,6 +343,11 @@ __orca_restore_attribution_path # Why: .zlogin is the final login startup file before the prompt is shown. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then + export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" +fi +${getPosixOmpShellWrapper()} +[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: zsh precmd runs before the prompt is drawn and before zle owns input, # which can double-echo startup commands. line-init fires when zle is ready. if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then @@ -366,11 +369,26 @@ fi [`${bashDir}/rcfile`, bashRc] ] as const - for (const [path, content] of files) { - const dir = path.slice(0, path.lastIndexOf('/')) - mkdirSync(dir, { recursive: true }) - writeFileSync(path, content, 'utf8') - chmodSync(path, 0o644) + try { + for (const [path, content] of files) { + const dir = path.slice(0, path.lastIndexOf('/')) + mkdirSync(dir, { recursive: true }) + writeFileSync(path, content, 'utf8') + chmodSync(path, 0o644) + } + } catch (error) { + // Why: wrapper file creation can fail due to read-only filesystems, permission + // issues, or disk space. Rather than crashing, log the error and continue. + // The shell will launch without the wrapper, which means no shell-ready marker + // but at least the PTY is usable. + const errorMessage = + error instanceof Error + ? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})` + : String(error) + console.error(`[shell-ready] Failed to create wrapper files in ${root}: ${errorMessage}`) + console.error('[shell-ready] Shell will launch without wrapper (no shell-ready marker)') + // Reset the flag so next attempt will try again + didEnsureShellReadyWrappers = false } } @@ -394,6 +412,7 @@ function getWrappedShellLaunchConfig( args: ['-l'], env: { ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(), + ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(), ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`, ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0' }, diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts index 1932af4ce5d..29daf5af532 100644 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -179,4 +179,61 @@ describe('SshFilesystemProvider readFile streaming', () => { }) await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/count mismatch/i) }) + + it('rejects a short final chunk instead of zero-filling the buffer', async () => { + // Two declared chunks, but the final one delivers a single byte. The chunk + // count matches (2), so the old code resolved with a zero-filled tail. The + // exact-length check must reject this. + const totalSize = 256 * 1024 * 2 + mux.request.mockImplementation(async () => { + setImmediate(() => { + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 0, + data: Buffer.alloc(256 * 1024).toString('base64') + }) + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 1, + data: Buffer.alloc(1).toString('base64') + }) + mux._emitMethod('fs.streamEnd', { streamId: 1 }) + }) + return { + streamId: 1, + totalSize, + isBinary: true, + chunkEncoding: 'base64', + resultEncoding: 'base64' + } + }) + await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/length mismatch/i) + }) + + it('rejects a short non-final chunk before later chunks arrive', async () => { + const totalSize = 256 * 1024 * 2 + mux.request.mockImplementation(async () => { + setImmediate(() => { + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 0, + data: Buffer.alloc(1).toString('base64') + }) + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 1, + data: Buffer.alloc(256 * 1024).toString('base64') + }) + mux._emitMethod('fs.streamEnd', { streamId: 1 }) + }) + return { + streamId: 1, + totalSize, + isBinary: true, + chunkEncoding: 'base64', + resultEncoding: 'base64' + } + }) + await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/length mismatch/i) + }) }) diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 84435e8d5a9..07b64914be5 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -3,6 +3,7 @@ SFTP binary writes, watch fan-out, and provider lifecycle tests together so transport parity regressions are visible in one suite. */ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshFilesystemProvider } from './ssh-filesystem-provider' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' type MockMultiplexer = { request: ReturnType @@ -180,6 +181,41 @@ describe('SshFilesystemProvider', () => { }) }) + describe('lstat', () => { + it('sends fs.lstat request', async () => { + const statResult = { size: 12, type: 'symlink', mtime: 1234567890 } + mux.request.mockResolvedValue(statResult) + + const result = await provider.lstat('/home/user/link.txt') + expect(mux.request).toHaveBeenCalledWith('fs.lstat', { filePath: '/home/user/link.txt' }) + expect(result).toEqual(statResult) + }) + + it('falls back to SFTP lstat when connected to an older relay', async () => { + mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) + const sftp = { + lstat: vi.fn((_path: string, callback: (err: Error | undefined, stats: unknown) => void) => + callback(undefined, { + size: 12, + mtime: 1234567, + isDirectory: () => false, + isSymbolicLink: () => true + }) + ), + end: vi.fn() + } + provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) + + await expect(provider.lstat('/home/user/link.txt')).resolves.toEqual({ + size: 12, + type: 'symlink', + mtime: 1234567000 + }) + expect(sftp.lstat).toHaveBeenCalledWith('/home/user/link.txt', expect.any(Function)) + expect(sftp.end).toHaveBeenCalled() + }) + }) + it('scanWorkspaceSpace sends an abortable bulk scan request', async () => { const result = { sizeBytes: 1024, @@ -224,6 +260,29 @@ describe('SshFilesystemProvider', () => { }) }) + it('renameNoClobber sends fs.renameNoClobber request', async () => { + await provider.renameNoClobber('/home/old.txt', '/home/new.txt') + expect(mux.request).toHaveBeenCalledWith('fs.renameNoClobber', { + oldPath: '/home/old.txt', + newPath: '/home/new.txt' + }) + }) + + it('renameNoClobber fails closed when the relay lacks safe rename support', async () => { + mux.request.mockRejectedValueOnce( + Object.assign(new Error('Method not found'), { code: JsonRpcErrorCode.MethodNotFound }) + ) + + await expect(provider.renameNoClobber('/home/old.txt', '/home/new.txt')).rejects.toThrow( + 'Remote safe rename is unavailable' + ) + expect(mux.request).toHaveBeenCalledTimes(1) + expect(mux.request).toHaveBeenCalledWith('fs.renameNoClobber', { + oldPath: '/home/old.txt', + newPath: '/home/new.txt' + }) + }) + it('copy sends fs.copy request', async () => { await provider.copy('/home/src.txt', '/home/dst.txt') expect(mux.request).toHaveBeenCalledWith('fs.copy', { diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 99444ffd6b4..6268a22aa6b 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -5,7 +5,7 @@ import type { IFilesystemProvider, FileStat, FileReadResult } from './types' import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' -import type { SFTPWrapper } from 'ssh2' +import type { SFTPWrapper, Stats } from 'ssh2' type SftpFactory = () => Promise type WatchRegistration = { @@ -15,6 +15,28 @@ type WatchRegistration = { const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 +function fileStatFromSftpStats(stats: Stats): FileStat { + let type: FileStat['type'] = 'file' + if (stats.isDirectory()) { + type = 'directory' + } else if (stats.isSymbolicLink()) { + type = 'symlink' + } + return { size: stats.size, type, mtime: stats.mtime * 1000 } +} + +function lstatViaSftp(sftp: SFTPWrapper, filePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.lstat(filePath, (err, stats) => { + if (err) { + reject(err) + return + } + resolve(fileStatFromSftpStats(stats)) + }) + }) +} + export class SshFilesystemProvider implements IFilesystemProvider { private connectionId: string private mux: SshChannelMultiplexer @@ -140,6 +162,27 @@ export class SshFilesystemProvider implements IFilesystemProvider { return (await this.mux.request('fs.stat', { filePath })) as FileStat } + async lstat(filePath: string): Promise { + try { + return (await this.mux.request('fs.lstat', { filePath })) as FileStat + } catch (err) { + if (!isMethodNotFoundError(err)) { + throw err + } + if (!this.createSftp) { + throw new Error('remote_lstat_unavailable') + } + const sftp = await this.createSftp() + try { + // Why: older relays predate fs.lstat, but SFTP can still preserve + // symlink identity for orphaned-worktree safety checks. + return await lstatViaSftp(sftp, filePath) + } finally { + sftp.end() + } + } + } + async scanWorkspaceSpace( rootPath: string, options?: { signal?: AbortSignal } @@ -171,6 +214,19 @@ export class SshFilesystemProvider implements IFilesystemProvider { await this.mux.request('fs.rename', { oldPath, newPath }) } + async renameNoClobber(oldPath: string, newPath: string): Promise { + try { + await this.mux.request('fs.renameNoClobber', { oldPath, newPath }) + } catch (err) { + if (isMethodNotFoundError(err)) { + // Why: falling back to raw fs.rename can silently clobber the target on + // older relays. Fail closed and let reconnect deploy the safe relay. + throw new Error('Remote safe rename is unavailable. Reconnect the SSH target and retry.') + } + throw err + } + } + async copy(source: string, destination: string): Promise { await this.mux.request('fs.copy', { source, destination }) } diff --git a/src/main/providers/ssh-git-provider-merge.test.ts b/src/main/providers/ssh-git-provider-merge.test.ts new file mode 100644 index 00000000000..2a6107c8adc --- /dev/null +++ b/src/main/providers/ssh-git-provider-merge.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' +import { SshGitProvider } from './ssh-git-provider' + +describe('SshGitProvider merge operations', () => { + it('abortMerge sends git.abortMerge request', async () => { + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } + const provider = new SshGitProvider('conn-1', mux as never) + + await provider.abortMerge('/home/user/repo') + + expect(mux.request).toHaveBeenCalledWith('git.abortMerge', { + worktreePath: '/home/user/repo' + }) + }) +}) diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index d5e0cb73343..6e35f7d499f 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -647,6 +647,14 @@ describe('SshGitProvider', () => { }) }) + it('renameCurrentBranch sends the narrow branch-rename request', async () => { + await provider.renameCurrentBranch('/home/user/feat', 'you/fix-auth') + expect(mux.request).toHaveBeenCalledWith('git.renameCurrentBranch', { + worktreePath: '/home/user/feat', + newBranch: 'you/fix-auth' + }) + }) + it('isGitRepo always returns true for remote paths', () => { expect(provider.isGitRepo('/any/path')).toBe(true) }) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index f99352b64d0..a1a1ab4f1cf 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -281,6 +281,10 @@ export class SshGitProvider implements IGitProvider { })) as GitConflictOperation } + async abortMerge(worktreePath: string): Promise { + await this.mux.request('git.abortMerge', { worktreePath }) + } + async getBranchCompare(worktreePath: string, baseRef: string): Promise { return (await this.mux.request('git.branchCompare', { worktreePath, @@ -402,6 +406,17 @@ export class SshGitProvider implements IGitProvider { await this.mux.request('git.removeWorktree', { worktreePath, force, ...options }) } + async worktreeIsClean(worktreePath: string): Promise<{ clean: boolean; stdout?: string }> { + return (await this.mux.request('git.worktreeIsClean', { worktreePath })) as { + clean: boolean + stdout?: string + } + } + + async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { + await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) + } + async exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> { return (await this.mux.request('git.exec', { args, cwd })) as { stdout: string diff --git a/src/main/providers/ssh-pty-id.ts b/src/main/providers/ssh-pty-id.ts index 992111cd664..7b1f430c12c 100644 --- a/src/main/providers/ssh-pty-id.ts +++ b/src/main/providers/ssh-pty-id.ts @@ -1,54 +1,2 @@ -const SSH_PTY_ID_PREFIX = 'ssh:' -const SSH_PTY_ID_SEPARATOR = '@@' - -// Why: SSH relays allocate target-local ids like "pty-1"; app-wide routing -// needs the target id embedded so two relays cannot collide after restore. -export type ParsedSshPtyId = { - connectionId: string - relayPtyId: string -} - -export function parseAppSshPtyId(ptyId: string): ParsedSshPtyId | null { - if (!ptyId.startsWith(SSH_PTY_ID_PREFIX)) { - return null - } - const separatorIndex = ptyId.indexOf(SSH_PTY_ID_SEPARATOR, SSH_PTY_ID_PREFIX.length) - if (separatorIndex === -1) { - return null - } - const encodedConnectionId = ptyId.slice(SSH_PTY_ID_PREFIX.length, separatorIndex) - const relayPtyId = ptyId.slice(separatorIndex + SSH_PTY_ID_SEPARATOR.length) - if (!encodedConnectionId || !relayPtyId) { - return null - } - try { - return { - connectionId: decodeURIComponent(encodedConnectionId), - relayPtyId - } - } catch { - return null - } -} - -export function toAppSshPtyId(connectionId: string, relayPtyId: string): string { - const parsed = parseAppSshPtyId(relayPtyId) - if (parsed) { - if (parsed.connectionId !== connectionId) { - throw new Error(`PTY ${relayPtyId} belongs to SSH connection "${parsed.connectionId}"`) - } - return relayPtyId - } - return `${SSH_PTY_ID_PREFIX}${encodeURIComponent(connectionId)}${SSH_PTY_ID_SEPARATOR}${relayPtyId}` -} - -export function toRelaySshPtyId(connectionId: string, ptyId: string): string { - const parsed = parseAppSshPtyId(ptyId) - if (!parsed) { - return ptyId - } - if (parsed.connectionId !== connectionId) { - throw new Error(`PTY ${ptyId} belongs to SSH connection "${parsed.connectionId}"`) - } - return parsed.relayPtyId -} +export { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../../shared/ssh-pty-id' +export type { ParsedSshPtyId } from '../../shared/ssh-pty-id' diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 6a088bcbf43..1c8c72288d3 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -119,7 +119,14 @@ export class SshPtyProvider implements IPtyProvider { cols: opts.cols, rows: opts.rows, cwd: opts.cwd, - env: opts.env + env: opts.env, + // Why: the relay's plugin-overlay env augmenter needs to know which + // Pi-compatible agent is being launched (`pi` vs `omp`) so it mirrors + // the right `~/./agent` source dir on the remote disk. The + // relay does not execute `command` itself — the user types it into + // the shell — but receiving it as a hint lets overlay resolution be + // per-launch instead of always-Pi. + ...(opts.command ? { command: opts.command } : {}) }) return { ...(result as PtySpawnResult), diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 3bf122ec3df..fdd50bcb9d9 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -28,9 +28,14 @@ export type PtySpawnOptions = { /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ worktreeId?: string - /** Daemon session ID for reattach. When provided, the daemon reconnects - * to an existing session instead of creating a new one. */ + /** Daemon session ID. A caller-provided ID is treated as an attach request; + * daemon hosts also pass minted IDs for fresh sessions that need stable + * per-PTY state before provider.spawn returns. */ sessionId?: string + /** True when the caller minted this daemon session for a fresh terminal. + * Existing-session attach paths must stay false so recovery checks do not + * replace the daemon out from under a still-live PTY. */ + isNewSession?: boolean /** Why: allows the renderer to request a specific shell for a single new * terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without * changing the user's persistent default shell setting. Only consulted on @@ -129,11 +134,13 @@ export type IFilesystemProvider = { writeFileBase64(filePath: string, contentBase64: string): Promise writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise stat(filePath: string): Promise + lstat?(filePath: string): Promise deletePath(targetPath: string, recursive?: boolean): Promise createFile(filePath: string): Promise createDir(dirPath: string): Promise createDirNoClobber(dirPath: string): Promise rename(oldPath: string, newPath: string): Promise + renameNoClobber(oldPath: string, newPath: string): Promise copy(source: string, destination: string): Promise realpath(filePath: string): Promise search(opts: SearchOptions): Promise @@ -166,6 +173,7 @@ export type IGitProvider = { discardChanges(worktreePath: string, filePath: string): Promise bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise detectConflictOperation(worktreePath: string): Promise + abortMerge(worktreePath: string): Promise getBranchCompare(worktreePath: string, baseRef: string): Promise getCommitCompare(worktreePath: string, commitId: string): Promise getUpstreamStatus(worktreePath: string, pushTarget?: GitPushTarget): Promise @@ -199,10 +207,12 @@ export type IGitProvider = { force?: boolean, options?: { deleteBranch?: boolean } ): Promise + renameCurrentBranch?(worktreePath: string, newBranch: string): Promise isGitRepo(path: string): boolean isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> getRemoteFileUrl(worktreePath: string, relativePath: string, line: number): Promise + worktreeIsClean(worktreePath: string): Promise<{ clean: boolean; stdout?: string }> } // ─── Provider Registry ────────────────────────────────────────────── diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 0adeaa83b6a..7f2e13d596f 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -34,13 +34,20 @@ describe('resolveWindowsShellLaunchArgs', () => { const piRestoreIndex = command.indexOf( '$env:PI_CODING_AGENT_DIR = $env:ORCA_PI_CODING_AGENT_DIR' ) + const ompRestoreIndex = command.indexOf( + '$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_CODING_AGENT_DIR' + ) + const codexRestoreIndex = command.indexOf('$env:CODEX_HOME = $env:ORCA_CODEX_HOME') const promptIndex = command.indexOf('function Global:prompt') expect(command).not.toContain('$PROFILE') expect(outputEncodingIndex).toBeGreaterThanOrEqual(0) expect(opencodeRestoreIndex).toBeGreaterThan(outputEncodingIndex) expect(piRestoreIndex).toBeGreaterThan(outputEncodingIndex) - expect(promptIndex).toBeGreaterThan(piRestoreIndex) + expect(ompRestoreIndex).toBeGreaterThan(piRestoreIndex) + expect(codexRestoreIndex).toBeGreaterThan(outputEncodingIndex) + expect(codexRestoreIndex).toBeGreaterThan(ompRestoreIndex) + expect(promptIndex).toBeGreaterThan(codexRestoreIndex) expect(command).toContain('Esc = [char]27') expect(command).toContain('Bel = [char]7') expect(command).toContain(')]133;D;$fakeExitCode$(') diff --git a/src/main/pty/omp-shell-wrapper.node-pty.test.ts b/src/main/pty/omp-shell-wrapper.node-pty.test.ts new file mode 100644 index 00000000000..2843b8a559b --- /dev/null +++ b/src/main/pty/omp-shell-wrapper.node-pty.test.ts @@ -0,0 +1,163 @@ +import { spawnSync } from 'child_process' +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import * as pty from 'node-pty' +import { afterEach, describe, expect, it } from 'vitest' +import { getPosixOmpShellWrapper } from './omp-shell-wrapper' + +const describePosix = process.platform === 'win32' ? describe.skip : describe +const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 +const itWithBash = hasBash ? it : it.skip + +const tempDirs: string[] = [] + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-omp-node-pty-')) + tempDirs.push(dir) + return dir +} + +function writeFakeOmp(binDir: string): void { + const ompPath = join(binDir, 'omp') + writeFileSync( + ompPath, + `#!/bin/sh +{ + printf 'PI=%s\\n' "$PI_CODING_AGENT_DIR" + i=0 + for arg in "$@"; do + i=$((i + 1)) + printf 'ARG%s=%s\\n' "$i" "$arg" + done +} > "$ORCA_CAPTURE_FILE" +`, + { mode: 0o755 } + ) + chmodSync(ompPath, 0o755) +} + +async function runInteractiveBashPty(args: { + rcfileContent: string + env: Record + input: string + cwd: string +}): Promise { + const rcfile = join(args.cwd, 'rcfile') + writeFileSync(rcfile, args.rcfileContent) + + const proc = pty.spawn('bash', ['--noprofile', '--rcfile', rcfile, '-i'], { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: args.cwd, + env: args.env + }) + + let output = '' + proc.onData((data) => { + output += data + }) + + const exitPromise = new Promise<{ exitCode: number }>((resolve) => { + proc.onExit(({ exitCode }) => resolve({ exitCode })) + }) + + let timeout: ReturnType | null = null + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`timed out waiting for bash PTY output:\n${output}`)), + 5000 + ) + }) + + try { + proc.write(args.input.replace(/\n/g, '\r')) + const { exitCode } = await Promise.race([exitPromise, timeoutPromise]) + expect(exitCode).toBe(0) + return output + } finally { + if (timeout !== null) { + clearTimeout(timeout) + } + try { + proc.kill() + } catch { + // The process may already have exited normally before cleanup runs. + } + } +} + +describePosix('OMP shell wrapper node-pty reproduction', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + itWithBash('reproduces why restored shells miss OMP status without the wrapper', async () => { + const tempDir = makeTempDir() + const binDir = join(tempDir, 'bin') + const piDir = join(tempDir, 'pi-agent') + const ompDir = join(tempDir, 'omp-agent') + const extensionDir = join(ompDir, 'extensions') + mkdirSync(binDir) + mkdirSync(piDir) + mkdirSync(extensionDir, { recursive: true }) + const statusExtension = join(extensionDir, 'orca-agent-status.ts') + writeFileSync(statusExtension, 'export default {}') + writeFakeOmp(binDir) + + const makeEnv = (captureFile: string, afterPiFile: string): Record => ({ + ...process.env, + HOME: tempDir, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + PI_CODING_AGENT_DIR: piDir, + ORCA_PI_CODING_AGENT_DIR: piDir, + ORCA_OMP_CODING_AGENT_DIR: ompDir, + ORCA_OMP_STATUS_EXTENSION: statusExtension, + ORCA_CAPTURE_FILE: captureFile, + ORCA_AFTER_PI_FILE: afterPiFile, + TERM: process.env.TERM || 'xterm-256color' + }) + + const unwrappedCapture = join(tempDir, 'unwrapped-capture') + const unwrappedAfterPi = join(tempDir, 'unwrapped-after-pi') + await runInteractiveBashPty({ + cwd: tempDir, + rcfileContent: '', + env: makeEnv(unwrappedCapture, unwrappedAfterPi), + input: `omp ask +printf '%s' "$PI_CODING_AGENT_DIR" > "$ORCA_AFTER_PI_FILE" +exit 0 +` + }) + + const unwrapped = readFileSync(unwrappedCapture, 'utf8') + expect(unwrapped).toContain(`PI=${piDir}`) + expect(unwrapped).toContain('ARG1=ask') + expect(unwrapped).not.toContain('ARG1=--extension') + + const wrappedCapture = join(tempDir, 'wrapped-capture') + const wrappedAfterPi = join(tempDir, 'wrapped-after-pi') + const wrappedOutput = await runInteractiveBashPty({ + cwd: tempDir, + rcfileContent: `[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +${getPosixOmpShellWrapper()}`, + env: makeEnv(wrappedCapture, wrappedAfterPi), + input: `type omp +omp ask +printf '%s' "$PI_CODING_AGENT_DIR" > "$ORCA_AFTER_PI_FILE" +exit 0 +` + }) + + const wrapped = readFileSync(wrappedCapture, 'utf8') + expect(wrappedOutput).toContain('omp is a function') + expect(wrapped).toContain(`PI=${ompDir}`) + expect(wrapped).toContain('ARG1=--extension') + expect(wrapped).toContain(`ARG2=${statusExtension}`) + expect(wrapped).toContain('ARG3=ask') + expect(readFileSync(wrappedAfterPi, 'utf8')).toBe(piDir) + }) +}) diff --git a/src/main/pty/omp-shell-wrapper.ts b/src/main/pty/omp-shell-wrapper.ts new file mode 100644 index 00000000000..c3d449d9171 --- /dev/null +++ b/src/main/pty/omp-shell-wrapper.ts @@ -0,0 +1,128 @@ +// Why: OMP 15.x discovers built-in user extensions from ~/.omp/agent, not +// PI_CODING_AGENT_DIR/extensions. Orca's per-PTY status extension must be +// passed explicitly when users type `omp` in an existing terminal. + +const OMP_SUBCOMMANDS = [ + 'acp', + 'agents', + 'auth-broker', + 'auth-gateway', + 'commit', + 'config', + 'grep', + 'grievances', + 'plugin', + 'setup', + 'shell', + 'read', + 'ssh', + 'stats', + 'update', + 'worktree', + 'wt', + 'search', + 'q' +] as const + +export function getPosixOmpShellWrapper(): string { + const subcommands = OMP_SUBCOMMANDS.join('|') + return `# Why: OMP does not auto-load Orca's PTY overlay extensions; wrap only +# interactive launch invocations so subcommands such as \`omp config\` keep +# their normal argv shape. +__orca_omp_is_subcommand() { + case "\${1:-}" in + ${subcommands}) return 0 ;; + esac + return 1 +} +__orca_omp_should_skip_extension() { + case "\${1:-}" in + help|--help|-h|--version|-v) return 0 ;; + esac + __orca_omp_is_subcommand "\${1:-}" +} +__orca_omp() { + local __orca_prev_pi="\${PI_CODING_AGENT_DIR-}" + local __orca_had_pi=0 + [[ -n "\${PI_CODING_AGENT_DIR+x}" ]] && __orca_had_pi=1 + [[ -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" + + local __orca_status=0 + if [[ -n "\${ORCA_OMP_STATUS_EXTENSION:-}" && -f "\${ORCA_OMP_STATUS_EXTENSION}" ]] && ! __orca_omp_should_skip_extension "\${1:-}"; then + if [[ "\${1:-}" == "launch" ]]; then + shift + command omp launch --extension "\${ORCA_OMP_STATUS_EXTENSION}" "$@" + else + command omp --extension "\${ORCA_OMP_STATUS_EXTENSION}" "$@" + fi + else + command omp "$@" + fi + __orca_status=$? + + if [[ $__orca_had_pi -eq 1 ]]; then + export PI_CODING_AGENT_DIR="$__orca_prev_pi" + else + unset PI_CODING_AGENT_DIR + fi + return $__orca_status +} +if [[ -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" || -n "\${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then + omp() { __orca_omp "$@"; } +fi +` +} + +export function getPowerShellOmpShellWrapper(): string { + const subcommands = OMP_SUBCOMMANDS.map((value) => `'${value}'`).join(', ') + return `# Why: OMP does not auto-load Orca's PTY overlay extensions; wrap only +# interactive launch invocations so subcommands such as \`omp config\` keep +# their normal argv shape. +function Global:__OrcaOmpIsSubcommand { + param([string]$Name) + $subcommands = @(${subcommands}) + return $subcommands -contains $Name +} +function Global:__OrcaOmpShouldSkipExtension { + param([string]$Name) + if (@("help", "--help", "-h", "--version", "-v") -contains $Name) { return $true } + return __OrcaOmpIsSubcommand -Name $Name +} +if ($env:ORCA_OMP_CODING_AGENT_DIR -or $env:ORCA_OMP_STATUS_EXTENSION) { + function Global:omp { + $orcaPrevPi = $env:PI_CODING_AGENT_DIR + $orcaHadPi = Test-Path Env:PI_CODING_AGENT_DIR + if ($env:ORCA_OMP_CODING_AGENT_DIR) { + $env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_CODING_AGENT_DIR + } + + $orcaStatus = 0 + $orcaCommand = Get-Command omp -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $orcaCommand) { + Write-Error "omp executable not found" + $orcaStatus = 127 + } elseif ($env:ORCA_OMP_STATUS_EXTENSION -and + (Test-Path -LiteralPath $env:ORCA_OMP_STATUS_EXTENSION) -and + -not (__OrcaOmpShouldSkipExtension -Name ([string]($args[0])))) { + if ($args.Count -gt 0 -and $args[0] -eq "launch") { + $orcaLaunchArgs = @($args | Select-Object -Skip 1) + & $orcaCommand.Source launch --extension $env:ORCA_OMP_STATUS_EXTENSION @orcaLaunchArgs + } else { + & $orcaCommand.Source --extension $env:ORCA_OMP_STATUS_EXTENSION @args + } + $orcaStatus = $LASTEXITCODE + } else { + & $orcaCommand.Source @args + $orcaStatus = $LASTEXITCODE + } + + if ($orcaHadPi) { + $env:PI_CODING_AGENT_DIR = $orcaPrevPi + } else { + Remove-Item Env:PI_CODING_AGENT_DIR -ErrorAction SilentlyContinue + } + $global:LASTEXITCODE = $orcaStatus + } +} +` +} diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts new file mode 100644 index 00000000000..cc6b832e9f6 --- /dev/null +++ b/src/main/pty/windows-environment-path.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { + mergePersistedWindowsPath, + readPersistedWindowsPathSegments +} from './windows-environment-path' + +describe('readPersistedWindowsPathSegments', () => { + it('reads machine and user Path values from the Windows registry', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce( + [ + '', + 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', + ' Path REG_EXPAND_SZ %SystemRoot%\\System32;C:\\Tools', + '' + ].join('\r\n') + ) + .mockReturnValueOnce( + ['', 'HKEY_CURRENT_USER\\Environment', ' Path REG_SZ C:\\Users\\me\\bin', ''].join( + '\r\n' + ) + ) + + const segments = readPersistedWindowsPathSegments({ + platform: 'win32', + execFileSync, + env: { SystemRoot: 'C:\\Windows' } + }) + + expect(segments).toEqual(['C:\\Windows\\System32', 'C:\\Tools', 'C:\\Users\\me\\bin']) + }) + + it('returns an empty list outside Windows', () => { + const execFileSync = vi.fn() + + expect(readPersistedWindowsPathSegments({ platform: 'linux', execFileSync })).toEqual([]) + expect(execFileSync).not.toHaveBeenCalled() + }) +}) + +describe('mergePersistedWindowsPath', () => { + it('appends missing persisted segments without reordering the inherited PATH', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce( + [ + '', + 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', + ' Path REG_EXPAND_SZ C:\\Windows\\System32;C:\\Existing', + '' + ].join('\r\n') + ) + .mockReturnValueOnce( + [ + '', + 'HKEY_CURRENT_USER\\Environment', + ' Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Local\\agy\\bin;C:\\Existing', + '' + ].join('\r\n') + ) + const env = { Path: 'C:\\Existing' } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env.Path).toBe( + 'C:\\Existing;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Local\\agy\\bin' + ) + }) + + it('uses PATH when that is the existing path key', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + const env = { PATH: 'C:\\Current' } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env).toEqual({ PATH: 'C:\\Current;C:\\Machine;C:\\User' }) + }) + + it('keeps the inherited process PATH when the target env has no path key', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + const env: Record = {} + + mergePersistedWindowsPath(env, { + platform: 'win32', + execFileSync, + env: { Path: 'C:\\Inherited' } + }) + + expect(env).toEqual({ Path: 'C:\\Inherited;C:\\Machine;C:\\User' }) + }) +}) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts new file mode 100644 index 00000000000..e17e29fa421 --- /dev/null +++ b/src/main/pty/windows-environment-path.ts @@ -0,0 +1,110 @@ +import { execFileSync } from 'node:child_process' + +type ExecFileSync = typeof execFileSync + +type ReadWindowsPathOptions = { + execFileSync?: ExecFileSync + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform +} + +const WINDOWS_PATH_REGISTRY_KEYS = [ + ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], + ['HKCU\\Environment', 'Path'] +] as const + +function parseRegistryPathValue(output: string, valueName: string): string | null { + const valuePattern = new RegExp(`^\\s*${valueName}\\s+REG_\\w+\\s+(.*)$`, 'i') + for (const line of output.split(/\r?\n/)) { + const match = valuePattern.exec(line) + if (match) { + return match[1]?.trim() ?? '' + } + } + return null +} + +function expandWindowsEnvironmentVariables(value: string, env: NodeJS.ProcessEnv): string { + return value.replace(/%([^%]+)%/g, (match, rawName: string) => { + const name = rawName.toLowerCase() + const envKey = Object.keys(env).find((key) => key.toLowerCase() === name) + return envKey && env[envKey] ? env[envKey] : match + }) +} + +function getPathDelimiter(platform: NodeJS.Platform): string { + return platform === 'win32' ? ';' : ':' +} + +function splitPathSegments(pathValue: string, pathDelimiter: string): string[] { + return pathValue + .split(pathDelimiter) + .map((segment) => segment.trim()) + .filter(Boolean) +} + +export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions = {}): string[] { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return [] + } + + const run = options.execFileSync ?? execFileSync + const env = options.env ?? process.env + const pathDelimiter = getPathDelimiter(platform) + const segments: string[] = [] + + for (const [key, valueName] of WINDOWS_PATH_REGISTRY_KEYS) { + try { + const output = run('reg.exe', ['query', key, '/v', valueName], { + encoding: 'utf8', + windowsHide: true + }) + const value = parseRegistryPathValue(output, valueName) + if (value) { + segments.push( + ...splitPathSegments(expandWindowsEnvironmentVariables(value, env), pathDelimiter) + ) + } + } catch { + // Registry access can fail in stripped test containers or remote-like + // Windows contexts. Existing PATH remains the fallback in those cases. + } + } + + return segments +} + +export function mergePersistedWindowsPath( + env: Record, + options: ReadWindowsPathOptions = {} +): void { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return + } + + const pathKey = env.Path !== undefined ? 'Path' : env.PATH !== undefined ? 'PATH' : 'Path' + const pathDelimiter = getPathDelimiter(platform) + const sourceEnv = options.env ?? process.env + const currentPath = env[pathKey] ?? sourceEnv.PATH ?? sourceEnv.Path ?? '' + const currentSegments = splitPathSegments(currentPath, pathDelimiter) + const existing = new Set(currentSegments.map((segment) => segment.toLowerCase())) + const missing = readPersistedWindowsPathSegments(options).filter((segment) => { + const normalized = segment.toLowerCase() + if (existing.has(normalized)) { + return false + } + existing.add(normalized) + return true + }) + + if (missing.length === 0) { + return + } + + // Why: Windows broadcasts PATH changes to future processes, but a running + // Electron app keeps its old environment. Append the persisted additions so + // newly installed CLIs resolve without unexpectedly reordering existing PATH. + env[pathKey] = [...currentSegments, ...missing].join(pathDelimiter) +} diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index eb9a4dbeb90..0d56e06fbe9 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -18,6 +18,7 @@ import { resolveOwnedClaudeManagedAuthPath } from '../claude-accounts/managed-auth-path' import { createOAuthUsageError, OAuthUsageError } from './claude-oauth-usage-error' +import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' @@ -318,7 +319,7 @@ export async function fetchClaudeRateLimits(options?: { session: null, weekly: null, updatedAt: Date.now(), - error: err.message, + error: withMacTailscaleDnsHint(err.message), status: 'error' } } @@ -340,7 +341,7 @@ export async function fetchClaudeRateLimits(options?: { session: null, weekly: null, updatedAt: Date.now(), - error: message, + error: withMacTailscaleDnsHint(message), status: 'error' } } diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index 4466f0d0b06..c416e3b2430 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -52,24 +52,24 @@ describe('fetchViaPty', () => { it('disposes node-pty listeners before killing the hidden PTY on timeout', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() + const killMock = vi.fn() spawnMock.mockReturnValue({ onData: vi.fn(() => onDataDisposable), onExit: vi.fn(() => onExitDisposable), write: vi.fn(), - kill: vi.fn() + kill: killMock }) const resultPromise = fetchViaPty() await vi.advanceTimersByTimeAsync(25_000) await resultPromise - const term = spawnMock.mock.results[0]?.value as { kill: ReturnType } expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - term.kill.mock.invocationCallOrder[0] + killMock.mock.invocationCallOrder[0] ) expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - term.kill.mock.invocationCallOrder[0] + killMock.mock.invocationCallOrder[0] ) }) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 8948e42455c..8f0dc78bc23 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -1,7 +1,12 @@ +/* eslint-disable max-lines -- Why: Claude PTY usage scraping keeps prompt +driving, parser, timers, and teardown in one state machine; splitting it would +make the lifecycle harder to audit. */ import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' +import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup' const PTY_TIMEOUT_MS = 25_000 const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit @@ -178,18 +183,10 @@ export async function fetchViaPty(options?: { env: spawnEnv }) const termDisposables: { dispose: () => void }[] = [] - const disposeTermListeners = (): void => { - for (const disposable of termDisposables.splice(0)) { - disposable.dispose() - } - } const timeout = setTimeout(() => { if (!resolved) { resolved = true - // Why: node-pty's NAPI callbacks can outlive the Electron JS - // environment if we kill the hidden PTY without disposing them first, - // which matches Orca's documented SIGABRT failure mode on shutdown. if (claude21UsageSettleTimer) { clearTimeout(claude21UsageSettleTimer) claude21UsageSettleTimer = null @@ -198,8 +195,7 @@ export async function fetchViaPty(options?: { clearInterval(enterInterval) enterInterval = null } - disposeTermListeners() - term.kill() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) // Even on timeout, try to parse whatever we collected const clean = stripTerminalControlSequences(output) const { session, weekly } = parsePtyUsage(clean) @@ -218,10 +214,12 @@ export async function fetchViaPty(options?: { session: null, weekly: null, updatedAt: Date.now(), - error: + error: withMacTailscaleDnsHint( CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean) ? describeClaudeUsageFailure(clean) : 'PTY timeout — /usage panel did not render', + clean + ), status: 'error' }) } @@ -256,8 +254,7 @@ export async function fetchViaPty(options?: { if (enterInterval) { clearInterval(enterInterval) } - disposeTermListeners() - term.kill() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) const clean = stripTerminalControlSequences(output) const { session, weekly } = parsePtyUsage(clean) @@ -268,7 +265,7 @@ export async function fetchViaPty(options?: { session: null, weekly: null, updatedAt: Date.now(), - error: describeClaudeUsageFailure(clean), + error: withMacTailscaleDnsHint(describeClaudeUsageFailure(clean), clean), status: 'error' }) } else { @@ -350,7 +347,7 @@ export async function fetchViaPty(options?: { } const onExitDisposable = term.onExit(() => { - disposeTermListeners() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) if (claude21UsageSettleTimer) { clearTimeout(claude21UsageSettleTimer) claude21UsageSettleTimer = null @@ -369,7 +366,10 @@ export async function fetchViaPty(options?: { session, weekly, updatedAt: Date.now(), - error: session || weekly ? null : 'CLI exited before /usage rendered', + error: + session || weekly + ? null + : withMacTailscaleDnsHint('CLI exited before /usage rendered', clean), status: session || weekly ? 'ok' : 'error' }) } diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 1eb64c38c3d..9a12722303f 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -49,6 +49,7 @@ describe('fetchCodexRateLimits', () => { it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() + const killMock = vi.fn() childSpawnMock.mockImplementation(() => { throw new Error('rpc unavailable') @@ -57,19 +58,18 @@ describe('fetchCodexRateLimits', () => { onData: vi.fn(() => onDataDisposable), onExit: vi.fn(() => onExitDisposable), write: vi.fn(), - kill: vi.fn() + kill: killMock }) const resultPromise = fetchCodexRateLimits() await vi.advanceTimersByTimeAsync(15_000) await resultPromise - const term = ptySpawnMock.mock.results[0]?.value as { kill: ReturnType } expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - term.kill.mock.invocationCallOrder[0] + killMock.mock.invocationCallOrder[0] ) expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - term.kill.mock.invocationCallOrder[0] + killMock.mock.invocationCallOrder[0] ) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index a3143daf1ba..c7f81df4b40 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -4,10 +4,13 @@ differences and ensure account-scoped env handling stays identical. */ import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { spawn } from 'node:child_process' import { resolveCodexCommand } from '../codex-cli/command' +import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' +import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup' const RPC_TIMEOUT_MS = 10_000 const PTY_TIMEOUT_MS = 15_000 +const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 export type FetchCodexRateLimitsOptions = { codexHomePath?: string | null @@ -88,6 +91,7 @@ function mapRpcWindow( async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { return new Promise((resolve) => { let buffer = '' + let stderr = '' let resolved = false let rpcId = 0 @@ -196,7 +200,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { + stderr += chunk.toString() + // Why: this background poll only needs recent failure context for hints. + if (stderr.length > MAX_DIAGNOSTIC_OUTPUT_LENGTH) { + stderr = stderr.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH) + } + }) + child.on('error', (err) => { if (!resolved) { resolved = true @@ -237,7 +249,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise void }[] = [] - const disposeTermListeners = (): void => { - for (const disposable of termDisposables.splice(0)) { - disposable.dispose() - } - } const timeout = setTimeout(() => { if (!resolved) { resolved = true - // Why: killing a hidden PTY without disposing node-pty's NAPI listener - // handles leaves ThreadSafeFunction callbacks alive into Electron - // shutdown, which can abort the app while Node cleans up its env. - disposeTermListeners() - term.kill() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) resolve({ provider: 'codex', session: null, weekly: null, updatedAt: Date.now(), - error: 'PTY timeout', + error: withMacTailscaleDnsHint('PTY timeout', output), status: 'error' }) } @@ -362,6 +365,11 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { output += data + // Why: this background fallback only needs recent status output for + // parsing and diagnostics; cap noisy TUI output like the Claude fallback. + if (output.length > MAX_DIAGNOSTIC_OUTPUT_LENGTH) { + output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH) + } // Wait for prompt, then send /status if (!sentStatus && />\s*$/.test(data)) { @@ -378,8 +386,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { - disposeTermListeners() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) if (!resolved) { resolved = true clearTimeout(timeout) @@ -413,7 +423,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { + const originalPlatform = process.platform + + afterEach(() => { + setPlatform(originalPlatform) + vi.clearAllMocks() + }) + + it('disposes listeners, kills the child, then destroys the PTY fd on POSIX', () => { + setPlatform('darwin') + const dataDisposable = { dispose: vi.fn() } + const exitDisposable = { dispose: vi.fn() } + const killMock = vi.fn() + const term = { + kill: killMock, + destroy: vi.fn() + } + + cleanupHiddenRateLimitPty(term, [dataDisposable, exitDisposable], { kill: true }) + + expect(dataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( + killMock.mock.invocationCallOrder[0] + ) + expect(exitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( + killMock.mock.invocationCallOrder[0] + ) + expect(killMock.mock.invocationCallOrder[0]).toBeLessThan( + term.destroy.mock.invocationCallOrder[0] + ) + }) + + it('releases the PTY fd without killing again after natural exit', () => { + setPlatform('darwin') + const killMock = vi.fn() + const term = { + kill: killMock, + destroy: vi.fn() + } + + cleanupHiddenRateLimitPty(term, [], { kill: false }) + + expect(killMock).not.toHaveBeenCalled() + expect(term.destroy).toHaveBeenCalledTimes(1) + }) + + it('neutralizes POSIX destroy-time SIGHUP after the intentional kill', () => { + setPlatform('linux') + const killMock = vi.fn() + const term = { + kill: killMock, + destroy: vi.fn(() => { + term.kill('SIGHUP') + }) + } + + cleanupHiddenRateLimitPty(term, [], { kill: true }) + + expect(killMock).toHaveBeenCalledTimes(1) + expect(killMock).toHaveBeenCalledWith() + }) + + it('does not neutralize kill on Windows because destroy closes ConPTY through kill', () => { + setPlatform('win32') + const term = { + kill: vi.fn(), + destroy: vi.fn(() => { + term.kill() + }) + } + + cleanupHiddenRateLimitPty(term, [], { kill: true }) + + expect(term.kill).toHaveBeenCalledTimes(2) + expect(term.destroy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts new file mode 100644 index 00000000000..215bccda7bc --- /dev/null +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -0,0 +1,37 @@ +type HiddenPty = { + kill: (signal?: string) => void + destroy?: () => void +} + +type Disposable = { + dispose: () => void +} + +export function cleanupHiddenRateLimitPty( + term: HiddenPty, + disposables: Disposable[], + options: { kill: boolean } +): void { + for (const disposable of disposables.splice(0)) { + disposable.dispose() + } + + if (options.kill) { + try { + term.kill() + } catch { + /* already exited */ + } + } + + // Why: node-pty destroy releases the master PTY fd; on POSIX, neutralize + // the post-close SIGHUP hook after exit/kill to avoid pid reuse. + if (process.platform !== 'win32') { + term.kill = () => {} + } + try { + term.destroy?.() + } catch { + /* already torn down */ + } +} diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts new file mode 100644 index 00000000000..cfd5eb3f2c5 --- /dev/null +++ b/src/main/repo-icon-autodetect.test.ts @@ -0,0 +1,105 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { afterEach, describe, expect, it } from 'vitest' +import { gitExecFileAsync } from './git/runner' +import { detectRepoIcon } from './repo-icon-autodetect' + +const PNG_1X1_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' + +const tempDirs: string[] = [] + +async function makeTempRepoDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'orca-repo-icon-')) + tempDirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe('detectRepoIcon', () => { + it('uses a small repo-local favicon PNG first', async () => { + const repoPath = await makeTempRepoDir() + await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) + await writeFile( + join(repoPath, 'package.json'), + JSON.stringify({ homepage: 'https://example.com' }) + ) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + type: 'image', + src: `data:image/png;base64,${PNG_1X1_BASE64}`, + source: 'file', + label: 'favicon.png' + }) + }) + + it('uses a package homepage favicon when no local icon file exists', async () => { + const repoPath = await makeTempRepoDir() + await writeFile( + join(repoPath, 'package.json'), + JSON.stringify({ homepage: 'https://app.example.com/docs' }) + ) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + type: 'image', + src: 'https://www.google.com/s2/favicons?domain=app.example.com&sz=64', + source: 'favicon', + label: 'Website favicon' + }) + }) + + it('resolves declared icon hrefs from project source files', async () => { + const repoPath = await makeTempRepoDir() + await writeFile(join(repoPath, 'index.html'), '') + await mkdir(join(repoPath, 'public', 'brand'), { recursive: true }) + await writeFile( + join(repoPath, 'public', 'brand', 'icon.png'), + Buffer.from(PNG_1X1_BASE64, 'base64') + ) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + type: 'image', + src: `data:image/png;base64,${PNG_1X1_BASE64}`, + source: 'file', + label: 'public/brand/icon.png' + }) + }) + + it('falls back to the GitHub owner avatar for GitHub repos', async () => { + const repoPath = await makeTempRepoDir() + await gitExecFileAsync(['init'], { cwd: repoPath }) + await gitExecFileAsync(['remote', 'add', 'origin', 'git@github.com:stablyai/orca.git'], { + cwd: repoPath + }) + + await expect(detectRepoIcon({ repoPath, kind: 'git' })).resolves.toEqual({ + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/orca' + }) + }) + + it('skips code-host package homepages so GitHub remotes stay repo-specific', async () => { + const repoPath = await makeTempRepoDir() + await writeFile( + join(repoPath, 'package.json'), + JSON.stringify({ homepage: 'https://github.com/stablyai/orca' }) + ) + await gitExecFileAsync(['init'], { cwd: repoPath }) + await gitExecFileAsync(['remote', 'add', 'origin', 'https://github.com/stablyai/orca.git'], { + cwd: repoPath + }) + + await expect(detectRepoIcon({ repoPath, kind: 'git' })).resolves.toEqual({ + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/orca' + }) + }) +}) diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts new file mode 100644 index 00000000000..86ef07414d4 --- /dev/null +++ b/src/main/repo-icon-autodetect.ts @@ -0,0 +1,299 @@ +import { readFile, stat } from 'fs/promises' +import type { RepoKind } from '../shared/types' +import { + faviconUrlFromWebsite, + MAX_REPO_ICON_UPLOAD_BYTES, + type RepoIcon +} from '../shared/repo-icon' +import { getRepoSlug } from './github/client' +import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' +import type { IFilesystemProvider } from './providers/types' +import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' + +const REPO_ICON_FILE_CANDIDATES = [ + 'favicon.png', + 'public/favicon.png', + 'app/favicon.png', + 'app/icon.png', + 'src/favicon.png', + 'src/app/icon.png', + 'assets/favicon.png', + 'assets/icon.png', + 'static/favicon.png', + 'logo.png', + 'public/logo.png' +] + +const REPO_ICON_SOURCE_FILE_CANDIDATES = [ + 'index.html', + 'public/index.html', + 'app/routes/__root.tsx', + 'src/routes/__root.tsx', + 'app/root.tsx', + 'src/root.tsx', + 'src/index.html' +] + +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i +const LINK_ICON_OBJECT_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i + +const WEBSITE_HOSTS_TO_SKIP = new Set([ + 'github.com', + 'www.github.com', + 'gitlab.com', + 'www.gitlab.com', + 'bitbucket.org', + 'www.bitbucket.org' +]) + +function isPngBuffer(buffer: Buffer): boolean { + return ( + buffer.length >= 8 && + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + ) +} + +function shouldUseWebsiteFavicon(rawUrl: string): boolean { + try { + const url = new URL(rawUrl.includes('://') ? rawUrl : `https://${rawUrl}`) + return !WEBSITE_HOSTS_TO_SKIP.has(url.hostname.toLowerCase()) + } catch { + return false + } +} + +function extractIconHref(source: string): string | null { + return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null +} + +function iconHrefCandidates(href: string): string[] { + const clean = href.replace(/^\/+/, '') + return [`public/${clean}`, clean] +} + +async function readLocalPngIcon(repoPath: string, relativePath: string): Promise { + const filePath = joinWorktreeRelativePath(repoPath, relativePath) + const info = await stat(filePath) + if (!info.isFile() || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return null + } + const buffer = await readFile(filePath) + if (!isPngBuffer(buffer)) { + return null + } + return { + type: 'image', + src: `data:image/png;base64,${buffer.toString('base64')}`, + source: 'file', + label: relativePath + } +} + +async function readRemotePngIcon( + repoPath: string, + fsProvider: IFilesystemProvider, + relativePath: string +): Promise { + const filePath = joinWorktreeRelativePath(repoPath, relativePath) + const info = await fsProvider.stat(filePath) + if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return null + } + const result = await fsProvider.readFile(filePath) + if (!result.isBinary || result.mimeType !== 'image/png' || !result.content) { + return null + } + const buffer = Buffer.from(result.content, 'base64') + if (!isPngBuffer(buffer)) { + return null + } + return { + type: 'image', + src: `data:image/png;base64,${buffer.toString('base64')}`, + source: 'file', + label: relativePath + } +} + +async function detectLocalPngIcon(repoPath: string): Promise { + for (const relativePath of REPO_ICON_FILE_CANDIDATES) { + try { + const icon = await readLocalPngIcon(repoPath, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next conventional icon path. + } + } + for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { + try { + const source = await readFile(joinWorktreeRelativePath(repoPath, sourceFile), 'utf8') + const href = extractIconHref(source) + if (!href) { + continue + } + for (const relativePath of iconHrefCandidates(href)) { + try { + const icon = await readLocalPngIcon(repoPath, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next href resolution. + } + } + } catch { + // Try the next source file. + } + } + return null +} + +async function detectRemotePngIcon( + repoPath: string, + fsProvider: IFilesystemProvider +): Promise { + for (const relativePath of REPO_ICON_FILE_CANDIDATES) { + try { + const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next conventional icon path. + } + } + for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, sourceFile)) + if (result.isBinary) { + continue + } + const href = extractIconHref(result.content) + if (!href) { + continue + } + for (const relativePath of iconHrefCandidates(href)) { + try { + const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next href resolution. + } + } + } catch { + // Try the next source file. + } + } + return null +} + +function packageHomepageIcon(packageJson: unknown): RepoIcon | null { + if (!packageJson || typeof packageJson !== 'object') { + return null + } + const homepage = (packageJson as { homepage?: unknown }).homepage + if (typeof homepage !== 'string' || !shouldUseWebsiteFavicon(homepage)) { + return null + } + const src = faviconUrlFromWebsite(homepage) + return src ? { type: 'image', src, source: 'favicon', label: 'Website favicon' } : null +} + +async function detectLocalPackageHomepageIcon(repoPath: string): Promise { + try { + const packageJsonPath = joinWorktreeRelativePath(repoPath, 'package.json') + const info = await stat(packageJsonPath) + if (!info.isFile() || info.size > 128 * 1024) { + return null + } + return packageHomepageIcon(JSON.parse(await readFile(packageJsonPath, 'utf8'))) + } catch { + return null + } +} + +async function detectRemotePackageHomepageIcon( + repoPath: string, + fsProvider: IFilesystemProvider +): Promise { + try { + const packageJsonPath = joinWorktreeRelativePath(repoPath, 'package.json') + const info = await fsProvider.stat(packageJsonPath) + if (info.type !== 'file' || info.size > 128 * 1024) { + return null + } + const result = await fsProvider.readFile(packageJsonPath) + if (result.isBinary) { + return null + } + return packageHomepageIcon(JSON.parse(result.content)) + } catch { + return null + } +} + +async function detectGitHubAvatarIcon( + repoPath: string, + connectionId?: string | null +): Promise { + try { + const slug = await getRepoSlug(repoPath, connectionId) + return slug + ? { + type: 'image', + src: `https://github.com/${encodeURIComponent(slug.owner)}.png?size=64`, + source: 'github', + label: `${slug.owner}/${slug.repo}` + } + : null + } catch { + return null + } +} + +export async function detectRepoIcon({ + repoPath, + kind, + connectionId +}: { + repoPath: string + kind: RepoKind + connectionId?: string | null +}): Promise { + try { + const fsProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined + const fileIcon = fsProvider + ? await detectRemotePngIcon(repoPath, fsProvider) + : await detectLocalPngIcon(repoPath) + if (fileIcon) { + return fileIcon + } + + const homepageIcon = fsProvider + ? await detectRemotePackageHomepageIcon(repoPath, fsProvider) + : await detectLocalPackageHomepageIcon(repoPath) + if (homepageIcon) { + return homepageIcon + } + + if (kind === 'git') { + return (await detectGitHubAvatarIcon(repoPath, connectionId)) ?? undefined + } + } catch { + // Repo creation must not fail because a best-effort icon probe failed. + } + return undefined +} diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index ffb6d2e1c94..c37a930ea97 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -135,6 +135,98 @@ describe('mobile subscribe integration', () => { vi.useRealTimers() }) + it('serializeMainTerminalBuffer reports the seq included in the headless snapshot', async () => { + vi.useRealTimers() + const { runtime } = createRuntime() + const first = 'one\r\n' + const second = 'two\r\n' + const third = 'three\r\n' + + runtime.onPtyData('pty-1', first, Date.now()) + const initialSnapshot = await runtime.serializeMainTerminalBuffer('pty-1') + expect(initialSnapshot?.seq).toBe(first.length) + + type HeadlessStateForTest = { + emulator: { write: (data: string) => Promise | void } + } + const headless = ( + runtime as unknown as { headlessTerminals: Map } + ).headlessTerminals.get('pty-1') + expect(headless).toBeDefined() + const originalWrite = headless!.emulator.write.bind(headless!.emulator) + const secondWriteGate: { release: (() => void) | null } = { release: null } + const secondWriteStarted = new Promise((resolve) => { + headless!.emulator.write = async (data: string): Promise => { + if (data === second) { + resolve() + await new Promise((release) => { + secondWriteGate.release = release + }) + } + await originalWrite(data) + } + }) + + try { + runtime.onPtyData('pty-1', second, Date.now()) + await secondWriteStarted + const racedSnapshot = runtime.serializeMainTerminalBuffer('pty-1') + runtime.onPtyData('pty-1', third, Date.now()) + if (!secondWriteGate.release) { + throw new Error('second write did not block') + } + secondWriteGate.release() + + const snapshot = await racedSnapshot + expect(snapshot?.seq).toBe(first.length + second.length) + expect(runtime.getPtyOutputSequence('pty-1')).toBe( + first.length + second.length + third.length + ) + + const finalSnapshot = await runtime.serializeMainTerminalBuffer('pty-1') + expect(finalSnapshot?.seq).toBe(first.length + second.length + third.length) + } finally { + headless!.emulator.write = originalWrite + secondWriteGate.release?.() + } + }) + + it('serializeMainTerminalBuffer returns an empty snapshot for an empty headless buffer', async () => { + const { runtime } = createRuntime() + type HeadlessStateForTest = { + emulator: { + isAlternateScreen: boolean + getSnapshot: (opts: { scrollbackRows?: number }) => { + rehydrateSequences: string + snapshotAnsi: string + cols: number + rows: number + } + } + outputSequence: number + writeChain: Promise + } + const runtimePrivate = runtime as unknown as { + headlessTerminals: Map + } + runtimePrivate.headlessTerminals.set('pty-empty', { + emulator: { + isAlternateScreen: false, + getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 }) + }, + outputSequence: 17, + writeChain: Promise.resolve() + }) + + await expect(runtime.serializeMainTerminalBuffer('pty-empty')).resolves.toEqual({ + data: '', + cols: 90, + rows: 30, + seq: 17 + }) + await expect(runtime.serializeTerminalBuffer('pty-empty')).resolves.toBeNull() + }) + it('handleMobileSubscribe resizes PTY to phone dims', async () => { const { runtime, ptySizes, resizes, notifications } = createRuntime() diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 75e83286713..b6ef370e796 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: this file is a command adapter for one external surface, Agent Browser automation. It stays separate from OrcaRuntimeService so runtime state does not grow further while browser routing remains easy to scan in one place. */ import { randomUUID } from 'crypto' import { ipcMain, webContents, type BrowserWindow } from 'electron' -import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' import type { BrowserBackResult, BrowserCaptureStartResult, @@ -248,22 +247,14 @@ export class RuntimeBrowserCommands { return { browserPageId: resolvedPageId, webContents: guest } } - // Why: browser tabs only mount (and become operable) when their worktree is - // the active worktree in the renderer AND activeTabType is 'browser'. If either - // condition is false, the webview stays in display:none and Electron won't start - // its guest process — dom-ready never fires, registerGuest never runs, and CLI - // browser commands fail with "CDP connection refused". + // Why: browser tabs must become paintable before their webview guest starts + // and registerGuest fires, but automation must not steal the user's visible + // worktree/browser pane. Ask the renderer to background-mount the worktree and + // acquire a hidden automation visibility lease instead of activating the UI. private async ensureBrowserWorktreeActive(worktreeId: string): Promise { const win = this.host.getAuthoritativeWindow() - const repoId = getRepoIdFromWorktreeId(worktreeId) - if (!repoId) { - return - } - win.webContents.send('ui:activateWorktree', { repoId, worktreeId }) - // Why: switching worktree alone sets activeView='terminal'. Browser webviews - // won't mount until activeTabType is 'browser'. Send a second IPC to flip it. win.webContents.send('browser:activateView', { worktreeId }) - // Why: give the renderer time to mount the webview after switching worktrees. + // Why: give the renderer time to mount the hidden paintable webview. // The webview needs to attach and fire dom-ready before registerGuest runs. await new Promise((resolve) => setTimeout(resolve, 500)) } diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index d0fc2c78f04..3e6b7a06c0e 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -3,14 +3,21 @@ import type * as Fs from 'fs' import type * as FsPromises from 'fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' -const { resolveAuthorizedPathMock, statMock, subscribeParcelWatcherMock, watchMock } = vi.hoisted( - () => ({ - resolveAuthorizedPathMock: vi.fn(), - statMock: vi.fn(), - subscribeParcelWatcherMock: vi.fn(), - watchMock: vi.fn() - }) -) +const { + lstatMock, + renameMock, + resolveAuthorizedPathMock, + statMock, + subscribeParcelWatcherMock, + watchMock +} = vi.hoisted(() => ({ + lstatMock: vi.fn(), + renameMock: vi.fn(), + resolveAuthorizedPathMock: vi.fn(), + statMock: vi.fn(), + subscribeParcelWatcherMock: vi.fn(), + watchMock: vi.fn() +})) vi.mock('fs', async () => { const actual = await vi.importActual('fs') @@ -24,6 +31,8 @@ vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') return { ...actual, + lstat: lstatMock, + rename: renameMock, stat: statMock } }) @@ -47,16 +56,47 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ })) import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' + +function enoent(): Error { + return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +} + +function mockStats(dev: number, ino: number) { + return { dev, ino, isDirectory: () => false } +} + +function createRuntimeFileCommands() { + const store = { + getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined) + } + const commands = new RuntimeFileCommands({ + getRuntimeId: () => 'runtime-1', + requireStore: () => store, + resolveWorktreeSelector: vi.fn(async () => ({ + id: 'wt-1', + repoId: 'repo-1', + path: '/repo' + })), + resolveRuntimeGitTarget: vi.fn(), + openFile: vi.fn() + } as never) + return { commands, store } +} describe('RuntimeFileCommands', () => { const originalPlatform = process.platform beforeEach(() => { vi.useFakeTimers() + lstatMock.mockReset() + renameMock.mockReset() resolveAuthorizedPathMock.mockReset() statMock.mockReset() subscribeParcelWatcherMock.mockReset() watchMock.mockReset() + lstatMock.mockRejectedValue(enoent()) + renameMock.mockResolvedValue(undefined) Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -98,6 +138,108 @@ describe('RuntimeFileCommands', () => { }) }) + it('renames a runtime-local file when destination does not exist', async () => { + const { commands } = createRuntimeFileCommands() + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts') + + expect(renameMock).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts') + }) + + it('allows runtime-local case-only rename with IPC parity guard behavior', async () => { + const { commands } = createRuntimeFileCommands() + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + lstatMock.mockImplementation(async (p: string) => { + if (p === '/repo/README.md' || p === '/repo/readme.md') { + return mockStats(10, 100) + } + throw enoent() + }) + + await commands.renameFileExplorerPath('id:wt-1', 'README.md', 'readme.md') + + expect(renameMock).toHaveBeenCalledWith('/repo/README.md', '/repo/readme.md') + }) + + it('rejects runtime-local true destination collisions', async () => { + const { commands } = createRuntimeFileCommands() + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + lstatMock.mockImplementation(async (p: string) => { + if (p === '/repo/old.ts') { + return mockStats(11, 110) + } + if (p === '/repo/new.ts') { + return mockStats(11, 111) + } + throw enoent() + }) + + await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow( + "A file or folder named 'new.ts' already exists in this location" + ) + + expect(renameMock).not.toHaveBeenCalled() + }) + + it('rejects runtime-local hard-link alias collisions', async () => { + const { commands } = createRuntimeFileCommands() + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + lstatMock.mockImplementation(async (p: string) => { + if (p === '/repo/README.md' || p === '/repo/README-hardlink.md') { + return mockStats(12, 120) + } + throw enoent() + }) + + await expect( + commands.renameFileExplorerPath('id:wt-1', 'README.md', 'README-hardlink.md') + ).rejects.toThrow("A file or folder named 'README-hardlink.md' already exists in this location") + + expect(renameMock).not.toHaveBeenCalled() + }) + + it('rejects runtime-local cross-parent case-only collisions', async () => { + const { commands } = createRuntimeFileCommands() + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + lstatMock.mockImplementation(async (p: string) => { + if (p === '/repo/src/README.md' || p === '/repo/docs/readme.md') { + return mockStats(13, 130) + } + throw enoent() + }) + + await expect( + commands.renameFileExplorerPath('id:wt-1', 'src/README.md', 'docs/readme.md') + ).rejects.toThrow("A file or folder named 'readme.md' already exists in this location") + + expect(renameMock).not.toHaveBeenCalled() + }) + + it('routes runtime remote rename through the SSH no-clobber provider method', async () => { + const renameNoClobber = vi.fn().mockResolvedValue(undefined) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never) + const { commands, store } = createRuntimeFileCommands() + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + + await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts') + + expect(renameNoClobber).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts') + expect(renameMock).not.toHaveBeenCalled() + }) + + it('propagates runtime remote no-clobber rename failures', async () => { + const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists')) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never) + const { commands, store } = createRuntimeFileCommands() + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + + await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow( + 'destination exists' + ) + expect(renameMock).not.toHaveBeenCalled() + }) + it('uses a conservative Node watcher for Windows runtime file watches', async () => { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index f5c5c90b618..3d850e5069e 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -53,6 +53,7 @@ import { getSshFilesystemProvider, SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' +import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' const MOBILE_FILE_LIST_LIMIT = 5000 @@ -542,14 +543,14 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.rename(oldTarget.path, newTarget.path) + await provider.renameNoClobber(oldTarget.path, newTarget.path) return { ok: true } } const store = this.host.requireStore() const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true }) const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true }) - await assertRuntimePathDoesNotExist(newPath) + await assertNoClobberRenameDestinationAvailable(oldPath, newPath) await rename(oldPath, newPath) return { ok: true } } diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index b7263884b2d..3f228f5b45c 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -8,6 +8,7 @@ import type * as CommitMessageTextGenerationModule from '../text-generation/comm import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' const mocks = vi.hoisted(() => ({ + abortMerge: vi.fn(), getStagedCommitContext: vi.fn(), generateCommitMessageFromContext: vi.fn(), resolveCommitMessageSettings: vi.fn(), @@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('../git/status', async () => ({ ...(await vi.importActual('../git/status')), + abortMerge: mocks.abortMerge, getStagedCommitContext: mocks.getStagedCommitContext })) @@ -57,6 +59,7 @@ function makeCommands(worktreePath: string): RuntimeGitCommands { describe('RuntimeGitCommands', () => { beforeEach(() => { + mocks.abortMerge.mockReset() mocks.getStagedCommitContext.mockReset() mocks.generateCommitMessageFromContext.mockReset() mocks.resolveCommitMessageSettings.mockReset() @@ -69,6 +72,34 @@ describe('RuntimeGitCommands', () => { } }) + it('aborts a local merge through the resolved worktree', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const commands = makeCommands(worktreePath) + mocks.abortMerge.mockResolvedValue(undefined) + + await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true }) + + expect(mocks.abortMerge).toHaveBeenCalledWith(worktreePath) + }) + + it('aborts a remote merge through the SSH git provider', async () => { + const provider = { abortMerge: vi.fn().mockResolvedValue(undefined) } + mocks.getSshGitProvider.mockReturnValue(provider) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree('/remote/repo'), + connectionId: 'conn-1' + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true }) + + expect(provider.abortMerge).toHaveBeenCalledWith('/remote/repo') + expect(mocks.abortMerge).not.toHaveBeenCalled() + }) + it('rejects slash-only git mutation paths before they can target the worktree root', async () => { const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) tempDirs.push(worktreePath) @@ -119,7 +150,9 @@ describe('RuntimeGitCommands', () => { expect.objectContaining({ commitMessageAi: { enabled: true, agentId: 'codex' } }), - 'local' + 'local', + 'commitMessage', + null ) expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( context, @@ -172,7 +205,9 @@ describe('RuntimeGitCommands', () => { expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith( expect.any(Object), - 'ssh:conn-1' + 'ssh:conn-1', + 'commitMessage', + null ) expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( context, diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 0425afb400c..0c81af3dfc2 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -9,14 +9,18 @@ import type { GitUpstreamStatus, GitWorktreeInfo, GlobalSettings, + Repo, TuiAgent, Worktree } from '../../shared/types' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import { mergeLegacyCommitMessageAiIntoSourceControlAi } from '../../shared/source-control-ai' +import type { SourceControlAiOperation } from '../../shared/source-control-ai-types' import { getRemoteFileUrl } from '../git/repo' import { + abortMerge, bulkDiscardChanges, bulkStageFiles, bulkUnstageFiles, @@ -61,11 +65,36 @@ import { gitExecFileAsync } from '../git/runner' export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo } type RuntimeCommitMessageSettingsOverride = Partial< - Pick + Pick< + GlobalSettings, + 'commitMessageAi' | 'sourceControlAi' | 'agentCmdOverrides' | 'enableGitHubAttribution' + > > & { commitMessageDiscoveryHostKey?: string } +function getRuntimeGitGenerationSettings( + settings: GlobalSettings, + settingsOverride: RuntimeCommitMessageSettingsOverride | undefined, + operation: SourceControlAiOperation +): GlobalSettings { + const mergedSettings = { + ...settings, + ...settingsOverride + } + if ( + settingsOverride?.commitMessageAi !== undefined && + settingsOverride.sourceControlAi === undefined + ) { + mergedSettings.sourceControlAi = mergeLegacyCommitMessageAiIntoSourceControlAi( + settings.sourceControlAi, + settingsOverride.commitMessageAi, + { pullRequestInstructionsFromLegacy: operation === 'pullRequest' } + ) + } + return mergedSettings +} + function normalizeRuntimeGitRelativePath(filePath: string): string { const relativePath = normalizeRuntimeRelativePath(filePath) if (relativePath === '') { @@ -79,7 +108,7 @@ function normalizeRuntimeGitRelativePath(filePath: string): string { export type RuntimeGitCommandHost = { resolveRuntimeGitTarget( selector: string - ): Promise<{ worktree: ResolvedRuntimeGitWorktree; connectionId?: string }> + ): Promise<{ worktree: ResolvedRuntimeGitWorktree; repo?: Repo; connectionId?: string }> getRuntimeSettings(): GlobalSettings getCommitMessageAgentEnvironment?(): CommitMessageAgentEnvironmentResolvers | undefined } @@ -148,6 +177,20 @@ export class RuntimeGitCommands { return detectConflictOperation(target.worktree.path) } + async abortRuntimeGitMerge(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.abortMerge(target.worktree.path) + return { ok: true } + } + await abortMerge(target.worktree.path) + return { ok: true } + } + async getRuntimeGitDiff( worktreeSelector: string, filePath: string, @@ -373,11 +416,14 @@ export class RuntimeGitCommands { settingsOverride?.commitMessageDiscoveryHostKey ?? getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null) const resolvedSettings = resolveCommitMessageSettings( - { - ...this.host.getRuntimeSettings(), - ...settingsOverride - }, - discoveryHostKey + getRuntimeGitGenerationSettings( + this.host.getRuntimeSettings(), + settingsOverride, + 'commitMessage' + ), + discoveryHostKey, + 'commitMessage', + target.repo ?? null ) if (!resolvedSettings.ok) { return { success: false, error: resolvedSettings.error } @@ -454,11 +500,14 @@ export class RuntimeGitCommands { settingsOverride?.commitMessageDiscoveryHostKey ?? getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null) const resolvedSettings = resolveCommitMessageSettings( - { - ...this.host.getRuntimeSettings(), - ...settingsOverride - }, - discoveryHostKey + getRuntimeGitGenerationSettings( + this.host.getRuntimeSettings(), + settingsOverride, + 'pullRequest' + ), + discoveryHostKey, + 'pullRequest', + target.repo ?? null ) if (!resolvedSettings.ok) { return { success: false, error: resolvedSettings.error } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5f7ac93ecce..55b45ce2f16 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'events' -import { mkdtemp, rm } from 'fs/promises' +import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' import { ipcMain } from 'electron' @@ -560,9 +560,12 @@ function deferred(): { return { promise, resolve, reject } } -function createStaleRuntimeWorktreeStore(worktreeId: string) { +function createStaleRuntimeWorktreeStore( + worktreeId: string, + metaOverrides: Partial = {} +) { const metaById: Record = { - [worktreeId]: makeWorktreeMeta() + [worktreeId]: makeWorktreeMeta(metaOverrides) } const removeWorktreeMeta = vi.fn((id: string) => { delete metaById[id] @@ -652,6 +655,26 @@ computeWorktreePathMock.mockImplementation( ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath) describe('OrcaRuntimeService', () => { + it('rejects relative paths for runtime nested repo scan/import', async () => { + const runtime = new OrcaRuntimeService({ + ...store, + createProjectGroup: vi.fn(), + moveProjectToGroup: vi.fn() + } as never) + + await expect(runtime.scanNestedRepos('relative/project')).rejects.toThrow( + 'Project path must be an absolute path' + ) + await expect( + runtime.importNestedRepos({ + parentPath: 'relative/project', + groupName: 'Project', + projectPaths: ['relative/project/api'], + mode: 'group' + }) + ).rejects.toThrow('Project path must be an absolute path') + }) + it('starts unavailable with no authoritative window', () => { const runtime = createRuntime() @@ -1524,6 +1547,7 @@ describe('OrcaRuntimeService', () => { expect(gitProvider.removeWorktree).toHaveBeenCalledWith('/remote/feature', true) expect(removeWorktree).not.toHaveBeenCalled() expect(listWorktrees).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/remote/feature`) }) it('rejects SSH-backed runtime removal of the main worktree before provider deletion', async () => { @@ -2128,6 +2152,167 @@ describe('OrcaRuntimeService', () => { } }) + it('rejects runtime cloneRepo dot-segment URLs before spawning git', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const runtime = createRuntime() + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const destination = join(tempRoot, 'destination') + + try { + await expect(runtime.cloneRepo('file:///tmp/source/.', destination)).rejects.toThrow( + 'Invalid repository name derived from URL' + ) + expect(spawnSpy).not.toHaveBeenCalled() + } finally { + spawnSpy.mockRestore() + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('rejects runtime cloneRepo parent-segment URLs before spawning git', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const runtime = createRuntime() + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const destination = join(tempRoot, 'destination') + + try { + await expect(runtime.cloneRepo('file:///tmp/source/..', destination)).rejects.toThrow( + 'Invalid repository name derived from URL' + ) + expect(spawnSpy).not.toHaveBeenCalled() + } finally { + spawnSpy.mockRestore() + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('removes an owned runtime clone target when git exits unsuccessfully', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + spawnSpy.mockReturnValue(proc as never) + const runtime = createRuntime() + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const clonePath = join(destination, 'repo-badge-color') + + try { + const clonePromise = runtime.cloneRepo( + 'https://example.com/repo-badge-color.git', + destination + ) + await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1)) + await writeFile(join(clonePath, 'partial.txt'), 'git wrote this before failing') + proc.stderr.emit('data', Buffer.from('fatal: repository not found\n')) + proc.emit('close', 128, null) + + await expect(clonePromise).rejects.toThrow('Clone failed: fatal: repository not found') + await expect(lstat(clonePath)).rejects.toThrow() + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('preserves an existing runtime clone target when git exits unsuccessfully', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + spawnSpy.mockReturnValue(proc as never) + const runtime = createRuntime() + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const clonePath = join(destination, 'repo-badge-color') + + try { + await mkdir(clonePath) + await writeFile(join(clonePath, 'user-file.txt'), 'keep me') + const clonePromise = runtime.cloneRepo( + 'https://example.com/repo-badge-color.git', + destination + ) + await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1)) + proc.emit('close', 128, null) + + await expect(clonePromise).rejects.toThrow('Clone failed') + await expect(lstat(join(clonePath, 'user-file.txt'))).resolves.toBeTruthy() + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('skips runtime clone failure cleanup when the owned target is replaced', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + spawnSpy.mockReturnValue(proc as never) + const runtime = createRuntime() + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const clonePath = join(destination, 'repo-badge-color') + const replacementFile = join(clonePath, 'replacement.txt') + + try { + const clonePromise = runtime.cloneRepo( + 'https://example.com/repo-badge-color.git', + destination + ) + await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1)) + await rm(clonePath, { recursive: true, force: true }) + await mkdir(clonePath) + await writeFile(replacementFile, 'new owner') + proc.emit('close', 128, null) + + await expect(clonePromise).rejects.toThrow('Clone failed') + await expect(lstat(replacementFile)).resolves.toBeTruthy() + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('serializes concurrent runtime clones for the same target', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const firstProc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + firstProc.stderr = new EventEmitter() + spawnSpy.mockReturnValueOnce(firstProc as never) + const added: Record[] = [] + const colorStore = { + ...store, + getRepos: () => [...added] as never, + addRepo: (repo: Record) => { + added.push(repo) + }, + getRepo: (id: string) => added.find((repo) => repo.id === id) as never + } + const runtime = new OrcaRuntimeService(colorStore as never) + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + + try { + const firstClonePromise = runtime.cloneRepo( + 'https://example.com/repo-badge-color.git', + destination + ) + const secondClonePromise = runtime.cloneRepo( + 'https://example.com/repo-badge-color.git', + destination + ) + await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setImmediate(resolve)) + expect(spawnSpy).toHaveBeenCalledTimes(1) + + firstProc.emit('close', 0, null) + await expect(firstClonePromise).resolves.toMatchObject({ + path: join(destination, 'repo-badge-color') + }) + await expect(secondClonePromise).resolves.toMatchObject({ + path: join(destination, 'repo-badge-color') + }) + expect(spawnSpy).toHaveBeenCalledTimes(1) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + it('associates controller PTYs with mixed-case Windows and UNC cwd paths', async () => { vi.mocked(listWorktrees).mockResolvedValue([ { @@ -3491,6 +3676,51 @@ describe('OrcaRuntimeService', () => { expect(futureCursorRead.limited).toBe(false) }) + // Why: PR #2553 fixed Orca CLI terminal reads so older retained output stays + // reachable by cursor; this guards that pagination without allowing previews + // to regress into full-transcript RPC payloads. + it('keeps terminal read payloads bounded while retained output remains pageable', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + const linePayload = 'x'.repeat(24) + const lines = Array.from( + { length: 2000 }, + (_, index) => `line-${index.toString().padStart(4, '0')}-${linePayload}` + ) + runtime.onPtyData('pty-1', `${lines.join('\n')}\n`, 100) + + const preview = await runtime.readTerminal(terminal.handle) + expect(Buffer.byteLength(JSON.stringify(preview), 'utf8')).toBeLessThan(10_000) + expect(preview.tail).toHaveLength(120) + expect(preview.tail[0]).toBe(lines.at(-120)) + expect(preview.limited).toBe(true) + expect(preview.oldestCursor).toBe('0') + expect(preview.nextCursor).toBe('2000') + expect(preview.latestCursor).toBe('2000') + + const collected: string[] = [] + let cursor = Number(preview.oldestCursor) + const latestCursor = Number(preview.latestCursor) + for (let pageIndex = 0; cursor < latestCursor; pageIndex += 1) { + expect(pageIndex).toBeLessThan(10) + const page = await runtime.readTerminal(terminal.handle, { cursor, limit: 333 }) + expect(Buffer.byteLength(JSON.stringify(page), 'utf8')).toBeLessThan(16_000) + expect(page.tail.length).toBeGreaterThan(0) + expect(page.tail.length).toBeLessThanOrEqual(333) + expect(page.returnedLineCount).toBe(page.tail.length) + + collected.push(...page.tail) + const nextCursor = Number(page.nextCursor) + expect(nextCursor).toBeGreaterThan(cursor) + cursor = nextCursor + } + + expect(collected).toHaveLength(lines.length) + expect(collected.findIndex((line, index) => line !== lines[index])).toBe(-1) + }) + it('bounds retained partial terminal output before preview reads', async () => { const runtime = new OrcaRuntimeService(store) @@ -5601,6 +5831,32 @@ describe('OrcaRuntimeService', () => { ) }) + it('strips Orca provenance fields from runtime metadata updates', async () => { + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const setWorktreeMeta = vi.fn((worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateManagedWorktreeMeta(`id:${TEST_WORKTREE_ID}`, { + comment: 'keep me', + orcaCreatedAt: 123, + orcaCreationSource: 'runtime', + orcaCreationWorkspaceLayout: { path: '/tmp', nestWorkspaces: false } + }) + + expect(setWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, { comment: 'keep me' }) + }) + it('ignores stale instance-mismatched lineage when validating manual cycle repairs', async () => { const parentPath = '/tmp/worktree-a' const childPath = '/tmp/worktree-b' @@ -7920,6 +8176,45 @@ describe('OrcaRuntimeService', () => { } }) + it('resolves an exact path selector when duplicate repo entries expose the same path', async () => { + const runtime = new OrcaRuntimeService(store) + const duplicatePath = '/tmp/workspaces/runtime-duplicate-selector' + const getRepos = vi.spyOn(store, 'getRepos').mockReturnValue([ + { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }, + { + id: 'repo-duplicate-entry', + path: '/tmp/repo-secondary-worktree', + displayName: 'repo-secondary-worktree', + badgeColor: 'red', + addedAt: 2 + } + ]) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: duplicatePath, + head: 'def', + branch: 'runtime-duplicate-selector', + isBare: false, + isMainWorktree: false + } + ]) + + try { + const worktree = await runtime.showManagedWorktree(`path:${duplicatePath}`) + + expect(worktree.id).toBe(`${TEST_REPO_ID}::${duplicatePath}`) + expect(worktree.path).toBe(duplicatePath) + } finally { + getRepos.mockRestore() + } + }) + it('keeps CLI-created worktrees successful when initial terminal creation fails', async () => { const runtime = new OrcaRuntimeService(store) const spawn = vi.fn().mockRejectedValue(new Error('pty unavailable')) @@ -8081,6 +8376,7 @@ describe('OrcaRuntimeService', () => { expect(runHook).not.toHaveBeenCalled() expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(TEST_WORKTREE_ID) expect(result.warning).toBe( `orca.yaml archive hook skipped for ${TEST_WORKTREE_PATH}; pass --run-hooks to run it.` ) @@ -8143,6 +8439,7 @@ describe('OrcaRuntimeService', () => { expect(removeWorktree).not.toHaveBeenCalled() expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) } finally { @@ -8150,6 +8447,121 @@ describe('OrcaRuntimeService', () => { } }) + it('force-removes a legacy Orca-created runtime orphaned worktree directory after Git tracking is gone', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-orphan-')) + const repoPath = join(parentDir, 'repo') + const orphanPath = join(parentDir, 'orphan') + const adminWorktreePath = join(repoPath, '.git', 'worktrees', 'orphan') + const worktreeId = `${TEST_REPO_ID}::${orphanPath}` + await mkdir(orphanPath, { recursive: true }) + await mkdir(adminWorktreePath, { recursive: true }) + await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`) + await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`) + const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId, { + createdAt: Date.now() + }) + const runtimeStoreWithRepoPath = { + ...runtimeStore, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ], + getRepo: (id: string) => + id === TEST_REPO_ID + ? { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + : undefined + } + const runtime = new OrcaRuntimeService(runtimeStoreWithRepoPath as never) + const notifier = { worktreesChanged: vi.fn() } + runtime.setNotifier(notifier as never) + + try { + vi.mocked(listWorktrees).mockResolvedValue([]) + + await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({}) + + await expect(lstat(orphanPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(removeWorktree).not.toHaveBeenCalled() + expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) + expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() + expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) + } finally { + await rm(parentDir, { recursive: true, force: true }) + } + }) + + it('does not inspect or delete a local path when SSH runtime orphan cleanup has no filesystem provider', async () => { + const localPath = await mkdtemp(join(tmpdir(), 'orca-runtime-ssh-missing-fs-')) + const repo = { + id: 'repo-runtime-ssh-missing-fs', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-missing-fs' + } + const worktreeId = `${repo.id}::${localPath}` + const metaById: Record = { + [worktreeId]: makeWorktreeMeta({ + orcaCreatedAt: Date.now(), + orcaCreationSource: 'ssh' + }) + } + const removeWorktreeMeta = vi.fn((id: string) => { + delete metaById[id] + }) + const runtimeStore = { + ...store, + getRepos: () => [repo], + getRepo: (id: string) => (id === repo.id ? repo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Partial) => { + metaById[id] = { ...(metaById[id] ?? makeWorktreeMeta()), ...meta } + return metaById[id] + }, + removeWorktreeMeta + } + const gitProvider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: repo.path, + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + } + registerSshGitProvider(repo.connectionId, gitProvider as never) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).rejects.toThrow( + 'SSH filesystem provider unavailable' + ) + + await expect(lstat(localPath)).resolves.toBeTruthy() + expect(removeWorktree).not.toHaveBeenCalled() + expect(removeWorktreeMeta).not.toHaveBeenCalled() + } finally { + unregisterSshGitProvider(repo.connectionId) + await rm(localPath, { recursive: true, force: true }) + } + }) + it('still rejects forced runtime unregistered delete paths that exist on disk', async () => { const existingWorktreePath = await mkdtemp(join(tmpdir(), 'orca-runtime-remove-existing-')) const worktreeId = `${TEST_REPO_ID}::${existingWorktreePath}` @@ -8275,6 +8687,7 @@ describe('OrcaRuntimeService', () => { await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).resolves.toEqual({}) expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(TEST_WORKTREE_ID) }) it('runs archive hooks for CLI worktree removal when hooks are explicitly enabled', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1c5b3c053b3..cb01bdb1189 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9,9 +9,15 @@ import { import type { AgentStatus } from '../../shared/agent-detection' import type { AgentStatusOrchestrationContext } from '../../shared/agent-status-types' import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' +import { + cleanupClaimedCloneTarget, + claimCloneTarget, + deriveValidatedClonePath, + getClonePathComparisonKey +} from '../git/repo-clone-path' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { createHash, randomUUID } from 'crypto' -import { basename, isAbsolute, join } from 'path' +import { isAbsolute, join } from 'path' import { mkdir, readFile, readdir, rm, stat } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' @@ -43,12 +49,17 @@ import type { WorktreeStartupLaunch, LinearIssueUpdate, LinearWorkspaceSelection, + NestedRepoScanResult, + ProjectGroup, + ProjectGroupImportMode, + ProjectGroupImportResult, TabGroupLayoutNode, TuiAgent } from '../../shared/types' import type { FeatureInteractionId } from '../../shared/feature-interactions' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id' import { isFolderRepo } from '../../shared/repo-kind' +import { getNextProjectGroupOrder } from '../../shared/project-groups' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' import { FIRST_PANE_ID } from '../../shared/pane-key' @@ -63,6 +74,7 @@ import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' +import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { upsertProjectTrustLevelInContent } from '../codex/config-toml-trust' import { isWindowsAbsolutePathLike, @@ -304,6 +316,7 @@ import { assertWorktreeCleanForRemoval, removeWorktree } from '../git/worktree' +import type { AddWorktreeResult } from '../git/worktree' import { isENOENT } from '../ipc/filesystem-auth' import { createSetupRunnerScript, @@ -346,9 +359,13 @@ import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { + assertWorktreeDoesNotContainRegisteredWorktree, + canCleanupUnregisteredOrcaWorktreeDirectory, canSafelyRemoveOrphanedWorktreeDirectory, findRegisteredDeletableWorktree, - isWorktreePathMissing + isWorktreePathMissing, + ORPHANED_WORKTREE_DIRECTORY_MESSAGE, + stripOrcaProvenanceMetaUpdates } from '../worktree-removal-safety' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { HeadlessEmulator } from '../daemon/headless-emulator' @@ -358,6 +375,7 @@ import type { IFilesystemProvider, IPtyProvider } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' import { getActiveMultiplexer } from '../ipc/ssh' +import { detectRepoIcon } from '../repo-icon-autodetect' import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService } from '../codex-accounts/service' import type { RateLimitService } from '../rate-limits/service' @@ -366,6 +384,16 @@ import type { RateLimitState } from '../../shared/rate-limit-types' import type { VoiceSettings } from '../../shared/speech-types' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' +import { scanNestedRepos } from '../project-groups/nested-repo-discovery' +import { + createNestedProjectGroupResolver, + resolveNestedRepoSelection +} from '../project-groups/nested-repo-import' + +function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): string { + console.warn(`[project-groups] ${context}`, error) + return 'Repository could not be imported' +} type RuntimeAccountServices = { claudeAccounts: ClaudeAccountService @@ -393,7 +421,12 @@ type RuntimeStore = { getRepo: Store['getRepo'] addRepo: Store['addRepo'] updateRepo: Store['updateRepo'] - removeRepo?: Store['removeRepo'] + getProjectGroups?: Store['getProjectGroups'] + createProjectGroup?: Store['createProjectGroup'] + updateProjectGroup?: Store['updateProjectGroup'] + deleteProjectGroup?: Store['deleteProjectGroup'] + moveProjectToGroup?: Store['moveProjectToGroup'] + removeProject?: Store['removeProject'] reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] getWorktreeMeta: Store['getWorktreeMeta'] @@ -423,6 +456,7 @@ type RuntimeStore = { branchPrefixCustom: string defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] visibleTaskProviders?: GlobalSettings['visibleTaskProviders'] @@ -537,6 +571,9 @@ type RuntimePtyWorktreeRecord = { type RuntimeHeadlessTerminal = { emulator: HeadlessEmulator + // Why: serialize can race with newer writes appended to writeChain; return + // the seq actually painted into this emulator, not the latest PTY seq. + outputSequence: number writeChain: Promise } @@ -998,6 +1035,7 @@ export class OrcaRuntimeService { private resolvedWorktreeCache: ResolvedWorktreeCache | null = null private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null private resolvedWorktreeGeneration = 0 + private cloneInFlightByPath = new Map>() private agentDetector: AgentDetector | null = null private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() @@ -1038,6 +1076,7 @@ export class OrcaRuntimeService { private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() private ptysById = new Map() private headlessTerminals = new Map() + private ptyOutputSequenceById = new Map() // Why: per-PTY hydration state guards against double-hydration. Keys: // 'pending' → maybeHydrateHeadlessFromRenderer is in flight // 'done' → hydration completed (success or skip); never run again @@ -1304,6 +1343,7 @@ export class OrcaRuntimeService { GlobalSettings, | 'defaultTuiAgent' | 'agentCmdOverrides' + | 'agentStatusHooksEnabled' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'visibleTaskProviders' @@ -1318,6 +1358,7 @@ export class OrcaRuntimeService { return { defaultTuiAgent: settings.defaultTuiAgent ?? null, agentCmdOverrides: settings.agentCmdOverrides ?? {}, + agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, defaultTaskSource: settings.defaultTaskSource ?? 'github', defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues', visibleTaskProviders: settings.visibleTaskProviders ?? ['github', 'gitlab', 'linear'], @@ -1330,6 +1371,7 @@ export class OrcaRuntimeService { updateClientSettings( updates: Pick< Partial, + | 'agentStatusHooksEnabled' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'defaultRepoSelection' @@ -1340,6 +1382,7 @@ export class OrcaRuntimeService { GlobalSettings, | 'defaultTuiAgent' | 'agentCmdOverrides' + | 'agentStatusHooksEnabled' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'visibleTaskProviders' @@ -1347,10 +1390,17 @@ export class OrcaRuntimeService { | 'defaultLinearTeamSelection' | 'githubProjects' > { - if (!this.store?.updateSettings) { + if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('runtime_unavailable') } + const before = this.store.getSettings().agentStatusHooksEnabled !== false this.store.updateSettings(updates) + if ( + typeof updates.agentStatusHooksEnabled === 'boolean' && + before !== updates.agentStatusHooksEnabled + ) { + applyAgentStatusHooksEnabled(updates.agentStatusHooksEnabled) + } return this.getClientSettings() } @@ -2033,6 +2083,8 @@ export class OrcaRuntimeService { this.gitCommands.getRuntimeGitHistory.bind(this.gitCommands) getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] = this.gitCommands.getRuntimeGitConflictOperation.bind(this.gitCommands) + abortRuntimeGitMerge: RuntimeGitCommands['abortRuntimeGitMerge'] = + this.gitCommands.abortRuntimeGitMerge.bind(this.gitCommands) getRuntimeGitDiff: RuntimeGitCommands['getRuntimeGitDiff'] = this.gitCommands.getRuntimeGitDiff.bind(this.gitCommands) getRuntimeGitBranchCompare: RuntimeGitCommands['getRuntimeGitBranchCompare'] = @@ -2086,11 +2138,11 @@ export class OrcaRuntimeService { private async resolveRuntimeGitTarget( worktreeSelector: string - ): Promise<{ worktree: ResolvedWorktree; connectionId?: string }> { + ): Promise<{ worktree: ResolvedWorktree; repo?: Repo; connectionId?: string }> { const store = this.requireStore() const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = store.getRepo(worktree.repoId) - return { worktree, connectionId: repo?.connectionId ?? undefined } + return { worktree, repo, connectionId: repo?.connectionId ?? undefined } } onMobileSessionTabsChanged( @@ -2147,7 +2199,9 @@ export class OrcaRuntimeService { this.recordPtyWorktree(ptyId, worktreeId, { connected: true }) } - onPtyData(ptyId: string, data: string, at: number): void { + onPtyData(ptyId: string, data: string, at: number): number { + const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + data.length + this.ptyOutputSequenceById.set(ptyId, outputSequence) this.recentPtyOutputById.set( ptyId, `${this.recentPtyOutputById.get(ptyId) ?? ''}${data}`.slice(-RECENT_PTY_OUTPUT_LIMIT) @@ -2167,7 +2221,7 @@ export class OrcaRuntimeService { // that the later seed-resolve would overwrite, dropping the live byte. // See docs/mobile-prefer-renderer-scrollback.md. this.maybeHydrateHeadlessFromRenderer(ptyId) - this.trackHeadlessTerminalData(ptyId, data) + this.trackHeadlessTerminalData(ptyId, data, outputSequence) // Why: extract OSC title from raw PTY data before tail-buffer processing // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) @@ -2176,11 +2230,30 @@ export class OrcaRuntimeService { const oscTitle = extractLastOscTitle(data) const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null + let normalizedData: string | null = null + const getNormalizedData = (): string => { + normalizedData ??= normalizeTerminalChunk(data) + return normalizedData + } const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + const ptyTailBefore = pty + ? { + lines: pty.tailBuffer, + partialLine: pty.tailPartialLine, + truncated: pty.tailTruncated, + linesTotal: pty.tailLinesTotal + } + : null + let ptyTailAfter: ReturnType | null = null if (pty) { pty.connected = true pty.lastOutputAt = at - const nextTail = appendToTailBuffer(pty.tailBuffer, pty.tailPartialLine, data) + const nextTail = appendNormalizedToTailBuffer( + pty.tailBuffer, + pty.tailPartialLine, + getNormalizedData() + ) + ptyTailAfter = nextTail pty.tailBuffer = nextTail.lines pty.tailPartialLine = nextTail.partialLine pty.tailTruncated = pty.tailTruncated || nextTail.truncated @@ -2210,12 +2283,37 @@ export class OrcaRuntimeService { leaf.connected = true leaf.writable = this.graphStatus === 'ready' leaf.lastOutputAt = at - const nextTail = appendToTailBuffer(leaf.tailBuffer, leaf.tailPartialLine, data) - leaf.tailBuffer = nextTail.lines - leaf.tailPartialLine = nextTail.partialLine - leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated - leaf.tailLinesTotal += nextTail.newCompleteLines - leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) + if ( + pty && + ptyTailBefore && + ptyTailAfter && + tailStateMatches( + leaf.tailBuffer, + leaf.tailPartialLine, + leaf.tailTruncated, + leaf.tailLinesTotal, + ptyTailBefore + ) + ) { + // Why: the leaf and PTY record usually mirror the same terminal. Reuse + // the PTY tail update instead of splitting large output twice. + leaf.tailBuffer = pty.tailBuffer + leaf.tailPartialLine = pty.tailPartialLine + leaf.tailTruncated = pty.tailTruncated + leaf.tailLinesTotal = pty.tailLinesTotal + leaf.preview = pty.preview + } else { + const nextTail = appendNormalizedToTailBuffer( + leaf.tailBuffer, + leaf.tailPartialLine, + getNormalizedData() + ) + leaf.tailBuffer = nextTail.lines + leaf.tailPartialLine = nextTail.partialLine + leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated + leaf.tailLinesTotal += nextTail.newCompleteLines + leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) + } if (oscTitle !== null) { // Why: keep the latest OSC title on the leaf so worktree.ps can @@ -2252,6 +2350,11 @@ export class OrcaRuntimeService { listener(data) } } + return outputSequence + } + + getPtyOutputSequence(ptyId: string): number { + return this.ptyOutputSequenceById.get(ptyId) ?? 0 } subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void { @@ -2320,10 +2423,17 @@ export class OrcaRuntimeService { serializeTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} - ): Promise<{ data: string; cols: number; rows: number } | null> { + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } + serializeMainTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { + return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) + } + async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> { const leaf = this.resolveLeafForHandle(handle) if (!leaf?.ptyId) { @@ -2363,6 +2473,7 @@ export class OrcaRuntimeService { const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state: RuntimeHeadlessTerminal = { emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + outputSequence: 0, writeChain: Promise.resolve() } this.headlessTerminals.set(ptyId, state) @@ -2403,6 +2514,7 @@ export class OrcaRuntimeService { const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state: RuntimeHeadlessTerminal = { emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + outputSequence: 0, writeChain: Promise.resolve() } this.headlessTerminals.set(ptyId, state) @@ -2468,10 +2580,13 @@ export class OrcaRuntimeService { } } - private trackHeadlessTerminalData(ptyId: string, data: string): void { + private trackHeadlessTerminalData(ptyId: string, data: string, outputSequence: number): void { const state = this.getOrCreateHeadlessTerminal(ptyId) state.writeChain = state.writeChain - .then(() => state.emulator.write(data)) + .then(async () => { + await state.emulator.write(data) + state.outputSequence = outputSequence + }) .catch(() => { // Best-effort state tracking; live streaming must continue even if // xterm rejects a malformed or raced write during shutdown. @@ -2486,6 +2601,7 @@ export class OrcaRuntimeService { const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state: RuntimeHeadlessTerminal = { emulator: new HeadlessEmulator({ cols: size.cols, rows: size.rows }), + outputSequence: 0, writeChain: Promise.resolve() } this.headlessTerminals.set(ptyId, state) @@ -2511,7 +2627,7 @@ export class OrcaRuntimeService { private async serializeTerminalBufferFromAvailableState( ptyId: string, opts: { scrollbackRows?: number } = {} - ): Promise<{ data: string; cols: number; rows: number } | null> { + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) if (headlessSnapshot) { return headlessSnapshot @@ -2545,8 +2661,8 @@ export class OrcaRuntimeService { private async serializeHeadlessTerminalBuffer( ptyId: string, - opts: { scrollbackRows?: number } = {} - ): Promise<{ data: string; cols: number; rows: number } | null> { + opts: { scrollbackRows?: number; includeEmpty?: boolean } = {} + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { const state = this.headlessTerminals.get(ptyId) if (!state) { return null @@ -2563,7 +2679,9 @@ export class OrcaRuntimeService { const scrollbackRows = state.emulator.isAlternateScreen ? 0 : requested const snapshot = state.emulator.getSnapshot({ scrollbackRows }) const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi - return data.length > 0 ? { data, cols: snapshot.cols, rows: snapshot.rows } : null + return data.length > 0 || opts.includeEmpty === true + ? { data, cols: snapshot.cols, rows: snapshot.rows, seq: state.outputSequence } + : null } private disposeHeadlessTerminal(ptyId: string): void { @@ -2684,6 +2802,10 @@ export class OrcaRuntimeService { this.commitMessageAgentEnv = resolvers } + getCommitMessageAgentEnvironmentResolvers(): CommitMessageAgentEnvironmentResolvers | undefined { + return this.commitMessageAgentEnv ?? undefined + } + async startMobileDictation(params: { dictationId: string modelId?: string @@ -3266,6 +3388,7 @@ export class OrcaRuntimeService { this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.ptyOutputSequenceById.delete(ptyId) // Layout state machine: clear `layouts` and `layoutQueues`. Any // already-queued applyLayout work for this ptyId will run, but every // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and @@ -4984,6 +5107,171 @@ export class OrcaRuntimeService { return this.store?.getRepos() ?? [] } + listProjectGroups(): ProjectGroup[] { + return this.store?.getProjectGroups?.() ?? [] + } + + async createProjectGroup(input: { + name: string + parentPath?: string | null + parentGroupId?: string | null + createdFrom?: ProjectGroup['createdFrom'] + }): Promise { + if (!this.store?.createProjectGroup) { + throw new Error('runtime_unavailable') + } + const group = this.store.createProjectGroup({ + name: input.name, + parentPath: input.parentPath ?? null, + parentGroupId: input.parentGroupId ?? null, + createdFrom: input.createdFrom ?? 'manual' + }) + this.notifier?.reposChanged() + return group + } + + async updateProjectGroup( + groupId: string, + updates: Partial> + ): Promise { + if (!this.store?.updateProjectGroup) { + throw new Error('runtime_unavailable') + } + const updated = this.store.updateProjectGroup(groupId, updates) + if (updated) { + this.notifier?.reposChanged() + } + return updated + } + + async deleteProjectGroup(groupId: string): Promise<{ deleted: boolean }> { + if (!this.store?.deleteProjectGroup) { + throw new Error('runtime_unavailable') + } + const deleted = this.store.deleteProjectGroup(groupId) + if (deleted) { + this.notifier?.reposChanged() + } + return { deleted } + } + + async moveProjectToGroup( + repoSelector: string, + groupId: string | null, + order?: number + ): Promise { + if (!this.store?.moveProjectToGroup) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + const moved = this.store.moveProjectToGroup(repo.id, groupId, order) + if (!moved) { + throw new Error('repo_not_found') + } + this.notifier?.reposChanged() + return moved + } + + async scanNestedRepos(path: string): Promise { + if (!isAbsolute(path)) { + throw new Error('Project path must be an absolute path') + } + return scanNestedRepos({ path }) + } + + async importNestedRepos(args: { + parentPath: string + groupName: string + projectPaths: string[] + mode: ProjectGroupImportMode + }): Promise { + if (!this.store?.createProjectGroup || !this.store?.moveProjectToGroup) { + throw new Error('runtime_unavailable') + } + if (!isAbsolute(args.parentPath)) { + throw new Error('Project path must be an absolute path') + } + const scan = await scanNestedRepos({ path: args.parentPath }) + const selection = resolveNestedRepoSelection({ scan, projectPaths: args.projectPaths }) + const groupResolver = createNestedProjectGroupResolver({ + parentPath: scan.selectedPath, + groupName: args.groupName, + mode: args.mode, + createGroup: (input) => this.store!.createProjectGroup!(input) + }) + const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map( + (repoPath) => ({ + path: repoPath, + status: 'failed', + error: 'Repository was not found in the nested repo scan result' + }) + ) + for (const repoPath of selection.selectedPaths) { + try { + if (!isGitRepo(repoPath)) { + results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) + continue + } + const existing = this.store + .getRepos() + .find((repo) => runtimePathsEqual(repo.path, repoPath)) + const group = groupResolver.getGroupForRepo(repoPath) + if (existing) { + if (group) { + this.store.moveProjectToGroup(existing.id, group.id) + } + results.push({ path: repoPath, projectId: existing.id, status: 'already-known' }) + continue + } + const repo: Repo = { + id: randomUUID(), + path: repoPath, + displayName: getRepoName(repoPath), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + addedAt: Date.now(), + kind: 'git', + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: false, + ...(group + ? { + projectGroupId: group.id, + projectGroupOrder: getNextProjectGroupOrder(this.store.getRepos(), group.id) + } + : {}) + } + this.store.addRepo(repo) + results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) + } catch (error) { + results.push({ + path: repoPath, + status: 'failed', + error: sanitizeNestedRepoRuntimeImportError( + 'Failed to import nested repository in runtime', + error + ) + }) + } + } + const importedCount = results.filter((entry) => entry.status === 'imported').length + const alreadyKnownCount = results.filter((entry) => entry.status === 'already-known').length + const failedCount = results.filter((entry) => entry.status === 'failed').length + if (importedCount + alreadyKnownCount === 0) { + for (const group of groupResolver.getCreatedGroups().reverse()) { + this.store.deleteProjectGroup?.(group.id) + } + } + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + const rootGroup = groupResolver.getRootGroup() + return { + ...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}), + projects: results, + importedCount, + alreadyKnownCount, + failedCount + } + } + async listSparsePresets(repoSelector: string) { if (!this.store?.getSparsePresets) { throw new Error('runtime_unavailable') @@ -5023,7 +5311,7 @@ export class OrcaRuntimeService { if (!isAbsolute(path)) { // Why: remote clients may run in a different cwd than the server. Require // server-side repo paths to be explicit so `orca serve` cwd is irrelevant. - throw new Error('Repo path must be an absolute path') + throw new Error('Project path must be an absolute path') } if (kind === 'git' && !isGitRepo(path)) { throw new Error(`Not a valid git repository: ${path}`) @@ -5034,11 +5322,13 @@ export class OrcaRuntimeService { return existing } + const repoIcon = await detectRepoIcon({ repoPath: path, kind }) const repo: Repo = { id: randomUUID(), path, displayName: getRepoName(path), badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(repoIcon ? { repoIcon } : {}), addedAt: Date.now(), kind, ...(kind === 'git' @@ -5148,11 +5438,13 @@ export class OrcaRuntimeService { return { repo: raceWinner } } + const repoIcon = await detectRepoIcon({ repoPath: targetPath, kind: repoKind }) const repo: Repo = { id: randomUUID(), path: targetPath, displayName: trimmedName, badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(repoIcon ? { repoIcon } : {}), addedAt: Date.now(), kind: repoKind, ...(repoKind === 'git' @@ -5175,30 +5467,92 @@ export class OrcaRuntimeService { } const trimmedUrl = url.trim() const trimmedDestination = destination.trim() - const repoName = basename(trimmedUrl.replace(/\.git\/?$/, '')) - if (!repoName) { - throw new Error('Could not determine repository name from URL') - } if (!trimmedDestination) { throw new Error('Clone destination is required') } - if (!isAbsolute(trimmedDestination)) { - throw new Error('Clone destination must be an absolute path') + const clonePath = deriveValidatedClonePath({ url: trimmedUrl, destination: trimmedDestination }) + const clonePathKey = getClonePathComparisonKey(clonePath) + const previous = this.cloneInFlightByPath.get(clonePathKey) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.then( + () => current, + () => current + ) + this.cloneInFlightByPath.set(clonePathKey, tail) + + try { + await previous + return await this.cloneRepoAfterPathLock( + trimmedUrl, + trimmedDestination, + clonePath, + clonePathKey + ) + } finally { + release() + if (this.cloneInFlightByPath.get(clonePathKey) === tail) { + this.cloneInFlightByPath.delete(clonePathKey) + } } + } + + private async cloneRepoAfterPathLock( + trimmedUrl: string, + trimmedDestination: string, + clonePath: string, + clonePathKey: string + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const existingBeforeClone = this.store + .getRepos() + .find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey) + if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) { + return existingBeforeClone + } + await mkdir(trimmedDestination, { recursive: true }) - const clonePath = join(trimmedDestination, repoName) + const claimedTarget = await claimCloneTarget(clonePath) await new Promise((resolve, reject) => { - const proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], { - cwd: trimmedDestination, - stdio: ['ignore', 'ignore', 'pipe'] - }) + let proc: ReturnType + try { + proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], { + cwd: trimmedDestination, + stdio: ['ignore', 'ignore', 'pipe'] + }) + } catch (err) { + void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => { + const message = err instanceof Error ? err.message : String(err) + reject(new Error(`Clone failed: ${message}`)) + }) + return + } let stderrTail = '' + let settled = false proc.stderr?.on('data', (chunk: Buffer) => { stderrTail = (stderrTail + chunk.toString()).slice(-4096) }) - proc.on('error', (error) => reject(new Error(`Clone failed: ${error.message}`))) - proc.on('close', (code, signal) => { - if (signal === 'SIGTERM') { + const finishClone = async ( + code: number | null, + signal: NodeJS.Signals | null, + error?: Error + ) => { + if (settled) { + return + } + settled = true + const cloneSucceeded = !error && code === 0 && !signal + if (!cloneSucceeded) { + await cleanupClaimedCloneTarget(clonePath, claimedTarget) + } + + if (error) { + reject(new Error(`Clone failed: ${error.message}`)) + } else if (signal === 'SIGTERM') { reject(new Error('Clone aborted')) } else if (code === 0) { resolve() @@ -5206,10 +5560,18 @@ export class OrcaRuntimeService { const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' reject(new Error(`Clone failed: ${lastLine}`)) } + } + proc.on('error', (error) => { + void finishClone(null, null, error) + }) + proc.on('close', (code, signal) => { + void finishClone(code, signal) }) }) - const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, clonePath)) + const existing = this.store + .getRepos() + .find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey) if (existing) { if (isFolderRepo(existing)) { const updated = this.store.updateRepo(existing.id, { kind: 'git' }) @@ -5221,11 +5583,13 @@ export class OrcaRuntimeService { return existing } + const repoIcon = await detectRepoIcon({ repoPath: clonePath, kind: 'git' }) const repo: Repo = { id: randomUUID(), path: clonePath, displayName: getRepoName(clonePath), badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(repoIcon ? { repoIcon } : {}), addedAt: Date.now(), kind: 'git', externalWorktreeVisibility: 'hide', @@ -5274,6 +5638,9 @@ export class OrcaRuntimeService { | 'issueSourcePreference' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'projectGroupId' + | 'projectGroupOrder' + | 'sourceControlAi' > > ): Promise { @@ -5290,12 +5657,12 @@ export class OrcaRuntimeService { return updated } - async removeRepo(repoSelector: string): Promise<{ removed: true }> { - if (!this.store?.removeRepo) { + async removeProject(repoSelector: string): Promise<{ removed: true }> { + if (!this.store?.removeProject) { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(repoSelector) - this.store.removeRepo(repo.id) + this.store.removeProject(repo.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.reposChanged() @@ -5629,7 +5996,8 @@ export class OrcaRuntimeService { head: args.head, title: args.title, body: args.body, - draft: args.draft + draft: args.draft, + useTemplate: args.useTemplate }, repo.connectionId ?? null ) @@ -7217,44 +7585,43 @@ export class OrcaRuntimeService { } const existingBranchOption = { checkoutExistingBranch } - if (sparseDirectories.length > 0) { - await (checkoutExistingBranch - ? addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - existingBranchOption - ) - : addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - )) - } else { - await (checkoutExistingBranch - ? addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - false, - existingBranchOption - ) - : addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - )) - } + const addResult: AddWorktreeResult = + (await (sparseDirectories.length > 0 + ? checkoutExistingBranch + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + existingBranchOption + ) + : addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + : checkoutExistingBranch + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + existingBranchOption + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ))) ?? {} let configuredPushTarget: GitPushTarget | undefined if (preparedPushTarget) { @@ -7527,7 +7894,10 @@ export class OrcaRuntimeService { }, ...(lineageInput ? { lineage, warnings: lineageWarnings } : {}), ...(setup ? { setup } : {}), - ...(warning ? { warning } : {}) + ...(warning ? { warning } : {}), + ...(addResult.localBaseRefRefresh + ? { localBaseRefRefresh: addResult.localBaseRefRefresh } + : {}) } } @@ -8114,7 +8484,10 @@ export class OrcaRuntimeService { createdAt: Date.now() }) } - this.store.setWorktreeMeta(worktree.id, omitUndefinedProperties(metaUpdates)) + this.store.setWorktreeMeta( + worktree.id, + stripOrcaProvenanceMetaUpdates(omitUndefinedProperties(metaUpdates)) + ) // Why: unlike renderer-initiated optimistic updates, CLI callers need an // explicit push so the editor refreshes metadata changed outside the UI. this.invalidateResolvedWorktreeCache() @@ -8417,6 +8790,13 @@ export class OrcaRuntimeService { } } + private removeWorktreeMetadataAndHistory(store: RuntimeStore, worktreeId: string): void { + // Why: terminal history is keyed by worktree ID, so metadata cleanup must + // also purge history before the same path-derived ID can be recreated. + store.removeWorktreeMeta(worktreeId) + deleteWorktreeHistoryDir(worktreeId) + } + async removeManagedWorktree( worktreeSelector: string, force = false, @@ -8460,13 +8840,13 @@ export class OrcaRuntimeService { console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) }) } - store.removeWorktreeMeta(removalTarget.id) - deleteWorktreeHistoryDir(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.invalidateResolvedWorktreeCache() this.notifier?.worktreesChanged(repo.id) return {} } const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null const registeredWorktrees = repo.connectionId ? await provider!.listWorktrees(repo.path) : await listWorktrees(repo.path) @@ -8478,6 +8858,68 @@ export class OrcaRuntimeService { registeredWorktrees ) if (!registeredWorktree) { + let canCleanOrphanedDirectory = false + const knownOrcaLayouts = repo.connectionId + ? [] + : buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) + if ( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: removedMeta, + worktreePath: removalTarget.path, + repo, + knownOrcaLayouts + }) + ) { + if (repo.connectionId) { + if (!fsProvider) { + throw new Error('SSH filesystem provider unavailable') + } + if (!fsProvider.lstat) { + throw new Error('SSH filesystem provider lstat unavailable') + } + canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( + removalTarget.path, + repo.path, + (path) => fsProvider.lstat!(path), + (path) => fsProvider.readFile(path) + ) + } else { + canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( + removalTarget.path, + repo.path + ) + } + } + if (canCleanOrphanedDirectory) { + assertWorktreeDoesNotContainRegisteredWorktree(removalTarget.path, registeredWorktrees) + if (!force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + if (repo.connectionId) { + await fsProvider!.deletePath(removalTarget.path, true) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + removalTarget.id, + removedPushTarget, + store + ) + } else { + await rm(removalTarget.path, { recursive: true, force: true }) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store + ) + } + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifier?.worktreesChanged(repo.id) + return {} + } if (force && (await isRuntimeWorktreePathMissing(repo, removalTarget.path))) { // Why: runtime clients can retry a force delete after another surface // already removed the worktree. Finish Orca cleanup without touching @@ -8497,7 +8939,7 @@ export class OrcaRuntimeService { store )) this.clearOptimisticReconcileToken(removalTarget.id) - store.removeWorktreeMeta(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) @@ -8519,7 +8961,7 @@ export class OrcaRuntimeService { store ) this.clearOptimisticReconcileToken(removalTarget.id) - store.removeWorktreeMeta(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) @@ -8603,7 +9045,7 @@ export class OrcaRuntimeService { store ) this.clearOptimisticReconcileToken(removalTarget.id) - store.removeWorktreeMeta(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) @@ -8621,7 +9063,7 @@ export class OrcaRuntimeService { store ) this.clearOptimisticReconcileToken(removalTarget.id) - store.removeWorktreeMeta(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) @@ -9417,6 +9859,13 @@ export class OrcaRuntimeService { candidates = worktrees.filter((worktree) => runtimePathsEqual(worktree.path, selector.slice(5)) ) + if (candidates.length > 1) { + // Why: registering another worktree from the same Git repo makes git + // report the same physical worktree path under multiple repo IDs. + // A path selector is already exact, so prefer the first resolved row + // instead of surfacing a duplicate-registration ambiguity. + candidates = [candidates[0]] + } } else if (selector.startsWith('branch:')) { const branchSelector = selector.slice(7) candidates = worktrees.filter((worktree) => @@ -10012,6 +10461,14 @@ export class OrcaRuntimeService { this.resolvedWorktreeCache = null } + /** Invalidate the worktree cache and tell the renderer to re-list, after an + * out-of-band branch change (e.g. auto-rename-from-work) so the new branch + * name surfaces without waiting for the next ambient refresh. */ + notifyBranchRenamed(repoId: string): void { + this.invalidateResolvedWorktreeCache() + this.notifier?.worktreesChanged(repoId) + } + private recordPtyWorktree( ptyId: string, worktreeId: string, @@ -10421,6 +10878,7 @@ export class OrcaRuntimeService { leafId: tab.leafId, title: leaf?.paneTitle ?? syncedTab?.title ?? pty?.title ?? tab.title, ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), + ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), ...(tab.agentStatus ? { agentStatus: tab.agentStatus } : {}), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), isActive: tab.isActive, @@ -11338,11 +11796,18 @@ export class OrcaRuntimeService { description?: string, workspaceId?: string, parentIssueId?: string, - projectId?: string | null + projectId?: string | null, + options?: { + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] + } ): ReturnType { return createLinearIssue(teamId, title, description, workspaceId, { parentId: parentIssueId, - projectId + projectId, + ...options }) } @@ -11922,17 +12387,16 @@ function buildTerminalWaitText(lines: string[], partialLine: string, preview: st return waitText.length > 0 ? waitText : preview } -function appendToTailBuffer( +function appendNormalizedToTailBuffer( previousLines: string[], previousPartialLine: string, - chunk: string + normalizedChunk: string ): { lines: string[] partialLine: string truncated: boolean newCompleteLines: number } { - const normalizedChunk = normalizeTerminalChunk(chunk) if (normalizedChunk.length === 0) { return { lines: previousLines, @@ -11981,6 +12445,37 @@ function appendToTailBuffer( } } +function tailStateMatches( + lines: string[], + partialLine: string, + truncated: boolean, + linesTotal: number, + snapshot: { + lines: string[] + partialLine: string + truncated: boolean + linesTotal: number + } +): boolean { + if ( + partialLine !== snapshot.partialLine || + truncated !== snapshot.truncated || + linesTotal !== snapshot.linesTotal || + lines.length !== snapshot.lines.length + ) { + return false + } + if (lines === snapshot.lines) { + return true + } + for (let index = 0; index < lines.length; index++) { + if (lines[index] !== snapshot.lines[index]) { + return false + } + } + return true +} + function buildTailLines(lines: string[], partialLine: string): string[] { return partialLine.length > 0 ? [...lines, partialLine] : lines } @@ -12416,6 +12911,11 @@ function mergeWorktreeStatus( } function normalizeTerminalChunk(chunk: string): string { + // Why: most high-throughput PTY chunks are plain printable text. Avoid + // running every ANSI/OSC regex over megabytes that do not need normalization. + if (!terminalChunkNeedsNormalization(chunk)) { + return chunk + } return chunk .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') @@ -12426,6 +12926,22 @@ function normalizeTerminalChunk(chunk: string): string { .replace(/[^\x09\x0a\x20-\x7e]/g, '') } +function terminalChunkNeedsNormalization(chunk: string): boolean { + for (let index = 0; index < chunk.length; index++) { + const code = chunk.charCodeAt(index) + if ( + code === 0x1b || + code === 0x0d || + code < 0x09 || + (code > 0x0a && code < 0x20) || + code > 0x7e + ) { + return true + } + } + return false +} + function maxTimestamp(left: number | null, right: number | null): number | null { if (left === null) { return right diff --git a/src/main/runtime/orchestration-cli-subprocess.test.ts b/src/main/runtime/orchestration-cli-subprocess.test.ts index 054328a9653..d07cac5872c 100644 --- a/src/main/runtime/orchestration-cli-subprocess.test.ts +++ b/src/main/runtime/orchestration-cli-subprocess.test.ts @@ -16,7 +16,7 @@ // `pnpm run build:cli`. The verification gate explicitly builds the CLI // before running this file. import { spawn } from 'child_process' -import { existsSync, mkdtempSync } from 'fs' +import { existsSync, mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { describe, expect, it } from 'vitest' @@ -31,6 +31,40 @@ const CLI_PATH = join(process.cwd(), 'out', 'cli', 'index.js') const describeIfBuilt = existsSync(CLI_PATH) ? describe : describe.skip +async function runBuiltCli( + userDataPath: string, + args: string[], + extraEnv: Record = {} +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const child = spawn(process.execPath, [CLI_PATH, ...args], { + env: { + ...process.env, + ORCA_USER_DATA_PATH: userDataPath, + ORCA_TERMINAL_HANDLE: 'term_cli', + ...extraEnv + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + const stdoutChunks: string[] = [] + const stderrChunks: string[] = [] + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (d) => stdoutChunks.push(d)) + child.stderr.on('data', (d) => stderrChunks.push(d)) + + const exitCode = await new Promise((resolveExit, rejectExit) => { + child.once('exit', (code) => resolveExit(code ?? 1)) + child.once('error', rejectExit) + }) + + return { + exitCode, + stdout: stdoutChunks.join(''), + stderr: stderrChunks.join('') + } +} + describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { it('emits newline-flushed JSON heartbeats to stderr while waiting', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-sub-')) @@ -156,3 +190,88 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { } }, 30_000) }) + +describeIfBuilt('orca orchestration reset subprocess', () => { + it('validates reset scopes against an isolated runtime through the built CLI', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-reset-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) + await server.start() + + try { + const send = await runBuiltCli(userDataPath, [ + 'orchestration', + 'send', + '--to', + 'term_target', + '--subject', + 'hello', + '--json' + ]) + expect(send.exitCode, send.stderr).toBe(0) + + const create = await runBuiltCli(userDataPath, [ + 'orchestration', + 'task-create', + '--spec', + 'throwaway task', + '--json' + ]) + expect(create.exitCode, create.stderr).toBe(0) + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(1) + + const invalid = await runBuiltCli(userDataPath, [ + 'orchestration', + 'reset', + '--tasks', + '--messages', + '--json' + ]) + expect(invalid.exitCode).toBe(1) + const invalidPayload = JSON.parse(invalid.stdout) as { + ok: boolean + error: { code: string; message: string } + } + expect(invalidPayload.ok).toBe(false) + expect(invalidPayload.error.code).toBe('invalid_argument') + expect(invalidPayload.error.message).toContain('Choose exactly one reset scope') + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(1) + + const resetTasks = await runBuiltCli(userDataPath, [ + 'orchestration', + 'reset', + '--tasks', + '--json' + ]) + expect(resetTasks.exitCode, resetTasks.stderr).toBe(0) + expect(JSON.parse(resetTasks.stdout)).toMatchObject({ ok: true, result: { reset: 'tasks' } }) + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(0) + + const recreate = await runBuiltCli(userDataPath, [ + 'orchestration', + 'task-create', + '--spec', + 'throwaway task after partial reset', + '--json' + ]) + expect(recreate.exitCode, recreate.stderr).toBe(0) + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(1) + + const resetAll = await runBuiltCli(userDataPath, ['orchestration', 'reset', '--json']) + expect(resetAll.exitCode, resetAll.stderr).toBe(0) + expect(JSON.parse(resetAll.stdout)).toMatchObject({ ok: true, result: { reset: 'all' } }) + expect(db.getInbox()).toHaveLength(0) + expect(db.listTasks()).toHaveLength(0) + } finally { + db.close() + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + }, 30_000) +}) diff --git a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts index 070468c9a13..87c127bbb1f 100644 --- a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts +++ b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { PersistedUIState } from '../../../shared/types' import { getDefaultUIState } from '../../../shared/constants' +import { + ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE, + ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY +} from '../../../shared/runtime-rpc-feature-interaction-source' import { RpcDispatcher } from './dispatcher' import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' @@ -43,6 +47,26 @@ const METHODS = [ params: z.object({}), handler: () => ({ clicked: true }) }), + defineMethod({ + name: 'browser.tabCreate', + params: z.object({}), + handler: () => ({ browserPageId: 'page-1' }) + }), + defineMethod({ + name: 'browser.tabShow', + params: z.object({}), + handler: () => ({ tab: { id: 'page-1' } }) + }), + defineMethod({ + name: 'browser.viewport', + params: z.object({}), + handler: () => ({ ok: true }) + }), + defineMethod({ + name: 'browser.eval', + params: z.object({}), + handler: () => ({ value: 'ok' }) + }), defineStreamingMethod({ name: 'browser.screencast', params: z.object({}), @@ -137,24 +161,28 @@ describe('RpcDispatcher feature interactions', () => { expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled() }) - it('records screencast starts but not screencast cleanup', async () => { + it('records unmarked browser display RPCs as agent browser use', async () => { const runtime = makeRuntime() const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) const replies: string[] = [] + await dispatcher.dispatch(makeRequest('browser.tabCreate')) + await dispatcher.dispatch(makeRequest('browser.tabShow')) await dispatcher.dispatch(makeRequest('browser.screencast.unsubscribe')) - expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled() + expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(2) + expect(runtime.recordFeatureInteraction).toHaveBeenNthCalledWith(1, 'agent-browser-use') + expect(runtime.recordFeatureInteraction).toHaveBeenNthCalledWith(2, 'agent-browser-use') await dispatcher.dispatchStreaming(makeRequest('browser.screencast'), (response) => { replies.push(response) }) expect(replies).toHaveLength(2) + expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(3) expect(runtime.recordFeatureInteraction).toHaveBeenLastCalledWith('agent-browser-use') - expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1) }) - it('records streaming runtime feature use when the stream only returns a start result', async () => { + it('records binary-only browser display streams as agent browser use', async () => { const runtime = makeRuntime() const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) const replies: string[] = [] @@ -168,6 +196,25 @@ describe('RpcDispatcher feature interactions', () => { expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use') }) + it('does not record browser pane UI-originated browser RPCs as agent browser use', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + const browserPaneUiParams = { + [ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE + } + + await dispatcher.dispatch(makeRequest('browser.viewport', browserPaneUiParams)) + await dispatcher.dispatch(makeRequest('browser.eval', browserPaneUiParams)) + await dispatcher.dispatchStreaming( + makeRequest('browser.screencast', browserPaneUiParams), + () => {} + ) + await dispatcher.dispatch(makeRequest('browser.click')) + + expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1) + expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use') + }) + it('records each successful non-streaming runtime feature interaction', async () => { const runtime = makeRuntime() const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 2852830c5f8..add223a2d02 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -15,6 +15,7 @@ import { } from './core' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { FeatureInteractionId } from '../../../shared/feature-interactions' +import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source' import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors' import { ALL_RPC_METHODS } from './methods' import type { OrcaRuntimeService } from '../orca-runtime' @@ -67,7 +68,7 @@ export class RpcDispatcher { runtime: this.runtime, signal: options?.signal }) - this.recordRuntimeFeatureInteraction(request.method, result) + this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params) return successResponse(request.id, meta, result) } catch (error) { return this.mapError(request, meta, error) @@ -118,7 +119,7 @@ export class RpcDispatcher { sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) - this.recordRuntimeFeatureInteraction(request.method, result) + this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params) reply(JSON.stringify(successResponse(request.id, meta, result))) } catch (error) { reply(JSON.stringify(this.mapError(request, meta, error))) @@ -131,7 +132,8 @@ export class RpcDispatcher { this.recordRuntimeFeatureInteraction( request.method, result, - recordedStreamingFeatureInteractions + recordedStreamingFeatureInteractions, + request.params ) const response = successResponse(request.id, meta, result) response.streaming = true @@ -154,7 +156,8 @@ export class RpcDispatcher { this.recordRuntimeFeatureInteraction( request.method, result, - recordedStreamingFeatureInteractions + recordedStreamingFeatureInteractions, + request.params ) } catch (error) { reply(JSON.stringify(this.mapError(request, meta, error))) @@ -200,9 +203,10 @@ export class RpcDispatcher { private recordRuntimeFeatureInteraction( method: string, result: unknown, - alreadyRecorded?: Set + alreadyRecorded?: Set, + rawParams?: unknown ): void { - const id = getRuntimeFeatureInteractionId(method, result) + const id = getRuntimeFeatureInteractionId(method, result, rawParams) if (!id) { return } @@ -220,7 +224,8 @@ export class RpcDispatcher { function getRuntimeFeatureInteractionId( method: string, - result: unknown + result: unknown, + rawParams?: unknown ): FeatureInteractionId | null { if (method === 'browser.profileImportFromBrowser') { return hasBooleanResult(result, 'ok') ? 'cookie-import' : null @@ -231,6 +236,9 @@ function getRuntimeFeatureInteractionId( if (method === 'browser.screencast.unsubscribe') { return null } + if (method.startsWith('browser.') && isBrowserPaneUiRuntimeRpcParams(rawParams)) { + return null + } if (method.startsWith('browser.') && !method.startsWith('browser.profile')) { return 'agent-browser-use' } diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 0ff6c417dfc..5d7c499b309 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -108,6 +108,8 @@ describe('client UI RPC methods', () => { it('persists UI updates on the runtime host and returns the updated state', async () => { const updated: PersistedUIState = { ...getDefaultUIState(), + rightSidebarOpen: false, + rightSidebarTab: 'checks', showActiveOnly: true, filterRepoIds: ['repo-1'] } @@ -119,6 +121,8 @@ describe('client UI RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('ui.set', { + rightSidebarOpen: false, + rightSidebarTab: 'checks', showActiveOnly: true, hideSleepingWorkspaces: true, filterRepoIds: ['repo-1'] @@ -126,6 +130,8 @@ describe('client UI RPC methods', () => { ) expect(runtime.updateUIState).toHaveBeenCalledWith({ + rightSidebarOpen: false, + rightSidebarTab: 'checks', showActiveOnly: true, hideSleepingWorkspaces: true, filterRepoIds: ['repo-1'] @@ -254,6 +260,21 @@ describe('client UI RPC methods', () => { expect(runtime.updateUIState).not.toHaveBeenCalled() }) + it('rejects unknown feature tip ids', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('ui.set', { featureTipsSeenIds: ['voice-dictation', 'unknown-tip'] }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.updateUIState).not.toHaveBeenCalled() + }) + it('rejects unknown feature interaction ids for increment RPC', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 44d93a0433d..7a8c9bbbbf0 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -3,12 +3,14 @@ import { isFeatureInteractionId, type FeatureInteractionId } from '../../../../shared/feature-interactions' +import { isFeatureTipId } from '../../../../shared/feature-tips' import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { PersistedUIState } from '../../../../shared/types' import { defineMethod, type RpcMethod } from '../core' const NullableString = z.string().nullable() const StringArray = z.array(z.string()) +const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })) const UnknownRecord = z.record(z.string(), z.unknown()) const UnknownRecordArray = z.array(UnknownRecord) const WorktreeCardProperty = z.enum([ @@ -106,6 +108,7 @@ const SettingsUpdate = z defaultTaskViewPreset: z .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) .optional(), + agentStatusHooksEnabled: z.boolean().optional(), defaultRepoSelection: z.array(z.string()).nullable().optional(), defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), githubProjects: GitHubProjectSettings.optional() @@ -118,6 +121,8 @@ const UiUpdate = z lastActiveRepoId: NullableString.optional(), lastActiveWorktreeId: NullableString.optional(), sidebarWidth: z.number().finite().optional(), + rightSidebarOpen: z.boolean().optional(), + rightSidebarTab: z.enum(['explorer', 'search', 'source-control', 'checks', 'ports']).optional(), rightSidebarWidth: z.number().finite().optional(), groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), showWorkspaceLineage: z.boolean().optional(), @@ -183,7 +188,7 @@ const UiUpdate = z sidekickSize: z.number().finite().optional(), taskResumeState: TaskResumeState.optional(), workspaceCleanup: WorkspaceCleanup.optional(), - featureTipsSeenIds: StringArray.optional(), + featureTipsSeenIds: FeatureTipIds.optional(), featureInteractions: FeatureInteractions.optional(), contextualToursSeenIds: StringArray.optional(), contextualToursAutoEligible: z.boolean().optional() diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 4d7322ecff2..d3c4edaea5a 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -330,6 +330,146 @@ describe('file RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) + it.each([ + ['missing content', { worktree: 'id:wt-1', relativePath: 'src/index.ts' }], + ['null content', { worktree: 'id:wt-1', relativePath: 'src/index.ts', content: null }], + ['non-string content', { worktree: 'id:wt-1', relativePath: 'src/index.ts', content: 0 }] + ])('rejects a write with %s instead of truncating the file', async (_name, params) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('files.write', params)) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.writeFileExplorerFile).not.toHaveBeenCalled() + }) + + it('still allows writing an explicit empty string (empty file)', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.write', { worktree: 'id:wt-1', relativePath: 'src/index.ts', content: '' }) + ) + + expect(runtime.writeFileExplorerFile).toHaveBeenCalledWith('id:wt-1', 'src/index.ts', '') + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('allows writing explicit empty base64 content', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.writeBase64', { + worktree: 'id:wt-1', + relativePath: 'assets/logo.png', + contentBase64: '' + }) + ) + + expect(runtime.writeFileExplorerFileBase64).toHaveBeenCalledWith( + 'id:wt-1', + 'assets/logo.png', + '' + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it.each([ + ['missing content', { worktree: 'id:wt-1', relativePath: 'assets/logo.png' }], + ['null content', { worktree: 'id:wt-1', relativePath: 'assets/logo.png', contentBase64: null }], + [ + 'non-string content', + { worktree: 'id:wt-1', relativePath: 'assets/logo.png', contentBase64: 0 } + ] + ])('rejects a base64 write with %s', async (_name, params) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('files.writeBase64', params)) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.writeFileExplorerFileBase64).not.toHaveBeenCalled() + }) + + it('allows writing an explicit empty base64 chunk', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64Chunk: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.writeBase64Chunk', { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + contentBase64: '', + append: true + }) + ) + + expect(runtime.writeFileExplorerFileBase64Chunk).toHaveBeenCalledWith( + 'id:wt-1', + 'assets/video.mov', + '', + true + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it.each([ + [ + 'missing content', + { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + append: true + } + ], + [ + 'null content', + { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + contentBase64: null, + append: true + } + ], + [ + 'non-string content', + { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + contentBase64: 0, + append: true + } + ] + ])('rejects a base64 chunk write with %s (inherits the schema)', async (_name, params) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64Chunk: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('files.writeBase64Chunk', params)) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.writeFileExplorerFileBase64Chunk).not.toHaveBeenCalled() + }) + it('commits staged runtime uploads without clobbering the final destination', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 3c5c65f9a74..fbcf31936de 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -30,18 +30,19 @@ const FileTreePath = WorktreeSelector.extend({ .pipe(z.string()) }) +// Why: write content must be a real string. Coercing a missing/non-string value +// to '' silently truncated the target file to empty instead of erroring. An +// explicit '' is still accepted (writing an empty file is legitimate). const FileWrite = FileOpen.extend({ content: z .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string()) + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) }) const FileWriteBase64 = FileOpen.extend({ contentBase64: z .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string()) + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) }) const FileWriteBase64Chunk = FileWriteBase64.extend({ diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 87b5e09ff40..6cb85a426b8 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -99,8 +99,33 @@ const CommitMessageAiSettings = z.object({ customAgentCommand: z.string() }) +const SourceControlAiSettings = CommitMessageAiSettings.omit({ customPrompt: true }).extend({ + instructionsByOperation: z.record(z.string(), z.string()).optional(), + modelOverridesByOperation: z + .record( + z.string(), + z.object({ + selectedModelByAgent: z.record(z.string(), z.string()).optional(), + selectedModelByAgentByHost: z + .record(z.string(), z.record(z.string(), z.string())) + .optional(), + selectedThinkingByModel: z.record(z.string(), z.string()).optional() + }) + ) + .optional(), + prCreationDefaults: z + .object({ + draft: z.boolean().optional(), + useTemplate: z.boolean().optional(), + generateDetailsOnOpen: z.boolean().optional(), + openAfterCreate: z.boolean().optional() + }) + .optional() +}) + export const GitGenerateCommitMessage = WorktreeSelector.extend({ commitMessageAi: CommitMessageAiSettings.optional(), + sourceControlAi: SourceControlAiSettings.optional(), agentCmdOverrides: z.record(z.string(), z.string()).optional(), enableGitHubAttribution: z.boolean().optional(), commitMessageDiscoveryHostKey: z.string().optional() diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index c97c0643702..0467f365d54 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -197,6 +197,7 @@ describe('git RPC methods', () => { defaultModelId: 'auto' }), cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }), + abortRuntimeGitMerge: vi.fn().mockResolvedValue({ ok: true }), pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }), getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3') } as unknown as OrcaRuntimeService @@ -216,6 +217,7 @@ describe('git RPC methods', () => { await dispatcher.dispatch( makeRequest('git.cancelGenerateCommitMessage', { worktree: 'id:wt-1' }) ) + await dispatcher.dispatch(makeRequest('git.abortMerge', { worktree: 'id:wt-1' })) await dispatcher.dispatch( makeRequest('git.push', { worktree: 'id:wt-1', @@ -237,6 +239,7 @@ describe('git RPC methods', () => { agentCmdOverrides: { cursor: 'cursor-agent' } }) expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1') + expect(runtime.abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1') expect(runtime.pushRuntimeGit).toHaveBeenCalledWith( 'id:wt-1', true, diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 013cf21563b..c84216e61d4 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -51,6 +51,11 @@ export const GIT_METHODS: RpcMethod[] = [ params: WorktreeSelector, handler: async (params, { runtime }) => runtime.getRuntimeGitConflictOperation(params.worktree) }), + defineMethod({ + name: 'git.abortMerge', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.abortRuntimeGitMerge(params.worktree) + }), defineMethod({ name: 'git.diff', params: GitDiff, @@ -149,6 +154,7 @@ export const GIT_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => { if ( params.commitMessageAi === undefined && + params.sourceControlAi === undefined && params.agentCmdOverrides === undefined && params.enableGitHubAttribution === undefined && params.commitMessageDiscoveryHostKey === undefined @@ -159,6 +165,9 @@ export const GIT_METHODS: RpcMethod[] = [ ...(params.commitMessageAi !== undefined ? { commitMessageAi: params.commitMessageAi as GlobalSettings['commitMessageAi'] } : {}), + ...(params.sourceControlAi !== undefined + ? { sourceControlAi: params.sourceControlAi as GlobalSettings['sourceControlAi'] } + : {}), ...(params.agentCmdOverrides !== undefined ? { agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides'] @@ -205,6 +214,7 @@ export const GIT_METHODS: RpcMethod[] = [ } if ( params.commitMessageAi === undefined && + params.sourceControlAi === undefined && params.agentCmdOverrides === undefined && params.enableGitHubAttribution === undefined && params.commitMessageDiscoveryHostKey === undefined @@ -215,6 +225,9 @@ export const GIT_METHODS: RpcMethod[] = [ ...(params.commitMessageAi !== undefined ? { commitMessageAi: params.commitMessageAi as GlobalSettings['commitMessageAi'] } : {}), + ...(params.sourceControlAi !== undefined + ? { sourceControlAi: params.sourceControlAi as GlobalSettings['sourceControlAi'] } + : {}), ...(params.agentCmdOverrides !== undefined ? { agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides'] diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index f03f5193d6d..c556b348cf9 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -38,7 +38,8 @@ const HostedReviewCreate = z.object({ head: z.string().optional(), title: requiredString('Missing title'), body: z.string().optional(), - draft: z.boolean().optional() + draft: z.boolean().optional(), + useTemplate: z.boolean().optional() }) export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ @@ -96,7 +97,8 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ head: params.head, title: params.title, body: params.body, - draft: params.draft + draft: params.draft, + useTemplate: params.useTemplate }) }) ] diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts index e542ce42650..5e1ee00f872 100644 --- a/src/main/runtime/rpc/methods/linear.test.ts +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -110,7 +110,13 @@ describe('linear RPC methods', () => { 'Details', 'workspace-1', undefined, - undefined + undefined, + { + assigneeId: undefined, + labelIds: undefined, + priority: undefined, + stateId: undefined + } ) expect(runtime.linearCreateIssue).toHaveBeenCalledWith( 'team-1', @@ -118,7 +124,13 @@ describe('linear RPC methods', () => { undefined, 'workspace-1', 'issue-3', - 'project-1' + 'project-1', + { + assigneeId: undefined, + labelIds: undefined, + priority: undefined, + stateId: undefined + } ) expect(runtime.linearUpdateIssue).toHaveBeenCalledWith( 'issue-3', diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index a16e8637e04..bf661e3532d 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -3,6 +3,8 @@ import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const +const LinearPriority = z.number().int().min(0).max(4).optional() +const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() const Connect = z.object({ apiKey: requiredString('Invalid API key') @@ -38,7 +40,11 @@ const CreateIssue = z.object({ description: OptionalString, workspaceId: OptionalString, parentIssueId: OptionalString, - projectId: z.union([z.string(), z.null()]).optional() + projectId: z.union([z.string(), z.null()]).optional(), + stateId: OptionalString, + priority: LinearPriority, + assigneeId: z.union([z.string(), z.null()]).optional(), + labelIds: LinearLabelIds }) const IssueId = z.object({ @@ -71,6 +77,7 @@ const IssueUpdate = z.object({ updates: z.object({ stateId: OptionalString, title: OptionalString, + description: z.string().optional(), assigneeId: z.union([z.string(), z.null()]).optional(), estimate: z.union([z.number().int().min(0), z.null()]).optional(), priority: z.number().int().min(0).max(4).optional(), @@ -127,7 +134,13 @@ export const LINEAR_METHODS: RpcMethod[] = [ params.description?.trim() || undefined, params.workspaceId, params.parentIssueId, - params.projectId + params.projectId, + { + stateId: params.stateId, + priority: params.priority, + assigneeId: params.assigneeId, + labelIds: params.labelIds + } ) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 27cc27cfff7..aa5121f5f93 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1022,10 +1022,14 @@ describe('orchestration RPC methods', () => { }) describe('orchestration.reset', () => { - it('resets all state', async () => { - setup() + function seedResetState(): void { db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) db.createTask({ spec: 'work' }) + } + + it('resets all state', async () => { + setup() + seedResetState() const result = (await call('orchestration.reset', { all: true })) as { reset: string } expect(result.reset).toBe('all') @@ -1035,8 +1039,7 @@ describe('orchestration RPC methods', () => { it('resets tasks only', async () => { setup() - db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) - db.createTask({ spec: 'work' }) + seedResetState() await call('orchestration.reset', { tasks: true }) expect(db.getInbox()).toHaveLength(1) @@ -1045,12 +1048,52 @@ describe('orchestration RPC methods', () => { it('resets messages only', async () => { setup() - db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) - db.createTask({ spec: 'work' }) + seedResetState() await call('orchestration.reset', { messages: true }) expect(db.getInbox()).toHaveLength(0) expect(db.listTasks()).toHaveLength(1) }) + + it.each([ + ['empty params', {}], + ['false-only params', { all: false }], + ['multi-scope task and messages params', { tasks: true, messages: true }], + ['multi-scope all and tasks params', { all: true, tasks: true }], + ['non-boolean params', { all: 'true' }] + ])('rejects %s without mutating state', async (_name, params) => { + setup() + seedResetState() + + await expect(call('orchestration.reset', params)).rejects.toThrow() + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(1) + }) + + it('ignores false scopes when exactly one scope is true', async () => { + setup() + seedResetState() + + const result = (await call('orchestration.reset', { all: false, tasks: true })) as { + reset: string + } + + expect(result.reset).toBe('tasks') + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(0) + }) + + it('ignores non-boolean scopes when exactly one real boolean scope is true', async () => { + setup() + seedResetState() + + const result = (await call('orchestration.reset', { all: 'true', messages: true })) as { + reset: string + } + + expect(result.reset).toBe('messages') + expect(db.getInbox()).toHaveLength(0) + expect(db.listTasks()).toHaveLength(1) + }) }) }) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index b2d31e98902..7584ef6aafa 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -136,11 +136,23 @@ const AskParams = z.object({ from: OptionalString }) -const ResetParams = z.object({ - all: OptionalBoolean, - tasks: OptionalBoolean, - messages: OptionalBoolean -}) +const ResetParams = z + .object({ + all: OptionalBoolean, + tasks: OptionalBoolean, + messages: OptionalBoolean + }) + .superRefine((params, ctx) => { + const selectedScopeCount = [params.all, params.tasks, params.messages].filter( + (scope) => scope === true + ).length + if (selectedScopeCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' + }) + } + }) export const ORCHESTRATION_METHODS: RpcMethod[] = [ defineMethod({ @@ -582,8 +594,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ db.resetMessages() return { reset: 'messages' } } - db.resetAll() - return { reset: 'all' } + throw new Error('Invalid reset scope') } }) ] diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 792484a5f45..c18bc1c5372 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -61,13 +61,33 @@ describe('repo RPC methods', () => { }) }) + it('shows a repo with the CLI-compatible response shape', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue({ + id: 'repo-1', + path: '/srv/projects/orca', + kind: 'git' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('repo.show', { repo: 'repo-1' })) + + expect(runtime.showRepo).toHaveBeenCalledWith('repo-1') + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', path: '/srv/projects/orca' } } + }) + }) + it('lists sparse checkout presets for a repo', async () => { const runtime = { getRuntimeId: () => 'test-runtime', listSparsePresets: vi.fn().mockResolvedValue([ { id: 'preset-1', - repoId: 'repo-1', + projectId: 'repo-1', name: 'Frontend', directories: ['src/renderer'], createdAt: 1, @@ -93,7 +113,7 @@ describe('repo RPC methods', () => { getRuntimeId: () => 'test-runtime', saveSparsePreset: vi.fn().mockResolvedValue({ id: 'preset-1', - repoId: 'repo-1', + projectId: 'repo-1', name: 'Frontend', directories: ['src/renderer'], createdAt: 1, @@ -205,4 +225,99 @@ describe('repo RPC methods', () => { result: { repo: { id: 'repo-1', issueSourcePreference: 'origin' } } }) }) + + it('routes project group mutations to the runtime server', async () => { + const group = { + id: 'group-1', + name: 'Platform', + parentPath: '/srv/platform', + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const runtime = { + getRuntimeId: () => 'test-runtime', + listProjectGroups: vi.fn().mockReturnValue([group]), + createProjectGroup: vi.fn().mockResolvedValue(group), + updateProjectGroup: vi.fn().mockResolvedValue({ ...group, name: 'Core' }), + deleteProjectGroup: vi.fn().mockResolvedValue({ deleted: true }), + moveProjectToGroup: vi.fn().mockResolvedValue({ id: 'repo-1', projectGroupId: group.id }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + await dispatcher.dispatch(makeRequest('projectGroup.list')) + await dispatcher.dispatch( + makeRequest('projectGroup.create', { + name: 'Platform', + parentPath: '/srv/platform', + createdFrom: 'folder-scan' + }) + ) + await dispatcher.dispatch( + makeRequest('projectGroup.update', { + groupId: group.id, + updates: { name: 'Core', isCollapsed: true } + }) + ) + await dispatcher.dispatch(makeRequest('projectGroup.delete', { groupId: group.id })) + const moveResponse = await dispatcher.dispatch( + makeRequest('projectGroup.moveProject', { + repo: 'repo-1', + groupId: group.id, + order: 2 + }) + ) + + expect(runtime.listProjectGroups).toHaveBeenCalled() + expect(runtime.createProjectGroup).toHaveBeenCalledWith({ + name: 'Platform', + parentPath: '/srv/platform', + createdFrom: 'folder-scan' + }) + expect(runtime.updateProjectGroup).toHaveBeenCalledWith(group.id, { + name: 'Core', + isCollapsed: true + }) + expect(runtime.deleteProjectGroup).toHaveBeenCalledWith(group.id) + expect(runtime.moveProjectToGroup).toHaveBeenCalledWith('repo-1', group.id, 2) + expect(moveResponse).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', projectGroupId: group.id } } + }) + }) + + it('allows separate nested-repo imports without a group name', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + importNestedRepos: vi.fn().mockResolvedValue({ + repos: [{ path: '/srv/platform/api', projectId: 'repo-1', status: 'imported' }], + importedCount: 1, + alreadyKnownCount: 0, + failedCount: 0 + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('projectGroup.importNested', { + parentPath: '/srv/platform', + projectPaths: ['/srv/platform/api'], + mode: 'separate' + }) + ) + + expect(runtime.importNestedRepos).toHaveBeenCalledWith({ + parentPath: '/srv/platform', + groupName: '', + projectPaths: ['/srv/platform/api'], + mode: 'separate' + }) + expect(response).toMatchObject({ + ok: true, + result: { importedCount: 1, failedCount: 0 } + }) + }) }) diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 951156eea16..22abcf495cb 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import { sanitizeRepoIcon } from '../../../../shared/repo-icon' +import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') @@ -28,6 +29,13 @@ const RepoSetBaseRef = z.object({ ref: requiredString('Missing base ref') }) +const RepoSourceControlAiOverrides = z + .unknown() + .optional() + .transform((value) => + value === undefined ? undefined : normalizeRepoSourceControlAiOverrides(value) + ) + const RepoUpdate = RepoSelector.extend({ updates: z.object({ displayName: OptionalString, @@ -42,7 +50,10 @@ const RepoUpdate = RepoSelector.extend({ symlinkPaths: z.array(z.string()).optional(), issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), externalWorktreeVisibility: z.enum(['hide', 'show']).optional(), - externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional() + externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), + projectGroupId: OptionalString.nullable().optional(), + projectGroupOrder: OptionalFiniteNumber, + sourceControlAi: RepoSourceControlAiOverrides }) }) @@ -59,6 +70,54 @@ const RepoReorder = z.object({ orderedIds: z.array(z.string()) }) +const ProjectGroupCreate = z.object({ + name: requiredString('Missing group name'), + parentPath: OptionalString, + parentGroupId: OptionalString.nullable().optional(), + createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional() +}) + +const ProjectGroupUpdate = z.object({ + groupId: requiredString('Missing group id'), + updates: z.object({ + name: OptionalString, + isCollapsed: z.boolean().optional(), + tabOrder: OptionalFiniteNumber, + color: OptionalString.nullable().optional() + }) +}) + +const ProjectGroupSelector = z.object({ + groupId: requiredString('Missing group id') +}) + +const ProjectGroupMoveProject = z.object({ + repo: requiredString('Missing repo selector'), + groupId: OptionalString.nullable(), + order: OptionalFiniteNumber +}) + +const ProjectGroupScanNested = z.object({ + path: requiredString('Missing folder path') +}) + +const ProjectGroupImportNested = z.discriminatedUnion('mode', [ + z.object({ + parentPath: requiredString('Missing parent path'), + groupName: requiredString('Missing group name'), + projectPaths: z.array(z.string()), + mode: z.literal('group') + }), + z.object({ + parentPath: requiredString('Missing parent path'), + // Why: "Import separately" does not create a group, so SSH must accept the + // same empty group-name state that the local dialog allows. + groupName: z.string().optional().default(''), + projectPaths: z.array(z.string()), + mode: z.literal('separate') + }) +]) + const RepoIssueCommandWrite = RepoSelector.extend({ content: z.string() }) @@ -75,6 +134,47 @@ export const REPO_METHODS: RpcMethod[] = [ params: null, handler: (_params, { runtime }) => ({ repos: runtime.listRepos() }) }), + defineMethod({ + name: 'projectGroup.list', + params: null, + handler: (_params, { runtime }) => ({ groups: runtime.listProjectGroups() }) + }), + defineMethod({ + name: 'projectGroup.create', + params: ProjectGroupCreate, + handler: async (params, { runtime }) => ({ + group: await runtime.createProjectGroup(params) + }) + }), + defineMethod({ + name: 'projectGroup.update', + params: ProjectGroupUpdate, + handler: async (params, { runtime }) => ({ + group: await runtime.updateProjectGroup(params.groupId, params.updates) + }) + }), + defineMethod({ + name: 'projectGroup.delete', + params: ProjectGroupSelector, + handler: async (params, { runtime }) => runtime.deleteProjectGroup(params.groupId) + }), + defineMethod({ + name: 'projectGroup.moveProject', + params: ProjectGroupMoveProject, + handler: async (params, { runtime }) => ({ + repo: await runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order) + }) + }), + defineMethod({ + name: 'projectGroup.scanNested', + params: ProjectGroupScanNested, + handler: async (params, { runtime }) => runtime.scanNestedRepos(params.path) + }), + defineMethod({ + name: 'projectGroup.importNested', + params: ProjectGroupImportNested, + handler: async (params, { runtime }) => runtime.importNestedRepos(params) + }), defineMethod({ name: 'repo.sparsePresets', params: RepoSelector, @@ -131,7 +231,7 @@ export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'repo.rm', params: RepoSelector, - handler: async (params, { runtime }) => runtime.removeRepo(params.repo) + handler: async (params, { runtime }) => runtime.removeProject(params.repo) }), defineMethod({ name: 'repo.reorder', diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index c6bf5b329ee..bacf4b81dc8 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -761,6 +761,7 @@ describe('OrcaRuntimeRpcServer', () => { .fn() .mockResolvedValue({ hasUpstream: true, ahead: 1, behind: 0 }) const rebaseRuntimeGitFromBase = vi.fn().mockResolvedValue({ ok: true }) + const abortRuntimeGitMerge = vi.fn().mockResolvedValue({ ok: true }) const bulkStageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true }) const bulkUnstageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true }) const getRuntimeGitDiff = vi.fn().mockResolvedValue({ @@ -842,6 +843,7 @@ describe('OrcaRuntimeRpcServer', () => { getRuntimeGitStatus, getRuntimeGitUpstreamStatus, rebaseRuntimeGitFromBase, + abortRuntimeGitMerge, bulkStageRuntimeGitPaths, bulkUnstageRuntimeGitPaths, getRuntimeGitDiff, @@ -1400,6 +1402,16 @@ describe('OrcaRuntimeRpcServer', () => { (response) => replies.push(JSON.parse(response) as Record), () => {} ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_git_abort_merge', + method: 'git.abortMerge', + deviceToken: mobile.token, + params: { worktree: 'id:wt-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_git_bulk_unstage', @@ -1612,6 +1624,7 @@ describe('OrcaRuntimeRpcServer', () => { expect.objectContaining({ id: 'req_git_rebase_from_base', ok: true }) ) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_bulk_stage', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_abort_merge', ok: true })) expect(replies).toContainEqual( expect.objectContaining({ id: 'req_git_bulk_unstage', ok: true }) ) @@ -1646,6 +1659,7 @@ describe('OrcaRuntimeRpcServer', () => { expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined, undefined) expect(getRuntimeGitUpstreamStatus).toHaveBeenCalledWith('id:wt-1') expect(bulkStageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['a.ts', 'b.ts']) + expect(abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1') expect(bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts']) expect(openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true) expect(getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', false, undefined) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 6c100cfc458..69a05660f80 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -140,10 +140,12 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'browser.screencast.unsubscribe', 'browser.tabCreate', 'browser.viewport', + 'files.createFile', 'files.list', 'files.open', 'files.openDiff', 'files.read', + 'git.abortMerge', 'git.bulkStage', 'git.bulkUnstage', 'git.commit', diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts new file mode 100644 index 00000000000..30a8820bf23 --- /dev/null +++ b/src/main/shell-templates.ts @@ -0,0 +1,73 @@ +// Why: local PTYs and the daemon/SSH path must use identical ZDOTDIR discovery; +// small drift here breaks different terminal transports in different ways. + +function quotePosixSingle(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string { + const header = headerPrefix + ? `Orca ${headerPrefix} zsh shell-ready wrapper` + : 'Orca zsh shell-ready wrapper' + return `# ${header} +_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}" +_orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}" +_orca_zshenv_source_dir="\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" +_orca_zshenv_path="" +unset ORCA_ZSHENV_SOURCE_DIR + +# Normalize fallback and source roots before reading user .zshenv so nested +# Orca PTYs never source another Orca wrapper recursively. +while [[ "\${_orca_user_zdotdir}" == */ ]]; do + _orca_user_zdotdir="\${_orca_user_zdotdir%/}" +done +case "\${_orca_user_zdotdir}" in + ""|*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;; +esac +while [[ "\${_orca_zshenv_source_dir}" == */ ]]; do + _orca_zshenv_source_dir="\${_orca_zshenv_source_dir%/}" +done +case "\${_orca_zshenv_source_dir}" in + ""|*/shell-ready/zsh) _orca_zshenv_source_dir="$HOME" ;; +esac + +# Why: source at wrapper top level, not in a function/subshell, so .zshenv +# exports, functions, path/fpath typesets, and zsh options keep normal scope. +unset ZDOTDIR +if [[ -n "\${_orca_zshenv_source_dir:-}" && -f "\${_orca_zshenv_source_dir}/.zshenv" ]]; then + _orca_zshenv_path="\${_orca_zshenv_source_dir}/.zshenv" +fi +if [[ -n "\${_orca_zshenv_path:-}" ]]; then + source "\${_orca_zshenv_path}" +fi + +_orca_discovered_zdotdir="\${ZDOTDIR:-}" + +while [[ "\${_orca_discovered_zdotdir}" == */ ]]; do + _orca_discovered_zdotdir="\${_orca_discovered_zdotdir%/}" +done + +case "\${_orca_discovered_zdotdir}" in + *[![:space:]]*) ;; + *) _orca_discovered_zdotdir="" ;; +esac + +if [[ -n "\${_orca_discovered_zdotdir}" && ! -d "\${_orca_discovered_zdotdir}" ]]; then + [[ "\${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '\${_orca_discovered_zdotdir}' does not exist, falling back" >&2 + _orca_discovered_zdotdir="" +fi + +export ORCA_ORIG_ZDOTDIR="\${_orca_discovered_zdotdir:-\${_orca_user_zdotdir:-$HOME}}" + +while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do + ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}" +done + +case "\${ORCA_ORIG_ZDOTDIR}" in + ""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; +esac + +export ZDOTDIR=${quotePosixSingle(zshDir)} +unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir +` +} diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index 7e9f140bfda..9ea8c02d8c1 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -262,16 +262,16 @@ export function buildSkillDiscoverySources( ) ] - const repoPaths = new Set() + const projectPaths = new Set() for (const repo of args.repos ?? []) { if (repo.connectionId) { continue } - repoPaths.add(repo.path) + projectPaths.add(repo.path) } - repoPaths.add(cwd) + projectPaths.add(cwd) - for (const repoPath of repoPaths) { + for (const repoPath of projectPaths) { const label = `Repo ${basename(repoPath)}` roots.push( source( diff --git a/src/main/speech/model-manager.test.ts b/src/main/speech/model-manager.test.ts index 6c25e9a5e50..5e43cc52845 100644 --- a/src/main/speech/model-manager.test.ts +++ b/src/main/speech/model-manager.test.ts @@ -2,16 +2,25 @@ import { createHash } from 'crypto' import { mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { SPEECH_MODEL_CATALOG } from './model-catalog' import { ModelManager } from './model-manager' +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-speech-models-test' } })) +vi.mock('child_process', async () => { + const actual = await vi.importActual('child_process') + return { ...(actual as Record), spawn: spawnMock } +}) + type ModelManagerInternals = { verifyArchiveSha256: (archivePath: string, expectedSha256: string) => Promise downloadFile: ( @@ -21,9 +30,19 @@ type ModelManagerInternals = { modelId: string, isAborted: () => boolean ) => Promise + extractArchive: ( + archivePath: string, + destDir: string, + modelId: string, + isAborted: () => boolean + ) => Promise } describe('ModelManager', () => { + beforeEach(() => { + spawnMock.mockReset() + }) + it('requires pinned SHA-256 hashes for every catalog archive', () => { for (const manifest of SPEECH_MODEL_CATALOG) { expect(manifest.archiveSha256).toMatch(/^[a-f0-9]{64}$/) @@ -65,4 +84,59 @@ describe('ModelManager', () => { rmSync(dir, { recursive: true, force: true }) } }) + + it('clears extraction abort polling when the child does not close', async () => { + vi.useFakeTimers() + const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + try { + const handlers: Record void)[]> = { + close: [], + error: [] + } + const stderrHandlers: ((chunk: Buffer) => void)[] = [] + const child = { + stderr: { + on: vi.fn((_event: string, cb: (chunk: Buffer) => void) => { + stderrHandlers.push(cb) + return child.stderr + }), + off: vi.fn((_event: string, cb: (chunk: Buffer) => void) => { + const index = stderrHandlers.indexOf(cb) + if (index !== -1) { + stderrHandlers.splice(index, 1) + } + return child.stderr + }) + }, + kill: vi.fn(), + on: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }) + } + spawnMock.mockReturnValue(child) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + + const extraction = manager.extractArchive(join(dir, 'model.tar.bz2'), dir, 'm', () => true) + const rejection = expect(extraction).rejects.toThrow('Aborted') + await vi.advanceTimersByTimeAsync(250) + await rejection + + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(child.kill).toHaveBeenCalledTimes(1) + expect(handlers.close).toHaveLength(0) + expect(handlers.error).toHaveLength(0) + expect(stderrHandlers).toHaveLength(0) + + vi.advanceTimersByTime(1000) + expect(child.kill).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 9c25c728bca..e31fad2156f 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -367,36 +367,66 @@ export class ModelManager { ) let stderr = '' - child.stderr?.on('data', (chunk: Buffer) => { - stderr += chunk.toString() - }) - - const timeout = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error('Extraction timed out after 10 minutes')) - }, 600_000) - const abortPoll = setInterval(() => { - if (isAborted()) { - child.kill('SIGKILL') - reject(new Error('Aborted')) + let settled = false + let timeout: ReturnType | null = null + let abortPoll: ReturnType | null = null + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null } - }, 250) - - child.on('close', (code) => { - clearTimeout(timeout) - clearInterval(abortPoll) + if (abortPoll) { + clearInterval(abortPoll) + abortPoll = null + } + child.stderr?.off('data', onStderrData) + child.off('close', onClose) + child.off('error', onError) + } + const fail = (error: Error, killChild = false): void => { + if (settled) { + return + } + settled = true + cleanup() + if (killChild) { + child.kill('SIGKILL') + } + reject(error) + } + const onStderrData = (chunk: Buffer): void => { + stderr += chunk.toString() + } + const onClose = (code: number | null): void => { + if (settled) { + return + } + settled = true + cleanup() if (code === 0) { resolve() } else { reject(new Error(`tar exited with code ${code}: ${stderr.slice(0, 500)}`)) } - }) + } + const onError = (err: Error): void => { + fail(err) + } - child.on('error', (err) => { - clearTimeout(timeout) - clearInterval(abortPoll) - reject(err) - }) + child.stderr?.on('data', onStderrData) + timeout = setTimeout(() => { + fail(new Error('Extraction timed out after 10 minutes'), true) + }, 600_000) + abortPoll = setInterval(() => { + if (isAborted()) { + // Why: if the extraction child wedges and never emits close/error, + // the abort poller must still clear itself when we reject. + fail(new Error('Aborted'), true) + } + }, 250) + + child.on('close', onClose) + child.on('error', onError) }) } diff --git a/src/main/speech/stt-service.test.ts b/src/main/speech/stt-service.test.ts index 6bd82d3c281..fb4561b1dd7 100644 --- a/src/main/speech/stt-service.test.ts +++ b/src/main/speech/stt-service.test.ts @@ -29,6 +29,10 @@ const { MockWorker, getCreatedWorkerCount, getLastWorker, resetWorkers } = vi.ho return this } + listenerCount(eventName: string): number { + return this.listeners.get(eventName)?.size ?? 0 + } + removeAllListeners(): this { this.listeners.clear() return this @@ -239,4 +243,33 @@ describe('SttService', () => { vi.useRealTimers() } }) + + it('does not retain or reuse a worker that timed out while stopping', async () => { + vi.useFakeTimers() + try { + const service = new SttService({ + getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }), + getModelDir: vi.fn().mockReturnValue('/tmp/model-a') + } as never) + + await service.startDictation('model-a', vi.fn(), undefined, 'desktop') + const firstWorker = getLastWorker() + expect(firstWorker).toBeDefined() + firstWorker!.emitStoppedOnStop = false + + const stopPromise = service.stopDictation('desktop') + await vi.advanceTimersByTimeAsync(60_000) + await stopPromise + + expect(firstWorker!.terminated).toBe(true) + expect(firstWorker!.listenerCount('message')).toBe(0) + + await service.startDictation('model-a', vi.fn(), undefined, 'desktop') + + expect(getCreatedWorkerCount()).toBe(2) + expect(getLastWorker()).not.toBe(firstWorker) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index e31218645dc..e943565bf56 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: speech worker ownership, warm reuse, and +timeout teardown must stay co-located so dictation lifecycle state cannot drift. */ import { Worker } from 'worker_threads' import { join } from 'path' import { app } from 'electron' @@ -232,26 +234,62 @@ export class SttService { const worker = this.worker worker.postMessage({ type: 'stop' }) + let forcedTeardown = false await new Promise((resolve) => { - const timeout = setTimeout(() => { - worker.terminate() + let settled = false + let timeout: ReturnType | null = null + + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + worker.off('message', onStopped) + } + + const finish = (): void => { + if (settled) { + return + } + settled = true + cleanup() resolve() - }, STOP_DICTATION_TIMEOUT_MS) + } const onStopped = (msg: { type: string; text?: string; error?: string }) => { if (msg.type === 'stopped') { - clearTimeout(timeout) - worker.off('message', onStopped) - resolve() + finish() } } + + timeout = setTimeout(() => { + if (settled) { + return + } + settled = true + forcedTeardown = true + cleanup() + // Why: a worker that cannot finish dictation is no longer reusable; do + // not keep it in the warm-worker slot or retain its message listeners. + worker.removeAllListeners() + void worker.terminate().finally(resolve) + }, STOP_DICTATION_TIMEOUT_MS) + worker.on('message', onStopped) }) if (this.worker === worker) { - this.activeOwner = null - this.eventSink = null - this.scheduleIdleTeardown() + if (forcedTeardown) { + this.worker = null + this.activeModelId = null + this.activeHotwordsFilePath = undefined + this.activeOwner = null + this.eventSink = null + } else { + this.activeOwner = null + this.eventSink = null + this.scheduleIdleTeardown() + } } } diff --git a/src/main/ssh/ssh-connection-manager.ts b/src/main/ssh/ssh-connection-manager.ts index 6e87cee1e99..d568d439409 100644 --- a/src/main/ssh/ssh-connection-manager.ts +++ b/src/main/ssh/ssh-connection-manager.ts @@ -18,6 +18,13 @@ export class SshConnectionManager { this.callbacks = callbacks } + setCallbacks(callbacks: SshConnectionCallbacks): void { + this.callbacks = callbacks + for (const connection of this.connections.values()) { + connection.setCallbacks(callbacks) + } + } + async connect(target: SshTarget): Promise { const existing = this.connections.get(target.id) if (existing?.getState().status === 'connected') { @@ -60,6 +67,14 @@ export class SshConnectionManager { this.connections.delete(targetId) } + async reconnect(targetId: string): Promise { + const conn = this.connections.get(targetId) + if (!conn) { + return + } + await conn.reconnect() + } + getConnection(targetId: string): SshConnection | undefined { return this.connections.get(targetId) } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 55e100953df..644d5e51cd9 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -35,6 +35,11 @@ vi.mock('ssh2', () => { on(event: string, handler: (...args: unknown[]) => void) { eventHandlers?.set(event, handler) } + off(event: string, handler: (...args: unknown[]) => void) { + if (eventHandlers?.get(event) === handler) { + eventHandlers.delete(event) + } + } connect(config?: unknown) { this.lastConnectConfig = config setTimeout(() => { @@ -176,6 +181,16 @@ describe('SshConnection', () => { expect(clientInstances[0].setNoDelay).toHaveBeenCalledWith(true) }) + it('removes startup listeners after ssh2 connect succeeds', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + + await conn.connect() + + expect(eventHandlers.has('ready')).toBe(false) + // The remaining error listener is the steady-state disconnect handler. + expect(eventHandlers.has('error')).toBe(true) + }) + it('enables TCP_NODELAY on the new ssh2 client after a reconnect cycle', async () => { // Why: guards the "Nagle is re-enabled because someone refactored only // the initial connect path" regression class. attemptConnect bumps @@ -201,6 +216,23 @@ describe('SshConnection', () => { expect(clientInstances[1].setNoDelay).toHaveBeenCalledWith(true) }) + it('forces a fresh SSH connection for an explicit reconnect', async () => { + const states: string[] = [] + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => states.push(state.status)) + }) + ) + await conn.connect() + + await conn.reconnect() + + expect(clientInstances).toHaveLength(2) + expect(states).toEqual(['connecting', 'connected', 'reconnecting', 'connecting', 'connected']) + expect(conn.getState().status).toBe('connected') + }) + it('transitions through connecting → connected states', async () => { const states: string[] = [] const callbacks = createCallbacks({ diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 422a1f2b7b1..989010712fe 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -70,6 +70,10 @@ export class SshConnection { return { ...this.target } } + setCallbacks(callbacks: SshConnectionCallbacks): void { + this.callbacks = callbacks + } + // Why: exposes whether a passphrase/password is already cached in-memory for // this connection. Used by ssh:needsPassphrasePrompt so callers can decide // whether a manual-reconnect will prompt or go through silently. Without this, @@ -339,6 +343,23 @@ export class SshConnection { } } + async reconnect(): Promise { + if (this.disposed || this.state.status === 'connecting') { + return + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + // Why: OS sleep/wake can leave ssh2 thinking a dead TCP socket is still + // connected. Tear down the local transport and run the normal reconnect + // path so the relay session can reattach remote PTYs after wake. + this.closeTransportsForReconnect() + this.state.reconnectAttempt = 0 + this.setState('reconnecting') + await this.runReconnectAttempt(0) + } + private async doSystemSshProbe(connectGeneration: number): Promise { this.useSystemSshTransport = true this.client = null @@ -429,10 +450,17 @@ export class SshConnection { return new Promise((resolve, reject) => { const client = new SshClient() let settled = false - client.on('ready', () => { + + const cleanupStartupListeners = (): void => { + client.off('ready', onReady) + client.off('error', onStartupError) + } + + const onReady = (): void => { if (settled) { return } + cleanupStartupListeners() // Why: connect() completion races with explicit disconnect(). Once a // newer connect attempt or disconnect bumps the generation/disposed // state, this late ready event must not resurrect the torn-down client. @@ -465,15 +493,20 @@ export class SshConnection { this.setState('connected') this.setupDisconnectHandler(client) resolve() - }) - client.on('error', (err) => { + } + + const onStartupError = (err: Error): void => { if (settled) { return } + cleanupStartupListeners() settled = true client.destroy() reject(err) - }) + } + + client.on('ready', onReady) + client.on('error', onStartupError) client.connect(config) }) } @@ -515,30 +548,57 @@ export class SshConnection { if (this.disposed) { return } - try { - // Why: reset reconnectAttempt before attemptConnect so setState('connected') - // broadcasts reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment. - this.state.reconnectAttempt = 0 - await this.attemptConnect() - } catch (err) { - if (this.disposed) { - return - } - const error = err instanceof Error ? err : new Error(String(err)) - if (isAuthError(error) || isPassphraseError(error)) { - this.setState('auth-failed', error.message) - return - } - if (!isTransientError(error)) { - this.setState('error', error.message) - return - } - this.state.reconnectAttempt = attempt + 1 - this.scheduleReconnect() - } + await this.runReconnectAttempt(attempt) }, RECONNECT_BACKOFF_MS[attempt]) } + private async runReconnectAttempt(attempt: number): Promise { + try { + // Why: reset reconnectAttempt before attemptConnect so setState('connected') + // broadcasts reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment. + this.state.reconnectAttempt = 0 + await this.attemptConnect() + } catch (err) { + if (this.disposed) { + return + } + const error = err instanceof Error ? err : new Error(String(err)) + if (isAuthError(error) || isPassphraseError(error)) { + this.setState('auth-failed', error.message) + return + } + if (!isTransientError(error)) { + this.setState('error', error.message) + return + } + this.state.reconnectAttempt = attempt + 1 + this.scheduleReconnect() + } + } + + private closeTransportsForReconnect(): void { + this.connectGeneration += 1 + const client = this.client + this.client = null + try { + client?.end() + client?.destroy() + } catch { + /* best-effort transport teardown */ + } + this.proxyProcess?.kill() + this.proxyProcess = null + this.systemOperationAbortController.abort() + this.systemOperationAbortController = new AbortController() + for (const channel of this.systemCommandChannels) { + channel.close() + } + this.systemCommandChannels.clear() + this.systemSsh?.kill() + this.systemSsh = null + this.useSystemSshTransport = false + } + async connectViaSystemSsh(): Promise { if (this.disposed) { throw new Error('Connection disposed') diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index b06ab52ed4e..cda490c8580 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -64,6 +64,7 @@ export async function readFileViaStream( let expectedSeq = 0 let receivedChunks = 0 let totalChunks = 0 + let bytesReceived = 0 let settled = false // Why: chunk/end/error frames may arrive in the same dispatch tick as the @@ -128,10 +129,13 @@ export async function readFileViaStream( } const offset = seq * STREAM_CHUNK_SIZE const decoded = Buffer.from(data, 'base64') - if (offset + decoded.length > totalSize) { + // Why: a short chunk would leave the pre-allocated buffer zero-filled and + // resolve as silently-corrupt data; validate each chunk's exact length. + const expectedLength = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) + if (decoded.length !== expectedLength) { fail( new StreamProtocolError( - `Chunk overflows declared totalSize: offset=${offset} len=${decoded.length} total=${totalSize}` + `Chunk length mismatch for stream ${id}: seq=${seq} expected=${expectedLength} got=${decoded.length}` ) ) return @@ -143,6 +147,7 @@ export async function readFileViaStream( decoded.copy(buffer, offset) expectedSeq += 1 receivedChunks += 1 + bytesReceived += decoded.length } const handleEnd = (params: Record): void => { @@ -161,6 +166,16 @@ export async function readFileViaStream( ) return } + // Why: redundant given the per-chunk length + count checks, but kept as a + // last-line invariant guard; never resolve with fewer bytes than declared. + if (bytesReceived !== totalSize) { + fail( + new StreamProtocolError( + `Byte count mismatch for stream ${id}: expected ${totalSize}, received ${bytesReceived}` + ) + ) + return + } if (!buffer) { fail(new StreamProtocolError(`Stream end before metadata for stream ${id}`)) return diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 6c6a7bd1d88..ee4fbb8bb9a 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -7,6 +7,7 @@ import type { SshConnection } from './ssh-connection' import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' +import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' const { muxRequestMock, installRemoteManagedAgentHooksMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn(), @@ -179,6 +180,11 @@ describe('SshRelaySession', () => { ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD ) expect(installPluginsCallIndex).toBeGreaterThanOrEqual(0) + const installPluginsParams = muxRequestMock.mock.calls[installPluginsCallIndex]?.[1] + expect(installPluginsParams).toMatchObject({ + piExtensionSource: expect.stringContaining('/hook/pi'), + ompExtensionSource: expect.stringContaining('/hook/omp') + }) expect(mockConn.sftp).toHaveBeenCalledTimes(1) expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca') expect(sftp.end).toHaveBeenCalledTimes(1) @@ -519,6 +525,30 @@ describe('SshRelaySession', () => { expect(deployAndLaunchRelay).toHaveBeenCalledWith(mockConn, undefined, 600, 'target-1') }) + it('restores the configured relay grace after establish', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(mockConn, 600) + + expect(session.getMux()?.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { + graceTimeSeconds: 600 + }) + }) + + it('sets relay grace to unlimited before host sleep', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + vi.mocked(session.getMux()!.notify).mockClear() + + session.prepareForHostSleep() + + expect(session.getMux()?.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { + graceTimeSeconds: 0 + }) + }) + it('cleans up port forwards on dispose', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 4d04c11988e..73b72b8647b 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -19,6 +19,7 @@ import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { SshGitProvider } from '../providers/ssh-git-provider' import { agentHookServer } from '../agent-hooks/server' import { installRemoteManagedAgentHooks } from '../agent-hooks/remote-managed-hook-installers' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_NOTIFICATION_METHOD, @@ -47,12 +48,29 @@ import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events' import { PortScanner } from './ssh-port-scanner' import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' -import type { DetectedPort } from '../../shared/ssh-types' +import { + DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, + type DetectedPort, + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD +} from '../../shared/ssh-types' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' +function normalizeRelayGracePeriodSeconds(graceTimeSeconds: number | undefined): number { + const raw = graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS + const requested = Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS + return requested === 0 + ? 0 + : Math.max( + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requested) + ) +} + export class SshRelaySession { private _state: RelaySessionState = 'idle' private mux: SshChannelMultiplexer | null = null @@ -92,6 +110,20 @@ export class SshRelaySession { ) => void ) {} + refreshEnvironment( + getMainWindow: () => BrowserWindow | null, + store: Store, + portForwardManager: SshPortForwardManager, + runtime?: OrcaRuntimeService, + onDetectedPortsChanged?: (targetId: string, ports: DetectedPort[], platform: string) => void + ): void { + this.getMainWindow = getMainWindow + this.store = store + this.portForwardManager = portForwardManager + this.runtime = runtime + this.onDetectedPortsChanged = onDetectedPortsChanged + } + setOnRelayLost(cb: (targetId: string) => void): void { this._onRelayLost = cb } @@ -132,6 +164,14 @@ export class SshRelaySession { return this.portScanner } + prepareForHostSleep(): void { + const mux = this.mux + if (!mux || mux.isDisposed() || this.isDisposed()) { + return + } + mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { graceTimeSeconds: 0 }) + } + // Why: single entry point for relay setup — used by both initial connect // and app-restart reconnect. Having one path eliminates the risk of // forgetting a registration step. @@ -202,6 +242,7 @@ export class SshRelaySession { throw new Error('Session disposed during establish') } + this.configureRelayGraceTime(mux, graceTimeSeconds) this.watchMuxForRelayLoss(mux) this._state = 'ready' this.startPortScanning() @@ -327,6 +368,7 @@ export class SshRelaySession { return } + this.configureRelayGraceTime(mux, graceTimeSeconds) this.watchMuxForRelayLoss(mux) this._state = 'ready' this.startPortScanning() @@ -461,13 +503,22 @@ export class SshRelaySession { return true } + private configureRelayGraceTime( + mux: SshChannelMultiplexer, + graceTimeSeconds: number | undefined + ): void { + mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { + graceTimeSeconds: normalizeRelayGracePeriodSeconds(graceTimeSeconds) + }) + } + // Why: the relay can inject ORCA_AGENT_HOOK_* env into SSH PTYs, but // hook-script agents (Claude/Codex/Gemini/etc.) still need their config // files on the remote host to call Orca's managed script. Install those // configs before registering the PTY provider so newly spawned agent panes // report status from their first prompt. private async installManagedHooksOnRemote(mux: SshChannelMultiplexer): Promise { - if (!isRemoteAgentHooksEnabled()) { + if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { return } @@ -519,13 +570,14 @@ export class SshRelaySession { // they upgrade. Hook-script-based agents use a separate explicit remote // installer flow because that mutates user-owned agent config files. private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise { - if (!isRemoteAgentHooksEnabled()) { + if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { return } try { await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { opencodePluginSource: openCodeInternals.getOpenCodePluginSource(), - piExtensionSource: getPiAgentStatusExtensionSource() + piExtensionSource: getPiAgentStatusExtensionSource('pi'), + ompExtensionSource: getPiAgentStatusExtensionSource('omp') }) } catch (err) { // Why: -32601 = older relay without the handler (treat as soft skip). @@ -548,6 +600,11 @@ export class SshRelaySession { } } + private areAgentStatusHooksEnabled(): boolean { + const store = this.store as { getSettings?: Store['getSettings'] } + return isAgentStatusHooksEnabled(store.getSettings?.()) + } + private wireUpRemoteWorkspaceEvents(mux: SshChannelMultiplexer): void { mux.onNotification((method, params) => { notifyRemoteWorkspaceHandlers(this.targetId, method, params) @@ -740,16 +797,18 @@ export class SshRelaySession { } private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { - const getWin = this.getMainWindow ptyProvider.onData((payload) => { - this.runtime?.onPtyData(payload.id, payload.data, Date.now()) - const win = getWin() + const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) + const win = this.getMainWindow() if (win && !win.isDestroyed()) { - win.webContents.send('pty:data', payload) + win.webContents.send('pty:data', { + ...payload, + ...(typeof seq === 'number' ? { seq, rawLength: payload.data.length } : {}) + }) } }) ptyProvider.onReplay((payload) => { - const win = getWin() + const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:replay', payload) } @@ -760,7 +819,7 @@ export class SshRelaySession { deletePtyOwnership(payload.id) this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') this.runtime?.onPtyExit(payload.id, payload.code) - const win = getWin() + const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', payload) } diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 2552323e9eb..9ecd76cbddc 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -151,6 +151,20 @@ describe('configureDevUserDataPath', () => { }) }) +describe('shouldInstallManagedHooks', () => { + it('keeps managed hook auto-install enabled for default dev runs', async () => { + const { shouldInstallManagedHooks } = await import('./configure-process') + + expect(shouldInstallManagedHooks(true)).toBe(true) + }) + + it('allows managed hook auto-install for packaged runs', async () => { + const { shouldInstallManagedHooks } = await import('./configure-process') + + expect(shouldInstallManagedHooks(false)).toBe(true) + }) +}) + describe('installDevParentDisconnectQuit', () => { it('quits the dev app when the supervising IPC channel disconnects', async () => { const { app } = await import('electron') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 3d52ee258d3..2fe01bc9122 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -135,6 +135,17 @@ export function configureDevUserDataPath(isDev: boolean): void { app.setPath('userData', join(app.getPath('appData'), 'orca-dev')) } +export function shouldInstallManagedHooks(isDev: boolean): boolean { + void isDev + // Why: managed hook installation now targets Orca-owned, environment-scoped + // homes for Codex rather than the user's default ~/.codex state, so plain + // dev runs need the install path enabled to keep hook-backed agent statuses + // accurate without an opt-in flag. The remaining agents still rely on the + // shared startup installer loop, so keep the policy uniformly on until + // they are migrated to more granular ownership seams. + return true +} + export function installDevParentDisconnectQuit(isDev: boolean): void { if (!isDev || typeof process.send !== 'function') { return diff --git a/src/main/stats/agent-detector.ts b/src/main/stats/agent-detector.ts index bed49d970f3..b16f3720b1e 100644 --- a/src/main/stats/agent-detector.ts +++ b/src/main/stats/agent-detector.ts @@ -24,6 +24,18 @@ type PtyRecord = { * orca-runtime.ts normalizeTerminalChunk but avoids importing the runtime. */ function hasMeaningfulContent(chunk: string): boolean { + // Why: large plain-text PTY bursts should not pay the full ANSI stripping + // chain just to prove they contain visible output. + for (let index = 0; index < chunk.length; index++) { + const code = chunk.charCodeAt(index) + if (code === 0x1b || code < 0x09 || (code > 0x0d && code < 0x20) || code > 0x7e) { + break + } + if (code > 0x20) { + return true + } + } + const stripped = chunk .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index 4581168b562..5d4558b5cca 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -27,16 +27,26 @@ function readInheritedOrShellEnvVar(name: string, sourceName?: string): string | function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.ProcessEnv } | null { const configVar = - agentId === 'opencode' ? 'OPENCODE_CONFIG_DIR' : agentId === 'pi' ? 'PI_CODING_AGENT_DIR' : null + agentId === 'opencode' + ? 'OPENCODE_CONFIG_DIR' + : agentId === 'pi' || agentId === 'omp' + ? 'PI_CODING_AGENT_DIR' + : null if (!configVar) { return null } + // Why: each kind owns a distinct ORCA_*_SOURCE_* shadow so a headless commit + // run from inside an OMP overlay restores the OMP source dir, never the Pi + // one (and vice versa). PI_CODING_AGENT_DIR is the binary-facing var both + // kinds emit — see src/main/pi/titlebar-extension-service.ts. const sourceVar = agentId === 'opencode' ? 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' : agentId === 'pi' ? 'ORCA_PI_SOURCE_AGENT_DIR' - : undefined + : agentId === 'omp' + ? 'ORCA_OMP_SOURCE_AGENT_DIR' + : undefined const value = readInheritedOrShellEnvVar(configVar, sourceVar) if (!value) { diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index afebe18cb72..d267ce15375 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -5,11 +5,14 @@ import { spawn } from 'child_process' import type * as ChildProcess from 'child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../shared/constants' +import { sourceControlAiSettingsFromLegacy } from '../../shared/source-control-ai' +import type { GlobalSettings } from '../../shared/types' import { cancelGenerateCommitMessageLocal, cancelGeneratePullRequestFieldsLocal, discoverCommitMessageModelsLocal, discoverCommitMessageModelsRemote, + generateBranchNameFromContext, generateCommitMessageFromContext, generatePullRequestFieldsFromContext, resolveCommitMessageSettings, @@ -26,6 +29,10 @@ vi.mock('child_process', async (importOriginal) => { const spawnMock = vi.mocked(spawn) +function syncSourceControlAiFromLegacy(settings: GlobalSettings): void { + settings.sourceControlAi = sourceControlAiSettingsFromLegacy(settings.commitMessageAi) +} + function withPlatform(platform: NodeJS.Platform, fn: () => T): T { const original = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: platform }) @@ -52,6 +59,7 @@ describe('resolveCommitMessageSettings', () => { customPrompt: 'Use Conventional Commits.', customAgentCommand: '' } + settings.sourceControlAi = undefined const result = resolveCommitMessageSettings(settings) @@ -76,6 +84,7 @@ describe('resolveCommitMessageSettings', () => { customPrompt: '', customAgentCommand: '' } + syncSourceControlAiFromLegacy(settings) const result = resolveCommitMessageSettings(settings) @@ -125,6 +134,7 @@ describe('resolveCommitMessageSettings', () => { customPrompt: '', customAgentCommand: '' } + syncSourceControlAiFromLegacy(settings) const result = resolveCommitMessageSettings(settings) @@ -153,6 +163,7 @@ describe('resolveCommitMessageSettings', () => { customPrompt: '', customAgentCommand: '' } + syncSourceControlAiFromLegacy(settings) const result = resolveCommitMessageSettings(settings, 'ssh:conn-1') @@ -175,6 +186,7 @@ describe('resolveCommitMessageSettings', () => { customPrompt: '', customAgentCommand: '' } + syncSourceControlAiFromLegacy(settings) const result = resolveCommitMessageSettings(settings) @@ -199,6 +211,7 @@ describe('resolveCommitMessageSettings', () => { customPrompt: '', customAgentCommand: '' } + syncSourceControlAiFromLegacy(settings) const result = resolveCommitMessageSettings(settings) @@ -221,6 +234,7 @@ describe('resolveCommitMessageSettings', () => { customPrompt: '', customAgentCommand: '' } + syncSourceControlAiFromLegacy(settings) const result = resolveCommitMessageSettings(settings) @@ -243,10 +257,11 @@ describe('resolveCommitMessageSettings', () => { customPrompt: '', customAgentCommand: ' ' } + syncSourceControlAiFromLegacy(settings) expect(resolveCommitMessageSettings(settings)).toEqual({ ok: false, - error: 'Custom command is empty. Add one in Settings -> Git -> AI Commit Messages.' + error: 'Custom command is empty. Add one in Settings -> Git -> Source Control AI.' }) }) }) @@ -976,6 +991,93 @@ describe('generateCommitMessageFromContext', () => { }) }) +describe('generateBranchNameFromContext', () => { + it('sanitizes remote agent output into a short branch slug', async () => { + const result = await generateBranchNameFromContext( + { firstPrompt: 'Fix login flow' }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => ({ + stdout: '"Fix/Login Flow now please"\n', + stderr: '', + exitCode: 0, + timedOut: false + }) + } + ) + + expect(result).toEqual({ + success: true, + slug: 'fix-login-flow-now', + agentLabel: 'agent' + }) + }) + + it('fails when remote agent output sanitizes to an empty branch slug', async () => { + const result = await generateBranchNameFromContext( + { firstPrompt: 'Fix login flow' }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => ({ + stdout: '!!! ___\n', + stderr: '', + exitCode: 0, + timedOut: false + }) + } + ) + + expect(result).toEqual({ + success: false, + error: 'Generated branch name was empty after sanitization.' + }) + }) + + it('includes branch-name custom instructions in the generated prompt', async () => { + let prompt = '' + await generateBranchNameFromContext( + { firstPrompt: 'Fix login flow' }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent', + customPrompt: 'Prefer auth terminology.' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async (plan) => { + prompt = plan.stdinPayload ?? '' + return { + stdout: 'fix-login-flow\n', + stderr: '', + exitCode: 0, + timedOut: false + } + } + } + ) + + expect(prompt).toContain('Additional user instructions:') + expect(prompt).toContain('Prefer auth terminology.') + }) +}) + describe('trimGeneratedCommitMessage', () => { it('removes trailing whitespace from generated messages', () => { const message = trimGeneratedCommitMessage('Update docs\n\n') diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 8700c541ff3..c56d309b50c 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -2,7 +2,7 @@ spawn failure handling, and output normalization; keeping them together prevents those paths from drifting. */ import { exec, spawn, type ChildProcess } from 'child_process' -import type { GlobalSettings, TuiAgent } from '../../shared/types' +import type { GlobalSettings, Repo, TuiAgent } from '../../shared/types' import { buildCommitMessagePrompt, splitGeneratedCommitMessage, @@ -20,11 +20,12 @@ import { extractAgentErrorMessage } from '../../shared/commit-message-prompt' import { - CUSTOM_AGENT_ID, + buildBranchNamePrompt, + sanitizeBranchSlug, + type BranchNameWorkContext +} from '../../shared/branch-name-from-work' +import { getCommitMessageAgentSpec, - getCommitMessageModel, - isCustomAgentId, - resolveCommitMessageAgentChoice, type CommitMessageAgentCapability, type CommitMessageModelCapability } from '../../shared/commit-message-agent-spec' @@ -34,24 +35,23 @@ import { type CommitMessagePlan } from '../../shared/commit-message-plan' import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/commit-message-host-key' +import { + resolveSourceControlAiForOperation, + type ResolvedSourceControlAiGenerationParams +} from '../../shared/source-control-ai' +import type { SourceControlAiOperation } from '../../shared/source-control-ai-types' import { resolveCliCommand } from '../codex-cli/command' import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError, WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR } from '../win32-utils' +import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' const GENERATION_TIMEOUT_MS = 60_000 const MAX_AGENT_OUTPUT_BYTES = 4 * 1024 * 1024 -export type GenerateCommitMessageParams = { - agentId: TuiAgent | 'custom' - model: string - thinkingLevel?: string - customPrompt?: string - customAgentCommand?: string - agentCommandOverride?: string -} +export type GenerateCommitMessageParams = ResolvedSourceControlAiGenerationParams export type GenerateCommitMessageResult = | { success: true; message: string; agentLabel?: string } @@ -111,82 +111,26 @@ export function trimGeneratedCommitMessage(message: string): string { export function resolveCommitMessageSettings( settings: GlobalSettings, - discoveryHostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY + discoveryHostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY, + operation: SourceControlAiOperation = 'commitMessage', + repo?: Pick | null ): ResolveCommitMessageSettingsResult { - const config = settings.commitMessageAi - if (!config?.enabled) { - return { ok: false, error: 'Enable AI commit messages in Settings -> Git.' } - } + const resolved = resolveSourceControlAiForOperation({ + settings, + repo, + operation, + discoveryHostKey + }) + return resolved.ok ? { ok: true, params: resolved.value.params } : resolved +} - const agentChoice = resolveCommitMessageAgentChoice(config.agentId, settings.defaultTuiAgent) - if (!agentChoice) { - return { - ok: false, - error: - `Default agent "${settings.defaultTuiAgent}" does not support AI commit messages. ` + - 'Choose Claude or Codex in Settings -> Git -> AI Commit Messages.' - } - } - - if (isCustomAgentId(agentChoice)) { - const customAgentCommand = config.customAgentCommand.trim() - if (!customAgentCommand) { - return { - ok: false, - error: 'Custom command is empty. Add one in Settings -> Git -> AI Commit Messages.' - } - } - return { - ok: true, - params: { - agentId: CUSTOM_AGENT_ID, - model: '', - customPrompt: config.customPrompt, - customAgentCommand - } - } - } - - const agentId = agentChoice - const spec = getCommitMessageAgentSpec(agentId) - if (!spec) { - return { ok: false, error: `Agent "${agentId}" does not support AI commit messages.` } - } - - const hostSelectedModels = config.selectedModelByAgentByHost?.[discoveryHostKey] - const legacySelectedModels = - discoveryHostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY ? config.selectedModelByAgent : undefined - const persistedModelId = - hostSelectedModels?.[agentId] ?? legacySelectedModels?.[agentId] ?? spec.defaultModelId - const discoveredModels = - config.discoveredModelsByAgentByHost?.[discoveryHostKey]?.[agentId] ?? - (discoveryHostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY - ? (config.discoveredModelsByAgent?.[agentId] ?? []) - : []) - const model = - spec.models.find((candidate) => candidate.id === persistedModelId) ?? - discoveredModels.find((candidate) => candidate.id === persistedModelId) ?? - getCommitMessageModel(agentId, spec.defaultModelId) - if (!model) { - return { ok: false, error: `No model is available for ${spec.label}.` } - } - - const persistedThinking = config.selectedThinkingByModel[model.id] - const thinkingLevel = model.thinkingLevels?.some((level) => level.id === persistedThinking) - ? persistedThinking - : model.defaultThinkingLevel - - const agentCommandOverride = settings.agentCmdOverrides?.[agentId]?.trim() - return { - ok: true, - params: { - agentId, - model: model.id, - thinkingLevel, - customPrompt: config.customPrompt, - ...(agentCommandOverride ? { agentCommandOverride } : {}) - } - } +export function resolveTextGenerationParams( + settings: GlobalSettings, + discoveryHostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY, + operation: SourceControlAiOperation = 'commitMessage', + repo?: Pick | null +): ResolveCommitMessageSettingsResult { + return resolveCommitMessageSettings(settings, discoveryHostKey, operation, repo) } function sanitizeAgentFailureDetail(detail: string | null): string | null { @@ -197,8 +141,15 @@ function sanitizeAgentFailureDetail(detail: string | null): string | null { return trimmed.length > 240 ? `${trimmed.slice(0, 240).trimEnd()}...` : trimmed } -function userFacingAgentFailure(label: string): string { - return `${label} failed. Check the agent CLI configuration and try again.` +function userFacingAgentFailure( + label: string, + detail?: string | null, + options?: { includeLocalMacDnsHint?: boolean } +): string { + const message = `${label} failed. Check the agent CLI configuration and try again.` + return options?.includeLocalMacDnsHint === false + ? message + : withMacTailscaleDnsHint(message, detail) } function userFacingUnsafeWindowsBatchArgs(label: string): string { @@ -241,7 +192,10 @@ function finalizeModelDiscoveryOutput( }) return { success: false, - error: `${spec.label} model discovery failed. Check the agent CLI configuration and try again.` + error: withMacTailscaleDnsHint( + `${spec.label} model discovery failed. Check the agent CLI configuration and try again.`, + safeDetail + ) } } let models = spec.modelDiscovery?.parse(stdout) ?? [] @@ -474,7 +428,7 @@ function killProcessTree(child: ChildProcess): void { } } -type LocalGenerationOperation = 'commit-message' | 'pull-request-fields' +type LocalGenerationOperation = 'commit-message' | 'pull-request-fields' | 'branch-name' // Keying by operation plus `local:${cwd}` keeps local cancellation independent // from SSH worktrees and from other generation features in the same worktree. @@ -619,8 +573,9 @@ function finalizeFromAgentOutput(args: { label: string emptyResultName: string finalize: (result: InternalTextGenerationResult) => void + includeLocalMacDnsHint?: boolean }): void { - const { code, stdout, stderr, label, emptyResultName, finalize } = args + const { code, stdout, stderr, label, emptyResultName, finalize, includeLocalMacDnsHint } = args if (code !== 0) { const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr)) console.error('[commit-message] Generator failed:', { @@ -630,7 +585,10 @@ function finalizeFromAgentOutput(args: { stdout, stderr }) - finalize({ success: false, error: userFacingAgentFailure(label) }) + finalize({ + success: false, + error: userFacingAgentFailure(label, safeDetail, { includeLocalMacDnsHint }) + }) return } const cleaned = cleanGeneratedCommitMessage(stdout) @@ -644,7 +602,10 @@ function finalizeFromAgentOutput(args: { stdout, stderr }) - finalize({ success: false, error: userFacingAgentFailure(label) }) + finalize({ + success: false, + error: userFacingAgentFailure(label, safeDetail, { includeLocalMacDnsHint }) + }) return } finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` }) @@ -709,7 +670,9 @@ async function runRemotePlan( stderr: result.stderr, label, emptyResultName, - finalize: resolve + finalize: resolve, + // Why: remote agent output reflects the SSH target, not this Mac's DNS. + includeLocalMacDnsHint: false }) }) } @@ -802,3 +765,37 @@ export async function generatePullRequestFieldsFromContext( : await runLocalPlan(planned.plan, target.cwd, target.env, 'details', 'pull-request-fields') return formatPullRequestFieldsGenerationResult(internalResult, context) } + +export type GenerateBranchNameResult = + | { success: true; slug: string; agentLabel?: string } + | { success: false; error: string; canceled?: boolean } + +/** + * Generate a short kebab-case branch name from the work the agent is starting. + * Reuses the commit-message generation plan + spawn machinery; only the prompt + * and the post-processing (slug sanitization) differ. + */ +export async function generateBranchNameFromContext( + context: BranchNameWorkContext, + params: GenerateCommitMessageParams, + target: CommitMessageGenerationTarget +): Promise { + const prompt = buildBranchNamePrompt(context, params.customPrompt ?? '') + const planned = planCommitMessageGeneration(params, prompt) + if (!planned.ok) { + return { success: false, error: planned.error } + } + + const internalResult = + target.kind === 'remote' + ? await runRemotePlan(planned.plan, target, 'branch name') + : await runLocalPlan(planned.plan, target.cwd, target.env, 'branch name', 'branch-name') + if (!internalResult.success) { + return internalResult + } + const slug = sanitizeBranchSlug(internalResult.rawOutput) + if (!slug) { + return { success: false, error: 'Generated branch name was empty after sanitization.' } + } + return { success: true, slug, agentLabel: internalResult.agentLabel } +} diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 4b52fdbb7e2..5b979b3bbfe 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -20,6 +20,7 @@ type UpdaterHandlerContext = { clearBackgroundCheckLaunchPending: () => void clearAvailableUpdateContext: () => void consumeMissingManifestPrereleaseFallbackResult: () => { userInitiated: boolean } | null + getPublishingWindowLastGoodCheck: () => { lastGoodTag: string } | null getMissingManifestPrereleaseFallbackUserInitiated: () => boolean | null getCurrentStatus: () => UpdateStatus getKnownReleaseUrl: () => string | undefined @@ -50,6 +51,7 @@ export function registerAutoUpdaterHandlers({ clearBackgroundCheckLaunchPending, clearAvailableUpdateContext, consumeMissingManifestPrereleaseFallbackResult, + getPublishingWindowLastGoodCheck, getMissingManifestPrereleaseFallbackUserInitiated, getCurrentStatus, getKnownReleaseUrl, @@ -123,13 +125,14 @@ export function registerAutoUpdaterHandlers({ clearBackgroundCheckLaunchPending() // --- synchronous preamble (runs before any await) --- const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult() + const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck() const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() setUserInitiatedCheck(false) // Guard: don't show an update that isn't actually newer than what's running. if (compareVersions(info.version, app.getVersion()) <= 0) { clearAvailableUpdateContext() - if (missingManifestFallback) { + if (missingManifestFallback || publishingWindowLastGoodCheck) { // Why: a fallback manifest at the current version is still the result of // a transient missing primary manifest, so keep the short retry cadence. scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) @@ -166,10 +169,10 @@ export function registerAutoUpdaterHandlers({ // timestamp persisted for a check that never showed a result. setAvailableVersion(info.version) setAvailableReleaseUrl(null) - if (missingManifestFallback) { - // Why: offering the previous good release is only a temporary fallback; - // keep probing soon so users can move to the newest tag once its - // platform manifest finishes publishing. + if (missingManifestFallback || publishingWindowLastGoodCheck) { + // Why: offering a previous/last-good release is only a temporary + // fallback; keep probing soon so users can move to the newest tag once + // its platform manifest finishes publishing. scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) } else { recordCompletedUpdateCheck() @@ -186,12 +189,14 @@ export function registerAutoUpdaterHandlers({ clearBackgroundCheckLaunchPending() resetMacInstallState() const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult() + const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck() const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() setUserInitiatedCheck(false) clearAvailableUpdateContext() - if (missingManifestFallback) { - // Why: the primary/newest release manifest was missing, so fallback - // not-available is still a transient release-transition outcome. + if (missingManifestFallback || publishingWindowLastGoodCheck) { + // Why: the primary/newest release manifest/assets were missing, so a + // last-good not-available result is still a transient release-transition + // outcome and must not suppress the next retry for 24 hours. scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) } else { recordCompletedUpdateCheck() diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index 70e302304d0..9428550aa08 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -64,6 +64,10 @@ export function isMissingUpdateManifestFailure(message: string): boolean { ) } +export function isReleaseAssetsPublishingFailure(message: string): boolean { + return message.toLowerCase().includes('latest release assets are still publishing') +} + /** Identifies update-check failures that are transient or infrastructure-related * (e.g. network blips, GitHub release transitions) and should NOT be surfaced * to the user as errors. */ @@ -79,6 +83,7 @@ export function isBenignCheckFailure(message: string): boolean { // During that window electron-updater may fail the check even though // nothing is wrong on the client side. return ( + isReleaseAssetsPublishingFailure(message) || isGitHubReleaseTransitionFailure(normalizedMessage) || normalizedMessage.includes('no published versions on github') ) diff --git a/src/main/updater-prerelease-feed-readiness.test.ts b/src/main/updater-prerelease-feed-readiness.test.ts new file mode 100644 index 00000000000..d51d8cb4179 --- /dev/null +++ b/src/main/updater-prerelease-feed-readiness.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { netFetchMock } = vi.hoisted(() => ({ + netFetchMock: vi.fn() +})) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock } +})) + +function buildAtomFeed(tags: string[]): string { + return `${tags + .map( + (tag) => + `${tag}` + ) + .join('')}` +} + +function buildManifest(tag: string): string { + const version = tag.replace(/^v/i, '') + return [ + `version: ${version}`, + 'files:', + ` - url: Orca-${version}-arm64-mac.zip`, + ' sha512: test', + `path: Orca-${version}-arm64-mac.zip` + ].join('\n') +} + +function isPlatformManifestRequest(url: string): boolean { + return /\/latest(?:-[a-z]+)?\.yml$/.test(url) +} + +function respondWithAtom( + tags: string[], + missingManifestTags: string[] = [], + missingAssetTags: string[] = [] +): void { + const missingManifests = new Set(missingManifestTags) + const missingAssets = new Set(missingAssetTags) + netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ ok: true, text: () => Promise.resolve(buildAtomFeed(tags)) }) + } + + const manifestMatch = url.match(/\/releases\/download\/([^/]+)\/latest(?:-[a-z]+)?\.yml$/) + if (manifestMatch) { + const tag = decodeURIComponent(manifestMatch[1]) + return Promise.resolve({ + ok: !missingManifests.has(tag), + text: () => Promise.resolve(buildManifest(tag)) + }) + } + + const assetMatch = url.match(/\/releases\/download\/([^/]+)\/(.+)$/) + if (assetMatch && init?.method === 'HEAD') { + return Promise.resolve({ + ok: !missingAssets.has(decodeURIComponent(assetMatch[1])), + text: () => Promise.resolve('') + }) + } + + return Promise.resolve({ ok: false, text: () => Promise.resolve('') }) + }) +} + +describe('fetchNewerReleaseTagsWithReadiness', () => { + beforeEach(() => { + vi.resetModules() + netFetchMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('reports not-ready with a verified last-good tag when the newest assets are unavailable', async () => { + respondWithAtom(['v1.4.27', 'v1.4.26'], [], ['v1.4.27']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 1)).resolves.toEqual({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.26' + }) + }) + + it('does not return a last-good tag whose manifest asset is unavailable', async () => { + respondWithAtom(['v1.4.27', 'v1.4.26'], [], ['v1.4.27', 'v1.4.26']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 1)).resolves.toEqual({ + tags: [], + state: 'not-ready' + }) + }) + + it('reports no-newer separately from feed fetch failures', async () => { + respondWithAtom(['v1.4.26']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 1)).resolves.toEqual({ + tags: [], + state: 'no-newer' + }) + + netFetchMock.mockResolvedValue({ ok: false, text: () => Promise.resolve('') }) + await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 1)).resolves.toEqual({ + tags: [], + state: 'unavailable' + }) + }) + + it('requires every asset referenced by the manifest files list to be reachable', async () => { + netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(buildAtomFeed(['v1.4.28', 'v1.4.27'])) + }) + } + + const manifestMatch = url.match(/\/releases\/download\/([^/]+)\/latest(?:-[a-z]+)?\.yml$/) + if (manifestMatch) { + const version = decodeURIComponent(manifestMatch[1]).replace(/^v/i, '') + return Promise.resolve({ + ok: true, + text: () => + Promise.resolve( + [ + `version: ${version}`, + 'files:', + ` - url: Orca-${version}-mac.zip`, + ' sha512: test', + ` - url: Orca-${version}-arm64-mac.zip`, + ' sha512: test', + `path: Orca-${version}-mac.zip` + ].join('\n') + ) + }) + } + + if (init?.method === 'HEAD') { + return Promise.resolve({ + ok: !url.endsWith('/Orca-1.4.28-arm64-mac.zip'), + text: () => Promise.resolve('') + }) + } + + return Promise.resolve({ ok: false, text: () => Promise.resolve('') }) + }) + + const { fetchNewerReleaseTag, fetchNewerReleaseTagsWithReadiness } = + await import('./updater-prerelease-feed') + + expect(await fetchNewerReleaseTag('1.4.26')).toBeNull() + await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 1)).resolves.toEqual({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.27' + }) + }) + + it('accepts absolute manifest asset URLs without rewriting them to release asset paths', async () => { + const assetUrls: string[] = [] + netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(buildAtomFeed(['v1.4.27'])) + }) + } + + if (isPlatformManifestRequest(url)) { + return Promise.resolve({ + ok: true, + text: () => + Promise.resolve( + [ + 'version: 1.4.27', + 'files:', + ' - url: https://downloads.example.com/Orca-1.4.27-arm64-mac.zip', + ' sha512: test' + ].join('\n') + ) + }) + } + + if (init?.method === 'HEAD') { + assetUrls.push(url) + return Promise.resolve({ ok: true, text: () => Promise.resolve('') }) + } + + return Promise.resolve({ ok: false, text: () => Promise.resolve('') }) + }) + + const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') + + expect(await fetchNewerReleaseTag('1.4.26')).toBe('v1.4.27') + expect(assetUrls).toEqual(['https://downloads.example.com/Orca-1.4.27-arm64-mac.zip']) + }) + + it('treats malformed updater manifests as not ready', async () => { + netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(buildAtomFeed(['v1.4.28', 'v1.4.27'])) + }) + } + + if (url.includes('/releases/download/v1.4.28/')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve('files:\n - url: [') }) + } + + if (url.includes('/releases/download/v1.4.27/') && isPlatformManifestRequest(url)) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(buildManifest('v1.4.27')) }) + } + + if (init?.method === 'HEAD') { + return Promise.resolve({ ok: true, text: () => Promise.resolve('') }) + } + + return Promise.resolve({ ok: false, text: () => Promise.resolve('') }) + }) + + const { fetchNewerReleaseTag, fetchNewerReleaseTagsWithReadiness } = + await import('./updater-prerelease-feed') + + expect(await fetchNewerReleaseTag('1.4.26')).toBeNull() + await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 1)).resolves.toEqual({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.27' + }) + }) + + it('returns not-ready with an older ready update as last-good while newest is publishing', async () => { + respondWithAtom(['v1.4.27', 'v1.4.26'], ['v1.4.27']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.25', 1)).resolves.toEqual({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.26' + }) + }) + + it('uses prerelease last-good tags only for prerelease-aware checks', async () => { + respondWithAtom(['v1.4.27-rc.2', 'v1.4.27-rc.1', 'v1.4.26'], ['v1.4.27-rc.2']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect( + fetchNewerReleaseTagsWithReadiness('1.4.27-rc.1', 1, { includePrerelease: true }) + ).resolves.toEqual({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.27-rc.1' + }) + await expect( + fetchNewerReleaseTagsWithReadiness('1.4.26', 1, { includePrerelease: false }) + ).resolves.toEqual({ + tags: [], + state: 'no-newer' + }) + }) + + it('does not guess a last-good tag outside the bounded probe window', async () => { + respondWithAtom( + ['v1.4.33', 'v1.4.32', 'v1.4.31', 'v1.4.30', 'v1.4.29', 'v1.4.28', 'v1.4.27'], + ['v1.4.33', 'v1.4.32', 'v1.4.31', 'v1.4.30', 'v1.4.29', 'v1.4.28'] + ) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.27', 1)).resolves.toEqual({ + tags: [], + state: 'not-ready' + }) + }) +}) diff --git a/src/main/updater-prerelease-feed.test.ts b/src/main/updater-prerelease-feed.test.ts index f98b207c68e..e9f40a72a09 100644 --- a/src/main/updater-prerelease-feed.test.ts +++ b/src/main/updater-prerelease-feed.test.ts @@ -20,9 +20,25 @@ function buildAtomFeed(tags: string[]): string { return `${entries}` } -function respondWithAtom(tags: string[], missingManifestTags: string[] = []): void { +function buildManifest(tag: string): string { + const version = tag.replace(/^v/i, '') + return [ + `version: ${version}`, + 'files:', + ` - url: Orca-${version}-arm64-mac.zip`, + ' sha512: test', + `path: Orca-${version}-arm64-mac.zip` + ].join('\n') +} + +function respondWithAtom( + tags: string[], + missingManifestTags: string[] = [], + missingAssetTags: string[] = [] +): void { const missingManifests = new Set(missingManifestTags) - netFetchMock.mockImplementation((url: string) => { + const missingAssets = new Set(missingAssetTags) + netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { if (url === 'https://github.com/stablyai/orca/releases.atom') { return Promise.resolve({ ok: true, @@ -30,13 +46,25 @@ function respondWithAtom(tags: string[], missingManifestTags: string[] = []): vo }) } - const match = url.match(/\/releases\/download\/([^/]+)\/latest(?:-[a-z]+)?\.yml$/) - if (!match) { - return Promise.resolve({ ok: false, text: () => Promise.resolve('') }) + const manifestMatch = url.match(/\/releases\/download\/([^/]+)\/latest(?:-[a-z]+)?\.yml$/) + if (manifestMatch) { + const tag = decodeURIComponent(manifestMatch[1]) + return Promise.resolve({ + ok: !missingManifests.has(tag), + text: () => Promise.resolve(buildManifest(tag)) + }) + } + + const assetMatch = url.match(/\/releases\/download\/([^/]+)\/(.+)$/) + if (assetMatch && init?.method === 'HEAD') { + return Promise.resolve({ + ok: !missingAssets.has(decodeURIComponent(assetMatch[1])), + text: () => Promise.resolve('') + }) } return Promise.resolve({ - ok: !missingManifests.has(decodeURIComponent(match[1])), + ok: false, text: () => Promise.resolve('') }) }) @@ -83,8 +111,9 @@ describe('fetchNewerReleaseTag', () => { async (platform, manifestName) => { setPlatformForTest(platform) const manifestUrls: string[] = [] + const assetUrls: string[] = [] - netFetchMock.mockImplementation((url: string) => { + netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { if (url === 'https://github.com/stablyai/orca/releases.atom') { return Promise.resolve({ ok: true, @@ -92,7 +121,17 @@ describe('fetchNewerReleaseTag', () => { }) } - manifestUrls.push(url) + if (url.endsWith(manifestName)) { + manifestUrls.push(url) + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(buildManifest('v1.4.1')) + }) + } + + if (init?.method === 'HEAD') { + assetUrls.push(url) + } return Promise.resolve({ ok: true, text: () => Promise.resolve('') @@ -105,6 +144,9 @@ describe('fetchNewerReleaseTag', () => { expect(manifestUrls).toEqual([ `https://github.com/stablyai/orca/releases/download/v1.4.1/${manifestName}` ]) + expect(assetUrls).toEqual([ + 'https://github.com/stablyai/orca/releases/download/v1.4.1/Orca-1.4.1-arm64-mac.zip' + ]) } ) @@ -151,15 +193,43 @@ describe('fetchNewerReleaseTag', () => { expect(await fetchNewerReleaseTags('1.3.51-rc.6', 2)).toEqual(['v1.3.51-rc.7', 'v1.3.51-rc.6']) }) - it('skips feed tags whose platform updater manifest is missing', async () => { + it('reports not-ready with last-good when newer platform updater manifests are missing', async () => { respondWithAtom( ['v1.4.1-rc.4', 'v1.4.1-rc.3', 'v1.4.1-rc.2', 'v1.4.1-rc.1'], ['v1.4.1-rc.4', 'v1.4.1-rc.3'] ) - const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed') + const { fetchNewerReleaseTag, fetchNewerReleaseTagsWithReadiness } = + await import('./updater-prerelease-feed') - expect(await fetchNewerReleaseTags('1.4.1-rc.1', 2)).toEqual(['v1.4.1-rc.2', 'v1.4.1-rc.1']) + expect(await fetchNewerReleaseTag('1.4.1-rc.1')).toBeNull() + expect(await fetchNewerReleaseTagsWithReadiness('1.4.1-rc.1', 2)).toEqual({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.1-rc.2' + }) + }) + + it('reports not-ready with last-good when newer manifest assets are not reachable yet', async () => { + respondWithAtom(['v1.4.3', 'v1.4.2', 'v1.4.1'], [], ['v1.4.3']) + + const { fetchNewerReleaseTag, fetchNewerReleaseTagsWithReadiness } = + await import('./updater-prerelease-feed') + + expect(await fetchNewerReleaseTag('1.4.0')).toBeNull() + expect(await fetchNewerReleaseTagsWithReadiness('1.4.0', 1)).toEqual({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.2' + }) + }) + + it('returns null when the only newer tag has a manifest but its asset still 404s', async () => { + respondWithAtom(['v1.4.27'], [], ['v1.4.27']) + + const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') + + expect(await fetchNewerReleaseTag('1.4.26')).toBeNull() }) it('does not return the current tag as the primary update when newer manifests are missing', async () => { diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index 8e5d72fa527..ec328db10eb 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -1,4 +1,5 @@ import { net } from 'electron' +import { parse } from 'yaml' import { compareVersions, isPrereleaseVersion, isValidVersion } from './updater-fallback' const ATOM_FEED_URL = 'https://github.com/stablyai/orca/releases.atom' @@ -29,6 +30,10 @@ function getReleaseManifestUrl(tag: string): string { return `${getReleaseDownloadUrl(tag)}/${getPlatformManifestName()}` } +function getReleaseAssetUrl(tag: string, assetName: string): string { + return `${getReleaseDownloadUrl(tag)}/${encodeURIComponent(assetName)}` +} + export function normalizeTagToVersion(tag: string): string { return tag.replace(/^v/i, '') } @@ -67,15 +72,68 @@ async function fetchReleaseFeedTags(): Promise { } } -async function hasPlatformManifest(tag: string): Promise { +type ManifestAssetEntry = { + url?: unknown + path?: unknown +} + +function getManifestAssetNames(manifestText: string): string[] { + const parsed = parse(manifestText) as { + files?: ManifestAssetEntry[] + path?: unknown + } | null + + const names = new Set() + for (const file of Array.isArray(parsed?.files) ? parsed.files : []) { + const value = typeof file.url === 'string' ? file.url : file.path + if (typeof value === 'string' && value.trim()) { + names.add(value.trim()) + } + } + if (typeof parsed?.path === 'string' && parsed.path.trim()) { + names.add(parsed.path.trim()) + } + return [...names] +} + +async function isReleaseAssetAvailable(tag: string, assetName: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + + try { + const assetUrl = assetName.startsWith('http') + ? assetName + : getReleaseAssetUrl(tag, assetName.split('/').filter(Boolean).at(-1) ?? assetName) + const res = await net.fetch(assetUrl, { method: 'HEAD', signal: controller.signal }) + return res.ok + } catch { + return false + } finally { + clearTimeout(timeout) + } +} + +async function hasReadyPlatformManifest(tag: string): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) try { // Why: cancelled/draft releases can appear in GitHub's atom feed before - // they have updater manifests. Pinning to those tags makes every check 404. - const res = await net.fetch(getReleaseManifestUrl(tag), { signal: controller.signal }) - return res.ok + // they have updater manifests or the ZIP/exe/AppImage assets referenced by + // those manifests. Pinning to those tags makes download clicks 404. + const manifestUrl = getReleaseManifestUrl(tag) + const res = await net.fetch(manifestUrl, { signal: controller.signal }) + if (!res.ok) { + return false + } + const assetNames = getManifestAssetNames(await res.text()) + if (assetNames.length === 0) { + return false + } + const assetResults = await Promise.all( + assetNames.map((assetName) => isReleaseAssetAvailable(tag, assetName)) + ) + return assetResults.every(Boolean) } catch { return false } finally { @@ -100,6 +158,12 @@ type FetchNewerReleaseTagOptions = { includePrerelease?: boolean } +export type FetchNewerReleaseTagsResult = { + tags: string[] + state: 'ready' | 'no-newer' | 'not-ready' | 'unavailable' + lastGoodTag?: string +} + export async function fetchNewerReleaseTag( currentVersion: string, options: FetchNewerReleaseTagOptions = {} @@ -112,10 +176,18 @@ export async function fetchNewerReleaseTags( maxTags: number, options: FetchNewerReleaseTagOptions = {} ): Promise { + return (await fetchNewerReleaseTagsWithReadiness(currentVersion, maxTags, options)).tags +} + +export async function fetchNewerReleaseTagsWithReadiness( + currentVersion: string, + maxTags: number, + options: FetchNewerReleaseTagOptions = {} +): Promise { const includePrerelease = options.includePrerelease ?? true const tags = await fetchReleaseFeedTags() if (!tags || maxTags <= 0) { - return [] + return { tags: [], state: 'unavailable' } } const candidates = includePrerelease @@ -125,7 +197,7 @@ export async function fetchNewerReleaseTags( ({ version }) => compareVersions(version, currentVersion) > 0 ) if (newestNewerIndex === -1) { - return [] + return { tags: [], state: 'no-newer' } } // Why: a cancelled release can leave several feed entries without manifests, @@ -138,7 +210,7 @@ export async function fetchNewerReleaseTags( probeCandidates.map(async ({ tag, version }) => ({ tag, version, - hasManifest: await hasPlatformManifest(tag) + hasManifest: await hasReadyPlatformManifest(tag) })) ) @@ -146,12 +218,22 @@ export async function fetchNewerReleaseTags( ({ hasManifest, version }) => hasManifest && compareVersions(version, currentVersion) > 0 ) if (primaryIndex === -1) { - return [] + const lastGoodTag = manifestResults.find(({ hasManifest }) => hasManifest)?.tag + return lastGoodTag + ? { tags: [], state: 'not-ready', lastGoodTag } + : { tags: [], state: 'not-ready' } } - return manifestResults - .slice(primaryIndex) - .filter(({ hasManifest }) => hasManifest) - .slice(0, maxTags) - .map(({ tag }) => tag) + if (primaryIndex > 0) { + return { tags: [], state: 'not-ready', lastGoodTag: manifestResults[primaryIndex].tag } + } + + return { + tags: manifestResults + .slice(primaryIndex) + .filter(({ hasManifest }) => hasManifest) + .slice(0, maxTags) + .map(({ tag }) => tag), + state: 'ready' + } } diff --git a/src/main/updater.fallback.test.ts b/src/main/updater.fallback.test.ts index 882389a9acf..24d6729d9eb 100644 --- a/src/main/updater.fallback.test.ts +++ b/src/main/updater.fallback.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { compareVersions, + isBenignCheckFailure, isMissingUpdateManifestFailure, + isReleaseAssetsPublishingFailure, isPrereleaseVersion } from './updater-fallback' @@ -41,3 +43,20 @@ describe('isMissingUpdateManifestFailure', () => { expect(isMissingUpdateManifestFailure('Unable to find latest version on GitHub')).toBe(false) }) }) + +describe('isBenignCheckFailure', () => { + it('treats in-progress release asset publication as retryable', () => { + expect(isBenignCheckFailure('Latest release assets are still publishing')).toBe(true) + }) +}) + +describe('isReleaseAssetsPublishingFailure', () => { + it('only matches the explicit release-asset publishing sentinel', () => { + expect(isReleaseAssetsPublishingFailure('Latest release assets are still publishing')).toBe( + true + ) + expect( + isReleaseAssetsPublishingFailure('Cannot find channel "latest-mac.yml" update info: 404') + ).toBe(false) + }) +}) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 4d91f760097..a50ef2c3abe 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -131,7 +131,12 @@ const { fetchNewerReleaseTagsMock } = vi.hoisted(() => ({ })) vi.mock('./updater-prerelease-feed', () => ({ - fetchNewerReleaseTags: fetchNewerReleaseTagsMock, + fetchNewerReleaseTagsWithReadiness: async (...args: unknown[]) => { + const result = await fetchNewerReleaseTagsMock(...args) + return Array.isArray(result) + ? { tags: result, state: result.length > 0 ? 'ready' : 'no-newer' } + : result + }, getReleaseDownloadUrl: (tag: string) => `https://github.com/stablyai/orca/releases/download/${tag}` })) @@ -174,7 +179,7 @@ describe('updater', () => { }) it('deduplicates identical check errors from the event and rejected promise', async () => { - autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { + autoUpdaterMock.checkForUpdates.mockImplementation(() => { autoUpdaterMock.emit('checking-for-update') queueMicrotask(() => { autoUpdaterMock.emit('error', new Error('boom')) @@ -187,7 +192,7 @@ describe('updater', () => { const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') - setupAutoUpdater(mainWindow as never) + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) checkForUpdatesFromMenu() await vi.waitFor(() => { const statuses = sendMock.mock.calls @@ -205,7 +210,7 @@ describe('updater', () => { }) it('surfaces net::ERR_FAILED to user-initiated checks with a friendly message', async () => { - autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { + autoUpdaterMock.checkForUpdates.mockImplementation(() => { autoUpdaterMock.emit('checking-for-update') queueMicrotask(() => { autoUpdaterMock.emit('error', new Error('net::ERR_FAILED')) @@ -218,7 +223,7 @@ describe('updater', () => { const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') - setupAutoUpdater(mainWindow as never) + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) checkForUpdatesFromMenu() await vi.waitFor(() => { const statuses = sendMock.mock.calls @@ -245,6 +250,199 @@ describe('updater', () => { ) }) + it('shows checking immediately for a user-initiated check while feed pinning is pending', async () => { + let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {} + fetchNewerReleaseTagsMock.mockImplementation( + () => + new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => { + resolveTags = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + return new Promise(() => {}) + }) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + checkForUpdatesFromMenu() + + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'checking', + userInitiated: true + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + + resolveTags({ tags: [], state: 'no-newer' }) + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + + const checkingStatuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + .filter( + (status) => typeof status === 'object' && status !== null && status.state === 'checking' + ) + + expect(checkingStatuses).toEqual([{ state: 'checking', userInitiated: true }]) + }) + + it('keeps background checks event-driven before checking-for-update fires', async () => { + let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {} + fetchNewerReleaseTagsMock.mockImplementation( + () => + new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => { + resolveTags = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {})) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null }) + + expect( + sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + .some( + (status) => typeof status === 'object' && status !== null && status.state === 'checking' + ) + ).toBe(false) + + resolveTags({ tags: [], state: 'no-newer' }) + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + + autoUpdaterMock.emit('checking-for-update') + expect(sendMock).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'checking' }) + ) + }) + + it('promotes a pending background check to user-initiated without launching a duplicate check', async () => { + let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {} + fetchNewerReleaseTagsMock.mockImplementation( + () => + new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => { + resolveTags = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null }) + checkForUpdatesFromMenu() + + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'checking', + userInitiated: true + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + + resolveTags({ tags: [], state: 'no-newer' }) + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + + autoUpdaterMock.emit('update-not-available') + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'not-available', + userInitiated: true + }) + }) + + it('keeps promoted background promise failures user-initiated after a paired error event', async () => { + let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {} + fetchNewerReleaseTagsMock.mockImplementation( + () => + new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => { + resolveTags = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('error', new Error('net::ERR_FAILED')) + }) + return Promise.reject(new Error('net::ERR_FAILED')) + }) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null }) + checkForUpdatesFromMenu() + resolveTags({ tags: [], state: 'no-newer' }) + + await vi.waitFor(() => { + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + expect(statuses).toContainEqual( + expect.objectContaining({ + state: 'error', + userInitiated: true, + message: expect.stringContaining("Couldn't reach the update server") + }) + ) + }) + + const resultStatuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + .filter( + (status) => + typeof status === 'object' && + status !== null && + (status.state === 'idle' || status.state === 'error') + ) + + expect(resultStatuses).toEqual([ + expect.objectContaining({ + state: 'error', + userInitiated: true, + message: expect.stringContaining("Couldn't reach the update server") + }) + ]) + }) + + it('deduplicates repeated manual checks while the immediate checking status is active', async () => { + let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {} + fetchNewerReleaseTagsMock.mockImplementation( + () => + new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => { + resolveTags = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {})) + const mainWindow = { webContents: { send: vi.fn() } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + checkForUpdatesFromMenu() + checkForUpdatesFromMenu() + + resolveTags({ tags: [], state: 'no-newer' }) + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(1) + }) + it('opts into the RC channel when checkForUpdatesFromMenu is called with includePrerelease', async () => { appMock.getVersion.mockReturnValue('1.3.17') fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18-rc.1']) @@ -742,6 +940,39 @@ describe('updater', () => { expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null) }) + it('does not preserve a pending nudge for ordinary manifest transition errors', async () => { + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const setPendingUpdateNudgeId = vi.fn() + const setDismissedUpdateNudgeId = vi.fn() + const missingManifest = new Error( + 'Cannot find channel "latest-mac.yml" update info: HttpError: 404' + ) + + fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' }) + shouldApplyNudgeMock.mockReturnValue(true) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + return Promise.reject(missingManifest) + }) + + const { setupAutoUpdater } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { + getLastUpdateCheckAt: () => Date.now(), + setPendingUpdateNudgeId, + getPendingUpdateNudgeId: () => null, + getDismissedUpdateNudgeId: () => null, + setDismissedUpdateNudgeId + }) + + await vi.waitFor(() => { + expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null) + }) + + expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1') + }) + it('moves pending nudge to dismissed when dismissNudge is called', async () => { const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } @@ -896,6 +1127,390 @@ describe('updater', () => { }) }) + it('uses last-good concrete feed when a user-initiated check lands during publishing', async () => { + appMock.getVersion.mockReturnValue('1.4.26') + fetchNewerReleaseTagsMock.mockResolvedValue({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.26' + }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-not-available') + }) + return Promise.resolve(undefined) + }) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + const feedCallsBeforeCheck = autoUpdaterMock.setFeedURL.mock.calls.length + + checkForUpdatesFromMenu() + + await vi.waitFor(() => { + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + expect(statuses).toContainEqual({ state: 'checking', userInitiated: true }) + expect(statuses).toContainEqual({ state: 'not-available', userInitiated: true }) + }) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.26' + }) + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.27' + }) + expect(autoUpdaterMock.setFeedURL.mock.calls.slice(feedCallsBeforeCheck)).not.toContainEqual([ + { + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + } + ]) + expect(sendMock).not.toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'error' }) + ) + }) + + it('can offer an older fully published update while the newest release is publishing', async () => { + appMock.getVersion.mockReturnValue('1.4.25') + fetchNewerReleaseTagsMock.mockResolvedValue({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.26' + }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-available', { version: '1.4.26' }) + }) + return Promise.resolve(undefined) + }) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + + checkForUpdatesFromMenu() + + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'available', + version: '1.4.26', + changelog: null + }) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.26' + }) + }) + + it('keeps background publishing-window fallback on the short retry cadence', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-24T21:40:00Z')) + appMock.getVersion.mockReturnValue('1.4.26') + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.26' + }) + .mockResolvedValueOnce(['v1.4.27']) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + return new Promise(() => {}) + }) + const setLastUpdateCheckAt = vi.fn() + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { + getLastUpdateCheckAt: () => null, + setLastUpdateCheckAt + }) + + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.26' + }) + expect(setLastUpdateCheckAt).not.toHaveBeenCalled() + + autoUpdaterMock.emit('update-not-available') + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { state: 'not-available' }) + }) + expect(setLastUpdateCheckAt).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(59 * 60 * 1000) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(60 * 1000) + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.27' + }) + }) + + it('keeps background checks retryable while newer release assets are still publishing', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-24T21:40:00Z')) + appMock.getVersion.mockReturnValue('1.4.26') + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ tags: [], state: 'not-ready' }) + .mockResolvedValueOnce(['v1.4.27']) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const setLastUpdateCheckAt = vi.fn() + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { + getLastUpdateCheckAt: () => null, + setLastUpdateCheckAt + }) + + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(setLastUpdateCheckAt).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.27' + }) + }) + + it('keeps a nudge campaign pending when release assets are still publishing', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-24T21:40:00Z')) + appMock.getVersion.mockReturnValue('1.4.26') + fetchNudgeMock.mockResolvedValueOnce({ id: 'campaign-1', minVersion: '1.0.0' }) + fetchNudgeMock.mockResolvedValue(null) + shouldApplyNudgeMock.mockReturnValue(true) + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ tags: [], state: 'not-ready' }) + .mockResolvedValueOnce(['v1.4.27']) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + return Promise.resolve(undefined) + }) + let pendingNudgeId: string | null = null + const setPendingUpdateNudgeId = vi.fn((id: string | null) => { + pendingNudgeId = id + }) + const setDismissedUpdateNudgeId = vi.fn() + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { + getLastUpdateCheckAt: () => Date.now(), + getPendingUpdateNudgeId: () => pendingNudgeId, + getDismissedUpdateNudgeId: () => null, + setPendingUpdateNudgeId, + setDismissedUpdateNudgeId + }) + + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(1) + }) + expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1') + expect(setPendingUpdateNudgeId).not.toHaveBeenCalledWith(null) + expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled() + expect(pendingNudgeId).toBe('campaign-1') + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + autoUpdaterMock.emit('update-available', { version: '1.4.27' }) + await vi.advanceTimersByTimeAsync(0) + + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'available', + version: '1.4.27', + changelog: null, + activeNudgeId: 'campaign-1' + }) + expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled() + }) + + it('does not dismiss a nudge when last-good fallback is current during publishing', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-24T21:40:00Z')) + appMock.getVersion.mockReturnValue('1.4.26') + fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' }) + shouldApplyNudgeMock.mockReturnValue(true) + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.26' + }) + .mockResolvedValueOnce(['v1.4.27']) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) { + queueMicrotask(() => { + autoUpdaterMock.emit('update-not-available') + }) + } + return Promise.resolve(undefined) + }) + let pendingNudgeId: string | null = null + const setPendingUpdateNudgeId = vi.fn((id: string | null) => { + pendingNudgeId = id + }) + const setDismissedUpdateNudgeId = vi.fn() + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { + getLastUpdateCheckAt: () => Date.now(), + getPendingUpdateNudgeId: () => pendingNudgeId, + getDismissedUpdateNudgeId: () => null, + setPendingUpdateNudgeId, + setDismissedUpdateNudgeId + }) + + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + expect(sendMock).toHaveBeenCalledWith('updater:status', { state: 'not-available' }) + }) + + expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1') + expect(setPendingUpdateNudgeId).not.toHaveBeenCalledWith(null) + expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled() + expect(pendingNudgeId).toBe('campaign-1') + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + autoUpdaterMock.emit('update-available', { version: '1.4.27' }) + await vi.advanceTimersByTimeAsync(0) + + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'available', + version: '1.4.27', + changelog: null, + activeNudgeId: 'campaign-1' + }) + }) + + it('does not attach nudge dismissal to an older last-good available update', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-24T21:40:00Z')) + appMock.getVersion.mockReturnValue('1.4.25') + fetchNudgeMock.mockResolvedValueOnce({ id: 'campaign-1', minVersion: '1.0.0' }) + fetchNudgeMock.mockResolvedValue(null) + shouldApplyNudgeMock.mockReturnValue(true) + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ + tags: [], + state: 'not-ready', + lastGoodTag: 'v1.4.26' + }) + .mockResolvedValueOnce(['v1.4.27']) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) { + queueMicrotask(() => { + autoUpdaterMock.emit('update-available', { version: '1.4.26' }) + }) + } + return Promise.resolve(undefined) + }) + let pendingNudgeId: string | null = null + const setPendingUpdateNudgeId = vi.fn((id: string | null) => { + pendingNudgeId = id + }) + const setDismissedUpdateNudgeId = vi.fn() + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, dismissNudge } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { + getLastUpdateCheckAt: () => Date.now(), + getPendingUpdateNudgeId: () => pendingNudgeId, + getDismissedUpdateNudgeId: () => null, + setPendingUpdateNudgeId, + setDismissedUpdateNudgeId + }) + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'available', + version: '1.4.26', + changelog: null + }) + }) + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + const lastGoodStatus = statuses.find( + (status) => status.state === 'available' && status.version === '1.4.26' + ) + if (lastGoodStatus && 'activeNudgeId' in lastGoodStatus) { + dismissNudge() + } + + expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1') + expect(setPendingUpdateNudgeId).not.toHaveBeenCalledWith(null) + expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled() + expect(pendingNudgeId).toBe('campaign-1') + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + autoUpdaterMock.emit('update-available', { version: '1.4.27' }) + await vi.advanceTimersByTimeAsync(0) + + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'available', + version: '1.4.27', + changelog: null, + activeNudgeId: 'campaign-1' + }) + }) + it('retries a prerelease check once against the previous feed tag when the manifest is missing', async () => { appMock.getVersion.mockReturnValue('1.3.51-rc.6') fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6']) diff --git a/src/main/updater.ts b/src/main/updater.ts index 7b4579fb842..0359fc3898b 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -17,9 +17,13 @@ import { isBenignCheckFailure, isMissingUpdateManifestFailure, isPrereleaseVersion, + isReleaseAssetsPublishingFailure, statusesEqual } from './updater-fallback' -import { fetchNewerReleaseTags, getReleaseDownloadUrl } from './updater-prerelease-feed' +import { + fetchNewerReleaseTagsWithReadiness, + getReleaseDownloadUrl +} from './updater-prerelease-feed' import { fetchNudge, shouldApplyNudge } from './updater-nudge' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' @@ -51,10 +55,14 @@ let pendingQuitAndInstallTimer: ReturnType | null = null let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null let _getLastUpdateCheckAt: (() => number | null) | null = null let backgroundCheckLaunchPending = false +// Why: a manually promoted background check can emit an error event before the +// paired promise catch runs; keep the promotion attached to that launch. +let backgroundCheckPromotedToUserInitiated = false let activeUpdateNudgeId: string | null = null let awaitingNudgeCheckOutcome = false let nudgeCheckInFlight = false let lastNudgeCheckAt = 0 +let publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null let pendingPrereleaseFallback: { primaryTag: string fallbackTag: string @@ -105,6 +113,19 @@ function clearPendingUpdateNudge(): void { _setPendingUpdateNudgeId?.(null) } +function deferPendingUpdateNudgeUntilRetry(): void { + activeUpdateNudgeId = null + awaitingNudgeCheckOutcome = false +} + +function clearPublishingWindowLastGoodCheck(): void { + publishingWindowLastGoodCheck = null +} + +function getPublishingWindowLastGoodCheck(): { lastGoodTag: string } | null { + return publishingWindowLastGoodCheck +} + function getPersistedPendingUpdateNudgeId(): string | null { return _getPendingUpdateNudgeId?.() ?? null } @@ -123,29 +144,56 @@ function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { } function sendStatus(status: UpdateStatus): void { + const shouldPreserveNudgeForPublishingWindow = + publishingWindowLastGoodCheck !== null && + (status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error') if (awaitingNudgeCheckOutcome) { if (status.state === 'available') { - awaitingNudgeCheckOutcome = false + if (shouldPreserveNudgeForPublishingWindow) { + // Why: a last-good available update is only a temporary fallback; don't + // let dismissing that card consume the newest-release nudge campaign. + deferPendingUpdateNudgeUntilRetry() + } else { + awaitingNudgeCheckOutcome = false + } } else if ( status.state === 'idle' || status.state === 'not-available' || status.state === 'error' ) { - // Why: when a nudge-triggered check finds no update (or errors out), - // move the campaign to dismissed so it doesn't re-fire on the next - // poll cycle. Without this, a nudge whose version range includes - // already-up-to-date users would loop every 30 minutes, each time - // triggering a redundant checkForUpdates() and clearing the persisted - // dismissedUpdateVersion. - if (activeUpdateNudgeId) { - _setDismissedUpdateNudgeId?.(activeUpdateNudgeId) + if (shouldPreserveNudgeForPublishingWindow) { + // Why: last-good checks can legitimately say "not available" while + // the campaign's newest release is still publishing. + deferPendingUpdateNudgeUntilRetry() + } else { + // Why: when a nudge-triggered check finds no update (or errors out), + // move the campaign to dismissed so it doesn't re-fire on the next + // poll cycle. Without this, a nudge whose version range includes + // already-up-to-date users would loop every 30 minutes, each time + // triggering a redundant checkForUpdates() and clearing the persisted + // dismissedUpdateVersion. + if (activeUpdateNudgeId) { + _setDismissedUpdateNudgeId?.(activeUpdateNudgeId) + } + clearPendingUpdateNudge() } - clearPendingUpdateNudge() } } const decoratedStatus = decorateStatusWithActiveNudge(status) + if ( + status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error' + ) { + clearPublishingWindowLastGoodCheck() + } + // Why: reset the in-flight guard when the status moves past the // window where duplicate download() calls are possible. if ( @@ -296,6 +344,12 @@ async function sendCheckFailureStatus( // actionable cause. sendErrorStatus("Couldn't reach the update server. Try again in a few minutes.", true) } else { + if (isReleaseAssetsPublishingFailure(message)) { + // Why: a nudge-triggered check can land during the brief window where + // GitHub exposes a release before its updater assets are reachable. + // Keep the campaign pending so the short retry can still show it. + deferPendingUpdateNudgeUntilRetry() + } sendStatus({ state: 'idle' }) } return @@ -436,11 +490,15 @@ async function pinDefaultReleaseFeed(): Promise { // newer RC or the next stable. Stable users should only resolve stable tags. const currentVersion = app.getVersion() const includePrerelease = includePrereleaseActive || isPrereleaseVersion(currentVersion) - const releaseTags = await fetchNewerReleaseTags(currentVersion, includePrerelease ? 2 : 1, { - includePrerelease - }) - const newerTag = releaseTags[0] ?? null - const fallbackTag = includePrerelease ? (releaseTags[1] ?? null) : null + const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( + currentVersion, + includePrerelease ? 2 : 1, + { + includePrerelease + } + ) + const newerTag = releaseTagsResult.tags[0] ?? null + const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null pendingPrereleaseFallback = includePrerelease && newerTag && fallbackTag ? { @@ -461,13 +519,33 @@ async function pinDefaultReleaseFeed(): Promise { // the updater on a user's machine when something goes wrong. Cheap to keep, // invaluable when triaging. if (newerTag) { + clearPublishingWindowLastGoodCheck() const url = getReleaseDownloadUrl(newerTag) console.info( `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` ) autoUpdater.setFeedURL({ provider: 'generic', url }) + } else if (releaseTagsResult.state === 'not-ready') { + clearPrereleaseFallbackContext() + if (releaseTagsResult.lastGoodTag) { + // Why: during a publish window the newest tag is unsafe, but a verified + // last-good concrete feed lets electron-updater emit a real result. + const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag) + console.info( + `[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } + autoUpdater.setFeedURL({ provider: 'generic', url }) + return + } + clearPublishingWindowLastGoodCheck() + console.info( + `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are still publishing` + ) + throw new Error('Latest release assets are still publishing') } else { clearPrereleaseFallbackContext() + clearPublishingWindowLastGoodCheck() const url = 'https://github.com/stablyai/orca/releases/latest/download' console.info( `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` @@ -545,14 +623,21 @@ function runBackgroundUpdateCheck( // currentStatus flips to 'checking'. Track the launch in memory to dedupe // that gap without persisting a successful-check timestamp before the result. backgroundCheckLaunchPending = true + backgroundCheckPromotedToUserInitiated = false // Don't send 'checking' here — the 'checking-for-update' event handler does it, // and sending it from both places causes duplicate notifications (issue #35). const autoUpdater = getAutoUpdater() const launch = (): Promise => autoUpdater.checkForUpdates() const run = pinDefaultReleaseFeed().then(launch) void Promise.resolve(run).catch((err) => { + const wasUserInitiated = + userInitiatedCheck || backgroundCheckPromotedToUserInitiated || undefined backgroundCheckLaunchPending = false - void sendCheckFailureStatus(String(err?.message ?? err), undefined, 'promise', err) + backgroundCheckPromotedToUserInitiated = false + if (wasUserInitiated) { + userInitiatedCheck = false + } + void sendCheckFailureStatus(String(err?.message ?? err), wasUserInitiated, 'promise', err) }) } @@ -599,13 +684,20 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean enableIncludePrerelease() } + const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking' userInitiatedCheck = true // Why: a manual check is independent of any active nudge campaign. Reset the // nudge marker so the resulting status is not decorated with activeNudgeId, // which would cause a later dismiss to consume the campaign by accident. activeUpdateNudgeId = null - // Don't send 'checking' here — the 'checking-for-update' event handler does it, - // and sending it from both places causes duplicate notifications (issue #35). + // Why: manual checks should visibly respond before feed pinning or the + // electron-updater event fires; duplicate event broadcasts are suppressed by + // status equality below. + sendStatus({ state: 'checking', userInitiated: true }) + if (checkAlreadyInFlight) { + backgroundCheckPromotedToUserInitiated = true + return + } const autoUpdater = getAutoUpdater() const launch = (): Promise => autoUpdater.checkForUpdates() @@ -796,6 +888,7 @@ export function setupAutoUpdater( clearAvailableUpdateContext, consumeMissingManifestPrereleaseFallbackResult, getMissingManifestPrereleaseFallbackUserInitiated, + getPublishingWindowLastGoodCheck, getCurrentStatus: () => currentStatus, getKnownReleaseUrl, getPendingInstallVersion, diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 8d165a8074f..3e5f1a9dc9c 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -54,6 +54,10 @@ vi.mock('electron', () => ({ removeAllListeners: removeAllListenersMock, removeHandler: removeHandlerMock, handle: handleMock + }, + powerMonitor: { + on: vi.fn(), + off: vi.fn() } })) @@ -167,7 +171,8 @@ describe('attachMainWindowServices', () => { setPermissionRequestHandler: setPermissionRequestHandlerMock, setPermissionCheckHandler: setPermissionCheckHandlerMock, setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, - on: vi.fn() + on: vi.fn(), + removeListener: vi.fn() }) systemPreferencesAskForMediaAccessMock.mockResolvedValue(true) systemPreferencesGetMediaAccessStatusMock.mockReturnValue('granted') @@ -312,7 +317,8 @@ describe('attachMainWindowServices', () => { setPermissionRequestHandler: setPermissionRequestHandlerMock, setPermissionCheckHandler: setPermissionCheckHandlerMock, setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, - on: browserSessionOnMock + on: browserSessionOnMock, + removeListener: vi.fn() }) const mainWindowOnMock = vi.fn() @@ -374,12 +380,38 @@ describe('attachMainWindowServices', () => { }) }) + it('replaces the persistent browser-session download handler on re-attach', () => { + const browserSessionOnMock = vi.fn() + const browserSessionRemoveListenerMock = vi.fn() + sessionFromPartitionMock.mockReturnValue({ + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock, + setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, + on: browserSessionOnMock, + removeListener: browserSessionRemoveListenerMock + }) + + attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never) + attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never) + + const downloadOnCalls = browserSessionOnMock.mock.calls.filter( + ([eventName]) => eventName === 'will-download' + ) + const downloadRemoveCalls = browserSessionRemoveListenerMock.mock.calls.filter( + ([eventName]) => eventName === 'will-download' + ) + expect(downloadOnCalls).toHaveLength(2) + expect(downloadRemoveCalls).toHaveLength(2) + expect(downloadRemoveCalls[1][1]).toBe(downloadOnCalls[0][1]) + }) + it('clears browser guest registrations when the main window closes', () => { sessionFromPartitionMock.mockReturnValue({ setPermissionRequestHandler: setPermissionRequestHandlerMock, setPermissionCheckHandler: setPermissionCheckHandlerMock, setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, - on: vi.fn() + on: vi.fn(), + removeListener: vi.fn() }) const mainWindowOnMock = vi.fn() const mainWindow = createMainWindow() diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 84a2bb658c8..c982d3361a6 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto' import { app, ipcMain, session } from 'electron' -import type { BrowserWindow } from 'electron' +import type { BrowserWindow, Session } from 'electron' import type { Store } from '../persistence' import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' @@ -196,12 +196,7 @@ export function attachMainWindowServices( // signature while still denying the request. callback({ video: undefined, audio: undefined }) }) - browserSession.on('will-download', (_event, item, webContents) => { - // Why: browser-tab downloads need explicit product UX before arbitrary sites - // can write files through Orca. Pause the item and route it through - // BrowserManager so the user must explicitly accept the save path first. - browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) - }) + registerBrowserDownloadHandler(browserSession) mainWindow.on('closed', () => { // Why: parked browser webviews can outlive the visible tab body until the @@ -212,6 +207,24 @@ export function attachMainWindowServices( }) } +function handleBrowserWillDownload( + _event: Electron.Event, + item: Electron.DownloadItem, + webContents: Electron.WebContents +): void { + // Why: browser-tab downloads need explicit product UX before arbitrary sites + // can write files through Orca. Pause the item and route it through + // BrowserManager so the user must explicitly accept the save path first. + browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) +} + +function registerBrowserDownloadHandler(browserSession: Session): void { + // Why: browser sessions are process-persistent while main windows can be + // recreated; replace the named handler so re-attach does not stack listeners. + browserSession.removeListener('will-download', handleBrowserWillDownload) + browserSession.on('will-download', handleBrowserWillDownload) +} + function registerAppReloadHandler( mainWindow: BrowserWindow, onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void diff --git a/src/main/worktree-orphan-gitdir-proof.ts b/src/main/worktree-orphan-gitdir-proof.ts new file mode 100644 index 00000000000..bdde6d9ea69 --- /dev/null +++ b/src/main/worktree-orphan-gitdir-proof.ts @@ -0,0 +1,235 @@ +import { type posix, win32 } from 'path' + +type PathOps = typeof posix +export type StatPath = (path: string) => Promise +export type ReadPath = (path: string) => Promise + +export async function gitFileProvesOrphanedWorktreeDirectory(args: { + gitFilePath: string + worktreePath: string + repoPath: string + pathOps: PathOps + statPath: StatPath + readPath: ReadPath +}): Promise { + try { + const gitEntry = await args.statPath(args.gitFilePath) + if (!isGitFileStat(gitEntry)) { + return false + } + const gitFileContents = readFileResultToText(await args.readPath(args.gitFilePath)) + return gitFileContents + ? gitFilePointsAtRepoWorktree( + gitFileContents, + args.worktreePath, + args.repoPath, + args.pathOps, + args.statPath, + args.readPath + ) + : false + } catch { + return false + } +} + +function isGitFileStat(stat: unknown): boolean { + const fileStat = + stat && typeof stat === 'object' ? (stat as { isFile?: () => boolean; type?: unknown }) : null + return !!fileStat && (fileStat.isFile?.() === true || fileStat.type === 'file') +} + +function readFileResultToText(result: unknown): string | null { + if (typeof result === 'string') { + return result + } + if (Buffer.isBuffer(result)) { + return result.toString('utf8') + } + if (result instanceof Uint8Array) { + return Buffer.from(result).toString('utf8') + } + if (!result || typeof result !== 'object') { + return null + } + const remoteRead = result as { content?: unknown; isBinary?: unknown } + if (remoteRead.isBinary === true || typeof remoteRead.content !== 'string') { + return null + } + return remoteRead.content +} + +function resolveGitdirPath(gitdirPath: string, basePath: string, pathOps: PathOps): string { + return pathOps.isAbsolute(gitdirPath) + ? pathOps.resolve(gitdirPath) + : pathOps.resolve(basePath, gitdirPath) +} + +function areResolvedPathsEqual(leftPath: string, rightPath: string, pathOps: PathOps): boolean { + const left = pathOps.normalize(pathOps.resolve(leftPath)) + const right = pathOps.normalize(pathOps.resolve(rightPath)) + return pathOps === win32 ? left.toLowerCase() === right.toLowerCase() : left === right +} + +async function resolveRepoWorktreesPath( + repoPath: string, + pathOps: PathOps, + statPath: StatPath, + readPath: ReadPath +): Promise { + const repoGitPath = pathOps.join(repoPath, '.git') + try { + const repoGitEntry = await statPath(repoGitPath) + if (!isGitFileStat(repoGitEntry)) { + return pathOps.resolve(repoPath, '.git', 'worktrees') + } + const repoGitContents = readFileResultToText(await readPath(repoGitPath)) + const repoGitdirPath = repoGitContents ? parseGitdirPath(repoGitContents) : null + if (!repoGitdirPath) { + return null + } + const resolvedRepoGitdirPath = resolveGitdirPath(repoGitdirPath, repoPath, pathOps) + const parentPath = pathOps.dirname(resolvedRepoGitdirPath) + if ( + pathOps.basename(parentPath) === 'worktrees' && + (await repoGitdirIsLinkedWorktreeAdminEntry( + resolvedRepoGitdirPath, + repoPath, + pathOps, + readPath + )) + ) { + return parentPath + } + return pathOps.join(resolvedRepoGitdirPath, 'worktrees') + } catch { + return null + } +} + +async function repoGitdirIsLinkedWorktreeAdminEntry( + resolvedRepoGitdirPath: string, + repoPath: string, + pathOps: PathOps, + readPath: ReadPath +): Promise { + const adminGitdirPath = pathOps.join(resolvedRepoGitdirPath, 'gitdir') + try { + const adminGitdirContents = readFileResultToText(await readPath(adminGitdirPath)) + const repoGitPath = adminGitdirContents ? parseFirstLinePath(adminGitdirContents) : null + if (!repoGitPath) { + return false + } + const resolvedRepoGitPath = resolveGitdirPath(repoGitPath, resolvedRepoGitdirPath, pathOps) + const resolvedExpectedGitPath = pathOps.resolve(repoPath, '.git') + // Why: a separate git dir can live under a directory named `worktrees`; + // only the admin backlink proves the repo path is itself a linked worktree. + return areResolvedPathsEqual(resolvedRepoGitPath, resolvedExpectedGitPath, pathOps) + } catch { + return false + } +} + +async function gitFilePointsAtRepoWorktree( + contents: string, + worktreePath: string, + repoPath: string, + pathOps: PathOps, + statPath: StatPath, + readPath: ReadPath +): Promise { + const gitdirPath = parseGitdirPath(contents) + if (!gitdirPath) { + return false + } + const resolvedGitdirPath = resolveGitdirPath(gitdirPath, worktreePath, pathOps) + const repoWorktreesPath = await resolveRepoWorktreesPath(repoPath, pathOps, statPath, readPath) + if (!repoWorktreesPath || !containsPath(repoWorktreesPath, resolvedGitdirPath, pathOps)) { + return false + } + const relativeGitdirPath = pathOps.relative(repoWorktreesPath, resolvedGitdirPath) + if (relativeGitdirPath === '' || relativeGitdirPath.includes(pathOps.sep)) { + return false + } + return adminGitdirPointsAtCandidate(resolvedGitdirPath, worktreePath, pathOps, statPath, readPath) +} + +async function adminGitdirPointsAtCandidate( + resolvedGitdirPath: string, + worktreePath: string, + pathOps: PathOps, + statPath: StatPath, + readPath: ReadPath +): Promise { + const adminGitdirPath = pathOps.join(resolvedGitdirPath, 'gitdir') + try { + const adminGitdirContents = readFileResultToText(await readPath(adminGitdirPath)) + const candidateGitPath = adminGitdirContents ? parseFirstLinePath(adminGitdirContents) : null + if (!candidateGitPath) { + return false + } + const resolvedCandidateGitPath = resolveGitdirPath( + candidateGitPath, + resolvedGitdirPath, + pathOps + ) + const resolvedExpectedGitPath = pathOps.resolve(worktreePath, '.git') + // Why: copied .git files can target another worktree's admin entry; only + // Git's back-reference proves that entry still belongs to this candidate. + return areResolvedPathsEqual(resolvedCandidateGitPath, resolvedExpectedGitPath, pathOps) + } catch (error) { + if (!isMissingPathError(error)) { + return false + } + try { + await statPath(resolvedGitdirPath) + return false + } catch (statError) { + return isMissingPathError(statError) + } + } +} + +function containsPath(parentPath: string, childPath: string, pathOps: PathOps): boolean { + const relativePath = pathOps.relative(parentPath, childPath) + return ( + relativePath === '' || + (!!relativePath && !relativePath.startsWith('..') && !pathOps.isAbsolute(relativePath)) + ) +} + +function parseGitdirPath(contents: string): string | null { + const firstLine = contents.split(/\r?\n/, 1)[0]?.trim() + if (!firstLine) { + return null + } + const match = /^gitdir:\s*(.+)$/i.exec(firstLine) + const gitdirPath = match?.[1]?.trim() + return gitdirPath || null +} + +function parseFirstLinePath(contents: string): string | null { + const firstLine = contents.split(/\r?\n/, 1)[0]?.trim() + return firstLine || null +} + +function isMissingPathError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as NodeJS.ErrnoException).code) + : undefined + if (code === 'ENOENT' || code === 'ENOTDIR') { + return true + } + const message = + error instanceof Error + ? error.message + : error && typeof error === 'object' && 'message' in error + ? String((error as { message: unknown }).message) + : typeof error === 'string' + ? error + : '' + return /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( + message + ) +} diff --git a/src/main/worktree-removal-authority.test.ts b/src/main/worktree-removal-authority.test.ts new file mode 100644 index 00000000000..551edec5376 --- /dev/null +++ b/src/main/worktree-removal-authority.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { + canCleanupUnregisteredOrcaWorktreeDirectory, + isWorktreePathMissing, + stripOrcaProvenanceMetaUpdates +} from './worktree-removal-safety' + +describe('isWorktreePathMissing', () => { + it('recognizes missing-path errors from local and remote stat providers', async () => { + await expect( + isWorktreePathMissing('/missing', async () => { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }) + ).resolves.toBe(true) + + await expect( + isWorktreePathMissing('/missing', () => Promise.reject({ code: 'ENOTDIR' })) + ).resolves.toBe(true) + }) + + it('does not classify existing paths or unrelated stat failures as missing', async () => { + await expect(isWorktreePathMissing('/exists', async () => ({}))).resolves.toBe(false) + + await expect( + isWorktreePathMissing('/unknown', async () => { + throw new Error('permission denied') + }) + ).resolves.toBe(false) + }) +}) + +describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { + it('does not treat orcaCreatedAt alone as cleanup authority', () => { + expect( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: { orcaCreatedAt: Date.now() }, + worktreePath: '/outside/orphan', + repo: { path: '/repo' }, + knownOrcaLayouts: [] + }) + ).toBe(false) + expect( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: { + orcaCreatedAt: Date.now(), + orcaCreationSource: 'runtime' + }, + worktreePath: '/outside/orphan', + repo: { path: '/repo' }, + knownOrcaLayouts: [] + }) + ).toBe(true) + }) + + it('accepts legacy Orca-created metadata before explicit provenance existed', () => { + expect( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: { createdAt: Date.now() }, + worktreePath: '/outside/orphan', + repo: { path: '/repo' }, + knownOrcaLayouts: [] + }) + ).toBe(true) + }) + + it('accepts legacy repo-nested Orca workspace paths without metadata provenance', () => { + expect( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: undefined, + worktreePath: '/orca/workspaces/app/legacy-orphan', + repo: { path: '/repos/app' }, + knownOrcaLayouts: [{ path: '/orca/workspaces', nestWorkspaces: true }] + }) + ).toBe(true) + }) + + it('does not trust flat workspace-root paths without legacy metadata', () => { + expect( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: undefined, + worktreePath: '/orca/workspaces/legacy-orphan', + repo: { path: '/repos/app' }, + knownOrcaLayouts: [{ path: '/orca/workspaces', nestWorkspaces: false }] + }) + ).toBe(false) + }) +}) + +describe('stripOrcaProvenanceMetaUpdates', () => { + it('removes Orca-owned provenance fields from user metadata updates', () => { + expect( + stripOrcaProvenanceMetaUpdates({ + comment: 'keep me', + orcaCreatedAt: 123, + orcaCreationSource: 'desktop', + orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false } + }) + ).toEqual({ comment: 'keep me' }) + }) +}) diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index bde0f06acf7..cbecf00f052 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { GitWorktreeInfo } from '../shared/types' -import { getRegisteredDeletableWorktree, isWorktreePathMissing } from './worktree-removal-safety' +import { + canSafelyRemoveOrphanedWorktreeDirectory, + getRegisteredDeletableWorktree +} from './worktree-removal-safety' function makeGitWorktree(path: string, isMainWorktree = false): GitWorktreeInfo { return { @@ -12,6 +15,49 @@ function makeGitWorktree(path: string, isMainWorktree = false): GitWorktreeInfo } } +function missingPath(path: string): Error & { code: string } { + return Object.assign(new Error(`missing ${path}`), { code: 'ENOENT' }) +} + +function makeStatPath(filePaths: readonly string[], directoryPaths: readonly string[] = []) { + const files = new Set(filePaths) + const directories = new Set(directoryPaths) + return async (path: string) => { + if (files.has(path)) { + return { type: 'file' } + } + if (directories.has(path)) { + return { type: 'directory' } + } + throw missingPath(path) + } +} + +function makeReadPath(entries: readonly (readonly [string, unknown])[]) { + const files = new Map(entries) + return async (path: string) => { + if (!files.has(path)) { + throw missingPath(path) + } + return files.get(path) + } +} + +async function withProcessPlatform( + platform: NodeJS.Platform, + callback: () => Promise +): Promise { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: platform }) + try { + return await callback() + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } +} + describe('getRegisteredDeletableWorktree', () => { it('rejects deleting a worktree that contains another registered worktree', () => { expect(() => @@ -36,26 +82,194 @@ describe('getRegisteredDeletableWorktree', () => { }) }) -describe('isWorktreePathMissing', () => { - it('recognizes missing-path errors from local and remote stat providers', async () => { +describe('canSafelyRemoveOrphanedWorktreeDirectory', () => { + it('accepts a linked worktree .git file that points at this repo worktrees entry', async () => { await expect( - isWorktreePathMissing('/missing', async () => { - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }) - ).resolves.toBe(true) - - await expect( - isWorktreePathMissing('/missing', () => Promise.reject({ code: 'ENOTDIR' })) + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repo', + makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']), + makeReadPath([ + ['/workspaces/orphan/.git', 'gitdir: /repo/.git/worktrees/orphan\n'], + ['/repo/.git/worktrees/orphan/gitdir', '/workspaces/orphan/.git\n'] + ]) + ) ).resolves.toBe(true) }) - it('does not classify existing paths or unrelated stat failures as missing', async () => { - await expect(isWorktreePathMissing('/exists', async () => ({}))).resolves.toBe(false) + it('accepts remote filesystem provider readFile results for linked worktree .git files', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repo', + makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']), + makeReadPath([ + [ + '/workspaces/orphan/.git', + { isBinary: false, content: 'gitdir: /repo/.git/worktrees/orphan\n' } + ], + [ + '/repo/.git/worktrees/orphan/gitdir', + { isBinary: false, content: '/workspaces/orphan/.git\n' } + ] + ]) + ) + ).resolves.toBe(true) + }) + + it('preserves forward-slash UNC roots when probing linked worktree .git files', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '//Server/Share/orphan', + '//Server/Repo', + makeStatPath(['\\\\Server\\Share\\orphan\\.git'], ['\\\\Server\\Repo\\.git']), + makeReadPath([ + ['\\\\Server\\Share\\orphan\\.git', 'gitdir: //Server/Repo/.git/worktrees/orphan\n'], + ['\\\\Server\\Repo\\.git\\worktrees\\orphan\\gitdir', '//Server/Share/orphan/.git\n'] + ]) + ) + ).resolves.toBe(true) + }) + + it('rejects a plain .git directory for unregistered cleanup', async () => { + const readPath = vi.fn() await expect( - isWorktreePathMissing('/unknown', async () => { - throw new Error('permission denied') - }) + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repo', + async () => ({ type: 'directory' }), + readPath + ) + ).resolves.toBe(false) + + expect(readPath).not.toHaveBeenCalled() + }) + + it('rejects a gitdir file that points outside this repo worktrees directory', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repo', + makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']), + makeReadPath([ + [ + '/workspaces/orphan/.git', + { isBinary: false, content: 'gitdir: /other/.git/worktrees/orphan\n' } + ] + ]) + ) ).resolves.toBe(false) }) + + it('rejects a copied .git file when the admin entry points at another candidate path', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/reused', + '/repo', + makeStatPath(['/workspaces/reused/.git'], ['/repo/.git']), + makeReadPath([ + ['/workspaces/reused/.git', 'gitdir: /repo/.git/worktrees/other\n'], + ['/repo/.git/worktrees/other/gitdir', '/workspaces/other/.git\n'] + ]) + ) + ).resolves.toBe(false) + }) + + it('rejects POSIX admin backlinks that differ only by case', async () => { + await withProcessPlatform('win32', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/reused', + '/repo', + makeStatPath(['/workspaces/reused/.git'], ['/repo/.git']), + makeReadPath([ + ['/workspaces/reused/.git', 'gitdir: /repo/.git/worktrees/reused\n'], + ['/repo/.git/worktrees/reused/gitdir', '/workspaces/Reused/.git\n'] + ]) + ) + ).resolves.toBe(false) + }) + }) + + it('accepts a pruned admin entry when the candidate .git points under this repo worktrees dir', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repo', + makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']), + makeReadPath([['/workspaces/orphan/.git', 'gitdir: /repo/.git/worktrees/orphan\n']]) + ) + ).resolves.toBe(true) + }) + + it('rejects existing admin entries with a missing gitdir backlink', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repo', + makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git', '/repo/.git/worktrees/orphan']), + makeReadPath([['/workspaces/orphan/.git', 'gitdir: /repo/.git/worktrees/orphan\n']]) + ) + ).resolves.toBe(false) + }) + + it('rejects symlink .git entries from remote lstat-shaped providers', async () => { + const readPath = vi.fn() + + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repo', + async () => ({ type: 'symlink' }), + readPath + ) + ).resolves.toBe(false) + + expect(readPath).not.toHaveBeenCalled() + }) + + it('rejects separate-git-dir sibling repos when the admin gitdir is missing', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repo', + makeStatPath(['/workspaces/orphan/.git', '/repo/.git']), + makeReadPath([ + ['/workspaces/orphan/.git', 'gitdir: /git/other.git\n'], + ['/repo/.git', 'gitdir: /git/repo.git\n'] + ]) + ) + ).resolves.toBe(false) + }) + + it('rejects separate git dirs under worktrees when the admin gitdir is missing', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/reused', + '/repo', + makeStatPath(['/workspaces/reused/.git', '/repo/.git']), + makeReadPath([ + ['/workspaces/reused/.git', 'gitdir: /git/worktrees/other.git\n'], + ['/repo/.git', 'gitdir: /git/worktrees/repo.git\n'] + ]) + ) + ).resolves.toBe(false) + }) + + it('accepts a repo path that is itself a linked worktree', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repos/main-linked', + makeStatPath(['/workspaces/orphan/.git', '/repos/main-linked/.git']), + makeReadPath([ + ['/workspaces/orphan/.git', 'gitdir: /common/.git/worktrees/orphan\n'], + ['/repos/main-linked/.git', 'gitdir: /common/.git/worktrees/main-linked\n'], + ['/common/.git/worktrees/main-linked/gitdir', '/repos/main-linked/.git\n'], + ['/common/.git/worktrees/orphan/gitdir', '/workspaces/orphan/.git\n'] + ]) + ) + ).resolves.toBe(true) + }) }) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index b131aa2764d..5fcd962fc8a 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -1,17 +1,47 @@ -import { lstat } from 'fs/promises' +import { lstat, readFile } from 'fs/promises' import { homedir } from 'os' import { posix, win32 } from 'path' -import type { GitWorktreeInfo } from '../shared/types' +import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' +import type { GitWorktreeInfo, OrcaWorkspaceLayout, Repo, WorktreeMeta } from '../shared/types' +import { matchesStrongOrcaCreatePath } from '../shared/worktree-ownership' import { areWorktreePathsEqual } from './ipc/worktree-logic' +import { + gitFileProvesOrphanedWorktreeDirectory, + type ReadPath, + type StatPath +} from './worktree-orphan-gitdir-proof' type PathOps = typeof posix -function looksLikeWindowsPath(pathValue: string): boolean { - return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') -} +const ORCA_CREATION_SOURCES = new Set>([ + 'desktop', + 'runtime', + 'cli', + 'ssh' +]) +const ORCA_OWNED_PROVENANCE_META_KEYS = [ + 'orcaCreatedAt', + 'orcaCreationSource', + 'orcaCreationWorkspaceLayout' +] as const +type UnregisteredOrcaCleanupMeta = Pick< + WorktreeMeta, + | 'orcaCreatedAt' + | 'orcaCreationSource' + | 'createdAt' + | 'createdWithAgent' + | 'pushTarget' + | 'sparseBaseRef' + | 'sparsePresetId' + | 'preserveBranchOnDelete' +> + +export const ORPHANED_WORKTREE_DIRECTORY_MESSAGE = + 'Worktree is no longer registered with Git but its directory remains.' function getPathOps(...paths: string[]): PathOps { - return paths.some(looksLikeWindowsPath) ? win32 : posix + // Why: forward-slash UNC roots need win32 ops; POSIX joins collapse `//Server` to `/Server`. + return paths.some(isWindowsAbsolutePathLike) ? win32 : posix } function containsPath(parentPath: string, childPath: string, pathOps: PathOps): boolean { @@ -97,18 +127,76 @@ export function assertWorktreeDoesNotContainRegisteredWorktree( export async function canSafelyRemoveOrphanedWorktreeDirectory( worktreePath: string, - repoPath: string + repoPath: string, + statPath: StatPath = lstat, + readPath: ReadPath = (path) => readFile(path, 'utf8') ): Promise { if (isDangerousWorktreeRemovalPath(worktreePath, repoPath)) { return false } - try { - const gitEntry = await lstat(getPathOps(worktreePath).join(worktreePath, '.git')) - return gitEntry.isFile() || gitEntry.isDirectory() || gitEntry.isSymbolicLink() - } catch { - return false + const pathOps = getPathOps(worktreePath, repoPath) + const gitFilePath = pathOps.join(worktreePath, '.git') + return gitFileProvesOrphanedWorktreeDirectory({ + gitFilePath, + worktreePath, + repoPath, + pathOps, + statPath, + readPath + }) +} + +export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { + meta: UnregisteredOrcaCleanupMeta | null | undefined + worktreePath: string + repo: Pick + knownOrcaLayouts: readonly OrcaWorkspaceLayout[] +}): boolean { + if (hasCurrentOrcaCreationProvenance(args.meta)) { + return true } + + if (hasLegacyOrcaCreationEvidence(args.meta)) { + return true + } + + // Why: profiles created before explicit provenance can still contain Orca + // workspaces at the repo-specific workspaceDir// path shape. + return matchesStrongOrcaCreatePath(args.worktreePath, args.knownOrcaLayouts, args.repo) +} + +function hasCurrentOrcaCreationProvenance( + meta: Pick | null | undefined +): boolean { + return ( + typeof meta?.orcaCreatedAt === 'number' && + !!meta.orcaCreationSource && + ORCA_CREATION_SOURCES.has(meta.orcaCreationSource) + ) +} + +function hasLegacyOrcaCreationEvidence( + meta: UnregisteredOrcaCleanupMeta | null | undefined +): boolean { + return Boolean( + meta?.createdAt || + meta?.createdWithAgent || + meta?.pushTarget || + meta?.sparseBaseRef || + meta?.sparsePresetId || + meta?.preserveBranchOnDelete + ) +} + +export function stripOrcaProvenanceMetaUpdates( + updates: Partial | null | undefined +): Partial { + const sanitized = { ...updates } + for (const key of ORCA_OWNED_PROVENANCE_META_KEYS) { + delete sanitized[key] + } + return sanitized } function isMissingPathError(error: unknown): boolean { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f0383056c06..d0a63a30cc3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -90,9 +90,13 @@ import type { PRInfo, PRRefreshOutcome, Repo, + ProjectGroup, + ProjectGroupImportResult, + ProjectGroupImportMode, ShellHydrationFailureReason, SparsePreset, SearchOptions, + NestedRepoScanResult, SearchResult, StatsSummary, MemorySnapshot, @@ -206,6 +210,7 @@ import type { } from '../shared/developer-permissions-types' import type { ComputerUsePermissionId, + ComputerUsePermissionResetResult, ComputerUsePermissionSetupResult, ComputerUsePermissionStatusResult } from '../shared/computer-use-permissions-types' @@ -328,7 +333,7 @@ export type BrowserApi = { onNavigationUpdate: ( callback: (event: { browserPageId: string; url: string; title: string }) => void ) => () => void - onActivateView: (callback: (data: { worktreeId: string }) => void) => () => void + onActivateView: (callback: (data: { worktreeId?: string }) => void) => () => void onPaneFocus: ( callback: (data: { worktreeId: string | null; browserPageId: string }) => void ) => () => void @@ -577,6 +582,9 @@ export type AppApi = { * by settings panes that need a full restart to apply changes (e.g. the * terminal-window blur setting in TerminalWindowSection). */ relaunch: () => Promise + /** Restarts Orca through the normal quit pipeline so daemon-backed terminal + * sessions survive and can reattach after the new process starts. */ + restart: () => Promise /** Reloads the current app renderer through main so expected renderer * teardown can be classified before Electron emits process-gone events. */ reload: () => Promise @@ -629,6 +637,9 @@ export type PreloadApi = { | 'issueSourcePreference' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'projectGroupId' + | 'projectGroupOrder' + | 'sourceControlAi' > > }) => Promise @@ -660,6 +671,37 @@ export type PreloadApi = { }) => Promise onChanged: (callback: () => void) => () => void } + projectGroups: { + list: () => Promise + create: (args: { + name: string + parentPath?: string | null + parentGroupId?: string | null + createdFrom?: ProjectGroup['createdFrom'] + }) => Promise + update: (args: { + groupId: string + updates: Partial> + }) => Promise + delete: (args: { groupId: string }) => Promise + moveProject: (args: { + projectId: string + groupId: string | null + order?: number + }) => Promise + scanNested: (args: { + path: string + connectionId?: string + options?: Record + }) => Promise + importNested: (args: { + parentPath: string + groupName: string + projectPaths: string[] + connectionId?: string + mode: ProjectGroupImportMode + }) => Promise + } sparsePresets: { list: (args: { repoId: string }) => Promise save: (args: { @@ -772,7 +814,13 @@ export type PreloadApi = { getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> - onData: (callback: (data: { id: string; data: string }) => void) => () => void + getMainBufferSnapshot: ( + id: string, + opts?: { scrollbackRows?: number } + ) => Promise<{ data: string; cols: number; rows: number; seq?: number } | null> + onData: ( + callback: (data: { id: string; data: string; seq?: number; rawLength?: number }) => void + ) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void onExit: (callback: (data: { id: string; code: number }) => void) => () => void onSerializeBufferRequest: ( @@ -1184,6 +1232,10 @@ export type PreloadApi = { workspaceId?: string parentIssueId?: string projectId?: string | null + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] }) => Promise< | { ok: true; id: string; identifier: string; title: string; url: string } | { ok: false; error: string } @@ -1303,6 +1355,7 @@ export type PreloadApi = { antigravityStatus: () => Promise cursorStatus: () => Promise droidStatus: () => Promise + commandCodeStatus: () => Promise grokStatus: () => Promise copilotStatus: () => Promise hermesStatus: () => Promise @@ -1342,6 +1395,7 @@ export type PreloadApi = { openSetup: (args?: { id?: ComputerUsePermissionId }) => Promise + reset: () => Promise } shell: { openPath: (path: string) => Promise @@ -1559,6 +1613,7 @@ export type PreloadApi = { worktreePath: string connectionId?: string }) => Promise + abortMerge: (args: { worktreePath: string; connectionId?: string }) => Promise diff: (args: { worktreePath: string filePath: string @@ -1630,6 +1685,7 @@ export type PreloadApi = { }) => Promise<{ success: boolean; error?: string }> generateCommitMessage: (args: { worktreePath: string + repoId?: string connectionId?: string }) => Promise< | { success: true; message: string; agentLabel?: string } @@ -1654,6 +1710,7 @@ export type PreloadApi = { }) => Promise generatePullRequestFields: (args: { worktreePath: string + repoId?: string base: string title: string body: string diff --git a/src/preload/index.ts b/src/preload/index.ts index ec42351047d..d40637e19f1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -140,6 +140,8 @@ import { type EditorSaveDirtyFilesDetail } from '../shared/editor-save-events' import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' @@ -169,6 +171,62 @@ type NativeFileDropCallback = (data: NativeFileDropPayload) => void const nativeFileDropCallbacks: NativeFileDropCallback[] = [] let nativeFileDropListenerRegistered = false +type AppRestartPrepOptions = { + startedEventName: string + abortedEventName: string + continueOnSaveFailure: boolean + saveFailureLogPrefix: string +} + +function requestDirtyEditorFileSave(): Promise { + return new Promise((resolve, reject) => { + let claimed = false + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, { + detail: { + claim: () => { + claimed = true + }, + resolve, + reject: (message) => { + reject(new Error(message)) + } + } + }) + ) + + // Why: restart paths can run when no editor surface is mounted. When + // nothing claims the request there are no in-memory editor buffers to + // flush, so proceed with the normal shutdown path immediately. + if (!claimed) { + resolve() + } + }) +} + +async function prepareRendererForAppRestart({ + startedEventName, + abortedEventName, + continueOnSaveFailure, + saveFailureLogPrefix +}: AppRestartPrepOptions): Promise { + window.dispatchEvent(new Event(startedEventName)) + + try { + await requestDirtyEditorFileSave() + } catch (error) { + if (!continueOnSaveFailure) { + window.dispatchEvent(new Event(abortedEventName)) + throw error + } + console.warn(saveFailureLogPrefix, error) + } + + // Dispatch beforeunload now so terminal buffers are captured while panes are + // still mounted; update installs later bypass the ordinary close sequence. + window.dispatchEvent(new Event('beforeunload')) +} + const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { for (const callback of Array.from(nativeFileDropCallbacks)) { callback(data) @@ -358,6 +416,20 @@ const api = { getFeatureWallAssetBaseUrl: (): Promise => ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), + restart: async (): Promise => { + await prepareRendererForAppRestart({ + startedEventName: ORCA_APP_RESTART_STARTED_EVENT, + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + continueOnSaveFailure: false, + saveFailureLogPrefix: '[app-restart] Saving dirty files before restart failed:' + }) + try { + return await ipcRenderer.invoke('app:restart') + } catch (error) { + window.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + throw error + } + }, reload: (): Promise => ipcRenderer.invoke('app:reload'), // Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so // the renderer's keyboard-layout probe can distinguish Polish Pro / US @@ -455,6 +527,37 @@ const api = { } }, + projectGroups: { + list: (): Promise => ipcRenderer.invoke('projectGroups:list'), + create: (args: { + name: string + parentPath?: string | null + parentGroupId?: string | null + createdFrom?: 'manual' | 'folder-scan' | 'migration' + }): Promise => ipcRenderer.invoke('projectGroups:create', args), + update: (args: { groupId: string; updates: Record }): Promise => + ipcRenderer.invoke('projectGroups:update', args), + delete: (args: { groupId: string }): Promise => + ipcRenderer.invoke('projectGroups:delete', args), + moveProject: (args: { + projectId: string + groupId: string | null + order?: number + }): Promise => ipcRenderer.invoke('projectGroups:moveProject', args), + scanNested: (args: { + path: string + connectionId?: string + options?: Record + }): Promise => ipcRenderer.invoke('projectGroups:scanNested', args), + importNested: (args: { + parentPath: string + groupName: string + projectPaths: string[] + connectionId?: string + mode: 'group' | 'separate' + }): Promise => ipcRenderer.invoke('projectGroups:importNested', args) + }, + sparsePresets: { list: (args: { repoId: string }): Promise => ipcRenderer.invoke('sparsePresets:list', args), @@ -663,6 +766,12 @@ const api = { listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> => ipcRenderer.invoke('pty:listSessions'), + getMainBufferSnapshot: ( + id: string, + opts?: { scrollbackRows?: number } + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> => + ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), + /** Check if a PTY's shell has child processes (e.g. a running command). * Returns false for an idle shell prompt. */ hasChildProcesses: (id: string): Promise => @@ -676,9 +785,13 @@ const api = { * Returns `''` when the id is unknown or the platform cannot resolve one. */ getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), - onData: (callback: (data: { id: string; data: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => - callback(data) + onData: ( + callback: (data: { id: string; data: string; seq?: number; rawLength?: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { id: string; data: string; seq?: number; rawLength?: number } + ) => callback(data) ipcRenderer.on('pty:data', listener) return () => ipcRenderer.removeListener('pty:data', listener) }, @@ -1151,6 +1264,10 @@ const api = { workspaceId?: string parentIssueId?: string projectId?: string | null + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] }): Promise< | { ok: true; id: string; identifier: string; title: string; url: string } | { ok: false; error: string } @@ -1327,6 +1444,8 @@ const api = { ipcRenderer.invoke('agentHooks:cursorStatus'), droidStatus: (): Promise => ipcRenderer.invoke('agentHooks:droidStatus'), + commandCodeStatus: (): Promise => + ipcRenderer.invoke('agentHooks:commandCodeStatus'), grokStatus: (): Promise => ipcRenderer.invoke('agentHooks:grokStatus'), copilotStatus: (): Promise => ipcRenderer.invoke('agentHooks:copilotStatus'), @@ -1468,7 +1587,8 @@ const api = { computerUsePermissions: { getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), openSetup: (args?: { id?: string }): Promise => - ipcRenderer.invoke('computerUsePermissions:openSetup', args) + ipcRenderer.invoke('computerUsePermissions:openSetup', args), + reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') }, shell: { @@ -1699,8 +1819,8 @@ const api = { return () => ipcRenderer.removeListener('browser:navigation-update', listener) }, - onActivateView: (callback: (data: { worktreeId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + onActivateView: (callback: (data: { worktreeId?: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId?: string }) => callback(data) ipcRenderer.on('browser:activateView', listener) return () => ipcRenderer.removeListener('browser:activateView', listener) @@ -1885,53 +2005,16 @@ const api = { download: (): Promise => ipcRenderer.invoke('updater:download'), dismissNudge: (): Promise => ipcRenderer.invoke('updater:dismissNudge'), quitAndInstall: async (): Promise => { - // Why: quitAndInstall closes the BrowserWindow directly from the main - // process. Renderer beforeunload guards treat that like a normal window - // close unless we mark the updater path explicitly, and #300 introduced - // longer-lived editor dirty/autosave state that can otherwise veto the - // restart even after the update payload has been downloaded. - window.dispatchEvent(new Event(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT)) - - // Why: we wrap the save attempt in try/catch so that a save failure - // (e.g., unsupported dirty files or a write error) never silently - // prevents the update from installing. The user already clicked - // "install update" — proceeding with the restart is better than - // leaving them stuck with no feedback. - try { - await new Promise((resolve, reject) => { - let claimed = false - window.dispatchEvent( - new CustomEvent(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, { - detail: { - claim: () => { - claimed = true - }, - resolve, - reject: (message) => { - reject(new Error(message)) - } - } - }) - ) - - // Why: updater installs can run when no editor surface is mounted. - // When nothing claims the request there are no in-memory editor buffers - // to flush, so proceed with the normal shutdown path immediately. - if (!claimed) { - resolve() - } - }) - } catch (error) { - console.warn( - '[updater] Saving dirty files before quit failed; proceeding with install anyway:', - error - ) - } - - // Dispatch beforeunload to trigger terminal buffer capture before the - // update process bypasses the normal window close sequence (quitAndInstall - // removes close listeners, preventing beforeunload from firing naturally). - window.dispatchEvent(new Event('beforeunload')) + // Why: update installs must proceed even when a dirty-file auto-save + // fails; otherwise a downloaded update can get stuck behind hidden editor + // state. Manual app restart uses the same prep but aborts on save failure. + await prepareRendererForAppRestart({ + startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, + abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, + continueOnSaveFailure: true, + saveFailureLogPrefix: + '[updater] Saving dirty files before quit failed; proceeding with install anyway:' + }) try { return await ipcRenderer.invoke('updater:quitAndInstall') } catch (error) { @@ -2113,6 +2196,8 @@ const api = { ): Promise => ipcRenderer.invoke('git:history', args), conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:conflictOperation', args), + abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:abortMerge', args), diff: (args: { worktreePath: string filePath: string diff --git a/src/relay/agent-hook-integration.test.ts b/src/relay/agent-hook-integration.test.ts index 8b9df7feeae..5bdb80071f6 100644 --- a/src/relay/agent-hook-integration.test.ts +++ b/src/relay/agent-hook-integration.test.ts @@ -212,6 +212,65 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot ]) }) + it('clears remote Claude permission when approved PostToolUse matches the preceding tool use id', async () => { + const { port, token } = hookServer.getCoordinates() + const postClaude = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: `tab-7:${LEAF_7}`, + tabId: 'tab-7', + worktreeId: 'wt-7', + env: 'remote', + version: '1', + payload + }) + }) + + await expect( + postClaude({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }, + tool_use_id: 'toolu-approved-remote-post' + }) + ).resolves.toMatchObject({ status: 204 }) + await expect( + postClaude({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' } + }) + ).resolves.toMatchObject({ status: 204 }) + await expect( + postClaude({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }, + tool_use_id: 'toolu-approved-remote-post' + }) + ).resolves.toMatchObject({ status: 204 }) + + const start = Date.now() + while (orcaServer.getStatusSnapshot()[0]?.state !== 'working' && Date.now() - start < 1500) { + await new Promise((r) => setImmediate(r)) + } + expect(orcaServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: `tab-7:${LEAF_7}`, + connectionId: 'conn-test', + state: 'working', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'rm -rf /tmp/orca-2824-permission-target' + }) + ]) + }) + it('replays the cached last-status on agent_hook.requestReplay', async () => { // Why: register the listener BEFORE the initial POST so live notifications // are observed. setListener on a non-empty cache replays cached entries diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index d8f2f9077ef..ceb3432e4a4 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -52,6 +52,15 @@ export type StreamMetadata = { empty?: boolean } +type StreamChunkReader = { + read( + buffer: Buffer, + offset: number, + length: number, + position: number + ): Promise<{ bytesRead: number }> +} + export async function readRelayFileStreamMetadata( filePath: string, dispatcher: RelayDispatcher, @@ -142,11 +151,11 @@ async function pumpChunks( break } const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) - const { bytesRead } = await entry.handle.read(buffer, 0, want, offset) - if (bytesRead === 0) { + const bytesRead = await readFullStreamChunk(entry.handle, buffer, want, offset) + if (bytesRead !== want) { endReason = 'error' errorCode = 'ESTREAMTRUNCATED' - errorMessage = `File truncated mid-stream: expected ${totalSize}, got ${offset}` + errorMessage = `File truncated mid-stream: expected ${totalSize}, got ${offset + bytesRead}` break } if (context.isStale()) { @@ -202,6 +211,30 @@ async function pumpChunks( } } +// Why: fs.read() may return fewer bytes than requested before EOF. Fill each +// protocol chunk so strict clients reject corruption, not valid short reads. +async function readFullStreamChunk( + handle: StreamChunkReader, + buffer: Buffer, + length: number, + offset: number +): Promise { + let totalRead = 0 + while (totalRead < length) { + const { bytesRead } = await handle.read( + buffer, + totalRead, + length - totalRead, + offset + totalRead + ) + if (bytesRead === 0) { + break + } + totalRead += bytesRead + } + return totalRead +} + export function isTooManyStreamsError(err: unknown): err is TooManyStreamsError { return err instanceof Error && (err as { code?: number }).code === RelayErrorCode.TooManyStreams } diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index f9d784905f7..d1f815cf77c 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { RelayContext } from './context' import type { RelayDispatcher } from './dispatcher' +import { STREAM_CHUNK_SIZE } from './protocol' import * as fs from 'fs/promises' import * as path from 'path' import { mkdtempSync, writeFileSync } from 'fs' @@ -134,6 +135,50 @@ describe('FsHandler readFileStream', () => { expect(reassembled.equals(content)).toBe(true) }) + it('fills protocol chunks when fs.read returns short before EOF', async () => { + const filePath = path.join(tmpDir, 'short-read.png') + const content = Buffer.alloc(STREAM_CHUNK_SIZE + 17, 0x42) + writeFileSync(filePath, content) + + const sampleHandle = await fs.open(filePath, 'r') + const fileHandlePrototype = Object.getPrototypeOf(sampleHandle) as { + read: typeof sampleHandle.read + } + await sampleHandle.close() + + type PositionedRead = ( + this: typeof sampleHandle, + buffer: Buffer, + offset: number | null, + length: number | null, + position: number | null + ) => Promise<{ bytesRead: number; buffer: Buffer }> + const originalRead = fileHandlePrototype.read as unknown as PositionedRead + const readSpy = vi.spyOn(fileHandlePrototype, 'read').mockImplementation(async function ( + this: typeof sampleHandle, + buffer: Buffer, + offset: number | null = 0, + length: number | null = buffer.byteLength, + position: number | null = null + ) { + const readLength = position === 0 && length !== null ? Math.min(length, 5) : length + return originalRead.call(this, buffer, offset, readLength, position) + } as typeof sampleHandle.read) + try { + await dispatcher.callRequest('fs.readFileStream', { filePath }, { isStale: () => false }) + await waitFor(() => collectStream(dispatcher).end !== null) + } finally { + readSpy.mockRestore() + } + + const { chunks, end, err } = collectStream(dispatcher) + expect(err).toBeNull() + expect(end).not.toBeNull() + expect(chunks.map((c) => Buffer.from(c.data, 'base64').length)).toEqual([STREAM_CHUNK_SIZE, 17]) + const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) + expect(reassembled.equals(content)).toBe(true) + }) + it('returns empty:true for 0-byte files without opening a handle', async () => { const filePath = path.join(tmpDir, 'empty.txt') writeFileSync(filePath, '') diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index d3380032697..2670569cd6c 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -132,6 +132,7 @@ describe('FsHandler', () => { expect(methods).toContain('fs.createDir') expect(methods).toContain('fs.createDirNoClobber') expect(methods).toContain('fs.rename') + expect(methods).toContain('fs.renameNoClobber') expect(methods).toContain('fs.copy') expect(methods).toContain('fs.realpath') expect(methods).toContain('fs.search') @@ -287,6 +288,19 @@ describe('FsHandler', () => { expect(result.type).toBe('directory') }) + it('lstat returns symlink type without following links', async () => { + const targetFile = path.join(tmpDir, 'target.txt') + const linkPath = path.join(tmpDir, 'link.txt') + writeFileSync(targetFile, 'target') + symlinkSync(targetFile, linkPath) + + const result = (await dispatcher.callRequest('fs.lstat', { filePath: linkPath })) as { + type: string + } + + expect(result.type).toBe('symlink') + }) + it('workspaceSpaceScan returns bounded top-level size details', async () => { mkdirSync(path.join(tmpDir, 'node_modules')) writeFileSync(path.join(tmpDir, 'node_modules', 'pkg.js'), Buffer.alloc(512)) @@ -349,6 +363,43 @@ describe('FsHandler', () => { expect(content).toBe('content') }) + it('rename preserves raw fs.rename overwrite semantics', async () => { + const oldPath = path.join(tmpDir, 'old.txt') + const newPath = path.join(tmpDir, 'existing.txt') + writeFileSync(oldPath, 'new') + writeFileSync(newPath, 'keep') + + await dispatcher.callRequest('fs.rename', { oldPath, newPath }) + + expect(await fs.readFile(newPath, 'utf-8')).toBe('new') + await expect(fs.access(oldPath)).rejects.toThrow() + }) + + it('renameNoClobber moves files when destination is available', async () => { + const oldPath = path.join(tmpDir, 'old.txt') + const newPath = path.join(tmpDir, 'new.txt') + writeFileSync(oldPath, 'content') + + await dispatcher.callRequest('fs.renameNoClobber', { oldPath, newPath }) + + await expect(fs.access(oldPath)).rejects.toThrow() + expect(await fs.readFile(newPath, 'utf-8')).toBe('content') + }) + + it('renameNoClobber does not overwrite an existing destination', async () => { + const oldPath = path.join(tmpDir, 'old.txt') + const newPath = path.join(tmpDir, 'existing.txt') + writeFileSync(oldPath, 'new') + writeFileSync(newPath, 'keep') + + await expect( + dispatcher.callRequest('fs.renameNoClobber', { oldPath, newPath }) + ).rejects.toThrow() + + expect(await fs.readFile(newPath, 'utf-8')).toBe('keep') + expect(await fs.readFile(oldPath, 'utf-8')).toBe('new') + }) + it('copy duplicates files', async () => { const src = path.join(tmpDir, 'src.txt') const dst = path.join(tmpDir, 'dst.txt') diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 0d3202d213f..3760a6b5a87 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -23,6 +23,7 @@ import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler- import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { buildRelayCommandEnv } from './relay-command-env' +import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' type WatchState = { rootPath: string @@ -50,6 +51,16 @@ async function isDirectoryEntry( } } +function fileStatFromLstat(stats: Awaited>) { + let type: 'file' | 'directory' | 'symlink' = 'file' + if (stats.isDirectory()) { + type = 'directory' + } else if (stats.isSymbolicLink()) { + type = 'symlink' + } + return { size: stats.size, type, mtime: stats.mtimeMs } +} + export class FsHandler { private dispatcher: RelayDispatcher private watches = new Map() @@ -68,11 +79,13 @@ export class FsHandler { this.dispatcher.onRequest('fs.tempDir', () => this.tempDir()) this.dispatcher.onRequest('fs.writeFile', (p) => this.writeFile(p)) this.dispatcher.onRequest('fs.stat', (p) => this.stat(p)) + this.dispatcher.onRequest('fs.lstat', (p) => this.lstat(p)) this.dispatcher.onRequest('fs.deletePath', (p) => this.deletePath(p)) this.dispatcher.onRequest('fs.createFile', (p) => this.createFile(p)) this.dispatcher.onRequest('fs.createDir', (p) => this.createDir(p)) this.dispatcher.onRequest('fs.createDirNoClobber', (p) => this.createDirNoClobber(p)) this.dispatcher.onRequest('fs.rename', (p) => this.rename(p)) + this.dispatcher.onRequest('fs.renameNoClobber', (p) => this.renameNoClobber(p)) this.dispatcher.onRequest('fs.copy', (p) => this.copy(p)) this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) @@ -156,11 +169,12 @@ export class FsHandler { return { size: stats.size, type: 'symlink', mtime: stats.mtimeMs } } } - let type: 'file' | 'directory' | 'symlink' = 'file' - if (stats.isDirectory()) { - type = 'directory' - } - return { size: stats.size, type, mtime: stats.mtimeMs } + return fileStatFromLstat(stats) + } + + private async lstat(params: Record) { + const filePath = expandTilde(params.filePath as string) + return fileStatFromLstat(await lstat(filePath)) } private async deletePath(params: Record) { @@ -196,6 +210,15 @@ export class FsHandler { await rename(oldPath, newPath) } + private async renameNoClobber(params: Record) { + const oldPath = expandTilde(params.oldPath as string) + const newPath = expandTilde(params.newPath as string) + // Why: user-facing file renames must not inherit fs.rename's overwrite + // behavior; keep the guard inside the relay so SSH checks the remote FS. + await assertNoClobberRenameDestinationAvailable(oldPath, newPath) + await rename(oldPath, newPath) + } + private async copy(params: Record) { const source = expandTilde(params.source as string) const destination = expandTilde(params.destination as string) diff --git a/src/relay/git-handler-commit-diff-ops.ts b/src/relay/git-handler-commit-diff-ops.ts index 96ff993240e..c24eee3ba2e 100644 --- a/src/relay/git-handler-commit-diff-ops.ts +++ b/src/relay/git-handler-commit-diff-ops.ts @@ -1,5 +1,6 @@ import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops' -import { buildDiffResult, parseBranchDiff, parseBranchDiffNumstat } from './git-handler-utils' +import { buildDiffResult, parseBranchDiff } from './git-handler-utils' +import { parseNumstat } from '../shared/git-uncommitted-line-stats' const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ @@ -103,7 +104,7 @@ export async function commitCompare(git: GitExec, worktreePath: string, commitId git(diffArgs, worktreePath), git(numstatArgs, worktreePath) ]) - const entries = parseBranchDiff(stdout, parseBranchDiffNumstat(numstat)) + const entries = parseBranchDiff(stdout, parseNumstat(numstat)) summary.changedFiles = entries.length return { summary, entries } } catch (error) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index df4815da4db..02cfc3f5195 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -15,6 +15,12 @@ import { getEffectiveGitUpstreamStatus, splitRemoteBranchName } from '../shared/git-effective-upstream' +import { + applyLineStats, + collectUntrackedAdditions, + parseNumstat, + type GitLineStats +} from '../shared/git-uncommitted-line-stats' export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') @@ -117,6 +123,13 @@ export async function getStatusOp( // not a git repo or git not available } + // Why: attach per-area line counts for the sidebar. Diffs run after status + // (we need the entry list first) and only for areas that have entries, so a + // clean tree costs zero extra git calls. Staged and unstaged are diffed + // separately so each row reflects only its own staging area; untracked files + // have no baseline and count their full contents as additions. + await attachLineStats(git, worktreePath, entries) + return { entries, conflictOperation, @@ -127,6 +140,57 @@ export async function getStatusOp( } } +async function runNumstat( + git: GitExec, + worktreePath: string, + cached: boolean +): Promise> { + try { + const { stdout } = await git( + ['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'], + worktreePath, + { disableOptionalLocks: true } + ) + return parseNumstat(stdout) + } catch { + // Why: a numstat failure should leave rows without counts rather than break + // the whole status refresh. + return new Map() + } +} + +async function attachLineStats( + git: GitExec, + worktreePath: string, + entries: Record[] +): Promise { + if (entries.length === 0) { + return + } + const hasStaged = entries.some((entry) => entry.area === 'staged') + const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') + const untrackedPaths = entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path as string) + const emptyStats = new Map() + const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ + hasStaged ? runNumstat(git, worktreePath, true) : Promise.resolve(emptyStats), + hasUnstaged ? runNumstat(git, worktreePath, false) : Promise.resolve(emptyStats), + collectUntrackedAdditions(worktreePath, untrackedPaths) + ]) + for (const entry of entries) { + const filePath = entry.path as string + applyLineStats( + entry as { added?: number; removed?: number }, + entry.area === 'staged' + ? stagedStats.get(filePath) + : entry.area === 'unstaged' + ? unstagedStats.get(filePath) + : untrackedStats.get(filePath) + ) + } +} + function getShortBranchName(branch: string | undefined): string | null { const prefix = 'refs/heads/' return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index ad2b7964960..8345c7e3fa8 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -37,4 +37,14 @@ describe('parseStatusOutput', () => { { path: 'scratch.txt', status: 'untracked', area: 'untracked' } ]) }) + + it('parses rename records with spaces in the paths', () => { + const result = parseStatusOutput( + '2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n' + ) + + expect(result.entries).toEqual([ + { path: 'src/new name.ts', oldPath: 'src/old name.ts', status: 'renamed', area: 'staged' } + ]) + }) }) diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index e84e04a170e..ce7bcea9a74 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -7,6 +7,8 @@ */ import * as path from 'path' import { existsSync } from 'fs' +import { isBinaryBuffer } from '../shared/binary-buffer' +import type { GitLineStats } from '../shared/git-uncommitted-line-stats' export function parseBranchStatusChar(char: string): string { switch (char) { @@ -100,51 +102,9 @@ export function parseUnmergedEntry( /** * Parse `git diff --name-status` output into structured change entries. */ -export type BranchDiffLineStats = { - added?: number - removed?: number -} - -function parseNumstatCount(value: string): number | undefined { - if (value === '-') { - return undefined - } - const count = Number.parseInt(value, 10) - return Number.isFinite(count) ? count : undefined -} - -function normalizeNumstatPath(path: string): string { - const bracedRename = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(path) - if (bracedRename) { - return `${bracedRename[1]}${bracedRename[3]}${bracedRename[4]}` - } - const renameMarker = ' => ' - const markerIndex = path.lastIndexOf(renameMarker) - return markerIndex === -1 ? path : path.slice(markerIndex + renameMarker.length) -} - -export function parseBranchDiffNumstat(stdout: string): Map { - const stats = new Map() - for (const line of stdout.split(/\r?\n/)) { - if (!line) { - continue - } - const parts = line.split('\t') - const rawPath = parts.slice(2).join('\t') - if (!rawPath) { - continue - } - stats.set(normalizeNumstatPath(rawPath), { - added: parseNumstatCount(parts[0] ?? ''), - removed: parseNumstatCount(parts[1] ?? '') - }) - } - return stats -} - export function parseBranchDiff( stdout: string, - statsByPath: Map = new Map() + statsByPath: Map = new Map() ): Record[] { const entries: Record[] = [] for (const line of stdout.split(/\r?\n/)) { @@ -214,16 +174,6 @@ export function parseWorktreeList(output: string): Record[] { // ─── Binary / blob helpers ─────────────────────────────────────────── -export function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, 8192) - for (let i = 0; i < len; i++) { - if (buffer[i] === 0) { - return true - } - } - return false -} - export const PREVIEWABLE_MIME: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index d23eee18a28..707f916b113 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { GitExec } from './git-handler-ops' -import { removeWorktreeOp } from './git-handler-worktree-ops' +import { addWorktreeOp, removeWorktreeOp, worktreeIsCleanOp } from './git-handler-worktree-ops' function worktreeList(...entries: { path: string; branch?: string }[]): string { return entries @@ -14,6 +14,114 @@ function worktreeList(...entries: { path: string; branch?: string }[]): string { .join('\n\n') } +describe('addWorktreeOp', () => { + it('writes durable branch base config after creating an SSH new-branch worktree', async () => { + const git = vi.fn(async () => ({ stdout: '', stderr: '' })) + + await addWorktreeOp(git, { + repoPath: '/repo', + branchName: 'feature/test', + targetDir: '/repo-feature', + base: 'origin/main' + }) + + expect(git.mock.calls.map((call) => call[0])).toEqual([ + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], + [ + 'worktree', + 'add', + '--no-track', + '-b', + 'feature/test', + '/repo-feature', + 'refs/remotes/origin/main' + ], + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], + ['config', '--get', 'push.autoSetupRemote'] + ]) + expect(git.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'config', + '--local', + 'branch.feature/test.remote', + 'origin' + ]) + expect(git.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'config', + '--local', + 'branch.feature/test.merge', + 'refs/heads/main' + ]) + }) + + it('does not write branch base config when checking out an existing SSH branch', async () => { + const git = vi.fn(async () => ({ stdout: '', stderr: '' })) + + await addWorktreeOp(git, { + repoPath: '/repo', + branchName: 'feature/test', + targetDir: '/repo-feature', + base: 'origin/main', + checkoutExistingBranch: true + }) + + expect(git.mock.calls.map((call) => call[0])).toEqual([ + ['worktree', 'add', '/repo-feature', 'feature/test'] + ]) + }) + + it('does not write branch base config when SSH creation has no base', async () => { + const git = vi.fn(async () => ({ stdout: '', stderr: '' })) + + await addWorktreeOp(git, { + repoPath: '/repo', + branchName: 'feature/no-base', + targetDir: '/repo-feature' + }) + + expect(git.mock.calls.map((call) => call[0])).toEqual([ + ['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'], + ['config', '--get', 'push.autoSetupRemote'] + ]) + }) + + it('warns and unsets stale branch base config when SSH base persistence fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const git = vi.fn(async (args) => { + if (args[0] === 'config' && args[2] === '--replace-all') { + throw new Error('config locked') + } + return { stdout: '', stderr: '' } + }) + + await expect( + addWorktreeOp(git, { + repoPath: '/repo', + branchName: 'feature/test', + targetDir: '/repo-feature', + base: 'origin/main' + }) + ).resolves.toBeUndefined() + + expect(warnSpy).toHaveBeenCalledWith( + 'relay addWorktree: failed to set branch.feature/test.base for /repo-feature', + expect.any(Error) + ) + expect(git.mock.calls.map((call) => call[0])).toContainEqual([ + 'config', + '--local', + '--unset-all', + 'branch.feature/test.base' + ]) + warnSpy.mockRestore() + }) +}) + describe('removeWorktreeOp', () => { it('deletes the now-unused branch after removing an SSH worktree', async () => { const calls: string[] = [] @@ -110,3 +218,29 @@ describe('removeWorktreeOp', () => { expect(git).not.toHaveBeenCalledWith(['branch', '-D', 'feature/test'], expect.any(String)) }) }) + +describe('worktreeIsCleanOp', () => { + it('reports clean SSH worktrees without returning porcelain output', async () => { + const git = vi.fn(async () => ({ stdout: '\n', stderr: '' })) + + await expect(worktreeIsCleanOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ + clean: true, + stdout: undefined + }) + + expect(git).toHaveBeenCalledWith( + ['status', '--porcelain', '--untracked-files=all'], + '/repo-feature' + ) + }) + + it('returns porcelain output for dirty SSH worktrees', async () => { + const stdout = ' M src/file.ts\n?? scratch.txt\n' + const git = vi.fn(async () => ({ stdout, stderr: '' })) + + await expect(worktreeIsCleanOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ + clean: false, + stdout + }) + }) +}) diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts index 7fcdc8a8574..f032f66d276 100644 --- a/src/relay/git-handler-worktree-ops.ts +++ b/src/relay/git-handler-worktree-ops.ts @@ -11,6 +11,30 @@ import { parseWorktreeList } from './git-handler-utils' // ─── Worktree management ───────────────────────────────────────────── +async function persistRelayWorktreeCreationBase( + git: GitExec, + targetDir: string, + branchName: string, + effectiveBase: string +): Promise { + const configKey = `branch.${branchName}.base` + try { + await git(['config', '--local', '--replace-all', configKey, effectiveBase], targetDir) + } catch (error) { + console.warn(`relay addWorktree: failed to set ${configKey} for ${targetDir}`, error) + try { + // Why: SSH worktree creation shares branch config by name; clear stale + // metadata if replacing an old same-name base fails. + await git(['config', '--local', '--unset-all', configKey], targetDir) + } catch (unsetError) { + console.warn( + `relay addWorktree: failed to unset stale ${configKey} for ${targetDir}`, + unsetError + ) + } + } +} + export async function addWorktreeOp(git: GitExec, params: Record): Promise { const repoPath = params.repoPath as string const branchName = params.branchName as string @@ -61,6 +85,10 @@ export async function addWorktreeOp(git: GitExec, params: Record +): Promise<{ clean: boolean; stdout?: string }> { + const worktreePath = params.worktreePath as string + const { stdout } = await git(['status', '--porcelain', '--untracked-files=all'], worktreePath) + const clean = !stdout.trim() + return { clean, stdout: clean ? undefined : stdout } +} + // ─── Commit ────────────────────────────────────────────────────────── export async function commitChangesRelay( diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index fcd47f95158..e46f62aa899 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -7,7 +7,7 @@ import { GitHandler } from './git-handler' import { RelayContext } from './context' import * as fs from 'fs/promises' import * as path from 'path' -import { mkdtempSync, mkdirSync, writeFileSync } from 'fs' +import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { execFileSync } from 'child_process' import { @@ -45,6 +45,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.unstage') expect(methods).toContain('git.bulkStage') expect(methods).toContain('git.bulkUnstage') + expect(methods).toContain('git.abortMerge') expect(methods).toContain('git.discard') expect(methods).toContain('git.bulkDiscard') expect(methods).toContain('git.conflictOperation') @@ -59,10 +60,70 @@ describe('GitHandler', () => { expect(methods).toContain('git.listWorktrees') expect(methods).toContain('git.addWorktree') expect(methods).toContain('git.removeWorktree') + expect(methods).toContain('git.renameCurrentBranch') expect(methods).toContain('git.exec') expect(methods).toContain('git.isGitRepo') }) + describe('abortMerge', () => { + it('aborts an in-progress merge', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n') + gitCommit(tmpDir, 'initial') + const baseBranch = execFileSync('git', ['branch', '--show-current'], { + cwd: tmpDir, + encoding: 'utf-8', + stdio: 'pipe' + }).trim() + execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n') + gitCommit(tmpDir, 'feature change') + execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n') + gitCommit(tmpDir, 'main change') + + expect(() => + execFileSync('git', ['merge', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + ).toThrow() + await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).resolves.toBeUndefined() + + await dispatcher.callRequest('git.abortMerge', { worktreePath: tmpDir }) + + await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).rejects.toThrow() + await expect(fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8')).resolves.toBe('main\n') + }) + }) + + describe('renameCurrentBranch', () => { + it('renames only the checked-out branch through the narrow RPC', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') + gitCommit(tmpDir, 'initial') + execFileSync('git', ['checkout', '-b', 'you/Nautilus'], { cwd: tmpDir }) + + await dispatcher.callRequest('git.renameCurrentBranch', { + worktreePath: tmpDir, + newBranch: 'you/fix-auth' + }) + + const current = execFileSync('git', ['branch', '--show-current'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + expect(current).toBe('you/fix-auth') + }) + + it('rejects branch names that look like flags', async () => { + gitInit(tmpDir) + await expect( + dispatcher.callRequest('git.renameCurrentBranch', { + worktreePath: tmpDir, + newBranch: '-bad' + }) + ).rejects.toThrow('Branch name must not start with "-"') + }) + }) + describe('history', () => { it('returns bounded git history for a repo', async () => { gitInit(tmpDir) @@ -115,12 +176,20 @@ describe('GitHandler', () => { writeFileSync(path.join(tmpDir, 'new.txt'), 'new') const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { - entries: Record[] + entries: { + path?: unknown + status?: unknown + area?: unknown + added?: unknown + removed?: unknown + }[] } const untracked = result.entries.find((e) => e.path === 'new.txt') expect(untracked).toBeDefined() expect(untracked!.status).toBe('untracked') expect(untracked!.area).toBe('untracked') + expect(untracked!.added).toBe(1) + expect(untracked!.removed).toBeUndefined() }) it('returns ignored paths only when requested', async () => { @@ -171,12 +240,20 @@ describe('GitHandler', () => { writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { - entries: Record[] + entries: { + path?: unknown + status?: unknown + area?: unknown + added?: unknown + removed?: unknown + }[] } const modified = result.entries.find((e) => e.path === 'file.txt') expect(modified).toBeDefined() expect(modified!.status).toBe('modified') expect(modified!.area).toBe('unstaged') + expect(modified!.added).toBe(1) + expect(modified!.removed).toBe(1) }) it('detects staged files', async () => { @@ -187,11 +264,19 @@ describe('GitHandler', () => { execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { - entries: Record[] + entries: { + path?: unknown + status?: unknown + area?: unknown + added?: unknown + removed?: unknown + }[] } const staged = result.entries.find((e) => e.area === 'staged') expect(staged).toBeDefined() expect(staged!.status).toBe('modified') + expect(staged!.added).toBe(1) + expect(staged!.removed).toBe(1) }) // Why: regression for issue #1503 — git's default core.quotePath=true @@ -368,6 +453,59 @@ describe('GitHandler', () => { }) ).rejects.toThrow('outside the worktree') }) + + it('rejects untracked child paths through symlinked parents', async () => { + gitInit(tmpDir) + gitCommit(tmpDir, 'initial') + const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-')) + const outsideFile = path.join(outsideDir, 'keep.txt') + writeFileSync(outsideFile, 'outside') + symlinkSync( + outsideDir, + path.join(tmpDir, 'link'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + try { + await expect( + dispatcher.callRequest('git.discard', { + worktreePath: tmpDir, + filePath: 'link/keep.txt' + }) + ).rejects.toThrow('outside the worktree') + await expect(fs.access(outsideFile)).resolves.toBeUndefined() + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }) + } + }) + + it('rejects bulk untracked child paths through symlinked parents before deleting anything', async () => { + gitInit(tmpDir) + gitCommit(tmpDir, 'initial') + const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-')) + const outsideFile = path.join(outsideDir, 'keep.txt') + const untrackedFile = path.join(tmpDir, 'new.txt') + writeFileSync(outsideFile, 'outside') + writeFileSync(untrackedFile, 'untracked') + symlinkSync( + outsideDir, + path.join(tmpDir, 'link'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + try { + await expect( + dispatcher.callRequest('git.bulkDiscard', { + worktreePath: tmpDir, + filePaths: ['new.txt', 'link/keep.txt'] + }) + ).rejects.toThrow('outside the worktree') + await expect(fs.access(outsideFile)).resolves.toBeUndefined() + await expect(fs.access(untrackedFile)).resolves.toBeUndefined() + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }) + } + }) }) describe('conflictOperation', () => { @@ -790,6 +928,7 @@ describe('GitHandler', () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/remotes/origin/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set @@ -811,6 +950,13 @@ describe('GitHandler', () => { '/relay/wt', 'refs/remotes/origin/main' ], + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], ['config', '--get', 'push.autoSetupRemote'], ['config', '--local', 'push.autoSetupRemote', 'true'] ]) @@ -819,6 +965,7 @@ describe('GitHandler', () => { expect(gitMock.mock.calls[1]?.[1]).toBe('/relay/repo') expect(gitMock.mock.calls[2]?.[1]).toBe('/relay/wt') expect(gitMock.mock.calls[3]?.[1]).toBe('/relay/wt') + expect(gitMock.mock.calls[4]?.[1]).toBe('/relay/wt') }) it('checks out a selected existing local branch without creating a new branch', async () => { @@ -846,6 +993,7 @@ describe('GitHandler', () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/heads/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set @@ -859,6 +1007,7 @@ describe('GitHandler', () => { expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], ['worktree', 'add', '--no-track', '-b', 'feature/disambig', '/relay/wt', 'refs/heads/main'], + ['config', '--local', '--replace-all', 'branch.feature/disambig.base', 'refs/heads/main'], ['config', '--get', 'push.autoSetupRemote'], ['config', '--local', 'push.autoSetupRemote', 'true'] ]) @@ -869,6 +1018,7 @@ describe('GitHandler', () => { gitMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit} gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/heads/release/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set @@ -891,6 +1041,13 @@ describe('GitHandler', () => { '/relay/wt', 'refs/heads/release/main' ], + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/release.base', + 'refs/heads/release/main' + ], ['config', '--get', 'push.autoSetupRemote'], ['config', '--local', 'push.autoSetupRemote', 'true'] ]) @@ -900,6 +1057,7 @@ describe('GitHandler', () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // rev-parse refs/remotes/origin/main gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set @@ -927,6 +1085,7 @@ describe('GitHandler', () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockResolvedValueOnce({ stdout: 'false\n', stderr: '' }) // --get returns value await localDispatcher.callRequest('git.addWorktree', { @@ -940,6 +1099,7 @@ describe('GitHandler', () => { expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], ['worktree', 'add', '--no-track', '-b', 'feature/preserve', '/relay/wt', 'main'], + ['config', '--local', '--replace-all', 'branch.feature/preserve.base', 'main'], ['config', '--get', 'push.autoSetupRemote'] ]) }) @@ -952,6 +1112,7 @@ describe('GitHandler', () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --get success, empty value await localDispatcher.callRequest('git.addWorktree', { @@ -964,6 +1125,7 @@ describe('GitHandler', () => { expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], ['worktree', 'add', '--no-track', '-b', 'feature/empty', '/relay/wt', 'main'], + ['config', '--local', '--replace-all', 'branch.feature/empty.base', 'main'], ['config', '--get', 'push.autoSetupRemote'] ]) }) @@ -976,6 +1138,7 @@ describe('GitHandler', () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get non-unset const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -992,6 +1155,7 @@ describe('GitHandler', () => { expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], ['worktree', 'add', '--no-track', '-b', 'feature/corrupt', '/relay/wt', 'main'], + ['config', '--local', '--replace-all', 'branch.feature/corrupt.base', 'main'], ['config', '--get', 'push.autoSetupRemote'] ]) expect(warnSpy).toHaveBeenCalledWith( @@ -1005,6 +1169,7 @@ describe('GitHandler', () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset gitMock.mockRejectedValueOnce(new Error('config locked')) // --local set fails diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index e341e89618e..9e65119465b 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -2,12 +2,12 @@ protocol surface so local and SSH git behavior stay in one dispatch table. */ import { execFile } from 'child_process' import { promisify } from 'util' -import { rm } from 'fs/promises' import * as path from 'path' import type { RelayDispatcher } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' -import { parseBranchDiff, parseBranchDiffNumstat, parseWorktreeList } from './git-handler-utils' +import { parseBranchDiff, parseWorktreeList } from './git-handler-utils' +import { parseNumstat } from '../shared/git-uncommitted-line-stats' import { computeDiff, branchCompare as branchCompareOp, @@ -15,7 +15,12 @@ import { validateGitExecArgs } from './git-handler-ops' import { commitCompare as commitCompareOp, commitDiffEntry } from './git-handler-commit-diff-ops' -import { commitChangesRelay, addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' +import { + commitChangesRelay, + addWorktreeOp, + removeWorktreeOp, + worktreeIsCleanOp +} from './git-handler-worktree-ops' import { checkIgnoredPathsOp, detectConflictOperation, getStatusOp } from './git-handler-status-ops' import { resolveRelayPushTarget } from './git-handler-push-target' import { normalizeGitErrorMessage, isNoUpstreamError } from '../shared/git-remote-error' @@ -30,6 +35,10 @@ import { } from '../shared/git-effective-upstream' import { loadGitHistoryFromExecutor } from '../shared/git-history' import { buildRelayCommandEnv } from './relay-command-env' +import { + removeSafeUntrackedDiscardTarget, + removeSafeUntrackedDiscardTargets +} from '../shared/git-discard-path-safety' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -55,6 +64,7 @@ export class GitHandler { this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p)) this.dispatcher.onRequest('git.bulkStage', (p) => this.bulkStage(p)) this.dispatcher.onRequest('git.bulkUnstage', (p) => this.bulkUnstage(p)) + this.dispatcher.onRequest('git.abortMerge', (p) => this.abortMerge(p)) this.dispatcher.onRequest('git.discard', (p) => this.discard(p)) this.dispatcher.onRequest('git.bulkDiscard', (p) => this.bulkDiscard(p)) this.dispatcher.onRequest('git.conflictOperation', (p) => this.conflictOperation(p)) @@ -71,6 +81,8 @@ export class GitHandler { this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) + this.dispatcher.onRequest('git.worktreeIsClean', (p) => this.worktreeIsClean(p)) + this.dispatcher.onRequest('git.renameCurrentBranch', (p) => this.renameCurrentBranch(p)) this.dispatcher.onRequest('git.exec', (p) => this.exec(p)) this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p)) } @@ -175,6 +187,11 @@ export class GitHandler { } } + private async abortMerge(params: Record) { + const worktreePath = params.worktreePath as string + await this.git(['merge', '--abort'], worktreePath) + } + private normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') } @@ -208,7 +225,7 @@ export class GitHandler { const worktreePath = params.worktreePath as string const filePath = params.filePath as string - const resolved = this.assertInWorktree(worktreePath, filePath) + this.assertInWorktree(worktreePath, filePath) let tracked = false try { @@ -218,9 +235,14 @@ export class GitHandler { // untracked } - await (tracked - ? this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath) - : rm(resolved, { force: true, recursive: true })) + if (tracked) { + await this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath) + return + } + + await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) => + this.cleanUntrackedPaths(worktreePath, [targetPath]) + ) } private async bulkDiscard(params: Record) { @@ -247,19 +269,29 @@ export class GitHandler { const untrackedPaths = filePaths.filter( (filePath) => !this.isTrackedPathSpec(filePath, trackedPathSpecs) ) - - for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { - const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) - await this.git(['restore', '--worktree', '--source=HEAD', '--', ...chunk], worktreePath) - } - - await Promise.all( - untrackedPaths.map((filePath) => - rm(path.resolve(worktreePath, filePath), { force: true, recursive: true }) - ) + await removeSafeUntrackedDiscardTargets( + worktreePath, + untrackedPaths, + (targetPaths) => this.cleanUntrackedPaths(worktreePath, targetPaths), + async () => { + for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { + const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) + await this.git(['restore', '--worktree', '--source=HEAD', '--', ...chunk], worktreePath) + } + } ) } + private async cleanUntrackedPaths(worktreePath: string, filePaths: readonly string[]) { + for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) + if (chunk.length > 0) { + // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. + await this.git(['clean', '-ffdx', '--', ...chunk], worktreePath) + } + } + } + private async conflictOperation(params: Record) { const worktreePath = params.worktreePath as string return detectConflictOperation(worktreePath) @@ -285,7 +317,7 @@ export class GitHandler { ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], worktreePath ) - return parseBranchDiff(stdout, parseBranchDiffNumstat(numstat)) + return parseBranchDiff(stdout, parseNumstat(numstat)) }) } @@ -501,6 +533,25 @@ export class GitHandler { return { stdout, stderr } } + private async renameCurrentBranch(params: Record) { + const worktreePath = params.worktreePath + const newBranch = params.newBranch + if (typeof worktreePath !== 'string' || typeof newBranch !== 'string') { + throw new Error('Invalid branch rename request.') + } + if (newBranch.startsWith('-')) { + throw new Error('Branch name must not start with "-".') + } + try { + // Why: generic git.exec intentionally blocks destructive branch flags. + // This narrow RPC permits only the already-checked current-branch rename. + await this.git(['check-ref-format', '--branch', newBranch], worktreePath) + await this.git(['branch', '-m', newBranch], worktreePath) + } catch (error) { + throw new Error(normalizeGitErrorMessage(error)) + } + } + private async isGitRepo(params: Record) { const dirPath = params.dirPath as string try { @@ -528,4 +579,8 @@ export class GitHandler { private async removeWorktree(params: Record) { return removeWorktreeOp(this.git.bind(this), params) } + + private async worktreeIsClean(params: Record) { + return worktreeIsCleanOp(this.git.bind(this), params) + } } diff --git a/src/relay/git-status-output-parser.ts b/src/relay/git-status-output-parser.ts index 78f66e2129a..7dbb1fc33f7 100644 --- a/src/relay/git-status-output-parser.ts +++ b/src/relay/git-status-output-parser.ts @@ -74,11 +74,10 @@ export function parseStatusOutput(stdout: string): { if (line.startsWith('2 ')) { // Why: porcelain v2 type-2 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`. - // The new path is the last space-delimited token before the tab; origPath follows the tab. + // The new path starts after 9 fixed fields and can contain spaces; origPath follows the tab. const tabParts = line.split('\t') - const spaceParts = tabParts[0].split(' ') - const filePath = spaceParts.at(-1)! - const oldPath = tabParts[1] + const filePath = tabParts[0].split(' ').slice(9).join(' ') + const oldPath = tabParts.slice(1).join('\t') if (indexStatus !== '.') { entries.push({ path: filePath, diff --git a/src/relay/plugin-overlay-env.test.ts b/src/relay/plugin-overlay-env.test.ts index d23a767692b..8191151ac2c 100644 --- a/src/relay/plugin-overlay-env.test.ts +++ b/src/relay/plugin-overlay-env.test.ts @@ -40,7 +40,7 @@ describe('plugin overlay env source resolution', () => { expect(resolveOpenCodeSourceConfigDir(env, '/bin/zsh')).toBe( join(homeDir, 'company-opencode') ) - expect(resolvePiSourceAgentDir(env, '/bin/zsh')).toBe(join(homeDir, 'company-pi')) + expect(resolvePiSourceAgentDir(env, '/bin/zsh', 'pi')).toBe(join(homeDir, 'company-pi')) } ) diff --git a/src/relay/plugin-overlay-env.ts b/src/relay/plugin-overlay-env.ts index 431a49430f1..febfd5df440 100644 --- a/src/relay/plugin-overlay-env.ts +++ b/src/relay/plugin-overlay-env.ts @@ -1,4 +1,5 @@ import { readShellStartupEnvVar } from '../main/pty/shell-startup-env' +import type { PiAgentKind } from '../shared/pi-agent-kind' function firstNonEmpty(...values: (string | undefined)[]): string | undefined { return values.find((value) => typeof value === 'string' && value.length > 0) @@ -25,11 +26,32 @@ export function resolveOpenCodeSourceConfigDir( export function resolvePiSourceAgentDir( env: Record, - shell: string | undefined + shell: string | undefined, + kind: PiAgentKind ): string | undefined { - return firstNonEmpty( - env.ORCA_PI_SOURCE_AGENT_DIR, - readStartupEnv('PI_CODING_AGENT_DIR', env, shell), - env.PI_CODING_AGENT_DIR - ) + const sourceKey = kind === 'omp' ? 'ORCA_OMP_SOURCE_AGENT_DIR' : 'ORCA_PI_SOURCE_AGENT_DIR' + const overlayKey = kind === 'omp' ? 'ORCA_OMP_CODING_AGENT_DIR' : 'ORCA_PI_CODING_AGENT_DIR' + const otherOverlayKey = kind === 'omp' ? 'ORCA_PI_CODING_AGENT_DIR' : 'ORCA_OMP_CODING_AGENT_DIR' + + const sourceDir = firstNonEmpty(env[sourceKey]) + if (sourceDir) { + return sourceDir + } + + const startupDir = readStartupEnv('PI_CODING_AGENT_DIR', env, shell) + if (startupDir) { + return startupDir + } + + // Why: a mismatched Orca overlay shadow means this shell inherited the other + // Pi-compatible agent's PTY overlay. Do not remirror that overlay into this + // launch; let plugin-overlay default to the selected kind's own home dir. + if ( + env.PI_CODING_AGENT_DIR && + env.PI_CODING_AGENT_DIR !== env[overlayKey] && + env.PI_CODING_AGENT_DIR !== env[otherOverlayKey] + ) { + return env.PI_CODING_AGENT_DIR + } + return undefined } diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 5d7a7f03c33..2adad59e5de 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from 'os' import { basename, join } from 'path' import { PluginOverlayManager } from './plugin-overlay' +import { resolvePiSourceAgentDir } from './plugin-overlay-env' describe('PluginOverlayManager', () => { let homeDir: string @@ -77,6 +78,25 @@ describe('PluginOverlayManager', () => { expect(existsSync(file)).toBe(true) }) + it('uses the kind-specific Pi-compatible extension source when available', () => { + manager.setSources({ + piExtensionSource: '// pi extension', + ompExtensionSource: '// omp extension' + }) + + const piDir = manager.materializePi('tab-kind-pi:0', undefined, 'pi') + const ompDir = manager.materializePi('tab-kind-omp:0', undefined, 'omp') + + expect(piDir).not.toBeNull() + expect(ompDir).not.toBeNull() + expect(readFileSync(join(piDir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe( + '// pi extension' + ) + expect(readFileSync(join(ompDir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe( + '// omp extension' + ) + }) + it('mirrors the remote default Pi agent dir before adding Orca status extension', () => { const piAgentDir = join(homeDir, '.pi', 'agent') mkdirSync(join(piAgentDir, 'skills', 'my-skill'), { recursive: true }) @@ -122,26 +142,101 @@ describe('PluginOverlayManager', () => { ) }) + // Why: per-agent overlay source dir. The renderer picks Pi or OMP per + // launch, and the relay must mirror the right `~/./agent` source — + // disk-presence guessing (always-Pi or first-exists) shadows the other + // agent's user extensions when both dirs exist on the remote disk. + describe('per-agent default source dir (no cross-agent fallback)', () => { + function seedAgentDir(dotDir: '.pi' | '.omp', tag: string): string { + const agentDir = join(homeDir, dotDir, 'agent') + mkdirSync(join(agentDir, 'extensions', `${tag}-ext`), { recursive: true }) + writeFileSync(join(agentDir, 'extensions', `${tag}-ext`, 'ext.ts'), `${tag} extension`) + writeFileSync(join(agentDir, 'auth.json'), `${tag} token`) + return agentDir + } + + it('launching pi with both ~/.pi/agent and ~/.omp/agent present mirrors ~/.pi/agent into pi-overlays', () => { + seedAgentDir('.pi', 'pi') + seedAgentDir('.omp', 'omp') + + manager.setSources({ piExtensionSource: '// pi extension' }) + const dir = manager.materializePi('tab-relay-pi-both:0', undefined, 'pi') + + expect(dir).not.toBeNull() + // Pi overlays live under .orca-relay/pi-overlays, separate from OMP's tree. + expect(dir!).toMatch(/[\\/]\.orca-relay[\\/]pi-overlays[\\/]/) + expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('pi token') + const overlayExtensions = readdirSync(join(dir!, 'extensions')).sort() + expect(overlayExtensions).toContain('pi-ext') + expect(overlayExtensions).not.toContain('omp-ext') + }) + + it('launching omp with both ~/.pi/agent and ~/.omp/agent present mirrors ~/.omp/agent into omp-overlays', () => { + seedAgentDir('.pi', 'pi') + seedAgentDir('.omp', 'omp') + + manager.setSources({ piExtensionSource: '// pi extension' }) + const dir = manager.materializePi('tab-relay-omp-both:0', undefined, 'omp') + + expect(dir).not.toBeNull() + // CRITICAL: OMP overlays live in a distinct subtree (.orca-relay/omp-overlays) + // so the remote box never mixes Pi and OMP overlay state for the same paneKey. + expect(dir!).toMatch(/[\\/]\.orca-relay[\\/]omp-overlays[\\/]/) + expect(dir!).not.toMatch(/[\\/]pi-overlays[\\/]/) + // Even though ~/.pi/agent exists, the OMP launch MUST mirror OMP's + // source dir. Cross-agent fallback would silently shadow the user's + // OMP extensions on the remote. + expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('omp token') + const overlayExtensions = readdirSync(join(dir!, 'extensions')).sort() + expect(overlayExtensions).toContain('omp-ext') + expect(overlayExtensions).not.toContain('pi-ext') + }) + + it('launching omp when only ~/.pi/agent exists does NOT mirror Pi state', () => { + // Why: missing OMP source dir on the remote must materialize the + // overlay from empty — Orca's status extension only, no Pi state + // cross-pollinated in. + seedAgentDir('.pi', 'pi') + expect(existsSync(join(homeDir, '.omp'))).toBe(false) + + manager.setSources({ piExtensionSource: '// pi extension' }) + const dir = manager.materializePi('tab-relay-omp-empty:0', undefined, 'omp') + + expect(dir).not.toBeNull() + expect(dir!).toMatch(/[\\/]\.orca-relay[\\/]omp-overlays[\\/]/) + // Pi-only home must NOT leak into the OMP overlay. + expect(existsSync(join(dir!, 'auth.json'))).toBe(false) + const overlayExtensions = readdirSync(join(dir!, 'extensions')).sort() + expect(overlayExtensions).toEqual(['orca-agent-status.ts']) + }) + }) + it('does not override a missing preexisting Pi agent dir', () => { manager.setSources({ piExtensionSource: '// pi extension' }) expect(manager.materializePi('tab-missing-pi:0', join(homeDir, 'missing-pi'))).toBeNull() }) - it('clearOverlay removes both overlay roots for an id', () => { + it('clearOverlay removes opencode + every Pi-kind overlay root for an id', () => { manager.setSources({ opencodePluginSource: 'opencode', - piExtensionSource: 'pi' + piExtensionSource: 'pi', + ompExtensionSource: 'omp' }) const opencodeDir = manager.materializeOpenCode('tab-3:0')! - const piRoot = manager.materializePi('tab-3:0')! + const piDir = manager.materializePi('tab-3:0', undefined, 'pi')! + const ompDir = manager.materializePi('tab-3:0', undefined, 'omp')! + // Sanity: each kind got its own subtree, not a shared one. + expect(piDir).not.toBe(ompDir) expect(existsSync(opencodeDir)).toBe(true) - expect(existsSync(piRoot)).toBe(true) + expect(existsSync(piDir)).toBe(true) + expect(existsSync(ompDir)).toBe(true) manager.clearOverlay('tab-3:0') expect(existsSync(opencodeDir)).toBe(false) - expect(existsSync(piRoot)).toBe(false) + expect(existsSync(piDir)).toBe(false) + expect(existsSync(ompDir)).toBe(false) }) it.skipIf(process.platform === 'win32')( @@ -184,3 +279,33 @@ describe('PluginOverlayManager', () => { expect(existsSync(join(dir!, 'plugins', 'orca-opencode-status.js'))).toBe(true) }) }) + +describe('resolvePiSourceAgentDir', () => { + it('uses only the selected kind source shadow when resolving inherited overlays', () => { + const env = { + HOME: mkdtempSync(join(tmpdir(), 'plugin-overlay-env-')), + PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', + ORCA_PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', + ORCA_PI_SOURCE_AGENT_DIR: '/user/.pi/agent' + } + try { + expect(resolvePiSourceAgentDir(env, undefined, 'pi')).toBe('/user/.pi/agent') + expect(resolvePiSourceAgentDir(env, undefined, 'omp')).toBeUndefined() + } finally { + rmSync(env.HOME, { recursive: true, force: true }) + } + }) + + it('keeps explicit PI_CODING_AGENT_DIR values when they are not Orca overlays', () => { + const env = { + HOME: mkdtempSync(join(tmpdir(), 'plugin-overlay-env-')), + PI_CODING_AGENT_DIR: '/user/custom-omp-agent', + ORCA_PI_SOURCE_AGENT_DIR: '/user/.pi/agent' + } + try { + expect(resolvePiSourceAgentDir(env, undefined, 'omp')).toBe('/user/custom-omp-agent') + } finally { + rmSync(env.HOME, { recursive: true, force: true }) + } + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 905d4b5f3f1..f4904898f2a 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -29,14 +29,27 @@ import { import { homedir } from 'os' import { basename, join } from 'path' import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import type { PiAgentKind } from '../shared/pi-agent-kind' const RELAY_HOOKS_DIR = '.orca-relay' const OPENCODE_OVERLAY_SUBDIR = 'opencode-overlays' -const PI_OVERLAY_SUBDIR = 'pi-overlays' +const PI_OVERLAY_SUBDIR_BY_KIND: Record = { + pi: 'pi-overlays', + omp: 'omp-overlays' +} const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const PI_EXTENSION_FILE = 'orca-agent-status.ts' -const PI_AGENT_DIR_NAME = '.pi' const PI_AGENT_SUBDIR = 'agent' +// Why: source-dir resolution is keyed off the launching agent (Pi or OMP). +// Both consume `PI_CODING_AGENT_DIR` but default to different `~/./agent` +// paths on the remote disk. The renderer-chosen launch command flows in via +// the relay PtyEnvAugmenter ctx; never derived from disk presence (a +// cross-agent fallback shadows the other agent's user extensions when both +// are installed). +const PI_AGENT_HOME_DIR_NAME: Record = { + pi: '.pi', + omp: '.omp' +} function safeDirName(input: string): string { // Why: paneKey embeds tabId:paneId where tabId may itself contain @@ -52,22 +65,34 @@ function isUsableId(id: string): boolean { export type PluginSources = { /** Source body of `orca-opencode-status.js` to drop into /plugins/. */ opencodePluginSource?: string - /** Source body of `orca-agent-status.ts` to drop into /extensions/. */ + /** Source body of Pi's `orca-agent-status.ts` to drop into /extensions/. */ piExtensionSource?: string + /** Source body of OMP's `orca-agent-status.ts` to drop into /extensions/. */ + ompExtensionSource?: string +} + +export function getRelayPiStatusExtensionPath(agentDir: string): string { + return join(agentDir, 'extensions', PI_EXTENSION_FILE) } export class PluginOverlayManager { private opencodePluginSource: string | null = null - private piExtensionSource: string | null = null + private piExtensionSources: Record = { + pi: null, + omp: null + } private homeDir: string private opencodeRoot: string - private piRoot: string + private piRoots: Record constructor(opts?: { homeDir?: string }) { const home = opts?.homeDir ?? homedir() this.homeDir = home this.opencodeRoot = join(home, RELAY_HOOKS_DIR, OPENCODE_OVERLAY_SUBDIR) - this.piRoot = join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR) + this.piRoots = { + pi: join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR_BY_KIND.pi), + omp: join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR_BY_KIND.omp) + } } /** Replace the cached source bodies. Called from relay.ts when Orca sends @@ -83,7 +108,10 @@ export class PluginOverlayManager { this.opencodePluginSource = sources.opencodePluginSource } if (typeof sources.piExtensionSource === 'string') { - this.piExtensionSource = sources.piExtensionSource + this.piExtensionSources.pi = sources.piExtensionSource + } + if (typeof sources.ompExtensionSource === 'string') { + this.piExtensionSources.omp = sources.ompExtensionSource } } @@ -91,8 +119,15 @@ export class PluginOverlayManager { return this.opencodePluginSource !== null } - hasPiSource(): boolean { - return this.piExtensionSource !== null + hasPiSource(kind?: PiAgentKind): boolean { + if (kind) { + return this.getPiExtensionSource(kind) !== null + } + return this.piExtensionSources.pi !== null || this.piExtensionSources.omp !== null + } + + private getPiExtensionSource(kind: PiAgentKind): string | null { + return this.piExtensionSources[kind] ?? this.piExtensionSources.pi } private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { @@ -176,8 +211,8 @@ export class PluginOverlayManager { } } - private getDefaultPiAgentDir(): string { - return join(this.homeDir, PI_AGENT_DIR_NAME, PI_AGENT_SUBDIR) + private getDefaultPiAgentDir(kind: PiAgentKind): string { + return join(this.homeDir, PI_AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR) } private mirrorPiAgentDir(sourceAgentDir: string, overlayDir: string): void { @@ -221,30 +256,38 @@ export class PluginOverlayManager { } /** Materialize the Pi extension overlay for `id` and return the directory - * path that should be assigned to PI_CODING_AGENT_DIR. */ - materializePi(id: string, existingAgentDir?: string): string | null { - if (!this.piExtensionSource || !isUsableId(id)) { + * path that should be assigned to PI_CODING_AGENT_DIR. `kind` selects which + * Pi-compatible agent's source dir to mirror when `existingAgentDir` is + * not supplied - defaults to 'pi' for back-compat with pre-OMP callers. + * NEVER fall back across kinds: a missing source dir for the chosen kind + * materializes the overlay from empty (Orca extensions only) rather than + * silently mirroring the other agent's state. */ + materializePi(id: string, existingAgentDir?: string, kind: PiAgentKind = 'pi'): string | null { + const extensionSource = this.getPiExtensionSource(kind) + if (!extensionSource || !isUsableId(id)) { return null } - const dir = join(this.piRoot, safeDirName(id)) + const root = this.piRoots[kind] + const dir = join(root, safeDirName(id)) try { - // Why: PI_CODING_AGENT_DIR is Pi's whole state root. Mirror the remote + // Why: PI_CODING_AGENT_DIR is the whole state root for both Pi and OMP + // (OMP inherits the env-var name from Pi by design). Mirror the remote // user's default agent dir so Orca's status extension does not hide auth, // sessions, skills, prompts, themes, or user extensions inside SSH panes. - safeRemoveOverlay(dir, this.piRoot) + safeRemoveOverlay(dir, root) mkdirSync(dir, { recursive: true }) - const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir() + const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir(kind) if (existingAgentDir && !existsSync(existingAgentDir)) { return null } this.mirrorPiAgentDir(sourceAgentDir, dir) const extensionsDir = join(dir, 'extensions') mkdirSync(extensionsDir, { recursive: true }) - writeFileSync(join(extensionsDir, PI_EXTENSION_FILE), this.piExtensionSource) + writeFileSync(join(extensionsDir, PI_EXTENSION_FILE), extensionSource) return dir } catch (err) { process.stderr.write( - `[plugin-overlay] failed to materialize Pi overlay: ${err instanceof Error ? err.message : String(err)}\n` + `[plugin-overlay] failed to materialize ${kind} overlay: ${err instanceof Error ? err.message : String(err)}\n` ) return null } @@ -259,13 +302,16 @@ export class PluginOverlayManager { return } const safe = safeDirName(id) - for (const root of [this.opencodeRoot, this.piRoot]) { + // Why: sweep all overlay roots (OpenCode + each Pi-kind) because PTY exit + // doesn't know which kind materialized this id. Per-root scoping inside + // safeRemoveOverlay keeps each call bounded to its own tree. + for (const root of [this.opencodeRoot, ...Object.values(this.piRoots)]) { try { safeRemoveOverlay(join(root, safe), root) } catch (err) { // Why: log the failed cleanup so a permission/IO error is observable. // The leak is the failure mode the per-pane cache eviction exists to - // prevent — silent swallows would let it accumulate invisibly on + // prevent - silent swallows would let it accumulate invisibly on // long-running relays. process.stderr.write( `[plugin-overlay] failed to remove overlay dir ${join(root, safe)}: ${err instanceof Error ? err.message : String(err)}\n` diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 27fcf52ab81..a31d35259e9 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -146,6 +146,18 @@ describe('PtyHandler', () => { expect(onExpire).not.toHaveBeenCalled() }) + it('uses the configured grace time for future disconnect timers', () => { + const onExpire = vi.fn() + + handler.setGraceTimeMs(250) + handler.startGraceTimer(onExpire) + + vi.advanceTimersByTime(249) + expect(onExpire).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(onExpire).toHaveBeenCalledTimes(1) + }) + it('spawns a PTY and returns an id', async () => { const result = await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) expect(result).toEqual({ id: 'pty-1' }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index cd85ab11d5e..c6c6ae7e6f2 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -111,12 +111,17 @@ export type PtyExitListener = (event: { id: string; paneKey?: string }) => void /** Returns env to merge into the PTY's spawn env. Receives spawn context so * augmenters that need a per-PTY identity (e.g. OPENCODE_CONFIG_DIR overlay * paths derived from the renderer's paneKey) can compute it without pulling - * the renderer's env in twice. */ + * the renderer's env in twice. `command` is the renderer-chosen agent launch + * command (`pi`, `omp`, …) — supplied by ssh-pty-provider.ts so the Pi + * overlay can resolve the per-agent source dir without disk-presence + * guessing. NEVER undefined for client-driven spawns that target a + * Pi-compatible agent; may be undefined for CLI-launched bare shells. */ export type PtyEnvAugmenter = (ctx: { id: string paneKey?: string shell: string env: Record + command?: string }) => Record export class PtyHandler { @@ -145,6 +150,14 @@ export class PtyHandler { this.registerHandlers() } + setGraceTimeMs(graceTimeMs: number): void { + this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs)) + } + + get configuredGraceTimeMs(): number { + return this.graceTimeMs + } + /** Subscribe to PTY-exit events. Used by the relay-hook server to evict * per-paneKey cached payloads when the backing PTY ends. */ setExitListener(listener: PtyExitListener | null): void { @@ -175,7 +188,7 @@ export class PtyHandler { * otherwise agent-status over SSH silently breaks on every revive. */ private buildSpawnEnv( rendererEnv: Record | undefined, - ctx: { id: string; paneKey?: string; shell: string } + ctx: { id: string; paneKey?: string; shell: string; command?: string } ): Record { const baseEnv = { ...process.env, ...rendererEnv } as Record const augmented: Record = {} @@ -298,8 +311,12 @@ export class PtyHandler { // dirs) override renderer-supplied env so live remote paths and hook coords // win over local userData paths. The context lets overlay augmenters derive // per-PTY OpenCode/Pi directories from the stable paneKey when present. + // `command` is forwarded by ssh-pty-provider.ts only as a hint for + // overlay resolution — the relay still launches a login shell and the + // command is typed in via pty.data writes. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined - const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell }) + const command = typeof params.command === 'string' ? params.command : undefined + const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }) const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv) // Why: SSH exec channels give the relay a minimal environment without @@ -589,6 +606,16 @@ export class PtyHandler { revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId } const shell = resolveDefaultShell() + // Why: `command` is intentionally absent from this revive path because + // SerializedPtyEntry (see line 99) does not persist it — ManagedPty + // never stored the renderer-chosen launch command. The Pi/OMP overlay + // augmenter in src/relay/relay.ts therefore sees `ctx.command === + // undefined` for revived PTYs and falls back to the Pi-default kind + // (see detectPiAgentKindFromCommand in src/shared/pi-agent-kind.ts). + // Acceptable pre-OMP fallback: a cold-restart revived OMP shell that + // later relaunches `omp` keeps the historical behavior of loading the + // Pi overlay. Plumbing `command` through serialization is a separate, + // larger change (out of scope for PR #2662). const spawnEnv = this.buildSpawnEnv(revivedEnv, { id: entry.id, paneKey: entry.paneKey, diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 90e15c404b8..9eb69b3962c 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -1,6 +1,7 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { homedir } from 'os' import { basename, dirname, join } from 'path' +import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' const POSIX_LOGIN_ARGS = ['-l'] @@ -15,7 +16,9 @@ function quotePosixSingle(value: string): string { } function hasOverlayRestoreEnv(env: Record): boolean { - return Boolean(env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR) + return Boolean( + env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR || env.ORCA_OMP_CODING_AGENT_DIR + ) } function getWrapperRoot(env: Record): string { @@ -78,6 +81,10 @@ if [[ ! -o login ]]; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" + if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then + export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" + fi + ${getPosixOmpShellWrapper()} fi ` const zshLogin = `# Orca relay zsh overlay wrapper @@ -91,6 +98,10 @@ fi # Why: .zlogin is the final zsh login startup file before the prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then + export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" +fi +${getPosixOmpShellWrapper()} ` const bashRc = `# Orca relay bash overlay wrapper [[ -f /etc/profile ]] && source /etc/profile @@ -104,6 +115,10 @@ fi # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then + export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" +fi +${getPosixOmpShellWrapper()} # Why: SSH bash sessions need the same command lifecycle markers as local # bash so agent rows stop showing "working" when the foreground command exits. __orca_osc133_precmd() { diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 1b22d7c3a75..213d71623da 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -36,15 +36,19 @@ import { PortScanHandler } from './port-scan-handler' import { AgentExecHandler } from './agent-exec-handler' import { WorkspaceSessionHandler } from './workspace-session-handler' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' -import { PluginOverlayManager } from './plugin-overlay' +import { PluginOverlayManager, getRelayPiStatusExtensionPath } from './plugin-overlay' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' -import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import { + DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, + SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD +} from '../shared/ssh-types' import { assertPluginSourceUnderByteCap } from './plugin-source-limit' import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' +import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind' const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 const SOCK_NAME = 'relay.sock' @@ -302,6 +306,23 @@ async function main(): Promise { const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) void _workspaceSessionHandler + function configureRelayGraceTime(params: Record): { graceTimeMs: number } { + const seconds = Number(params.graceTimeSeconds) + if (Number.isFinite(seconds) && seconds >= 0) { + // Why: the host sends 0 before system sleep so live remote PTYs survive + // longer than the ordinary disconnect grace window. + ptyHandler.setGraceTimeMs(Math.floor(seconds) * 1000) + } + return { graceTimeMs: ptyHandler.configuredGraceTimeMs } + } + + dispatcher.onNotification(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, (params) => { + configureRelayGraceTime(params) + }) + dispatcher.onRequest(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, async (params) => + configureRelayGraceTime(params) + ) + // ── Agent-hook server ───────────────────────────────────────────── // Why: hosts a loopback HTTP receiver inside the relay process so agent // CLIs running in remote PTYs can post hook events without leaving the @@ -374,13 +395,44 @@ async function main(): Promise { } } if (pluginOverlay.hasPiSource()) { - const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell) - const dir = pluginOverlay.materializePi(overlayId, sourceDir) - if (dir) { - env.PI_CODING_AGENT_DIR = dir - env.ORCA_PI_CODING_AGENT_DIR = dir - if (sourceDir) { - env.ORCA_PI_SOURCE_AGENT_DIR = sourceDir + // Why: source-dir defaulting is keyed on which Pi-compatible agent is + // being launched (Pi vs OMP). The renderer-supplied `command` is the + // only signal - disk-presence guessing silently shadows the other + // agent's extensions when both `~/.pi/agent` and `~/.omp/agent` exist. + const kind = detectPiAgentKindFromCommand(ctx.command) + const hasLaunchCommand = typeof ctx.command === 'string' && ctx.command.trim().length > 0 + const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand + if (kind === 'pi') { + const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi') + const dir = pluginOverlay.materializePi(overlayId, sourceDir, 'pi') + if (dir) { + env.PI_CODING_AGENT_DIR = dir + // Why: shadow var is agent-scoped so remote shell-ready wrappers can + // restore Pi by default while the `omp` wrapper switches on demand. + env.ORCA_PI_CODING_AGENT_DIR = dir + if (sourceDir) { + env.ORCA_PI_SOURCE_AGENT_DIR = sourceDir + } + } + } + if (shouldPrepareOmpShadow) { + // Why: in a bare shell, PI_CODING_AGENT_DIR is historically Pi's + // default. Do not mirror it into OMP; use OMP's own default unless an + // OMP-scoped source shadow is already present from a nested Orca shell. + const sourceDir = + kind === 'omp' + ? resolvePiSourceAgentDir(ctx.env, ctx.shell, 'omp') + : ctx.env.ORCA_OMP_SOURCE_AGENT_DIR + const dir = pluginOverlay.materializePi(overlayId, sourceDir, 'omp') + if (dir) { + if (kind === 'omp') { + env.PI_CODING_AGENT_DIR = dir + } + env.ORCA_OMP_CODING_AGENT_DIR = dir + env.ORCA_OMP_STATUS_EXTENSION = getRelayPiStatusExtensionPath(dir) + if (sourceDir) { + env.ORCA_OMP_SOURCE_AGENT_DIR = sourceDir + } } } } @@ -422,16 +474,20 @@ async function main(): Promise { dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { const opencode = params.opencodePluginSource const pi = params.piExtensionSource + const omp = params.ompExtensionSource assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('piExtensionSource', pi) + assertPluginSourceUnderByteCap('ompExtensionSource', omp) pluginOverlay.setSources({ opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, - piExtensionSource: typeof pi === 'string' ? pi : undefined + piExtensionSource: typeof pi === 'string' ? pi : undefined, + ompExtensionSource: typeof omp === 'string' ? omp : undefined }) return { installed: { opencode: pluginOverlay.hasOpenCodeSource(), - pi: pluginOverlay.hasPiSource() + pi: pluginOverlay.hasPiSource('pi'), + omp: pluginOverlay.hasPiSource('omp') } } }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6ff5438ebf4..796307d8a33 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -21,6 +21,7 @@ import { import logo from '../../../resources/logo.svg' import { SYNC_FIT_PANES_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' import { syncZoomCSSVar } from '@/lib/ui-zoom' +import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' import { buildAppFontFamily } from '@/lib/app-font-family' import { toast } from 'sonner' import { Toaster } from '@/components/ui/sonner' @@ -96,6 +97,7 @@ import { hydratePersistedUIAfterStartupRead } from './lib/startup-ui-hydration' import { applyDocumentTheme } from './lib/document-theme' +import { getSystemPrefersDark } from './lib/terminal-theme' import { isEditableTarget } from './lib/editable-target' import { getSelectedTextForFileSearch } from './lib/file-search-selection' import { useShortcutLabel } from './hooks/useShortcutLabel' @@ -269,6 +271,7 @@ function App(): React.JSX.Element { useShallow((s) => ({ toggleSidebar: s.toggleSidebar, fetchRepos: s.fetchRepos, + fetchProjectGroups: s.fetchProjectGroups, fetchAllWorktrees: s.fetchAllWorktrees, fetchWorktreeLineage: s.fetchWorktreeLineage, fetchSettings: s.fetchSettings, @@ -410,6 +413,7 @@ function App(): React.JSX.Element { const persistedUIReady = useAppStore((s) => s.persistedUIReady) const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) + const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) const isFullScreen = useAppStore((s) => s.isFullScreen) const settings = useAppStore((s) => s.settings) const primarySelectionMiddleClickPaste = resolvePrimarySelectionMiddleClickPaste( @@ -566,6 +570,7 @@ function App(): React.JSX.Element { // the local filesystem and then hydrate stale local workspace state. await actions.fetchSettings() await actions.fetchRepos() + await actions.fetchProjectGroups() await actions.fetchAllWorktrees() await actions.fetchWorktreeLineage() const persistedUI = await window.api.ui.get() @@ -813,14 +818,27 @@ function App(): React.JSX.Element { useEffect(() => { let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState()) return useAppStore.subscribe((state, previousState) => { + const systemPrefersDark = getSystemPrefersDark() // Why: skip the key build entirely when every input field is unchanged // by reference. Mirrors every field used by // getRuntimeMobileSessionSyncKey so this gate covers every "could the // key have changed?" case. - if (canSkipRuntimeMobileSessionSyncKeyBuild(state, previousState)) { + if ( + canSkipRuntimeMobileSessionSyncKeyBuild( + state, + previousState, + systemPrefersDark, + previousKey.systemPrefersDark + ) + ) { return } - const nextKey = getRuntimeMobileSessionSyncKey(state, previousState, previousKey) + const nextKey = getRuntimeMobileSessionSyncKey( + state, + previousState, + previousKey, + systemPrefersDark + ) if (runtimeMobileSessionSyncKeysEqual(nextKey, previousKey)) { return } @@ -929,6 +947,8 @@ function App(): React.JSX.Element { const timer = window.setTimeout(() => { void window.api.ui.set({ sidebarWidth, + rightSidebarOpen, + rightSidebarTab, rightSidebarWidth, groupBy, sortBy, @@ -950,6 +970,8 @@ function App(): React.JSX.Element { }, [ persistedUIReady, sidebarWidth, + rightSidebarOpen, + rightSidebarTab, rightSidebarWidth, groupBy, sortBy, @@ -975,7 +997,12 @@ function App(): React.JSX.Element { // system const mq = window.matchMedia('(prefers-color-scheme: dark)') applyDocumentTheme('system') - const handler = (): void => applyDocumentTheme('system') + const handler = (): void => { + applyDocumentTheme('system') + // Why: system theme changes do not mutate the store, so mobile + // terminal colors need an explicit graph republish. + scheduleRuntimeGraphSync() + } mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) } @@ -1029,14 +1056,7 @@ function App(): React.JSX.Element { const workspaceActive = activeView === 'terminal' && activeWorktreeId !== null // Why: suppress right sidebar controls on full-page navigation surfaces // since those surfaces intentionally own the full content area. - const showRightSidebarControls = - activeView !== 'settings' && - activeView !== 'tasks' && - activeView !== 'activity' && - activeView !== 'automations' && - activeView !== 'space' && - activeView !== 'skills' && - activeView !== 'mobile' + const showRightSidebarControls = canShowRightSidebarForView(activeView) const handleToggleExpand = (): void => { if (!effectiveActiveTabId) { @@ -1095,13 +1115,7 @@ function App(): React.JSX.Element { }) } - const canRevealRightSidebar = - activeView !== 'tasks' && - activeView !== 'activity' && - activeView !== 'automations' && - activeView !== 'space' && - activeView !== 'skills' && - activeView !== 'mobile' + const canRevealRightSidebar = canShowRightSidebarForView(activeView) const openSearchSidebar = (query: string | null): void => { if (query && activeWorktreeId) { @@ -1182,7 +1196,12 @@ function App(): React.JSX.Element { // counterpart, so suppressing them here would silently no-op when // focus lives inside the floating panel. if (isFloatingWorkspacePanelFocused()) { - if (isFloatingWorkspacePanelShortcut(e, isMac)) { + if ( + isFloatingWorkspacePanelShortcut(e, shortcutPlatform, null, keybindings, { + context, + terminalShortcutPolicy: settings?.terminalShortcutPolicy + }) + ) { return } } @@ -1491,22 +1510,6 @@ function App(): React.JSX.Element { ) : null - useEffect(() => { - if ( - (activeView === 'tasks' || - activeView === 'activity' || - activeView === 'automations' || - activeView === 'space' || - activeView === 'skills' || - activeView === 'mobile') && - rightSidebarOpen - ) { - // Why: hide the right sidebar immediately when entering full-page - // navigation views so previous side-panel state cannot occlude them. - actions.setRightSidebarOpen(false) - } - }, [activeView, rightSidebarOpen, actions]) - return (
        { + it('renders working as a yellow spinner', () => { + const markup = renderMarkup('working') + + expect(markup).toContain('border-yellow-500') + expect(markup).toContain('border-t-transparent') + expect(markup).toContain('animate-spin') + }) + it('renders done as an emerald check icon', () => { const markup = renderMarkup('done') diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx new file mode 100644 index 00000000000..320cae13520 --- /dev/null +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -0,0 +1,233 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { LoaderCircle } from 'lucide-react' +import { toast } from 'sonner' + +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' +import { linearUpdateIssue } from '@/runtime/runtime-linear-client' +import type { LinearIssue } from '../../../shared/types' + +type LinearIssueTextEditorProps = { + issue: LinearIssue + onIssueChange: (patch: Pick | Pick) => void + density?: 'page' | 'drawer' + fields?: 'all' | 'title' | 'description' +} + +function useAutosizeTextArea(value: string): React.RefObject { + const ref = useRef(null) + + useEffect(() => { + const textarea = ref.current + if (!textarea) { + return + } + textarea.style.height = 'auto' + textarea.style.height = `${textarea.scrollHeight}px` + }, [value]) + + return ref +} + +export function LinearIssueTextEditor({ + issue, + onIssueChange, + density = 'page', + fields = 'all' +}: LinearIssueTextEditorProps): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) + const [titleDraft, setTitleDraft] = useState(issue.title) + const [descriptionDraft, setDescriptionDraft] = useState(issue.description ?? '') + const [savingField, setSavingField] = useState<'title' | 'description' | null>(null) + const submitShortcutLabel = getScreenSubmitShortcutLabel() + const titleRef = useAutosizeTextArea(titleDraft) + const descriptionRef = useAutosizeTextArea(descriptionDraft) + const lastIssueIdRef = useRef(issue.id) + const lastSyncedTitleRef = useRef(issue.title) + const lastSyncedDescriptionRef = useRef(issue.description ?? '') + + useEffect(() => { + const nextDescription = issue.description ?? '' + if (issue.id !== lastIssueIdRef.current) { + lastIssueIdRef.current = issue.id + lastSyncedTitleRef.current = issue.title + lastSyncedDescriptionRef.current = nextDescription + setTitleDraft(issue.title) + setDescriptionDraft(nextDescription) + return + } + + const previousTitle = lastSyncedTitleRef.current + const previousDescription = lastSyncedDescriptionRef.current + + // Why: optimistic saves can update one field while the user has unsaved + // edits in the other; only sync fields that still match the last source. + if (issue.title !== previousTitle && titleDraft === previousTitle) { + setTitleDraft(issue.title) + } + if (nextDescription !== previousDescription && descriptionDraft === previousDescription) { + setDescriptionDraft(nextDescription) + } + + lastSyncedTitleRef.current = issue.title + lastSyncedDescriptionRef.current = nextDescription + }, [descriptionDraft, issue.description, issue.id, issue.title, titleDraft]) + + const saveField = useCallback( + async (field: 'title' | 'description') => { + const nextTitle = titleDraft.trim() + const nextDescription = descriptionDraft.trimEnd() + if (field === 'title' && !nextTitle) { + setTitleDraft(issue.title) + toast.error('Title is required') + return + } + + const nextValue = field === 'title' ? nextTitle : nextDescription + const currentValue = field === 'title' ? issue.title : (issue.description ?? '') + if (nextValue === currentValue) { + return + } + + const patch = + field === 'title' + ? ({ title: nextTitle } as const) + : ({ description: nextDescription } as const) + setSavingField(field) + onIssueChange(patch) + patchLinearIssue(issue.id, patch) + try { + const result = await linearUpdateIssue(settings, issue.id, patch, issue.workspaceId) + if (!result.ok) { + throw new Error(result.error) + } + } catch (error) { + const revert = + field === 'title' + ? ({ title: issue.title } as const) + : ({ description: issue.description ?? '' } as const) + onIssueChange(revert) + patchLinearIssue(issue.id, revert) + if (field === 'title') { + setTitleDraft(issue.title) + } else { + setDescriptionDraft(issue.description ?? '') + } + toast.error(error instanceof Error ? error.message : `Failed to update ${field}`) + } finally { + setSavingField(null) + } + }, + [ + descriptionDraft, + issue.description, + issue.id, + issue.title, + issue.workspaceId, + onIssueChange, + patchLinearIssue, + settings, + titleDraft + ] + ) + + const handleDescriptionKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!isScreenSubmitShortcut(event)) { + return + } + event.preventDefault() + event.currentTarget.blur() + }, + [] + ) + + const handleTitleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + event.currentTarget.blur() + return + } + handleDescriptionKeyDown(event) + }, + [handleDescriptionKeyDown] + ) + + const titleClass = + density === 'page' + ? 'text-[28px] font-semibold leading-tight' + : 'text-[15px] font-semibold leading-tight' + const descriptionClass = + density === 'page' ? 'mt-7 px-3 text-[15px] leading-7' : 'px-3 text-[14px] leading-relaxed' + + return ( +
        + {fields !== 'description' ? ( +
        +