diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 842997448..8b272f95f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Start contributing to [Project AIRI](https://github.com/moeru-ai/airi) +# Start contributing to [Project Moeka](https://github.com/cuwayo/moeka) Hello! Thank you for your interest in contributing to this project. This guide will help you get started. @@ -6,44 +6,31 @@ Hello! Thank you for your interest in contributing to this project. This guide w - [Git](https://git-scm.com/downloads) - [Node.js 23+](https://nodejs.org/en/download/) -- [corepack](https://github.com/nodejs/corepack) -- [pnpm](https://pnpm.io/installation) +- [Bun 1.4+](https://bun.sh/docs/installation)
Windows setup -0. Download [Visual Studio](https://visualstudio.microsoft.com/downloads/) and follow the instructions here: https://rust-lang.github.io/rustup/installation/windows-msvc.html#walkthrough-installing-visual-studio-2022 - - > Make sure to install Windows SDK and C++ build tools when installing Visual Studio. - -1. Open PowerShell -2. Install [`scoop`](https://scoop.sh/) +0. Open PowerShell +1. Install [`scoop`](https://scoop.sh/) ```powershell Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression ``` -3. Install `git`, Node.js, `rustup`, `msvc` through `scoop` +2. Install `git` and Node.js through `scoop` ```powershell - scoop install git nodejs rustup - - # For Rust dependencies - # Not required if you are not going to develop on either crates or apps/tamagotchi - scoop install main/rust-msvc - # Rust & Windows specific - rustup toolchain install stable-x86_64-pc-windows-msvc - rustup default stable-x86_64-pc-windows-msvc + scoop install git nodejs ``` > https://stackoverflow.com/a/64121601 -4. Install `pnpm` through `corepack` +3. Install `bun` through the official script ```powershell - corepack enable - corepack prepare pnpm@latest --activate + powershell -c "irm bun.sh/install.ps1 | iex" ```
@@ -58,11 +45,10 @@ Hello! Thank you for your interest in contributing to this project. This guide w brew install git node ``` -2. Install `pnpm` through `corepack` +2. Install `bun` through `brew` ```shell - corepack enable - corepack prepare pnpm@latest --activate + brew install oven-sh/bun/bun ``` @@ -73,22 +59,10 @@ Hello! Thank you for your interest in contributing to this project. This guide w 0. Open terminal 1. Follow [nodesource/distributions: NodeSource Node.js Binary Distributions](https://github.com/nodesource/distributions?tab=readme-ov-file#table-of-contents) to install `node` 2. Follow [Git](https://git-scm.com/downloads/linux) to install `git` -3. Install `pnpm` through `corepack` +3. Install `bun` through the official script ```shell - corepack enable - corepack prepare pnpm@latest --activate - ``` - -4. If you would love to help to develop the desktop version, you will need those dependencies: - - ```shell - sudo apt install \ - libssl-dev \ - libglib2.0-dev \ - libgtk-3-dev \ - libjavascriptcoregtk-4.1-dev \ - libwebkit2gtk-4.1-dev + curl -fsSL https://bun.sh/install | bash ``` @@ -121,8 +95,8 @@ Click on the **Fork** button on the top right corner of the [moeru-ai/airi](http ## Clone ```shell -git clone https://github.com//airi.git -cd airi +git clone https://github.com//moeka.git +cd moeka ``` ## Create your working branch @@ -134,12 +108,7 @@ git checkout -b ## Install dependencies ```shell -corepack enable -pnpm install - -# For Rust dependencies -# Not required if you are not going to develop on either crates or apps/tamagotchi -cargo fetch +bun install ``` > [!NOTE] @@ -147,51 +116,22 @@ cargo fetch > We would recommend to install [@antfu/ni](https://github.com/antfu-collective/ni) to make your script simpler. > > ```shell -> corepack enable > npm i -g @antfu/ni > ``` > > Once installed, you can > -> - use `ni` for `pnpm install`, `npm install` and `yarn install`. -> - use `nr` for `pnpm run`, `npm run` and `yarn run`. +> - use `ni` for `bun install`, `npm install` and `yarn install`. +> - use `nr` for `bun run`, `npm run` and `yarn run`. > > You don't need to care about the package manager, `ni` will help you choose the right one. ## Choose the application you want to develop on -### Stage Tamagotchi (Desktop version) +### Stage Web ```shell -pnpm dev:tamagotchi -``` - -> [!NOTE] -> -> For [@antfu/ni](https://github.com/antfu-collective/ni) users, you can -> -> ```shell -> nr dev:tamagotchi -> ``` - -> [!NOTE] -> -> The `dev` and `start` scripts run `install-electron` before `electron-vite`. -> -> Electron 42 removed the `postinstall` script. The `electron` package now downloads its binary -> when you first run its `bin` entry. `electron-vite` reads `node_modules/electron/path.txt` -> directly, so it never starts that download. A fresh install therefore fails with -> `Error: Electron uninstall`. -> -> `install-electron` runs the same code as the removed `postinstall` script. It returns -> immediately when the binary is already present. -> -> Remove this step after `electron-vite` supports the lazy download. - -### Stage Web (Browser version for [airi.moeru.ai](https://airi.moeru.ai)) - -```shell -pnpm dev +bun run dev ``` > [!NOTE] @@ -209,7 +149,7 @@ Browse the live UI component storyboard at [airi.moeru.ai/ui](https://airi.moeru ### Documentation site ```shell -pnpm dev:docs +bun run dev:docs ``` > [!NOTE] @@ -240,14 +180,14 @@ Edit the credentials in `.env.local`. Migrate the database ```shell -pnpm -F @proj-airi/telegram-bot db:generate -pnpm -F @proj-airi/telegram-bot db:push +bun run --filter @proj-airi/telegram-bot db:generate +bun run --filter @proj-airi/telegram-bot db:push ``` Run the bot ```shell -pnpm -F @proj-airi/telegram-bot start +bun run --filter @proj-airi/telegram-bot start ``` > [!NOTE] @@ -275,7 +215,7 @@ Edit the credentials in `.env.local`. Run the bot ```shell -pnpm -F @proj-airi/discord-bot start +bun run --filter @proj-airi/discord-bot start ``` > [!NOTE] @@ -305,7 +245,7 @@ Edit the credentials in `.env.local`. Run the bot ```shell -pnpm -F @proj-airi/minecraft-bot start +bun run --filter @proj-airi/minecraft-bot start ``` > [!NOTE] @@ -323,13 +263,13 @@ pnpm -F @proj-airi/minecraft-bot start Please make sure lint (static checkers) and TypeScript compilers are satisfied: ```shell -pnpm lint && pnpm typecheck +bun run lint && bun run typecheck ``` If you are committing images, consider using AVIF format instead of PNG, JPG etc. You can convert existing images to AVIF by running: ```shell -pnpm to-avif ... +bun run to-avif ... ``` > [!NOTE] diff --git a/.github/ISSUE_TEMPLATE/ai-task.md b/.github/ISSUE_TEMPLATE/ai-task.md index 7b3a9e6c0..2a812f530 100644 --- a/.github/ISSUE_TEMPLATE/ai-task.md +++ b/.github/ISSUE_TEMPLATE/ai-task.md @@ -30,13 +30,13 @@ Describe the expected behavior or evidence target. Run the narrowest relevant command first: ```bash -pnpm -F exec vitest run +bun run --filter exec vitest run ``` If runtime contracts changed, also run: ```bash -pnpm -F typecheck +bun run --filter typecheck ``` ## Output required diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f9804f4fc..dc85fbf53 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ # Copilot repository instructions -This is the `moeru-ai/airi` pnpm monorepo. Prefer the smallest safe change and keep every task inside its stated scope. +This is the `cuwayo/moeka` Bun monorepo. Prefer the smallest safe change and keep every task inside its stated scope. ## General rules @@ -14,7 +14,7 @@ This is the `moeru-ai/airi` pnpm monorepo. Prefer the smallest safe change and k ## Validation -- Use package-scoped pnpm commands where possible. +- Use package-scoped Bun commands where possible. - Run the narrowest relevant test first. - Run typecheck if runtime contracts or exported types changed. - Run affected package tests if shared logic changed. diff --git a/.github/labels.yml b/.github/labels.yml index c5ce915db..3a250d188 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -1,9 +1,3 @@ -- name: 'apps/stage-pocket' - description: 'Mobile App: iOS / Android' - color: 'b8d5ff' -- name: 'apps/stage-tamagotchi' - description: 'Desktop App: Windows & macOS & Linux' - color: 'a1f7ef' - name: 'apps/stage-web' description: 'Web App: PWA & Browser' color: 'abedff' @@ -137,7 +131,7 @@ description: 'Scope related to providers we support' color: '4468eb' - name: 'scope/server-api' - description: 'Scope related to the server api we maintained, the public service of AIRI' + description: 'Scope related to the server api we maintained, the public service of Moeka' color: '4468eb' - name: 'scope/ui' description: 'Scope related to UI/UX, or interface improve, perf, and bugs' diff --git a/.github/scripts/publish-gitcode-release.sh b/.github/scripts/publish-gitcode-release.sh index 6d96bff26..586138b57 100644 --- a/.github/scripts/publish-gitcode-release.sh +++ b/.github/scripts/publish-gitcode-release.sh @@ -123,16 +123,16 @@ write_expected_github_asset_metadata() { gh api "repos/${GITHUB_REPOSITORY_NAME}/releases/tags/${encoded_tag}" \ > "${release_assets_json}" - jq -r '.assets[].name | select(test("^(AIRI-.*|latest-.*\\.yml)$"))' "${release_assets_json}" \ + jq -r '.assets[].name | select(test("^(Moeka-.*|latest-.*\\.yml)$"))' "${release_assets_json}" \ | sort \ > "${WORK_DIR}/expected-asset-names.txt" - jq -r '.assets[] | select(.name | test("^(AIRI-.*|latest-.*\\.yml)$")) | [.name, (.digest // "")] | @tsv' "${release_assets_json}" \ + jq -r '.assets[] | select(.name | test("^(Moeka-.*|latest-.*\\.yml)$")) | [.name, (.digest // "")] | @tsv' "${release_assets_json}" \ | sort \ > "${WORK_DIR}/expected-asset-digests.tsv" if [[ ! -s "${WORK_DIR}/expected-asset-names.txt" ]]; then - echo "::error::No GitHub release assets matched AIRI-* or latest-*.yml." + echo "::error::No GitHub release assets matched Moeka-* or latest-*.yml." exit 1 fi diff --git a/.github/workflows/autofix.yaml b/.github/workflows/autofix.yaml index eb31c6b1e..5e79b617f 100644 --- a/.github/workflows/autofix.yaml +++ b/.github/workflows/autofix.yaml @@ -16,20 +16,20 @@ jobs: - uses: actions/checkout@v6 # NOTICE: # Pin Node.js because 26.8.0 reports 26.8.0-alpha.0.0.0. - # sharp 0.29.3 rejects this prerelease version during pnpm install. + # sharp 0.29.3 rejects this prerelease version during bun install. # Source: https://github.com/nodejs/node/blob/v26.8.0/src/node_version.h # Remove this pin when Node.js 26 reports a stable version. - - uses: pnpm/setup@v2 + - uses: actions/setup-node@v6 with: - runtime: node@26.7.0 - cache: true - install: false + node-version: 26.7.0 - - run: pnpm install - - run: pnpm prune && pnpm dedupe - - run: pnpm run lint:fix - - run: docker run -v `pwd`:`pwd` -w `pwd` ghcr.io/realm/swiftlint:latest --fix - working-directory: ./apps/stage-pocket + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + + - run: bun install + - run: bun prune && bun dedupe + - run: bun run lint:fix # - name: AutoCorrect # uses: huacnlee/autocorrect-action@main diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf590db8a..50c81d881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,33 +18,19 @@ jobs: - uses: actions/checkout@v6 # NOTICE: # Pin Node.js because 26.8.0 reports 26.8.0-alpha.0.0.0. - # sharp 0.29.3 rejects this prerelease version during pnpm install. + # sharp 0.29.3 rejects this prerelease version during bun install. # Source: https://github.com/nodejs/node/blob/v26.8.0/src/node_version.h # Remove this pin when Node.js 26 reports a stable version. - - uses: pnpm/setup@v2 + - uses: actions/setup-node@v6 with: - runtime: node@26.7.0 - cache: true - install: false + node-version: 26.7.0 - # Setup .NET for Godot C# linting - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - uses: oven-sh/setup-bun@v2 with: - dotnet-version: '10.0.x' + bun-version: 1.4.2 - # Lint Godot C# code - - name: Lint Godot C# - working-directory: ./engines/stage-tamagotchi-godot - run: dotnet format stage-tamagotchi-godot.slnx --verify-no-changes - - - run: docker run -v `pwd`:`pwd` -w `pwd` ghcr.io/realm/swiftlint:latest - working-directory: ./apps/stage-pocket - - - run: pnpm install --frozen-lockfile - - run: pnpm run lint - - run: docker run -v `pwd`:`pwd` -w `pwd` ghcr.io/realm/swiftlint:latest - working-directory: ./apps/stage-pocket + - run: bun install --frozen-lockfile + - run: bun run lint build-test: name: Build Test (${{ matrix.app_name }}) @@ -53,19 +39,13 @@ jobs: matrix: include: - app_name: stage-web - command: pnpm -F @proj-airi/stage-web run build && pnpm -F @proj-airi/docs run build:base && mv ./docs/.vitepress/dist ./apps/stage-web/dist/docs && pnpm -F @proj-airi/stage-ui run story:build && mv ./packages/stage-ui/.histoire/dist ./apps/stage-web/dist/ui - - - app_name: stage-tamagotchi - command: pnpm -F @proj-airi/stage-tamagotchi run build - - - app_name: stage-tamagotchi-godot - command: cd engines/stage-tamagotchi-godot && dotnet restore && dotnet build -c ExportRelease + command: bun run --filter @proj-airi/stage-web build && bun run --filter @proj-airi/docs build:base && mv ./docs/.vitepress/dist ./apps/stage-web/dist/docs && bun run --filter @proj-airi/stage-ui story:build && mv ./packages/stage-ui/.histoire/dist ./apps/stage-web/dist/ui - app_name: ui-transitions - command: pnpm -F @proj-airi/ui-transitions run play:build + command: bun run --filter @proj-airi/ui-transitions play:build - app_name: ui-loading-screens - command: pnpm -F @proj-airi/ui-loading-screens run play:build + command: bun run --filter @proj-airi/ui-loading-screens play:build runs-on: ubuntu-latest steps: @@ -80,40 +60,20 @@ jobs: restore-keys: | ${{ runner.os }}-turbo- - - uses: pnpm/setup@v2 + - uses: actions/setup-node@v6 with: - runtime: node@26.7.0 - cache: true - install: false + node-version: 26.7.0 - # Setup .NET and Godot - - name: Setup .NET - if: matrix.app_name == 'stage-tamagotchi-godot' - uses: actions/setup-dotnet@v4 + - uses: oven-sh/setup-bun@v2 with: - dotnet-version: '10.0.x' + bun-version: 1.4.2 - - name: Setup Godot - if: matrix.app_name == 'stage-tamagotchi-godot' - uses: chickensoft-games/setup-godot@v2 - with: - version: 4.7.1 - use-dotnet: true - include-templates: true - - - run: pnpm install --frozen-lockfile - - run: pnpm run build:packages + - run: bun install --frozen-lockfile + - run: bun run build:packages - name: Build App run: ${{ matrix.command }} - - name: Export Godot Linux sidecar - if: matrix.app_name == 'stage-tamagotchi-godot' - working-directory: ./engines/stage-tamagotchi-godot - run: | - mkdir -p out/linux - godot --headless --export-release "Linux x64" out/linux/godot-stage - unit-test: name: Unit Test runs-on: ubuntu-latest @@ -129,34 +89,21 @@ jobs: restore-keys: | ${{ runner.os }}-turbo- - - uses: pnpm/setup@v2 + - uses: actions/setup-node@v6 with: - runtime: node@26.7.0 - cache: true - install: false + node-version: 26.7.0 - # # Setup .NET and Godot - # - name: Setup .NET - # if: matrix.app_name == 'stage-tamagotchi-godot' - # uses: actions/setup-dotnet@v4 - # with: - # dotnet-version: '10.0.x' + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 - # - name: Setup Godot - # if: matrix.app_name == 'stage-tamagotchi-godot' - # uses: chickensoft-games/setup-godot@v2 - # with: - # version: 4.7.1 - # use-dotnet: true - # include-templates: true - - - run: pnpm install --frozen-lockfile + - run: bun install --frozen-lockfile - name: Setup Playwright run: npx playwright install chromium - - run: pnpm run build:packages - - run: pnpm run test:run + - run: bun run build:packages + - run: bun run test:run env: NODE_OPTIONS: --no-experimental-webstorage @@ -168,14 +115,16 @@ jobs: - uses: actions/checkout@v6 # Node.js - - uses: pnpm/setup@v2 + - uses: actions/setup-node@v6 with: - runtime: node@26.7.0 - cache: true - install: false + node-version: 26.7.0 - - run: pnpm install --frozen-lockfile - - run: pnpm run typecheck + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + + - run: bun install --frozen-lockfile + - run: bun run typecheck check-provenance: name: Check Provenance @@ -188,7 +137,7 @@ jobs: id: check with: fail-on-provenance-change: true # optional, default: false - # lockfile: pnpm-lock.yaml # optional + # lockfile: bun.lock # optional # base-ref: origin/main # optional, default: origin/main # fail-on-downgrade: true # optional, default: true - name: Print result diff --git a/.github/workflows/deploy-cloudflare-auth-ui.yml b/.github/workflows/deploy-cloudflare-auth-ui.yml deleted file mode 100644 index 73db64583..000000000 --- a/.github/workflows/deploy-cloudflare-auth-ui.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Cloudflare Pages (Auth UI) - -on: - push: - branches: - - 'main' - - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - deploy: - name: Deploy - ui-server-auth - runs-on: ubuntu-latest - permissions: - contents: read - deployments: write - - environment: - name: Auth Production - url: https://accounts.airi.build/ui/ - - steps: - - uses: actions/checkout@v6 - # Turborepo - - name: Cache turbo build setup - uses: actions/cache@v5 - with: - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-turbo- - - uses: pnpm/setup@v2 - with: - runtime: node@26.7.0 - cache: true - install: false - # NOTICE: - # - # Keep the wrangler setup consistent with the stage-web deployment - # workflow. cloudflare/wrangler-action expects a usable wrangler binary - # when running repository-local pnpm workspaces. - - run: pnpm i -g wrangler@4 - - run: pnpm install --frozen-lockfile - - run: pnpm run build:packages - - name: Build ui-server-auth - run: pnpm -F @proj-airi/ui-server-auth run build - env: - VITE_ENABLE_ANALYTICS: 'true' - VITE_SERVER_URL: 'https://api.airi.build' - - - uses: cloudflare/wrangler-action@v4 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy ./apps/ui-server-auth/dist --project-name=airi-accounts-ui --branch=main - gitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-cloudflare-workers-dev-server.yml b/.github/workflows/deploy-cloudflare-workers-dev-server.yml deleted file mode 100644 index 74edf0ec5..000000000 --- a/.github/workflows/deploy-cloudflare-workers-dev-server.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: Cloudflare Workers (server-dev) - -on: - push: - branches: - - 'server-dev' - - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - deploy-stage-web: - name: Deploy - stage-web (server-dev) - runs-on: ubuntu-latest - permissions: - contents: read - deployments: write - - environment: - name: ServerDev - url: ${{ steps.preview-urls.outputs.preview_url }} - - steps: - - uses: actions/checkout@v6 - # Turborepo - - name: Cache turbo build setup - uses: actions/cache@v5 - with: - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-turbo- - - uses: pnpm/setup@v2 - with: - runtime: node@26.7.0 - cache: true - install: false - - run: pnpm i -g wrangler@4 - - run: pnpm install --frozen-lockfile - - run: pnpm run build:packages - - name: Build stage-web - run: | - pnpm -F @proj-airi/stage-web run build - - pnpm -F @proj-airi/docs run build:base - mv ./docs/.vitepress/dist ./apps/stage-web/dist/docs - cp ./apps/stage-web/dist/docs/sitemap.xml ./apps/stage-web/dist/sitemap.xml - - pnpm -F @proj-airi/stage-ui run story:build - mv ./packages/stage-ui/.histoire/dist ./apps/stage-web/dist/ui - env: - S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} - S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} - S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} - S3_REGION: ${{ secrets.S3_REGION }} - WARP_DRIVE_PUBLIC_BASE: ${{ secrets.WARP_DRIVE_PUBLIC_BASE }} - VITE_SERVER_URL: 'https://airi-server-dev.up.railway.app' - - - name: Wrangler Upload - id: wrangler-versions-upload - uses: cloudflare/wrangler-action@v4 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: versions upload -c ./apps/stage-web/wrangler.toml --preview-alias server-dev --message "GitHub Actions uploaded server-dev branch preview" --tag v0.0.1-server-dev - gitHubToken: ${{ secrets.GITHUB_TOKEN }} - - - name: Prepare preview URL - id: preview-urls - run: | - deployment_url="${{ steps.wrangler-versions-upload.outputs.deployment-url }}" - if [ -z "$deployment_url" ]; then - echo "Deployment URL from wrangler upload is empty." >&2 - exit 1 - fi - - host_without_scheme="${deployment_url#https://}" - host_without_scheme="${host_without_scheme#http://}" - preview_url="https://server-dev-${host_without_scheme#*-}" - - echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT" - echo "Preview URL: $preview_url" - - deploy-auth-ui: - name: Deploy - ui-server-auth (server-dev) - runs-on: ubuntu-latest - permissions: - contents: read - deployments: write - - environment: - name: AuthServerDev - url: https://server-dev.airi-server-auth.pages.dev/ui/ - - steps: - - uses: actions/checkout@v6 - # Turborepo - - name: Cache turbo build setup - uses: actions/cache@v5 - with: - path: .turbo - key: ${{ runner.os }}-turbo-auth-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-turbo-auth- - ${{ runner.os }}-turbo- - - uses: pnpm/setup@v2 - with: - runtime: node@26.7.0 - cache: true - install: false - - run: pnpm i -g wrangler@4 - - run: pnpm install --frozen-lockfile - - run: pnpm run build:packages - - name: Build ui-server-auth - run: pnpm -F @proj-airi/ui-server-auth run build - env: - VITE_SERVER_URL: 'https://airi-server-dev.up.railway.app' - - - name: Wrangler Pages Deploy - uses: cloudflare/wrangler-action@v4 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy ./apps/ui-server-auth/dist --project-name=airi-accounts-ui --branch=server-dev - gitHubToken: ${{ secrets.GITHUB_TOKEN }} - - - name: Print preview URL - run: | - echo "Preview URL: https://server-dev.airi-server-auth.pages.dev/ui/" diff --git a/.github/workflows/deploy-cloudflare-workers-preview-deploy.yml b/.github/workflows/deploy-cloudflare-workers-preview-deploy.yml deleted file mode 100644 index b1b66c74f..000000000 --- a/.github/workflows/deploy-cloudflare-workers-preview-deploy.yml +++ /dev/null @@ -1,260 +0,0 @@ -name: Cloudflare Workers (Preview) Deploy - -on: - workflow_run: - workflows: - - Cloudflare Workers (Preview) Prepare - types: - - completed - -jobs: - ask-for-approval: - name: Ask for approval to deploy - ${{ matrix.app_name }} - runs-on: ubuntu-latest - permissions: - pull-requests: write - contents: read - - strategy: - matrix: - include: - - app_name: stage-web - - steps: - - name: Download artifact - metadata - uses: dawidd6/action-download-artifact@v19 - with: - workflow_conclusion: success - run_id: ${{ github.event.workflow_run.id }} - name: preview-meta - path: preview-meta - allow_forks: true - - - name: Parse metadata - id: meta - run: | - node --input-type=module <<'NODE' - import { appendFile, readFile } from 'node:fs/promises' - - const metadata = JSON.parse(await readFile('preview-meta/preview-meta.json', 'utf8')) - if ( - !/^\d+$/.test(metadata.prNumber) - || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(metadata.repositoryFullName) - || !/^[a-f0-9]{40}$/i.test(metadata.headSha) - ) { - throw new Error('Preview metadata has an invalid shape') - } - - await appendFile(process.env.GITHUB_OUTPUT, [ - `PR_NUM=${metadata.prNumber}`, - `REPO_FULL_NAME=${metadata.repositoryFullName}`, - `HEAD_SHA=${metadata.headSha}`, - '', - ].join('\n')) - NODE - - - name: Find Comment - if: ${{ always() }} - uses: peter-evans/find-comment@v4 - id: fc - with: - issue-number: ${{ steps.meta.outputs.PR_NUM }} - comment-author: 'github-actions[bot]' - body-includes: to Cloudflare Workers (Preview) for *${{ matrix.app_name }}* - - - name: Comment on require approval - uses: peter-evans/create-or-update-comment@v5 - with: - comment-id: ${{ steps.fc.outputs.comment-id }} - issue-number: ${{ steps.meta.outputs.PR_NUM }} - edit-mode: replace - body: | - ## ⏳ Approval required for deploying to Cloudflare Workers (Preview) for *${{ matrix.app_name }}*. - - | Name | Link | - |:------------------------|:---------------------------------------------------------------------------------------| - | 🔭 Waiting for approval | For maintainers, approve [here](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) - - Hey, maintainers, kindly take some time to review and approve this deployment when you are available. Thank you! 🙏 - - on-success: - if: ${{ github.event.workflow_run.conclusion == 'success' }} - name: Deploy - ${{ matrix.app_name }} - runs-on: ubuntu-latest - environment: Preview - - permissions: - contents: read - pull-requests: write - - strategy: - matrix: - include: - - app_name: stage-web - wrangler_config_path: ./apps/stage-web/wrangler.toml - build_directory: ./apps/stage-web/dist - build_command: | - pnpm -F @proj-airi/stage-web run build - - pnpm -F @proj-airi/docs run build:base - mv ./docs/.vitepress/dist ./apps/stage-web/dist/docs - cp ./apps/stage-web/dist/docs/sitemap.xml ./apps/stage-web/dist/sitemap.xml - - pnpm -F @proj-airi/stage-ui run story:build - mv ./packages/stage-ui/.histoire/dist ./apps/stage-web/dist/ui - - steps: - - name: Download artifact - metadata - uses: dawidd6/action-download-artifact@v19 - with: - workflow_conclusion: success - run_id: ${{ github.event.workflow_run.id }} - name: preview-meta - path: preview-meta - allow_forks: true - - - name: Parse metadata - id: meta - run: | - node --input-type=module <<'NODE' - import { appendFile, readFile } from 'node:fs/promises' - - const metadata = JSON.parse(await readFile('preview-meta/preview-meta.json', 'utf8')) - if ( - !/^\d+$/.test(metadata.prNumber) - || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(metadata.repositoryFullName) - || !/^[a-f0-9]{40}$/i.test(metadata.headSha) - ) { - throw new Error('Preview metadata has an invalid shape') - } - - await appendFile(process.env.GITHUB_OUTPUT, [ - `PR_NUM=${metadata.prNumber}`, - `REPO_FULL_NAME=${metadata.repositoryFullName}`, - `HEAD_SHA=${metadata.headSha}`, - '', - ].join('\n')) - NODE - - - name: Checkout repository - uses: actions/checkout@v6 - with: - repository: ${{ steps.meta.outputs.REPO_FULL_NAME }} - ref: ${{ steps.meta.outputs.HEAD_SHA }} - fetch-depth: 1 - persist-credentials: false - # https://gh.io/securely-using-pull_request_target - allow-unsafe-pr-checkout: true - - - name: Setup pnpm - uses: pnpm/setup@v2 - with: - runtime: node@26.7.0 - cache: true - install: false - - # NOTICE: - # - # Here installing wrangler to global is required, or otherwise: - # ERR_PNPM_ADDING_TO_ROOT Running this command will add the dependency to the workspace root... - # error occurs. - # - # Since https://github.com/cloudflare/wrangler-action/pull/339#issuecomment-2667622947 rejected the -g support - # by saying un-reasonable 'I'm not sure if it's common ... to install packages to the global scope, ... might be introducing some unintended side effects.' - # - # Clearly I think installing with ` install` brings more unintended side effects... - # - # As suggested by https://github.com/cloudflare/wrangler-action/issues/181#issuecomment-2127990708, we should pre-install - # with our package manager and then use it in the action. - - run: pnpm i -g wrangler@4 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build packages - run: pnpm run build:packages - - - name: Build ${{ matrix.app_name }} - id: build - run: ${{ matrix.build_command }} - continue-on-error: true - env: - S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} - S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} - S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} - S3_REGION: ${{ secrets.S3_REGION }} - WARP_DRIVE_PUBLIC_BASE: ${{ secrets.WARP_DRIVE_PUBLIC_BASE }} - STAGE_WEB_WARP_DRIVE_PREFIX: proj-airi/stage-web/pr-${{ steps.meta.outputs.PR_NUM }}/ - STAGE_UI_WARP_DRIVE_PREFIX: proj-airi/stage-ui/pr-${{ steps.meta.outputs.PR_NUM }}/ - - - name: Find Comment - if: ${{ always() }} - uses: peter-evans/find-comment@v4 - id: fc - with: - issue-number: ${{ steps.meta.outputs.PR_NUM }} - comment-author: 'github-actions[bot]' - body-includes: to Cloudflare Workers (Preview) for *${{ matrix.app_name }}* - - - name: Comment on build failure - if: ${{ steps.build.outcome != 'success' }} - uses: peter-evans/create-or-update-comment@v5 - with: - comment-id: ${{ steps.fc.outputs.comment-id }} - issue-number: ${{ steps.meta.outputs.PR_NUM }} - edit-mode: replace - body: | - ## ❌ Deploy to Cloudflare Workers (Preview) for *${{ matrix.app_name }}* failed. - - | Name | Link | - |:---------------------|:---------------------------------------------------------------------------------------| - | 🔍 Latest deploy log | https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} | - - - name: Fail if build failed - if: ${{ steps.build.outcome != 'success' }} - run: | - echo "Build step failed; marking workflow as failed." - exit 1 - - - name: Wrangler Upload - if: ${{ steps.build.outcome == 'success' }} - id: wrangler-versions-upload - uses: cloudflare/wrangler-action@v4 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: versions upload -c ${{ matrix.wrangler_config_path }} --preview-alias pr-${{ steps.meta.outputs.PR_NUM }} --message "GitHub Actions uploaded preview for Pull Request ${{ steps.meta.outputs.PR_NUM }}" --tag v0.0.1-pr.${{ steps.meta.outputs.PR_NUM }} - gitHubToken: ${{ secrets.GITHUB_TOKEN }} - - - name: Prepare preview URLs - if: ${{ steps.build.outcome == 'success' }} - id: preview-urls - run: | - deployment_url="${{ steps.wrangler-versions-upload.outputs.deployment-url }}" - if [ -z "$deployment_url" ]; then - echo "Deployment URL from wrangler upload is empty." >&2 - exit 1 - fi - - host_without_scheme="${deployment_url#https://}" - host_without_scheme="${host_without_scheme#http://}" - preview_alias_url="https://pr-${{ steps.meta.outputs.PR_NUM }}-${host_without_scheme#*-}" - - echo "deploy_preview_url=$deployment_url" >> "$GITHUB_OUTPUT" - echo "pull_request_preview_url=$preview_alias_url" >> "$GITHUB_OUTPUT" - - - name: Create or update comment - if: ${{ steps.build.outcome == 'success' }} - uses: peter-evans/create-or-update-comment@v5 - with: - comment-id: ${{ steps.fc.outputs.comment-id }} - issue-number: ${{ steps.meta.outputs.PR_NUM }} - edit-mode: replace - body: | - ## ✅ Deploy to Cloudflare Workers (Preview) for *${{ matrix.app_name }}* ready! - - | Name | Link | - |:------------------------|:--------------------------------------------------------------------------------------------| - | 🔍 Latest deploy log | https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} | - | 😎 Deploy Preview | ${{ steps.preview-urls.outputs.deploy_preview_url }} | - | 🚀 Pull Request Preview | ${{ steps.preview-urls.outputs.pull_request_preview_url }} | diff --git a/.github/workflows/deploy-cloudflare-workers-preview-prepare.yml b/.github/workflows/deploy-cloudflare-workers-preview-prepare.yml deleted file mode 100644 index 9d055cd9e..000000000 --- a/.github/workflows/deploy-cloudflare-workers-preview-prepare.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Cloudflare Workers (Preview) Prepare - -on: - pull_request: - branches: - - main - - workflow_dispatch: - -jobs: - prepare: - name: Prepare preview sources - runs-on: ubuntu-latest - - steps: - - name: Persist checkout metadata - env: - PR_NUM: ${{ github.event.number }} - REPO_FULL_NAME: ${{ github.event.pull_request.head.repo.full_name }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - node --input-type=module <<'NODE' - import { writeFile } from 'node:fs/promises' - - await writeFile('preview-meta.json', JSON.stringify({ - prNumber: process.env.PR_NUM, - repositoryFullName: process.env.REPO_FULL_NAME, - headSha: process.env.HEAD_SHA, - })) - NODE - - - name: Upload metadata artifact - uses: actions/upload-artifact@v7 - with: - name: preview-meta - path: ./preview-meta.json - overwrite: true diff --git a/.github/workflows/deploy-cloudflare-workers.yml b/.github/workflows/deploy-cloudflare-workers.yml deleted file mode 100644 index e60b22603..000000000 --- a/.github/workflows/deploy-cloudflare-workers.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Cloudflare Workers - -on: - push: - branches: - - 'main' - - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - deploy: - name: Deploy - stage-web - runs-on: ubuntu-latest - permissions: - contents: read - deployments: write - - environment: - name: Production - url: https://airi.moeru.ai/docs/ - - steps: - - uses: actions/checkout@v6 - # Turborepo - - name: Cache turbo build setup - uses: actions/cache@v5 - with: - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-turbo- - - uses: pnpm/setup@v2 - with: - runtime: node@26.7.0 - cache: true - install: false - # NOTICE: - # - # Here installing wrangler to global is required, or otherwise: - # ERR_PNPM_ADDING_TO_ROOT Running this command will add the dependency to the workspace root... - # error occurs. - # - # Since https://github.com/cloudflare/wrangler-action/pull/339#issuecomment-2667622947 rejected the -g support - # by saying un-reasonable 'I'm not sure if it's common ... to install packages to the global scope, ... might be introducing some unintended side effects.' - # - # Clearly I think installing with ` install` brings more unintended side effects... - # - # As suggested by https://github.com/cloudflare/wrangler-action/issues/181#issuecomment-2127990708, we should pre-install - # with our package manager and then use it in the action. - - run: pnpm i -g wrangler@4 - - run: pnpm install --frozen-lockfile - - run: pnpm run build:packages - - name: Build stage-web - run: | - pnpm -F @proj-airi/stage-web run build - - pnpm -F @proj-airi/docs run build:base - mv ./docs/.vitepress/dist ./apps/stage-web/dist/docs - cp ./apps/stage-web/dist/docs/sitemap.xml ./apps/stage-web/dist/sitemap.xml - - pnpm -F @proj-airi/stage-ui run story:build - mv ./packages/stage-ui/.histoire/dist ./apps/stage-web/dist/ui - env: - S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} - S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} - S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} - S3_REGION: ${{ secrets.S3_REGION }} - VITE_ENABLE_ANALYTICS: 'true' - VITE_SERVER_URL: 'https://api.airi.build' - WARP_DRIVE_PUBLIC_BASE: ${{ secrets.WARP_DRIVE_PUBLIC_BASE }} - - - uses: cloudflare/wrangler-action@v4 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: deploy -c ./apps/stage-web/wrangler.toml - gitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-huggingface-spaces.yml b/.github/workflows/deploy-huggingface-spaces.yml index 6ec0e8c08..f158aa744 100644 --- a/.github/workflows/deploy-huggingface-spaces.yml +++ b/.github/workflows/deploy-huggingface-spaces.yml @@ -22,14 +22,16 @@ jobs: HF_TOKEN: ${{ secrets.HF_TOKEN }} HF_USERNAME: ${{ secrets.HF_USERNAME }} - - uses: pnpm/setup@v2 + - uses: actions/setup-node@v6 with: - runtime: node@26.7.0 - cache: true - install: false + node-version: 26.7.0 - - run: pnpm install --frozen-lockfile - - run: pnpm build + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + + - run: bun install --frozen-lockfile + - run: bun run build env: TARGET_HUGGINGFACE_SPACE: 'true' VITE_APP_TARGET_HUGGINGFACE_SPACE: 'true' diff --git a/.github/workflows/pr-triage.lock.yml b/.github/workflows/pr-triage.lock.yml index 1b0426550..73d5c8c6e 100644 --- a/.github/workflows/pr-triage.lock.yml +++ b/.github/workflows/pr-triage.lock.yml @@ -351,15 +351,15 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_7cd577a1928d35f4_EOF' - {"add_labels":{"allowed":["bug","feature","pending triage","apps/stage-pocket","apps/stage-tamagotchi","apps/stage-web","env/os-all","env/os-linux","env/os-macos","env/os-windows","priority/general","priority/nice-to-have","priority/urgent","scope/agent","scope/audio-input","scope/audio-output","scope/avatar","scope/avatar/live2d","scope/avatar/vrm","scope/documentation","scope/engineering","scope/extension","scope/game-playing-ai","scope/i18n","scope/providers","scope/server-api","scope/ui"],"max":12,"target":"${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"remove_labels":{"allowed":["bug","feature","pending triage","apps/stage-pocket","apps/stage-tamagotchi","apps/stage-web","env/os-all","env/os-linux","env/os-macos","env/os-windows","priority/general","priority/nice-to-have","priority/urgent","scope/agent","scope/audio-input","scope/audio-output","scope/avatar","scope/avatar/live2d","scope/avatar/vrm","scope/documentation","scope/engineering","scope/extension","scope/game-playing-ai","scope/i18n","scope/providers","scope/server-api","scope/ui"],"max":24,"target":"${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}"},"report_incomplete":{}} + {"add_labels":{"allowed":["bug","feature","pending triage","apps/stage-web","env/os-all","env/os-linux","env/os-macos","env/os-windows","priority/general","priority/nice-to-have","priority/urgent","scope/agent","scope/audio-input","scope/audio-output","scope/avatar","scope/avatar/live2d","scope/avatar/vrm","scope/documentation","scope/engineering","scope/extension","scope/game-playing-ai","scope/i18n","scope/providers","scope/server-api","scope/ui"],"max":12,"target":"${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"remove_labels":{"allowed":["bug","feature","pending triage","apps/stage-web","env/os-all","env/os-linux","env/os-macos","env/os-windows","priority/general","priority/nice-to-have","priority/urgent","scope/agent","scope/audio-input","scope/audio-output","scope/avatar","scope/avatar/live2d","scope/avatar/vrm","scope/documentation","scope/engineering","scope/extension","scope/game-playing-ai","scope/i18n","scope/providers","scope/server-api","scope/ui"],"max":24,"target":"${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}"},"report_incomplete":{}} GH_AW_SAFE_OUTPUTS_CONFIG_7cd577a1928d35f4_EOF - name: Write Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | { "description_suffixes": { - "add_labels": " CONSTRAINTS: Maximum 12 label(s) can be added. Only these labels are allowed: [\"bug\" \"feature\" \"pending triage\" \"apps/stage-pocket\" \"apps/stage-tamagotchi\" \"apps/stage-web\" \"env/os-all\" \"env/os-linux\" \"env/os-macos\" \"env/os-windows\" \"priority/general\" \"priority/nice-to-have\" \"priority/urgent\" \"scope/agent\" \"scope/audio-input\" \"scope/audio-output\" \"scope/avatar\" \"scope/avatar/live2d\" \"scope/avatar/vrm\" \"scope/documentation\" \"scope/engineering\" \"scope/extension\" \"scope/game-playing-ai\" \"scope/i18n\" \"scope/providers\" \"scope/server-api\" \"scope/ui\"]. Target: ${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}.", - "remove_labels": " CONSTRAINTS: Maximum 24 label(s) can be removed. Only these labels can be removed: [bug feature pending triage apps/stage-pocket apps/stage-tamagotchi apps/stage-web env/os-all env/os-linux env/os-macos env/os-windows priority/general priority/nice-to-have priority/urgent scope/agent scope/audio-input scope/audio-output scope/avatar scope/avatar/live2d scope/avatar/vrm scope/documentation scope/engineering scope/extension scope/game-playing-ai scope/i18n scope/providers scope/server-api scope/ui]. Target: ${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}." + "add_labels": " CONSTRAINTS: Maximum 12 label(s) can be added. Only these labels are allowed: [\"bug\" \"feature\" \"pending triage\" \"apps/stage-web\" \"env/os-all\" \"env/os-linux\" \"env/os-macos\" \"env/os-windows\" \"priority/general\" \"priority/nice-to-have\" \"priority/urgent\" \"scope/agent\" \"scope/audio-input\" \"scope/audio-output\" \"scope/avatar\" \"scope/avatar/live2d\" \"scope/avatar/vrm\" \"scope/documentation\" \"scope/engineering\" \"scope/extension\" \"scope/game-playing-ai\" \"scope/i18n\" \"scope/providers\" \"scope/server-api\" \"scope/ui\"]. Target: ${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}.", + "remove_labels": " CONSTRAINTS: Maximum 24 label(s) can be removed. Only these labels can be removed: [bug feature pending triage apps/stage-web env/os-all env/os-linux env/os-macos env/os-windows priority/general priority/nice-to-have priority/urgent scope/agent scope/audio-input scope/audio-output scope/avatar scope/avatar/live2d scope/avatar/vrm scope/documentation scope/engineering scope/extension scope/game-playing-ai scope/i18n scope/providers scope/server-api scope/ui]. Target: ${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}." }, "repo_params": {}, "dynamic_tools": [] @@ -1112,7 +1112,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_labels\":{\"allowed\":[\"bug\",\"feature\",\"pending triage\",\"apps/stage-pocket\",\"apps/stage-tamagotchi\",\"apps/stage-web\",\"env/os-all\",\"env/os-linux\",\"env/os-macos\",\"env/os-windows\",\"priority/general\",\"priority/nice-to-have\",\"priority/urgent\",\"scope/agent\",\"scope/audio-input\",\"scope/audio-output\",\"scope/avatar\",\"scope/avatar/live2d\",\"scope/avatar/vrm\",\"scope/documentation\",\"scope/engineering\",\"scope/extension\",\"scope/game-playing-ai\",\"scope/i18n\",\"scope/providers\",\"scope/server-api\",\"scope/ui\"],\"max\":12,\"target\":\"${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"remove_labels\":{\"allowed\":[\"bug\",\"feature\",\"pending triage\",\"apps/stage-pocket\",\"apps/stage-tamagotchi\",\"apps/stage-web\",\"env/os-all\",\"env/os-linux\",\"env/os-macos\",\"env/os-windows\",\"priority/general\",\"priority/nice-to-have\",\"priority/urgent\",\"scope/agent\",\"scope/audio-input\",\"scope/audio-output\",\"scope/avatar\",\"scope/avatar/live2d\",\"scope/avatar/vrm\",\"scope/documentation\",\"scope/engineering\",\"scope/extension\",\"scope/game-playing-ai\",\"scope/i18n\",\"scope/providers\",\"scope/server-api\",\"scope/ui\"],\"max\":24,\"target\":\"${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}\"},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_labels\":{\"allowed\":[\"bug\",\"feature\",\"pending triage\",\"apps/stage-web\",\"env/os-all\",\"env/os-linux\",\"env/os-macos\",\"env/os-windows\",\"priority/general\",\"priority/nice-to-have\",\"priority/urgent\",\"scope/agent\",\"scope/audio-input\",\"scope/audio-output\",\"scope/avatar\",\"scope/avatar/live2d\",\"scope/avatar/vrm\",\"scope/documentation\",\"scope/engineering\",\"scope/extension\",\"scope/game-playing-ai\",\"scope/i18n\",\"scope/providers\",\"scope/server-api\",\"scope/ui\"],\"max\":12,\"target\":\"${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"remove_labels\":{\"allowed\":[\"bug\",\"feature\",\"pending triage\",\"apps/stage-web\",\"env/os-all\",\"env/os-linux\",\"env/os-macos\",\"env/os-windows\",\"priority/general\",\"priority/nice-to-have\",\"priority/urgent\",\"scope/agent\",\"scope/audio-input\",\"scope/audio-output\",\"scope/avatar\",\"scope/avatar/live2d\",\"scope/avatar/vrm\",\"scope/documentation\",\"scope/engineering\",\"scope/extension\",\"scope/game-playing-ai\",\"scope/i18n\",\"scope/providers\",\"scope/server-api\",\"scope/ui\"],\"max\":24,\"target\":\"${{ github.event.pull_request.number || github.event.inputs.pull_request_number }}\"},\"report_incomplete\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/pr-triage.md b/.github/workflows/pr-triage.md index 30b46eb81..57d115032 100644 --- a/.github/workflows/pr-triage.md +++ b/.github/workflows/pr-triage.md @@ -33,8 +33,6 @@ safe-outputs: - bug - feature - pending triage - - apps/stage-pocket - - apps/stage-tamagotchi - apps/stage-web - env/os-all - env/os-linux @@ -64,8 +62,6 @@ safe-outputs: - bug - feature - pending triage - - apps/stage-pocket - - apps/stage-tamagotchi - apps/stage-web - env/os-all - env/os-linux @@ -105,7 +101,7 @@ The target pull request is: Managed labels: - Type: `bug`, `feature` -- App surface: `apps/stage-pocket`, `apps/stage-tamagotchi`, `apps/stage-web` +- App surface: `apps/stage-web` - Environment: `env/os-all`, `env/os-linux`, `env/os-macos`, `env/os-windows` - Scope: `scope/agent`, `scope/audio-input`, `scope/audio-output`, `scope/avatar`, `scope/avatar/live2d`, `scope/avatar/vrm`, `scope/documentation`, `scope/engineering`, `scope/extension`, `scope/game-playing-ai`, `scope/i18n`, `scope/providers`, `scope/server-api`, `scope/ui` - Priority: `priority/general`, `priority/nice-to-have`, `priority/urgent` @@ -160,9 +156,7 @@ Do not use web search. Do not use bash to modify files, create branches, post co ### App labels - Apply `apps/stage-web` when files under `apps/stage-web/` change, or the PR text explicitly says the change is for the web app or PWA/browser surface. -- Apply `apps/stage-tamagotchi` when files under `apps/stage-tamagotchi/` change, or the PR text explicitly says desktop/Electron/Windows/macOS/Linux app. -- Apply `apps/stage-pocket` when files under `apps/stage-pocket/` change, or the PR text explicitly says mobile/iOS/Android app. -- Multiple app labels are allowed when the evidence is explicit. +- Only `apps/stage-web` exists in this fork. Never apply a desktop or mobile app label. ### Environment labels diff --git a/.github/workflows/release-pkg.yaml b/.github/workflows/release-pkg.yaml index 63929cf66..c6abba880 100644 --- a/.github/workflows/release-pkg.yaml +++ b/.github/workflows/release-pkg.yaml @@ -21,11 +21,13 @@ jobs: with: fetch-depth: 0 - - uses: pnpm/setup@v2 + - uses: actions/setup-node@v6 with: - runtime: node@26.7.0 - cache: true - install: false + node-version: 26.7.0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 - name: Configure npm registry uses: actions/setup-node@v6 @@ -39,9 +41,9 @@ jobs: # https://github.com/e18e/ecosystem-issues/issues/201 - run: npm install -g npm@latest - - run: pnpm install --frozen-lockfile - - run: pnpm run build:packages + - run: bun install --frozen-lockfile + - run: bun run build:packages - - run: pnpm publish -r --access public --no-git-checks --dry-run + - run: bun run publish:packages -- --dry-run - - run: pnpm publish -r --access public --no-git-checks + - run: bun run publish:packages diff --git a/.github/workflows/release-pocket-android.yml b/.github/workflows/release-pocket-android.yml deleted file mode 100644 index 2dc346f16..000000000 --- a/.github/workflows/release-pocket-android.yml +++ /dev/null @@ -1,136 +0,0 @@ -name: Release Pocket (Android) - -permissions: - contents: write - actions: read - -on: - release: - types: - - prereleased - workflow_dispatch: - inputs: - build_only: - description: Build only (no upload to release) - required: false - default: false - type: boolean - tag: - description: Specific tag for the release upload - required: false - type: string - schedule: - - cron: '0 1 * * *' - -jobs: - build: - name: Build Android APK - runs-on: ubuntu-latest - env: - PRODUCT_NAME: 'AIRI' - # Every current trigger path produces an installable APK artifact, so ship the analytics-enabled build consistently. - VITE_ENABLE_ANALYTICS: 'true' - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '21' - - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - - - uses: pnpm/setup@v2 - with: - runtime: node@26.7.0 - cache: true - install: false - - - name: Install Android build tools - run: | - sdkmanager "build-tools;36.1.0" - echo "${ANDROID_SDK_ROOT:-$ANDROID_HOME}/build-tools/36.1.0" >> "$GITHUB_PATH" - - - run: pnpm install --frozen-lockfile - - run: pnpm run build:packages - - - name: Validate workflow dispatch release tag - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.tag == '' }} - run: | - echo "workflow_dispatch release uploads require the tag input" - exit 1 - - - name: Build web assets - run: pnpm -F @proj-airi/stage-pocket run build - - - name: Capacitor sync - run: pnpm -F @proj-airi/stage-pocket exec cap sync android - - - name: Extract android keystore - run: echo "${{ secrets.CAPACITOR_ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ${{ github.workspace }}/apps/stage-pocket/airi-pocket.jks - - - name: Build APK - working-directory: apps/stage-pocket/android - run: pnpm -F @proj-airi/stage-pocket exec cap build android - env: - CAPACITOR_ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/apps/stage-pocket/airi-pocket.jks - CAPACITOR_ANDROID_KEYSTORE_ALIAS: ${{ secrets.CAPACITOR_ANDROID_KEYSTORE_ALIAS }} - CAPACITOR_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.CAPACITOR_ANDROID_KEYSTORE_PASSWORD }} - CAPACITOR_ANDROID_KEYSTORE_ALIAS_PASSWORD: ${{ secrets.CAPACITOR_ANDROID_KEYSTORE_ALIAS_PASSWORD }} - - - name: Rename APK for distribution - env: - RELEASE_TAG_NAME: ${{ github.event.release.tag_name || inputs.tag }} - run: | - VERSION="${RELEASE_TAG_NAME#v}" - if [ -z "$VERSION" ]; then - VERSION="$(sed -n 's/^AIRI_VERSION_NAME=//p' apps/stage-pocket/android/app-version.properties)" - fi - - APK_NAME="${PRODUCT_NAME}-${VERSION}-android.apk" - APK_SOURCE="apps/stage-pocket/android/app/build/outputs/apk/release/app-release-signed.apk" - APK_BUNDLE_DIR="apps/stage-pocket/android/bundle" - - mkdir -p "$APK_BUNDLE_DIR" - mv "$APK_SOURCE" "$APK_BUNDLE_DIR/$APK_NAME" - - echo "APK_NAME=$APK_NAME" >> "$GITHUB_ENV" - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - - - name: Upload APK artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ env.APK_NAME }} - path: apps/stage-pocket/android/bundle/${{ env.APK_NAME }} - if-no-files-found: error - - - name: Upload to GitHub Releases - id: upload-github-release - if: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.build_only) }} - uses: softprops/action-gh-release@v2 - with: - files: apps/stage-pocket/android/bundle/${{ env.APK_NAME }} - append_body: true - tag_name: ${{ github.event.release.tag_name || inputs.tag }} - - - name: Setup butler - uses: remarkablegames/setup-butler@v2 - - # https://itch.io/docs/butler/pushing.html - - name: Upload Android to itch.io - if: ${{ github.event_name == 'release' }} - run: butler push apps/stage-pocket/android/bundle/${{ env.APK_NAME }} nekomeowww/airi:android --userversion ${{ env.VERSION }} - env: - BUTLER_API_KEY: ${{ secrets.GAME_PUBLISHING_ITCHIO }} - - - name: Publish release assets to GitCode - if: ${{ always() && github.repository == 'moeru-ai/airi' && steps.upload-github-release.outcome == 'success' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.build_only)) }} - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} - GITCODE_OWNER: ${{ secrets.GITCODE_OWNER }} - GITCODE_REPO: ${{ secrets.GITCODE_REPO }} - GITCODE_RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} - run: bash .github/scripts/publish-gitcode-release.sh diff --git a/.github/workflows/release-pocket-ios.yml b/.github/workflows/release-pocket-ios.yml deleted file mode 100644 index 1410d2ca6..000000000 --- a/.github/workflows/release-pocket-ios.yml +++ /dev/null @@ -1,180 +0,0 @@ -name: Release Pocket (iOS) - -permissions: - contents: write - actions: read - -on: - release: - types: - - prereleased - workflow_dispatch: - inputs: - testflight: - description: Upload to TestFlight - type: boolean - default: false - tag: - description: GitHub Release tag (empty to skip) - type: string - required: false - default: '' - -jobs: - build: - name: Build iOS - runs-on: macos-26 - env: - PRODUCT_NAME: 'AIRI' - VITE_ENABLE_ANALYTICS: 'true' - UPLOAD_TESTFLIGHT: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.testflight) }} - RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} - steps: - - uses: actions/checkout@v6 - - - uses: pnpm/setup@v2 - with: - runtime: node@26.7.0 - cache: true - install: false - - - run: pnpm install --frozen-lockfile - - run: pnpm run build:packages - - - name: Build assets - run: pnpm -F @proj-airi/stage-pocket run build - - - name: Sync assets to iOS - run: pnpm -F @proj-airi/stage-pocket exec cap sync ios - - - name: Install Apple certificate - env: - CERT_BASE64: ${{ secrets.APPLE_DISTRIBUTION_CERT_BASE64 }} - CERT_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_CERT_PASSWORD }} - run: | - CERT_PATH="$RUNNER_TEMP/certificate.p12" - KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" - KEYCHAIN_PASSWORD="$(openssl rand -hex 16)" - - echo "$CERT_BASE64" | base64 --decode -o "$CERT_PATH" - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 3600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$CERT_PASSWORD" \ - -T /usr/bin/codesign -T /usr/bin/security - - security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db - - echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" - - - name: Install provisioning profile - env: - PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }} - run: | - PROFILE_PATH="$RUNNER_TEMP/profile.mobileprovision" - echo "$PROFILE_BASE64" | base64 --decode -o "$PROFILE_PATH" - - mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/ - - - name: Read version from Xcode project - run: | - PBXPROJ="apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj" - VERSION="$(sed -n 's/.*MARKETING_VERSION = \(.*\);/\1/p' "$PBXPROJ" | head -1 | tr -d ' ')" - BUILD_NUMBER="$(sed -n 's/.*CURRENT_PROJECT_VERSION = \(.*\);/\1/p' "$PBXPROJ" | head -1 | tr -d ' ')" - - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" - - - name: Archive iOS - run: | - xcodebuild archive \ - -project apps/stage-pocket/ios/App/App.xcodeproj \ - -scheme App \ - -configuration Release \ - -destination "generic/platform=iOS" \ - -archivePath "$RUNNER_TEMP/App.xcarchive" \ - CURRENT_PROJECT_VERSION=${{ env.BUILD_NUMBER }} - - - name: Export IPA - run: | - xcodebuild -exportArchive \ - -archivePath "$RUNNER_TEMP/App.xcarchive" \ - -exportPath "$RUNNER_TEMP/output" \ - -exportOptionsPlist apps/stage-pocket/ios/ExportOptions.plist - - - name: Rename IPA for distribution - run: | - IPA_SOURCE="$(find "$RUNNER_TEMP/output" -name '*.ipa' -print -quit)" - IPA_NAME="${PRODUCT_NAME}-${VERSION}_(${BUILD_NUMBER})-ios.ipa" - - mv "$IPA_SOURCE" "$RUNNER_TEMP/output/$IPA_NAME" - - echo "IPA_NAME=$IPA_NAME" >> "$GITHUB_ENV" - - - name: Upload IPA artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ env.IPA_NAME }} - path: ${{ runner.temp }}/output/${{ env.IPA_NAME }} - if-no-files-found: error - - - name: Install Transporter - if: ${{ env.UPLOAD_TESTFLIGHT == 'true' }} - run: | - curl -fsSL "https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/ra/resources/download/public/Transporter__OSX/bin/" -o "$RUNNER_TEMP/itmstransporter.pkg" - sudo installer -pkg "$RUNNER_TEMP/itmstransporter.pkg" -target / - rm -f "$RUNNER_TEMP/itmstransporter.pkg" - - - name: Upload to TestFlight - if: ${{ env.UPLOAD_TESTFLIGHT == 'true' }} - uses: apple-actions/upload-testflight-build@v4 - with: - app-path: ${{ runner.temp }}/output/${{ env.IPA_NAME }} - issuer-id: ${{ secrets.APPSTORE_CONNECT_API_ISSUER_ID }} - api-key-id: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }} - api-private-key: ${{ secrets.APPSTORE_CONNECT_API_KEY }} - - - name: Upload to GitHub Releases - if: ${{ env.RELEASE_TAG != '' }} - uses: softprops/action-gh-release@v2 - with: - files: ${{ runner.temp }}/output/${{ env.IPA_NAME }} - append_body: true - fail_on_unmatched_files: true - tag_name: ${{ env.RELEASE_TAG }} - - - name: Clean up signing - if: always() - run: | - if [ -n "$KEYCHAIN_PATH" ]; then - security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true - fi - rm -f ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision - - publish-gitcode-release: - name: Publish GitCode Release Mirror - needs: build - if: ${{ always() && github.repository == 'moeru-ai/airi' && needs.build.result == 'success' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.tag != '')) }} - continue-on-error: true - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Validate release mirror tools - run: | - gh --version - jq --version - curl --version - - - name: Publish release assets to GitCode - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} - GITCODE_OWNER: ${{ secrets.GITCODE_OWNER }} - GITCODE_REPO: ${{ secrets.GITCODE_REPO }} - GITCODE_RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} - run: bash .github/scripts/publish-gitcode-release.sh diff --git a/.github/workflows/release-tamagotchi-steam.yml b/.github/workflows/release-tamagotchi-steam.yml deleted file mode 100644 index a928300bd..000000000 --- a/.github/workflows/release-tamagotchi-steam.yml +++ /dev/null @@ -1,278 +0,0 @@ -name: Release Tamagotchi to Steam - -permissions: - contents: read - actions: read - -on: - release: - types: - - prereleased - workflow_dispatch: - inputs: - upload_to_steam: - description: Upload the completed build to Steam - required: true - default: false - type: boolean - release_branch: - description: Steam branch to set live - required: true - default: internal-test - type: string - build_description: - description: Optional Steam build description - required: false - type: string - -concurrency: - group: steam-deploy-tamagotchi-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || 'internal-test' }} - cancel-in-progress: false - -jobs: - build: - name: Build ${{ matrix.depot }} depot - runs-on: ${{ matrix.os }} - env: - VITE_DISTRIBUTION: steam - VITE_DISABLE_CUSTOM_PROVIDERS: 'true' - VITE_DISABLE_FLUX_PURCHASE: 'true' - VITE_ENABLE_ANALYTICS: 'false' - strategy: - fail-fast: false - matrix: - include: - - depot: windows - os: windows-latest - builder-args: --windows --x64 - arch: x64 - - - depot: macos - os: macos-26 - builder-args: --macos --arm64 - arch: arm64 - - - depot: linux - os: ubuntu-latest - builder-args: --linux --x64 - arch: x64 - - steps: - # The Linux desktop build needs more space than the default hosted runner provides. - - name: Free Linux disk space - if: matrix.depot == 'linux' - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - - - uses: actions/checkout@v6 - - - name: Select Xcode 26.2 - if: matrix.depot == 'macos' - run: | - sudo xcode-select -s /Applications/Xcode_26.2.app - xcodebuild -version - - - name: Show macOS toolchain - if: matrix.depot == 'macos' - run: | - xcodebuild -version - actool --version - - - uses: pnpm/setup@v2 - with: - runtime: node@26.7.0 - cache: true - install: false - - - name: Install macOS system dependencies for node-canvas - if: matrix.depot == 'macos' - run: brew install pkg-config cairo pango jpeg giflib librsvg - - - run: pnpm install --frozen-lockfile - - - run: pnpm run build:packages - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Setup Godot - uses: chickensoft-games/setup-godot@v2 - with: - version: '4.7.1' - use-dotnet: true - include-templates: true - - - name: Export Godot Stage for Windows - if: matrix.depot == 'windows' - working-directory: ./engines/stage-tamagotchi-godot - shell: bash - run: | - mkdir -p out/win - godot --headless --export-release "Windows Desktop" out/win/godot-stage.exe - - - name: Export Godot Stage for macOS - if: matrix.depot == 'macos' - working-directory: ./engines/stage-tamagotchi-godot - run: | - mkdir -p out/mac - godot --headless --export-release "macOS" out/mac/godot-stage.app - - - name: Clean Godot Stage for Steam on macOS - if: matrix.depot == 'macos' - working-directory: ./engines/stage-tamagotchi-godot - run: | - # NOTICE: - # SteamPipe excludes *.pdb files after Electron signs the parent app. - # Removing Godot debug symbols and AppleDouble files before signing keeps macOS sealed resources aligned with the installed bundle. - # Source/context: Steam internal-test BuildID 24160350 and the game-ci SteamPipe FileExclusion "*.pdb" behavior. - # Removal condition: SteamPipe preserves these resources or the embedded Godot bundle no longer contains them. - find out/mac/godot-stage.app -name '*.pdb' -delete - find out/mac/godot-stage.app -name '._*' -delete - - - name: Export Godot Stage for Linux - if: matrix.depot == 'linux' - working-directory: ./engines/stage-tamagotchi-godot - run: | - mkdir -p out/linux - godot --headless --export-release "Linux ${{ matrix.arch }}" out/linux/godot-stage - - - name: Build Windows app - if: matrix.depot == 'windows' - run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=never - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Build macOS app - if: matrix.depot == 'macos' - run: | - echo "$CSC_CONTENT" | base64 --decode > apps/stage-tamagotchi/apple-developer-code-signing.p12 - export CSC_LINK="./apple-developer-code-signing.p12" - pnpm run -F @proj-airi/stage-tamagotchi build - pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=never - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_CONTENT: ${{ secrets.CSC_CONTENT }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_DEVELOPER_APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_DEVELOPER_APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_DEVELOPER_TEAM_ID }} - - - name: Build Linux app - if: matrix.depot == 'linux' - run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=never - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Package Windows depot - if: matrix.depot == 'windows' - shell: pwsh - run: | - New-Item -ItemType Directory -Force steam-content/windows | Out-Null - Copy-Item apps/stage-tamagotchi/dist/win-unpacked/* steam-content/windows -Recurse -Force - tar -cf steam-content-windows.tar -C steam-content windows - - - name: Package macOS depot - if: matrix.depot == 'macos' - run: | - app_path="apps/stage-tamagotchi/dist/mac-arm64/AIRI.app" - if [[ ! -d "$app_path" ]]; then - echo "::error::No macOS app bundle was produced." - exit 1 - fi - - mkdir -p steam-content/macos - ditto --norsrc "$app_path" steam-content/macos/AIRI.app - find steam-content/macos -name '._*' -delete - COPYFILE_DISABLE=1 tar -cf steam-content-macos.tar -C steam-content macos - - - name: Package Linux depot - if: matrix.depot == 'linux' - run: | - mkdir -p steam-content/linux - cp -a apps/stage-tamagotchi/dist/linux-unpacked/. steam-content/linux/ - tar -cf steam-content-linux.tar -C steam-content linux - - - name: Upload depot artifact - uses: actions/upload-artifact@v7 - with: - name: steam-content-${{ matrix.depot }} - path: steam-content-${{ matrix.depot }}.tar - if-no-files-found: error - overwrite: true - - deploy: - name: Upload to Steam - if: ${{ github.event_name == 'release' || inputs.upload_to_steam }} - needs: build - runs-on: ubuntu-latest - env: - STEAM_APP_ID: '3885340' - STEAM_DEBUG_BRANCH: 'false' - STEAM_RELEASE_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || 'internal-test' }} - STEAM_WINDOWS_DEPOT_ID: '3885342' - steps: - - name: Validate Steam branch - env: - RELEASE_BRANCH: ${{ env.STEAM_RELEASE_BRANCH }} - run: | - if [[ ! "$RELEASE_BRANCH" =~ ^[A-Za-z0-9._-]+$ ]]; then - echo "::error::Steam release branch may contain only letters, numbers, dots, underscores, and hyphens." - exit 1 - fi - - if [[ "$RELEASE_BRANCH" == "default" ]]; then - echo '::error::Steam release branch "default" must be promoted manually.' - exit 1 - fi - - - name: Normalize Steam build description - id: metadata - uses: actions/github-script@v9 - env: - INPUT_BUILD_DESCRIPTION: ${{ inputs.build_description }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - with: - result-encoding: string - script: | - const source = process.env.RELEASE_TAG || process.env.GITHUB_SHA - const fallback = `AIRI ${source} Steam candidate` - const normalizedDescription = (process.env.INPUT_BUILD_DESCRIPTION || fallback) - .replace(/[\r\n\t]+/g, ' ') - .replace(/[\u0000-\u001f\u007f]+/g, ' ') - .replaceAll('\\', '/') - .replaceAll('"', "'") - .replace(/ {2,}/g, ' ') - .trim() - .slice(0, 512) - - return normalizedDescription || fallback - - - name: Download Steam depots - uses: actions/download-artifact@v5 - with: - pattern: steam-content-* - path: steam-artifacts - merge-multiple: true - - - name: Prepare Steam upload root - run: | - mkdir -p steam-build - tar -xf steam-artifacts/steam-content-windows.tar -C steam-build - tar -xf steam-artifacts/steam-content-macos.tar -C steam-build - tar -xf steam-artifacts/steam-content-linux.tar -C steam-build - - - name: Deploy desktop builds to Steam - uses: game-ci/steam-deploy@v3.2.0 - with: - username: ${{ secrets.STEAM_USERNAME }} - configVdf: ${{ secrets.STEAM_CONFIG_VDF }} - appId: ${{ env.STEAM_APP_ID }} - firstDepotIdOverride: ${{ env.STEAM_WINDOWS_DEPOT_ID }} - buildDescription: ${{ steps.metadata.outputs.result }} - rootPath: steam-build - depot1Path: windows - depot2Path: macos - depot3Path: linux - releaseBranch: ${{ env.STEAM_RELEASE_BRANCH }} - debugBranch: ${{ env.STEAM_DEBUG_BRANCH }} diff --git a/.github/workflows/release-tamagotchi.yml b/.github/workflows/release-tamagotchi.yml deleted file mode 100644 index 95d09d79f..000000000 --- a/.github/workflows/release-tamagotchi.yml +++ /dev/null @@ -1,665 +0,0 @@ -name: Release Tamagotchi - -permissions: - contents: write - actions: read - -env: - BUNDLE_NAME: '' - DEB_BUNDLE_NAME: '' - RPM_BUNDLE_NAME: '' - FLATPAK_BUNDLE_NAME: '' - PRODUCT_NAME: 'AIRI' - VERSION: '' - -on: - release: - types: - - prereleased - workflow_dispatch: - inputs: - build_only: - description: Build only - required: false - default: false - type: boolean - artifacts_only: - description: Build and upload artifacts only - required: false - default: false - type: boolean - tag: - description: Specific tag/commit for the release (leave empty to auto-detect latest tag) - required: false - type: string - platform: - description: Platform - type: choice - options: - - all - - windows - - macos - - linux - schedule: - - cron: '0 0 * * *' - -jobs: - build: - name: Build - continue-on-error: ${{ matrix.skip }} # WORKAROUND: Skipping subsequent steps without failing the whole workflow - env: - VITE_ENABLE_ANALYTICS: ${{ ((github.event_name == 'release') || (github.event_name == 'workflow_dispatch' && !inputs.build_only)) && 'true' || 'false' }} - strategy: - matrix: - include: - - - os: macos-15-intel - artifact: darwin-x64 - target: x86_64-apple-darwin - arch: x64 - builder-args: --macos --x64 - skip: ${{ inputs.platform != '' && inputs.platform != 'all' && inputs.platform != 'macos' }} - - - os: macos-26 - artifact: darwin-arm64 - target: aarch64-apple-darwin - builder-args: --macos --arm64 - arch: arm64 - skip: ${{ inputs.platform != '' && inputs.platform != 'all' && inputs.platform != 'macos' }} - - - os: ubuntu-latest - artifact: linux-x64 - target: x86_64-unknown-linux-gnu - builder-args: --linux --x64 - arch: x64 - skip: ${{ inputs.platform != '' && inputs.platform != 'all' && inputs.platform != 'linux' }} - - - os: ubuntu-24.04-arm - artifact: linux-arm64 - target: aarch64-unknown-linux-gnu - builder-args: --linux --arm64 - arch: arm64 - skip: ${{ inputs.platform != '' && inputs.platform != 'all' && inputs.platform != 'linux' }} - - - os: windows-latest - artifact: windows-x64-setup - target: x86_64-pc-windows-msvc - builder-args: --windows --x64 - arch: x64 - skip: ${{ inputs.platform != '' && inputs.platform != 'all' && inputs.platform != 'windows' }} - - runs-on: ${{ matrix.os }} - steps: - - name: Skip the build (fail fast) - run: | - echo "Skipping build for ${{ matrix.os }}..." - echo "This will fail the job, but don't panic—this is expected when not building for all." - echo "This will be improved in the future." - exit ${{ matrix.skip && '1' || '0' }} - - # Why? - # - # failed to build archive at `/home/runner/work/airi/airi/target/x86_64-unknown-linux-gnu/release/deps/libapp_lib.rlib`: - # No space left on device (os error 28) - - name: Free Disk Space - if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - - - uses: actions/checkout@v6 - - - name: macOS Select Xcode 26.2 - if: matrix.os == 'macos-15-intel' || matrix.os == 'macos-26' - run: | - sudo xcode-select -s /Applications/Xcode_26.2.app - xcodebuild -version - - - name: macOS Show Toolchain - if: matrix.os == 'macos-15-intel' || matrix.os == 'macos-26' - run: | - xcodebuild -version - actool --version - - # NOTICE: - # pnpm 11 does not ship a working standalone executable for Intel macOS. - # Install pnpm through npm so it uses the Node.js runtime from setup-node. - # https://github.com/pnpm/pnpm/issues/11423 - # Remove this branch when the project uses pnpm 12 or later. - - uses: actions/setup-node@v6 - if: matrix.os == 'macos-15-intel' - with: - node-version: 26.7.0 - - - name: Install pnpm with npm - if: matrix.os == 'macos-15-intel' - run: npm install --global pnpm@11.24.0 - - - name: Get pnpm store path - if: matrix.os == 'macos-15-intel' - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - name: Cache pnpm store - if: matrix.os == 'macos-15-intel' - uses: actions/cache@v5 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-pnpm-store- - - - uses: pnpm/setup@v2 - if: matrix.os != 'macos-15-intel' - with: - runtime: node@26.7.0 - cache: true - install: false - - - name: Install macOS System Dependencies for node-canvas - if: runner.os == 'macOS' - run: | - brew install pkg-config cairo pango jpeg giflib librsvg - - - run: pnpm install --frozen-lockfile - - # --------- - # Build - # --------- - - - run: pnpm run build:packages - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Setup Godot - uses: chickensoft-games/setup-godot@v2 - with: - version: '4.7.1' - use-dotnet: true - include-templates: true - - - name: Export Godot Stage (Windows) - if: matrix.os == 'windows-latest' - working-directory: ./engines/stage-tamagotchi-godot - shell: bash - run: | - mkdir -p out/win - godot --headless --export-release "Windows Desktop" out/win/godot-stage.exe - - - name: Export Godot Stage (macOS) - if: matrix.os == 'macos-15-intel' || matrix.os == 'macos-26' - working-directory: ./engines/stage-tamagotchi-godot - shell: bash - run: | - mkdir -p out/mac - godot --headless --export-release "macOS" out/mac/godot-stage.app - - - name: Export Godot Stage (Linux) - if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' - working-directory: ./engines/stage-tamagotchi-godot - shell: bash - run: | - mkdir -p out/linux - godot --headless --export-release "Linux ${{ matrix.arch }}" out/linux/godot-stage - - - name: Build (Windows Only) # Windows - if: matrix.os == 'windows-latest' - run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=${{ (inputs.build_only || inputs.artifacts_only) && 'never' || 'onTagOrDraft' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Build (macOS Only) # macOS - if: matrix.os == 'macos-15-intel' || matrix.os == 'macos-26' - run: | - echo "$CSC_CONTENT" | base64 --decode > apps/stage-tamagotchi/apple-developer-code-signing.p12 - export CSC_LINK="./apple-developer-code-signing.p12" - pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=${{ (inputs.build_only || inputs.artifacts_only) && 'never' || 'onTagOrDraft' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_CONTENT: ${{ secrets.CSC_CONTENT }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_DEVELOPER_APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_DEVELOPER_APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_DEVELOPER_TEAM_ID }} - - - name: Build (Linux Only) # Linux - if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' - run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=${{ (inputs.build_only || inputs.artifacts_only) && 'never' || 'onTagOrDraft' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup Flatpak (Linux Only) - if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' }} - run: | - sudo apt update - sudo apt install -y flatpak flatpak-builder elfutils - flatpak --version - flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo - - - name: Build Flatpak (Linux Only) # Flatpak - if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' }} - working-directory: ./apps/stage-tamagotchi - run: | - mkdir -p dist - # Auto-install required SDK/Platform/BaseApp from Flathub in user scope - flatpak-builder --user --install-deps-from=flathub ./flatpak ai.moeru.airi.flatpak.yml --force-clean - flatpak build-export ./flatpak-repo ./flatpak - export FLATPAK_OUTPUT_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-output-filename flatpak) - flatpak build-bundle ./flatpak-repo dist/${FLATPAK_OUTPUT_NAME} ai.moeru.airi - - # --------- - # Nightly (schedule) builds only - # --------- - - - name: Get Artifacts Envs (Nightly + Windows Only) - if: ${{ github.event_name == 'schedule' && matrix.os == 'windows-latest' }} - working-directory: ./apps/stage-tamagotchi - run: | - echo "BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-bundle-name)" >> $env:GITHUB_ENV - echo "VERSION=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-version)" >> $env:GITHUB_ENV - - - name: Get Artifacts Envs (Nightly + Non-Windows) - if: ${{ github.event_name == 'schedule' && matrix.os != 'windows-latest' }} - working-directory: ./apps/stage-tamagotchi - run: | - echo "VERSION=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-version)" >> $GITHUB_ENV - - - name: Get Artifacts Envs (Nightly + macOS Only) - if: ${{ github.event_name == 'schedule' && (matrix.os == 'macos-26' || matrix.os == 'macos-15-intel') }} - working-directory: ./apps/stage-tamagotchi - run: | - echo "BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-bundle-name)" >> $GITHUB_ENV - - - name: Rename Artifacts (Nightly) - if: ${{ github.event_name == 'schedule' }} - run: - pnpm run -F @proj-airi/stage-tamagotchi rename-artifacts ${{ matrix.target }} - - - name: Get Linux Artifact Names (Nightly + Linux Only) - if: ${{ github.event_name == 'schedule' && (matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm') }} - working-directory: ./apps/stage-tamagotchi - run: | - echo "DEB_BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-filename deb)" >> $GITHUB_ENV - echo "RPM_BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-filename rpm)" >> $GITHUB_ENV - echo "FLATPAK_BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-filename flatpak)" >> $GITHUB_ENV - - - name: Upload Artifacts (Nightly + Non-Linux) - if: ${{ github.event_name == 'schedule' && (matrix.os != 'ubuntu-latest' && matrix.os != 'ubuntu-24.04-arm') }} - id: unsigned-artifacts-nightly - uses: actions/upload-artifact@v7 - with: - name: ${{ env.BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - - - name: Upload Artifacts (Nightly + Linux deb) - if: ${{ github.event_name == 'schedule' && (matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm') }} - uses: actions/upload-artifact@v7 - with: - name: ${{ env.DEB_BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.DEB_BUNDLE_NAME }} - - - name: Upload Artifacts (Nightly + Linux rpm) - if: ${{ github.event_name == 'schedule' && (matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm') }} - uses: actions/upload-artifact@v7 - with: - name: ${{ env.RPM_BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.RPM_BUNDLE_NAME }} - - - name: Upload Flatpak Artifact (Nightly + Linux Only) - if: ${{ github.event_name == 'schedule' && (matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm') }} - uses: actions/upload-artifact@v7 - with: - name: ${{ env.FLATPAK_BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.FLATPAK_BUNDLE_NAME }} - - - name: Sign Windows Artifacts with SignPath (Nightly + Windows Only) - id: signpath-nightly-windows - continue-on-error: true - uses: signpath/github-action-submit-signing-request@v2 - if: ${{ github.event_name == 'schedule' && (matrix.os == 'windows-latest' && github.repository == 'moeru-ai/airi') }} - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '${{ secrets.SIGNPATH_ORGANIZATION_ID }}' - project-slug: 'airi' - signing-policy-slug: 'test-signing' - artifact-configuration-slug: ci-github-actions-artifacts-windows - github-artifact-id: '${{ steps.unsigned-artifacts-nightly.outputs.artifact-id }}' - wait-for-completion: true - wait-for-completion-timeout-in-seconds: 900 # 15 minutes - download-signed-artifact-timeout-in-seconds: 900 # 15 minutes - output-artifact-directory: apps/stage-tamagotchi/bundle/signed/windows/ - - - name: Warn When Windows Signing Fails (Nightly) - if: ${{ github.event_name == 'schedule' && matrix.os == 'windows-latest' && steps.signpath-nightly-windows.outcome != 'success' }} - run: | - echo "::warning::SignPath Windows signing failed for nightly build. Continuing with unsigned artifact at apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }}." - - - name: Upload Signed Artifacts (Nightly + Non-Linux) - if: ${{ github.event_name == 'schedule' && matrix.os == 'windows-latest' && steps.signpath-nightly-windows.outcome == 'success' }} - uses: actions/upload-artifact@v7 - with: - name: ${{ env.BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/signed/windows/${{ env.BUNDLE_NAME }} - overwrite: true - - - name: Upload Unsigned Windows Artifacts Fallback (Nightly) - if: ${{ github.event_name == 'schedule' && matrix.os == 'windows-latest' && steps.signpath-nightly-windows.outcome != 'success' }} - uses: actions/upload-artifact@v7 - with: - name: ${{ env.BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - overwrite: true - - # NOTICE: Electron Builder generates latest-x64.yml during the build step, before this workflow - # submits the Windows installer to SignPath. SignPath re-signs the .exe and changes its bytes, - # which changes the updater hashes too. Regenerating latest-x64.yml from the signed installer keeps - # the published Windows update metadata aligned with the final released artifact. - - name: Regenerate Windows latest-x64.yml (Nightly + Windows Only) - if: ${{ github.event_name == 'schedule' && matrix.os == 'windows-latest' && steps.signpath-nightly-windows.outcome == 'success' }} - run: | - pnpm -F @proj-airi/stage-tamagotchi run regenerate-windows-latest --input apps/stage-tamagotchi/bundle/signed/windows/${{ env.BUNDLE_NAME }} --output apps/stage-tamagotchi/bundle/latest-x64.yml --version ${{ env.VERSION }} - - - name: Regenerate Windows latest-x64.yml From Unsigned Artifact (Nightly Fallback) - if: ${{ github.event_name == 'schedule' && matrix.os == 'windows-latest' && steps.signpath-nightly-windows.outcome != 'success' }} - run: | - pnpm -F @proj-airi/stage-tamagotchi run regenerate-windows-latest --input apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} --output apps/stage-tamagotchi/bundle/latest-x64.yml --version ${{ env.VERSION }} - - # --------- - # Workflow Dispatch only - # --------- - - - name: Get Artifacts Envs (Manual + Windows Only) - if: ${{ github.event_name == 'workflow_dispatch' && matrix.os == 'windows-latest' }} - working-directory: ./apps/stage-tamagotchi - run: | - echo "BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-bundle-name --release ${{ !inputs.build_only && !inputs.artifacts_only }} --tag ${{ inputs.tag }} --auto-tag ${{ !inputs.build_only }})" >> $env:GITHUB_ENV - echo "VERSION=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-version --release ${{ !inputs.build_only && !inputs.artifacts_only }} --tag ${{ inputs.tag }} --auto-tag ${{ !inputs.build_only }})" >> $env:GITHUB_ENV - - - name: Get Artifacts Envs (Manual + Non-Windows) - if: ${{ github.event_name == 'workflow_dispatch' && matrix.os != 'windows-latest' }} - working-directory: ./apps/stage-tamagotchi - run: | - echo "VERSION=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-version --release ${{ !inputs.build_only && !inputs.artifacts_only }} --tag ${{ inputs.tag }} --auto-tag ${{ !inputs.build_only }})" >> $GITHUB_ENV - - - name: Get Artifacts Envs (Manual + macOS Only) - if: ${{ github.event_name == 'workflow_dispatch' && (matrix.os == 'macos-26' || matrix.os == 'macos-15-intel') }} - working-directory: ./apps/stage-tamagotchi - run: | - echo "BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-bundle-name --release ${{ !inputs.build_only && !inputs.artifacts_only }} --tag ${{ inputs.tag }} --auto-tag ${{ !inputs.build_only }})" >> $GITHUB_ENV - - - name: Rename Artifacts (Manual) - if: ${{ github.event_name == 'workflow_dispatch' }} - run: | - pnpm run -F @proj-airi/stage-tamagotchi rename-artifacts ${{ matrix.target }} --release ${{ !inputs.build_only && !inputs.artifacts_only }} --tag ${{ inputs.tag }} --auto-tag ${{ !inputs.build_only }} - - - name: Get Linux Artifact Names (Manual + Non-Release + Linux Only) - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.artifacts_only && (matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm') }} - working-directory: ./apps/stage-tamagotchi - run: | - echo "DEB_BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-filename deb --release ${{ !inputs.build_only && !inputs.artifacts_only }} --tag ${{ inputs.tag }} --auto-tag ${{ !inputs.build_only }})" >> $GITHUB_ENV - echo "RPM_BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-filename rpm --release ${{ !inputs.build_only && !inputs.artifacts_only }} --tag ${{ inputs.tag }} --auto-tag ${{ !inputs.build_only }})" >> $GITHUB_ENV - echo "FLATPAK_BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-filename flatpak --release ${{ !inputs.build_only && !inputs.artifacts_only }} --tag ${{ inputs.tag }} --auto-tag ${{ !inputs.build_only }})" >> $GITHUB_ENV - - - name: Upload Artifacts (Manual + Non-Release + Non-Linux) - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.artifacts_only && (matrix.os != 'ubuntu-latest' && matrix.os != 'ubuntu-24.04-arm') }} - id: unsigned-artifacts-workflow-dispatch - uses: actions/upload-artifact@v7 - with: - name: ${{ env.BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - - - name: Upload Artifacts (Manual + Non-Release + Linux deb) - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.artifacts_only && (matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm') }} - uses: actions/upload-artifact@v7 - with: - name: ${{ env.DEB_BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.DEB_BUNDLE_NAME }} - - - name: Upload Artifacts (Manual + Non-Release + Linux rpm) - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.artifacts_only && (matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm') }} - uses: actions/upload-artifact@v7 - with: - name: ${{ env.RPM_BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.RPM_BUNDLE_NAME }} - - - name: Upload Flatpak Artifact (Manual + Non-Release + Linux) - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.artifacts_only && (matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm') }} - uses: actions/upload-artifact@v7 - with: - name: ${{ env.FLATPAK_BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.FLATPAK_BUNDLE_NAME }} - - - name: Sign Windows Artifacts with SignPath (Manual + Windows Only) - id: signpath-manual-windows - continue-on-error: true - uses: signpath/github-action-submit-signing-request@v2 - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.artifacts_only && (matrix.os == 'windows-latest' && github.repository == 'moeru-ai/airi') }} - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '${{ secrets.SIGNPATH_ORGANIZATION_ID }}' - project-slug: 'airi' - signing-policy-slug: 'test-signing' - artifact-configuration-slug: ci-github-actions-artifacts-windows - github-artifact-id: '${{ steps.unsigned-artifacts-workflow-dispatch.outputs.artifact-id }}' - wait-for-completion: true - wait-for-completion-timeout-in-seconds: 900 # 15 minutes - download-signed-artifact-timeout-in-seconds: 900 # 15 minutes - output-artifact-directory: apps/stage-tamagotchi/bundle/signed/windows/ - - - name: Warn When Windows Signing Fails (Manual) - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.artifacts_only && matrix.os == 'windows-latest' && steps.signpath-manual-windows.outcome != 'success' }} - run: | - echo "::warning::SignPath Windows signing failed for workflow_dispatch artifact build. Continuing with unsigned artifact at apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }}." - - - name: Move Signed Artifacts (Manual + Release + Windows Only) - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.artifacts_only && matrix.os == 'windows-latest' && steps.signpath-manual-windows.outcome == 'success' }} - run: | - Move-Item -Force apps/stage-tamagotchi/bundle/signed/windows/${{ env.BUNDLE_NAME }} apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - - - name: Upload Signed Artifacts (Manual + Windows Only) - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.artifacts_only && (matrix.os == 'windows-latest') }} - uses: actions/upload-artifact@v7 - with: - name: ${{ env.BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - overwrite: true - - # NOTICE: Electron Builder already wrote latest-x64.yml before SignPath signed the installer. - # After the signed .exe replaces the unsigned one, we must rebuild latest-x64.yml so its hashes - # describe the final Windows artifact that users will actually download. - - name: Regenerate Windows latest-x64.yml (Manual + Windows Only) - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && inputs.artifacts_only && matrix.os == 'windows-latest' }} - run: | - pnpm -F @proj-airi/stage-tamagotchi run regenerate-windows-latest --input apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} --output apps/stage-tamagotchi/bundle/latest-x64.yml --version ${{ env.VERSION }} - - - name: Upload To GitHub Releases (Manual + Release + Overwrite Release) - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.build_only && !inputs.artifacts_only }} - uses: softprops/action-gh-release@v2 - with: - files: | - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.exe - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.zip - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.dmg - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.deb - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.rpm - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.flatpak - apps/stage-tamagotchi/bundle/latest-*.yml - append_body: true - tag_name: ${{ inputs.tag }} - - # --------- - # Version push - # --------- - - - name: Rename Artifacts (Automatic) - if: ${{ github.event_name == 'release' }} - run: | - pnpm run -F @proj-airi/stage-tamagotchi rename-artifacts ${{ matrix.target }} --release --auto-tag - - - name: Get Artifacts Envs (Automatic + Windows Only) - if: ${{ github.event_name == 'release' && matrix.os == 'windows-latest' }} - working-directory: ./apps/stage-tamagotchi - run: | - echo "BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-bundle-name --release ${{ !inputs.build_only && !inputs.artifacts_only }} --tag ${{ inputs.tag }} --auto-tag ${{ !inputs.build_only }})" >> $env:GITHUB_ENV - echo "VERSION=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-version --release ${{ !inputs.build_only && !inputs.artifacts_only }} --tag ${{ inputs.tag }} --auto-tag ${{ !inputs.build_only }})" >> $env:GITHUB_ENV - - - name: Get Artifacts Envs (Automatic + Non-Windows) - if: ${{ github.event_name == 'release' && matrix.os != 'windows-latest' }} - working-directory: ./apps/stage-tamagotchi - run: | - echo "BUNDLE_NAME=$(pnpm exec tsx scripts/artifacts-metadata.ts ${{ matrix.target }} --get-bundle-name --release --auto-tag)" >> $GITHUB_ENV - - - name: Upload Artifacts (Automatic + Windows Only) - if: ${{ github.event_name == 'release' && matrix.os == 'windows-latest' }} - id: unsigned-artifacts-release - uses: actions/upload-artifact@v7 - with: - name: ${{ env.BUNDLE_NAME }} - path: apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - - - name: Sign Windows Artifacts with SignPath (Nightly + Windows Only) - id: signpath-release-windows - continue-on-error: true - uses: signpath/github-action-submit-signing-request@v2 - if: ${{ github.event_name == 'release' && (matrix.os == 'windows-latest' && github.repository == 'moeru-ai/airi') }} - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '${{ secrets.SIGNPATH_ORGANIZATION_ID }}' - project-slug: 'airi' - signing-policy-slug: 'test-signing' - artifact-configuration-slug: ci-github-actions-artifacts-windows - github-artifact-id: '${{ steps.unsigned-artifacts-release.outputs.artifact-id }}' - wait-for-completion: true - wait-for-completion-timeout-in-seconds: 900 # 15 minutes - download-signed-artifact-timeout-in-seconds: 900 # 15 minutes - output-artifact-directory: apps/stage-tamagotchi/bundle/signed/windows/ - - - name: Warn When Windows Signing Fails (Release) - if: ${{ github.event_name == 'release' && matrix.os == 'windows-latest' && steps.signpath-release-windows.outcome != 'success' }} - run: | - echo "::warning::SignPath Windows signing failed for release build. Continuing with unsigned artifact at apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }}." - - - name: Move Signed Artifacts (Automatic + Windows Only) - if: ${{ github.event_name == 'release' && matrix.os == 'windows-latest' && steps.signpath-release-windows.outcome == 'success' }} - run: | - Move-Item -Force apps/stage-tamagotchi/bundle/signed/windows/${{ env.BUNDLE_NAME }} apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - - # NOTICE: The root cause here is the build/signing order: latest-x64.yml is produced by Electron - # Builder before SignPath re-signs the installer. That post-build signing step mutates the .exe, - # so the updater metadata becomes stale. Recomputing latest-x64.yml after the signed file is moved - # into its final bundle path makes the release upload self-consistent again. - - name: Regenerate Windows latest manifest (Automatic + Windows Only) - if: ${{ github.event_name == 'release' && matrix.os == 'windows-latest' }} - run: | - pnpm -F @proj-airi/stage-tamagotchi run regenerate-windows-latest --input apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} --output apps/stage-tamagotchi/bundle/latest-x64.yml --version ${{ env.VERSION }} - - - name: Upload Distribution Artifact (Automatic + macOS arm64 Only) - if: ${{ github.event_name == 'release' && matrix.os == 'macos-26' }} - uses: actions/upload-artifact@v7 - with: - name: distribution-osx - path: apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - - - name: Upload Distribution Artifact (Automatic + Windows Only) - if: ${{ github.event_name == 'release' && matrix.os == 'windows-latest' }} - uses: actions/upload-artifact@v7 - with: - name: distribution-win - path: apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - - - name: Upload Distribution Artifact (Automatic + Linux x64 Only) - if: ${{ github.event_name == 'release' && matrix.os == 'ubuntu-latest' }} - uses: actions/upload-artifact@v7 - with: - name: distribution-linux-x64 - path: apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - - - name: Upload Distribution Artifact (Automatic + Linux arm64 Only) - if: ${{ github.event_name == 'release' && matrix.os == 'ubuntu-24.04-arm' }} - uses: actions/upload-artifact@v7 - with: - name: distribution-linux-arm64 - path: apps/stage-tamagotchi/bundle/${{ env.BUNDLE_NAME }} - - - name: Upload To GitHub Releases (Automatic) - if: ${{ github.event_name == 'release' }} - uses: softprops/action-gh-release@v2 - with: - # Possible auto updater files: - # Windows: latest-x64.yml - # macOS: latest-arm64-mac.yml, latest-x64-mac.yml - # Linux: latest-arm64-linux.yml (arm64), latest-x64-linux.yml (x64) - files: | - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.exe - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.zip - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.dmg - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.deb - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.rpm - apps/stage-tamagotchi/bundle/${{ env.PRODUCT_NAME }}-*.flatpak - apps/stage-tamagotchi/bundle/latest-*.yml - append_body: true - - publish-gitcode-release: - name: Publish GitCode Release Mirror - needs: build - if: ${{ always() && github.repository == 'moeru-ai/airi' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.build_only && !inputs.artifacts_only && inputs.tag != '')) }} - continue-on-error: true - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Validate release mirror tools - run: | - gh --version - jq --version - curl --version - - - name: Publish release assets to GitCode - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} - GITCODE_OWNER: ${{ secrets.GITCODE_OWNER }} - GITCODE_REPO: ${{ secrets.GITCODE_REPO }} - GITCODE_RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} - run: bash .github/scripts/publish-gitcode-release.sh - - publish-distribution: - name: Publish Distribution - needs: build - if: ${{ github.event_name == 'release' }} - runs-on: ubuntu-latest - strategy: - matrix: - include: - - artifact_name: distribution-osx - platform: osx - - artifact_name: distribution-win - platform: win - - artifact_name: distribution-linux-x64 - platform: linux - - artifact_name: distribution-linux-arm64 - platform: linux - steps: - - name: Resolve release version - run: | - echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - - - name: Download distribution artifact - uses: actions/download-artifact@v5 - with: - name: ${{ matrix.artifact_name }} - path: dist/${{ matrix.artifact_name }} - - - name: Resolve artifact path - run: | - echo "ARTIFACT_PATH=$(find dist/${{ matrix.artifact_name }} -maxdepth 1 -mindepth 1 | head -n 1)" >> $GITHUB_ENV - - - name: Setup butler - uses: remarkablegames/setup-butler@v2 - - # https://itch.io/docs/butler/pushing.html - - name: Publish distribution artifact to itch.io - run: butler push "${{ env.ARTIFACT_PATH }}" nekomeowww/airi:${{ matrix.platform }} --userversion "${{ env.VERSION }}" - env: - BUTLER_API_KEY: ${{ secrets.GAME_PUBLISHING_ITCHIO }} diff --git a/.github/workflows/release-vsix.yaml b/.github/workflows/release-vsix.yaml index e9bbf2855..89a53f93c 100644 --- a/.github/workflows/release-vsix.yaml +++ b/.github/workflows/release-vsix.yaml @@ -16,11 +16,13 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: pnpm/setup@v2 + - uses: actions/setup-node@v6 with: - runtime: node@26.7.0 - cache: true - install: false + node-version: 26.7.0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 - name: Configure npm registry uses: actions/setup-node@v6 @@ -29,11 +31,11 @@ jobs: # https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages registry-url: 'https://registry.npmjs.org' - - run: pnpm install --frozen-lockfile - - run: pnpm run build:packages - - run: pnpm run -F @proj-airi/airi-plugin-vscode build - - run: pnpm run -F vscode-airi build - - run: pnpm run publish + - run: bun install --frozen-lockfile + - run: bun run build:packages + - run: bun run --filter @proj-airi/airi-plugin-vscode build + - run: bun run --filter vscode-airi build + - run: bun run publish working-directory: ./integrations/vscode/vscode-airi env: VSCE_TOKEN: ${{ secrets.VSCE_TOKEN }} diff --git a/.github/workflows/sponsors-svg.yml b/.github/workflows/sponsors-svg.yml index 42ac623a4..ecf92a8c2 100644 --- a/.github/workflows/sponsors-svg.yml +++ b/.github/workflows/sponsors-svg.yml @@ -21,18 +21,20 @@ jobs: with: ref: main - - uses: pnpm/setup@v2 + - uses: actions/setup-node@v6 with: - runtime: node@26.7.0 - cache: true - install: false - - run: pnpm install --frozen-lockfile + node-version: 26.7.0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + - run: bun install --frozen-lockfile - name: Pull with rebase run: git pull origin main --rebase --autostash - name: Generate sponsors svg - run: pnpm run sponsors:generate + run: bun run sponsors:generate - name: Remove private SponsorKit cache run: rm -f docs/content/public/assets/sponsors/.cache.json diff --git a/.github/workflows/update-nix-assets-hash.yaml b/.github/workflows/update-nix-assets-hash.yaml deleted file mode 100644 index 47bd4df47..000000000 --- a/.github/workflows/update-nix-assets-hash.yaml +++ /dev/null @@ -1,75 +0,0 @@ -name: Update Nix assets Hash - -on: - workflow_dispatch: - push: - branches: - - main - paths: - - 'apps/stage-web/package.json' - - 'apps/stage-web/vite.config.ts' - -permissions: - contents: write - pull-requests: write - -jobs: - update: - if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork - runs-on: ubuntu-latest - steps: - # Why? - # - # failed to - # $ nix build .#airi-pnpm-deps - # > airi-pnpm-deps> Running phase: fixupPhase - # > error: writing to file: No space left on device - - name: Free Disk Space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - - - uses: actions/checkout@v6 - with: - ref: main # Use main regardless of workflow_dispatch branch - # Authenticate git with PAT so that pushing to head retriggers CI - token: ${{ secrets.HASH_UPDATE_TOKEN }} - - - uses: cachix/install-nix-action@v31 - with: - extra_nix_config: experimental-features = nix-command flakes - - - name: Update Hash - run: nix/update-assets-hash.sh - - - name: Check for changes - id: changes - run: | - if git diff --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - - name: Create PR - if: steps.changes.outputs.has_changes == 'true' - env: - # Create PR with PAT to trigger CI - GH_TOKEN: ${{ secrets.HASH_UPDATE_TOKEN }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add nix/assets-hash.txt - git commit -m 'chore(nix): update assets hash' - git push -f origin HEAD:chore/update-nix-assets-hash - - # Create PR if it doesn't already exist - existing_pr=$(gh pr list --head chore/update-nix-assets-hash --json number -q '.[0].number' 2>/dev/null || true) - if [ -z "$existing_pr" ]; then - gh pr create \ - --base main \ - --head chore/update-nix-assets-hash \ - --title 'chore(nix): update assets hash' \ - --body 'Auto-generated by CI to keep Nix asset hash up-to-date.' - fi - - # Enable auto-merge so PR merges once checks pass - gh pr merge --squash --auto --delete-branch chore/update-nix-assets-hash diff --git a/.github/workflows/update-nix-pnpm-deps-hash.yaml b/.github/workflows/update-nix-pnpm-deps-hash.yaml deleted file mode 100644 index fc7403212..000000000 --- a/.github/workflows/update-nix-pnpm-deps-hash.yaml +++ /dev/null @@ -1,74 +0,0 @@ -name: Update Nix pnpmDeps Hash - -on: - workflow_dispatch: - push: - branches: - - main - paths: - - 'pnpm-lock.yaml' - -permissions: - contents: write - pull-requests: write - -jobs: - update: - if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork - runs-on: ubuntu-latest - steps: - # Why? - # - # failed to - # $ nix build .#airi-pnpm-deps - # > airi-pnpm-deps> Running phase: fixupPhase - # > error: writing to file: No space left on device - - name: Free Disk Space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - - - uses: actions/checkout@v6 - with: - ref: main # Use main regardless of workflow_dispatch branch - # Authenticate git with PAT so that pushing to head retriggers CI - token: ${{ secrets.HASH_UPDATE_TOKEN }} - - - uses: cachix/install-nix-action@v31 - with: - extra_nix_config: experimental-features = nix-command flakes - - - name: Update Hash - run: nix/update-pnpm-deps-hash.sh - - - name: Check for changes - id: changes - run: | - if git diff --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - - name: Create PR - if: steps.changes.outputs.has_changes == 'true' - env: - # Create PR with PAT to trigger CI - GH_TOKEN: ${{ secrets.HASH_UPDATE_TOKEN }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add nix/pnpm-deps-hash.txt - git commit -m 'chore(nix): update pnpmDeps hash' - git push -f origin HEAD:chore/update-nix-pnpm-deps-hash - - # Create PR if it doesn't already exist - existing_pr=$(gh pr list --head chore/update-nix-pnpm-deps-hash --json number -q '.[0].number' 2>/dev/null || true) - if [ -z "$existing_pr" ]; then - gh pr create \ - --base main \ - --head chore/update-nix-pnpm-deps-hash \ - --title 'chore(nix): update pnpmDeps hash' \ - --body 'Auto-generated by CI to keep Nix pnpmDeps hash up-to-date.' - fi - - # Enable auto-merge so PR merges once checks pass - gh pr merge --squash --auto --delete-branch chore/update-nix-pnpm-deps-hash diff --git a/.github/workflows/update-post-release-readme-urls.yml b/.github/workflows/update-post-release-readme-urls.yml deleted file mode 100644 index 3b63984cc..000000000 --- a/.github/workflows/update-post-release-readme-urls.yml +++ /dev/null @@ -1,165 +0,0 @@ -name: Update (Post Release) README URLs - -on: - release: - types: - - published - - released - workflow_dispatch: - inputs: - tag: - description: 'Release tag (e.g., v0.10.2)' - required: true - type: string - -permissions: - contents: read - -concurrency: - group: update-post-release-readme-urls - cancel-in-progress: true - -jobs: - update: - # Keep prereleases from replacing the stable README download buttons. - if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease - runs-on: ubuntu-latest - env: - README_BRANCH: automation/update-readme-release-links - RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} - steps: - - uses: actions/checkout@v6 - with: - ref: main - persist-credentials: false - - - uses: pnpm/setup@v2 - with: - runtime: node@26.7.0 - cache: true - install: false - - run: pnpm install --frozen-lockfile - - - name: Update README download links - run: pnpm -F @proj-airi/stage-tamagotchi exec tsx scripts/update-readme-download-links.ts "$RELEASE_TAG" - - - name: Pull with rebase - run: git pull origin main --rebase --autostash - - - name: Prepare README release update - id: prepare - env: - GH_TOKEN: ${{ secrets.PAT_SLASH_COMMAND_DISPATCH }} - run: | - # The updater is only allowed to publish the generated README link changes. - if ! git diff --quiet -- . ':(exclude)README.md' ':(exclude)docs/README*.md'; then - echo "::error::Unexpected non-README changes were generated." - git status --short - exit 1 - fi - - git add README.md 'docs/README*.md' - if git diff --cached --quiet; then - echo "No README release links changed." - echo "changed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [ -z "$GH_TOKEN" ]; then - echo "::error::PAT_SLASH_COMMAND_DISPATCH is required so the README update PR triggers required checks." - exit 1 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - - REMOTE_BRANCH="$(git ls-remote --heads origin "refs/heads/${README_BRANCH}")" - if [ -n "$REMOTE_BRANCH" ]; then - git fetch origin "${README_BRANCH}:refs/remotes/origin/${README_BRANCH}" - fi - - git checkout -B "$README_BRANCH" - PR_TITLE="chore: update README release artifacts to ${RELEASE_TAG}" - git commit -m "$PR_TITLE" - git push --force-with-lease origin "$README_BRANCH" - - echo "changed=true" >> "$GITHUB_OUTPUT" - echo "pr_title=$PR_TITLE" >> "$GITHUB_OUTPUT" - - - name: Create or update README release PR - if: steps.prepare.outputs.changed == 'true' - uses: actions/github-script@v8 - env: - PR_TITLE: ${{ steps.prepare.outputs.pr_title }} - with: - github-token: ${{ secrets.PAT_SLASH_COMMAND_DISPATCH }} - script: | - const { owner, repo } = context.repo; - const head = `${owner}:${process.env.README_BRANCH}`; - const body = [ - '## Description', - '', - `Updates stable desktop download links after the ${process.env.RELEASE_TAG} release.`, - '', - '## Linked Issues', - '', - 'None.', - '', - '## Additional Context', - '', - 'Generated by the post-release README workflow.', - ].join('\n'); - - const { data: openPullRequests } = await github.rest.pulls.list({ - owner, - repo, - state: 'open', - base: 'main', - head, - per_page: 1, - }); - - let pullRequest; - if (openPullRequests.length > 0) { - const { data } = await github.rest.pulls.update({ - owner, - repo, - pull_number: openPullRequests[0].number, - title: process.env.PR_TITLE, - body, - }); - pullRequest = data; - core.info(`Updated pull request #${pullRequest.number}.`); - } - else { - const { data } = await github.rest.pulls.create({ - owner, - repo, - base: 'main', - head: process.env.README_BRANCH, - title: process.env.PR_TITLE, - body, - }); - pullRequest = data; - core.info(`Created pull request #${pullRequest.number}.`); - } - - if (!pullRequest.auto_merge) { - await github.graphql( - `mutation EnableAutoMerge($pullRequestId: ID!) { - enablePullRequestAutoMerge(input: { - pullRequestId: $pullRequestId - mergeMethod: SQUASH - }) { - pullRequest { - number - } - } - }`, - { - pullRequestId: pullRequest.node_id, - }, - ); - core.info(`Enabled squash auto-merge for pull request #${pullRequest.number}.`); - } diff --git a/.github/workflows/upload-crowdin-glossary.yml b/.github/workflows/upload-crowdin-glossary.yml index 102393afb..bd7176f72 100644 --- a/.github/workflows/upload-crowdin-glossary.yml +++ b/.github/workflows/upload-crowdin-glossary.yml @@ -19,18 +19,20 @@ jobs: fi - uses: actions/checkout@v6 - - uses: pnpm/setup@v2 + - uses: actions/setup-node@v6 with: - runtime: node@26.7.0 - cache: true - install: false + node-version: 26.7.0 - - run: pnpm install --frozen-lockfile + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + + - run: bun install --frozen-lockfile # The TBX file is generated, never committed, so it cannot drift from terms.yaml. # The schema also rejects a malformed entry here, before anything reaches Crowdin. - name: Generate TBX File - run: pnpm -F @proj-airi/i18n glossary:build + run: bun run --filter @proj-airi/i18n glossary:build # NOTICE: Crowdin does not document whether an import merges into the existing # concepts or adds duplicates beside them, and a merge cannot be undone. This diff --git a/.gitignore b/.gitignore index f64c80a87..394e8c8b6 100644 --- a/.gitignore +++ b/.gitignore @@ -133,9 +133,6 @@ plugins/development plugins-local plugins-development -apps/stage-pocket/ios/buildServer.json -apps/stage-pocket/airi-pocket.jks -apps/stage-tamagotchi/electron.vite.config.*.mjs docs/superpowers docs/research diff --git a/.tool-versions b/.tool-versions index 44a3a1ff5..a795b4d24 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ nodejs 26.7.0 -pnpm 11.24.0 +bun 1.4.2 dotnet 10 diff --git a/.vscode/launch.json b/.vscode/launch.json index a2941aa94..16194642f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,30 +1,14 @@ { "version": "0.2.0", "configurations": [ - { - "name": "Debug Electron", - "type": "node", - "request": "launch", - // Where to run the command - "runtimeExecutable": "pnpm", - "runtimeArgs": ["-F", "@proj-airi/stage-tamagotchi", "exec", "electron-vite", "--sourcemap"], - // Open terminal when debugging starts (Optional) - // Useful to see console.logs - // "console": "integratedTerminal", - // "internalConsoleOptions": "neverOpen", - "env": { - "REMOTE_DEBUGGING_PORT": "9222" - } - }, { "name": "Debug Minecraft Service", "type": "node", "request": "launch", // Where to run the command - // Where to run the command - "runtimeExecutable": "pnpm", - "runtimeArgs": ["-F", "@proj-airi/minecraft-bot", "exec", "tsx"], - "args": ["--env-file=.env", "--env-file-if-exists=.env.local", "src/main.ts"], + "runtimeExecutable": "bun", + "runtimeArgs": ["run", "start"], + "cwd": "${workspaceFolder}/integrations/minecraft", "envFile": "${workspaceFolder}/integrations/minecraft/.env", // Open terminal when debugging starts (Optional) // Useful to see console.logs @@ -34,36 +18,16 @@ "/**" ] }, - { - "name": "Debug Renderer Process", - "port": 9222, - "request": "attach", - "type": "chrome", - "webRoot": "${workspaceFolder}/src/renderer", - "timeout": 60000, - "presentation": { - "hidden": true - } - }, { "name": "Debug VSCode Extension", "type": "extensionHost", "request": "launch", "args": [ - "--extensionDevelopmentPath=${workspaceFolder}/plugins/airi-plugin-vscode" + "--extensionDevelopmentPath=${workspaceFolder}/integrations/vscode/airi-plugin-vscode" ], "outFiles": [ - "${workspaceFolder}/plugins/airi-plugin-vscode/dist/**/*.cjs" + "${workspaceFolder}/integrations/vscode/airi-plugin-vscode/dist/**/*.cjs" ] } - ], - "compounds": [ - { - "name": "Debug All", - "configurations": ["Debug Electron", "Debug Renderer Process"], - "presentation": { - "order": 1 - } - } ] } diff --git a/AGENTS.md b/AGENTS.md index da4df0664..f23b39101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,18 @@ -# Project AIRI Agent Guide +# Project Moeka Agent Guide Concise but detailed reference for contributors working across the `moeru-ai/airi` monorepo. Improve code when you touch it; avoid one-off patterns. ## Tech Stack (by surface) -- **Desktop (stage-tamagotchi)**: Electron, Vue, Vite, TypeScript, Pinia, VueUse, Eventa (IPC/RPC), UnoCSS, Vitest, ESLint. - **Web (stage-web)**: Vue 3 + Vue Router, Vite, TypeScript, Pinia, VueUse, UnoCSS, Vitest, ESLint. Backend: WIP. -- **Mobile (stage-pocket)**: Vue 3 + Vue Router, Vite, TypeScript, Pinia, VueUse, UnoCSS, Vitest, ESLint, Kotlin, Swift, Capacitor. +- **Other apps**: `apps/ui-server-auth` (auth UI for the hosted auth service), `apps/component-calling` (realtime audio demo). - **UI/Shared Packages**: - - `packages/stage-ui`: Core business components, composables, stores shared by stage-web & stage-tamagotchi (heart of stage work). + - `packages/stage-ui`: Core business components, composables, stores shared by the stage apps (heart of stage work). - `packages/stage-ui-three`: Three.js bindings + Vue components. - - `packages/stage-ui-pixi`: Planned Pixi bindings. - - `packages/stage-shared`: Shared logic across stage-ui, stage-ui-three, stage-web, stage-tamagotchi. + - `packages/stage-shared`: Shared logic across stage-ui, stage-ui-three, and stage-web. - `packages/ui`: Standardized primitives (inputs, textarea, buttons, layout) built on reka-ui; minimal business logic. - `packages/i18n`: Central translations. - Server channel: `packages/server-runtime`, `packages/server-sdk`, `packages/server-shared` (power `services/` and `plugins/`). - - Legacy: `crates/` (old Tauri desktop; current desktop is Electron). ## Structure & Responsibilities @@ -27,18 +24,18 @@ Concise but detailed reference for contributors working across the `moeru-ai/air - `server/docker-compose.yaml`: complete local backend stack. - **Apps** - `apps/stage-web`: Web app; composables/stores in `src/composables`, `src/stores`; pages in `src/pages`; devtools in `src/pages/devtools`; router config via `vite.config.ts`. - - `apps/stage-tamagotchi`: Electron app; renderer pages in `src/renderer/pages`; devtools in `src/renderer/pages/devtools`; settings layout at `src/renderer/layouts/settings.vue`; router config via `electron.vite.config.ts`. - - Settings/devtools routes rely on ` meta: layout: settings `; ensure routes/icons are registered accordingly (`apps/stage-tamagotchi/src/renderer/layouts/settings.vue`, `apps/stage-web/src/layouts/settings.vue`). + - `apps/ui-server-auth`: Auth UI served by the hosted auth service; pages in `src/pages`. + - Settings/devtools routes rely on ` meta: layout: settings `; ensure routes/icons are registered accordingly (`apps/stage-web/src/layouts/settings.vue`). - Shared page bases: `packages/stage-pages`. - - Stage pages: `apps/stage-web/src/pages`, `apps/stage-tamagotchi/src/renderer/pages` (plus devtools folders). + - Stage pages: `apps/stage-web/src/pages` (plus the devtools folder). - **Stage UI internals** (`packages/stage-ui/src`) - Providers: `stores/providers.ts` and `stores/providers/` (standardized provider definitions). - - Modules: `stores/modules/` (AIRI orchestration building blocks). + - Modules: `stores/modules/` (Moeka orchestration building blocks). - Composables: `composables/` (business-oriented Vue helpers). - Components: `components/`; scenarios in `components/scenarios/` for page/use-case-specific pieces. - Stories: `packages/stage-ui/stories`, `packages/stage-ui/histoire.config.ts` (e.g. `components/misc/Button.story.vue`). -- **IPC/Eventa**: Always use `@moeru/eventa` for type-safe, framework/runtime-agnostic IPC/RPC. Define contracts centrally (e.g., `apps/stage-tamagotchi/src/shared`) and follow usage patterns in `apps/stage-tamagotchi/src/main/services/electron` for main/renderer integration. -- **Dependency Injection**: Use `injeca` for services/electron modules/plugins/frontend; see `apps/stage-tamagotchi/src/main/index.ts` for composition patterns. +- **IPC/Eventa**: Always use `@moeru/eventa` for type-safe, framework/runtime-agnostic event and RPC contracts. Define contracts next to the module that owns them (e.g., `packages/stage-shared/src/beat-sync/eventa.ts`, `packages/plugin-protocol/src/types/events.ts`, `server/packages/server-sdk-shared/src/v2.ts`). +- **Dependency Injection**: Use `injeca` for services and server/plugin composition; see `server/apps/api/src/app.ts` and `server/apps/auth/src/server.ts` for composition patterns. - **Build/CI/Lint**: `.github/workflows` for pipelines; `eslint.config.js` for lint rules. - **Styles**: UnoCSS config at `uno.config.ts`; check `apps/stage-web/src/styles` for existing animations; prefer UnoCSS over Tailwind. @@ -46,48 +43,47 @@ Concise but detailed reference for contributors working across the `moeru-ai/air - `packages/stage-ui`: Core stage business components/composables/stores. - `src/stores/providers.ts` and `src/stores/providers/`: provider definitions (standardized). - - `src/stores/modules/`: AIRI orchestration modules. + - `src/stores/modules/`: Moeka orchestration modules. - `src/composables/`: reusable Vue composables (business-oriented). - `src/components/`: business components; `src/components/scenarios/` for page/use-case-specific pieces. - Stories: `packages/stage-ui/stories`, `packages/stage-ui/histoire.config.ts` (e.g. `components/misc/Button.story.vue`). - `packages/stage-ui-three`: Three.js bindings + Vue components. - `packages/stage-ui-pixi`: Planned Pixi bindings. -- `packages/stage-shared`: Shared logic across stage-ui, stage-ui-three, stage-web, stage-tamagotchi. +- `packages/stage-shared`: Shared logic across stage-ui, stage-ui-three, and stage-web. - `packages/ui`: Standardized primitives (inputs/textarea/buttons/layout) built on reka-ui. - `packages/i18n`: All translations. - Hosted backend: `server/apps/api`, `server/apps/auth`, `server/packages`, and local tooling under `server/dev`. - Server channel: `packages/server-runtime`, `packages/server-sdk`, `packages/server-shared` (power `services/` and `plugins/`). -- Legacy desktop: `crates/` (old Tauri; Electron is current). -- Pages: `packages/stage-pages` (shared bases); `apps/stage-web/src/pages` and `apps/stage-tamagotchi/src/renderer/pages` for app-specific pages; devtools live in each app’s `.../pages/devtools`. -- Router configs: `apps/stage-web/vite.config.ts`, `apps/stage-tamagotchi/electron.vite.config.ts`. -- Devtools/layouts: `apps/stage-tamagotchi/src/renderer/layouts/settings.vue`, `apps/stage-web/src/layouts/settings.vue`. -- IPC/Eventa contracts/examples: `apps/stage-tamagotchi/src/shared`, `apps/stage-tamagotchi/src/main/services/electron`. -- DI examples: `apps/stage-tamagotchi/src/main/index.ts` (injeca). +- Pages: `packages/stage-pages` (shared bases); `apps/stage-web/src/pages` and `apps/ui-server-auth/src/pages` for app-specific pages; devtools live in `apps/stage-web/src/pages/devtools`. +- Router config: `apps/stage-web/vite.config.ts`. +- Devtools/layouts: `apps/stage-web/src/layouts/settings.vue`. +- IPC/Eventa contracts/examples: `packages/stage-shared/src/beat-sync/eventa.ts`, `packages/plugin-protocol/src/types/events.ts`. +- DI examples: `server/apps/api/src/app.ts`, `server/apps/auth/src/server.ts` (injeca). - Styles: `uno.config.ts` (UnoCSS), `apps/stage-web/src/styles` (animations/reference). - Build pipeline refs: `.github/workflows`; lint rules in `eslint.config.js`. - Documented solutions: `docs/solutions/` records past fixes and workflow learnings, organized by category with YAML frontmatter (`module`, `tags`, `problem_type`); relevant when implementing, debugging, or verifying in documented areas. - Tailwind/UnoCSS: prefer UnoCSS; if standardizing styles, add shortcuts/rules/plugins in `uno.config.ts`. -## Commands (pnpm with filters) +## Commands (Bun with filters) -> Use pnpm workspace filters to scope tasks. Examples below are generic; replace the filter with the target workspace name (e.g. `@proj-airi/stage-tamagotchi`, `@proj-airi/stage-web`, `@proj-airi/stage-ui`, etc.). +> Use Bun workspace filters to scope tasks. Examples below are generic; replace the filter with the target workspace name (e.g. `@proj-airi/stage-web`, `@proj-airi/stage-ui`, etc.). - **Typecheck** - - `pnpm -F typecheck` - - Example: `pnpm -F @proj-airi/stage-tamagotchi typecheck` (runs `tsc` + `vue-tsc`). + - `bun run --filter typecheck` + - Example: `bun run --filter @proj-airi/stage-web typecheck` (runs `tsc` + `vue-tsc`). - **Unit tests (Vitest)** - - Targeted: `pnpm exec vitest run ` - e.g. `pnpm exec vitest run apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts` - - Workspace: `pnpm -F exec vitest run` - e.g. `pnpm -F @proj-airi/stage-tamagotchi exec vitest run` - - Root `pnpm test:run`: runs all tests across registered projects. If no tests are found, check `vitest.config.ts` include patterns. - - Root `vitest.config.ts` includes `apps/stage-tamagotchi` and other projects; each app/package can have its own `vitest.config`. + - Targeted: `bunx vitest run ` + e.g. `bunx vitest run packages/stage-ui/src/stores/providers/provider.test.ts` + - Workspace: `bun run --filter exec vitest run` + e.g. `bun run --filter @proj-airi/stage-web exec vitest run` + - Root `bun run test:run`: runs all tests across registered projects. If no tests are found, check `vitest.config.ts` include patterns. + - Root `vitest.config.ts` lists the registered test projects; each app/package can have its own `vitest.config`. - **Lint** - - `pnpm lint` and `pnpm lint:fix` - - Formatting is handled via ESLint; `pnpm lint:fix` applies formatting. + - `bun run lint` and `bun run lint:fix` + - Formatting is handled via ESLint; `bun run lint:fix` applies formatting. - **Build** - - `pnpm -F build` - - Example: `pnpm -F @proj-airi/stage-tamagotchi build` (typecheck + electron-vite build). + - `bun run --filter build` + - Example: `bun run --filter @proj-airi/stage-web build` (typecheck + vite build). ## Before You Start @@ -103,7 +99,7 @@ Concise but detailed reference for contributors working across the `moeru-ai/air - For testing, Vitest, regression reproduction, mocks, or test import-boundary work, always use [`enforce-rules-for-vitest` skill](.agents/skills/enforce-rules-for-vitest/SKILL.md). - For UnoCSS, Vue styling, UI components, animations, icons, or color-mode work, always use [`enforce-rules-for-unocss` skill](.agents/skills/enforce-rules-for-unocss/SKILL.md). - For web or Electron workflows that upload a local file through an HTML input, a dynamically created input, or a file chooser, invoke [`$use-agent-browser-with-input-file`](.agents/skills/use-agent-browser-with-input-file/SKILL.md). Also invoke `$agent-browser`, and invoke `$agent-browser-electron` when the target is Electron. -- For AIRI Live2D, VRM, or MMD import and rendering tests across stage-web, stage-tamagotchi, or stage-pocket, invoke [`$use-agent-browser-for-airi`](.agents/skills/use-agent-browser-for-airi/SKILL.md). It invokes `$use-agent-browser-with-input-file` for the upload mechanism and adds AIRI-specific routes, state preparation, format behavior, and renderer verification. +- For Moeka Live2D, VRM, or MMD import and rendering tests in stage-web, invoke [`$use-agent-browser-for-airi`](.agents/skills/use-agent-browser-for-airi/SKILL.md). It invokes `$use-agent-browser-with-input-file` for the upload mechanism and adds Moeka-specific routes, state preparation, format behavior, and renderer verification. - For editing, writing, refactoring, re-writing code, submitting issues, Pull Requests, and docs, comments, invoke [`$simple-english`](./agents/skills/simple-english/SKILL.md). ## Development Practices @@ -120,7 +116,7 @@ Concise but detailed reference for contributors working across the `moeru-ai/air ## TypeScript / IPC / Tools - Keep JSON Schemas provider-compliant (explicit `type: object`, required fields; avoid unbounded records). -- For Electron, and backend related packages, use `injeca` for dependency management; avoid new class hierarchies unless extending browser APIs (classes are harder to mock/test). +- For backend related packages, use `injeca` for dependency management; avoid new class hierarchies unless extending browser APIs (classes are harder to mock/test). - Centralize Eventa contracts; use `@moeru/eventa` for all events. - Import types from the module or package that owns the contract. Do not redeclare external/public contracts locally just to use a narrower subset, and do not route type imports through local runtime assembly modules when the original side-effect-free type source is available. - Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports. Write `./module` instead of `./module.ts` or `./module.js`; keep extensions only when the runtime or asset format requires them. @@ -140,7 +136,7 @@ Concise but detailed reference for contributors working across the `moeru-ai/air ### Glossary `packages/i18n/glossary/terms.yaml` gives the approved English term for each product concept. -`pnpm -F @proj-airi/i18n glossary:build` writes the TBX file that Crowdin imports. `schema.ts` +`bun run --filter @proj-airi/i18n glossary:build` writes the TBX file that Crowdin imports. `schema.ts` documents each field. - Read `terms.yaml` before you write or change a string that a user sees. Use the term it gives. @@ -240,7 +236,7 @@ as a first language. - Unlisted actions run in the caller renderer. Their mutations become full-state proposals when `state: true`. - Keep synchronization and persistence as separate boundaries. Give persisted synchronized state one explicit persistence owner. - Do not add bidirectional persistence composables or storage-event listeners to synchronized state. Use explicit persistence commands. -- Set the leadership mode explicitly for every Electron renderer. Utility and minimal windows must use `follower-only`. +- Set the leadership mode explicitly for every browser window or tab that runs the app. Utility and minimal windows must use `follower-only`. - Add a multi-window regression test for synchronization changes. A remote snapshot must not produce a local synchronized-state proposal. If a watcher calls a synchronized action, verify that repeated calls converge without repeated side effects. ### Readability Refactors @@ -267,9 +263,9 @@ as a first language. - Rebase pulls; branch naming `username/feat/short-name`; clear commit messages (gitmoji is prohibited). - Summarize changes, how tested (commands), and follow-ups. - Improve legacy you touch; avoid one-off patterns. -- Keep changes scoped; use workspace filters (`pnpm -F - - - - -
- - - - diff --git a/apps/stage-pocket/ios/.gitignore b/apps/stage-pocket/ios/.gitignore deleted file mode 100644 index eaee04eaf..000000000 --- a/apps/stage-pocket/ios/.gitignore +++ /dev/null @@ -1,16 +0,0 @@ -App/build -App/Pods -App/output -App/App/public -DerivedData -xcuserdata - -# Cordova plugins for Capacitor -capacitor-cordova-ios-plugins - -# Generated Config files -App/App/capacitor.config.json -App/App/config.xml - -App.xcarchive -archive.plist diff --git a/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj b/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj deleted file mode 100644 index 22e70cc4d..000000000 --- a/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj +++ /dev/null @@ -1,411 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 60; - objects = { - -/* Begin PBXBuildFile section */ - A1B2C3D42F10000100AA0001 /* HostWebSocketBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */; }; - A1B2C3D42F10000100AA0003 /* URLSessionHostWebSocketSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */; }; - A1B2C3D42F10000100AA0005 /* WeakScriptMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */; }; - A1B2C3D42F10000100AA0007 /* WebAuthenticationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0008 /* WebAuthenticationPlugin.swift */; }; - 0DF9DE312F0E0A42008AB01F /* AppIcon_LiquidGlass.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0DF9DE302F0E0A42008AB01F /* AppIcon_LiquidGlass.icon */; }; - 29ABB4BA2F03F2B400285F7F /* DevBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */; }; - 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; - 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; - 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; - 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; - 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; - 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; - 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; - 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostWebSocketBridge.swift; sourceTree = ""; }; - A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionHostWebSocketSession.swift; sourceTree = ""; }; - A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakScriptMessageHandler.swift; sourceTree = ""; }; - A1B2C3D42F10000100AA0008 /* WebAuthenticationPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthenticationPlugin.swift; sourceTree = ""; }; - 0DF9DE302F0E0A42008AB01F /* AppIcon_LiquidGlass.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon_LiquidGlass.icon; sourceTree = ""; }; - 29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevBridgeViewController.swift; sourceTree = ""; }; - 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; - 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; - 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; - 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 504EC3011FED79650016851F /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 504EC2FB1FED79650016851F = { - isa = PBXGroup; - children = ( - 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, - 504EC3061FED79650016851F /* App */, - 504EC3051FED79650016851F /* Products */, - ); - sourceTree = ""; - }; - 504EC3051FED79650016851F /* Products */ = { - isa = PBXGroup; - children = ( - 504EC3041FED79650016851F /* App.app */, - ); - name = Products; - sourceTree = ""; - }; - 504EC3061FED79650016851F /* App */ = { - isa = PBXGroup; - children = ( - 29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */, - A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */, - A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */, - A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */, - A1B2C3D42F10000100AA0008 /* WebAuthenticationPlugin.swift */, - 50379B222058CBB4000EE86E /* capacitor.config.json */, - 504EC3071FED79650016851F /* AppDelegate.swift */, - 504EC30B1FED79650016851F /* Main.storyboard */, - 504EC30E1FED79650016851F /* Assets.xcassets */, - 0DF9DE302F0E0A42008AB01F /* AppIcon_LiquidGlass.icon */, - 504EC3101FED79650016851F /* LaunchScreen.storyboard */, - 504EC3131FED79650016851F /* Info.plist */, - 2FAD9762203C412B000D30F8 /* config.xml */, - 50B271D01FEDC1A000F3C39B /* public */, - ); - path = App; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 504EC3031FED79650016851F /* App */ = { - isa = PBXNativeTarget; - buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; - buildPhases = ( - 504EC3001FED79650016851F /* Sources */, - 504EC3011FED79650016851F /* Frameworks */, - 504EC3021FED79650016851F /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = App; - packageProductDependencies = ( - 4D22ABE82AF431CB00220026 /* CapApp-SPM */, - ); - productName = App; - productReference = 504EC3041FED79650016851F /* App.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 504EC2FC1FED79650016851F /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 0920; - TargetAttributes = { - 504EC3031FED79650016851F = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; - compatibilityVersion = "Xcode 8.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 504EC2FB1FED79650016851F; - packageReferences = ( - D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, - ); - productRefGroup = 504EC3051FED79650016851F /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 504EC3031FED79650016851F /* App */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 504EC3021FED79650016851F /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, - 0DF9DE312F0E0A42008AB01F /* AppIcon_LiquidGlass.icon in Resources */, - 50B271D11FEDC1A000F3C39B /* public in Resources */, - 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, - 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, - 504EC30D1FED79650016851F /* Main.storyboard in Resources */, - 2FAD9763203C412B000D30F8 /* config.xml in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 504EC3001FED79650016851F /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, - 29ABB4BA2F03F2B400285F7F /* DevBridgeViewController.swift in Sources */, - A1B2C3D42F10000100AA0001 /* HostWebSocketBridge.swift in Sources */, - A1B2C3D42F10000100AA0003 /* URLSessionHostWebSocketSession.swift in Sources */, - A1B2C3D42F10000100AA0005 /* WeakScriptMessageHandler.swift in Sources */, - A1B2C3D42F10000100AA0007 /* WebAuthenticationPlugin.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - 504EC30B1FED79650016851F /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 504EC30C1FED79650016851F /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 504EC3111FED79650016851F /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 504EC3141FED79650016851F /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 504EC3151FED79650016851F /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 504EC3171FED79650016851F /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_LiquidGlass; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 25; - DEVELOPMENT_TEAM = 433DLLA855; - INFOPLIST_FILE = App/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = AIRI; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.12.0; - OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; - PRODUCT_BUNDLE_IDENTIFIER = "ai.moeru.airi-pocket"; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 504EC3181FED79650016851F /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_LiquidGlass; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 25; - DEVELOPMENT_TEAM = 433DLLA855; - INFOPLIST_FILE = App/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = AIRI; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.12.0; - PRODUCT_BUNDLE_IDENTIFIER = "ai.moeru.airi-pocket"; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "AIRI CI"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 504EC3141FED79650016851F /* Debug */, - 504EC3151FED79650016851F /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 504EC3171FED79650016851F /* Debug */, - 504EC3181FED79650016851F /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = "CapApp-SPM"; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { - isa = XCSwiftPackageProductDependency; - package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; - productName = "CapApp-SPM"; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 504EC2FC1FED79650016851F /* Project object */; -} diff --git a/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a62..000000000 --- a/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003..000000000 --- a/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 406356938..000000000 --- a/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,24 +0,0 @@ -{ - "originHash" : "038c691ceff1e23bf770957b72599f2aea21bf50e55def35674c9756739e4af4", - "pins" : [ - { - "identity" : "capacitor-swift-pm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", - "state" : { - "revision" : "f1a8fadf1437c23b825c818fb6509c9dbbae2f61", - "version" : "8.3.1" - } - }, - { - "identity" : "osbarcodelib-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OutSystems/OSBarcodeLib-iOS.git", - "state" : { - "revision" : "1ae7a716331be720f9f1075ef033276014c341ec", - "version" : "2.1.1" - } - } - ], - "version" : 3 -} diff --git a/apps/stage-pocket/ios/App/App/AppDelegate.swift b/apps/stage-pocket/ios/App/App/AppDelegate.swift deleted file mode 100644 index a8083a6fd..000000000 --- a/apps/stage-pocket/ios/App/App/AppDelegate.swift +++ /dev/null @@ -1,76 +0,0 @@ -import UIKit -import Capacitor - -@UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? - - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - // Override point for customization after application launch. - return true - } - - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. - // This can occur for certain types of temporary interruptions - // (such as an incoming phone call or SMS message) or when the user quits - // the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate - // graphics rendering callbacks. Games should use this method to pause the game. - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, - // and store enough application state information to restore your application - // to its current state in case it is terminated later. - // If your application supports background execution, this method is called - // instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; - // here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application - // was inactive. If the application was previously in the background, - // optionally refresh the user interface. - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. - // See also applicationDidEnterBackground:. - } - - func application( - _ app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:] - ) -> Bool { - // Called when the app was launched with a url. Feel free to add additional - // processing here, but if you want the App API to support tracking app url opens, - // make sure to keep this call - return ApplicationDelegateProxy.shared.application(app, open: url, options: options) - } - - func application( - _ application: UIApplication, - continue userActivity: NSUserActivity, - restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void - ) -> Bool { - // Called when the app was launched with an activity, including Universal Links. - // Feel free to add additional processing here, but if you want the App API - // to support tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application( - application, - continue: userActivity, - restorationHandler: restorationHandler - ) - } - -} diff --git a/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Body.png b/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Body.png deleted file mode 100644 index 11da46481..000000000 Binary files a/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Body.png and /dev/null differ diff --git a/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Ear_L.png b/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Ear_L.png deleted file mode 100644 index 911de7f26..000000000 Binary files a/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Ear_L.png and /dev/null differ diff --git a/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Ear_R.png b/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Ear_R.png deleted file mode 100644 index f56fc054d..000000000 Binary files a/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Ear_R.png and /dev/null differ diff --git a/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Hard_Shadow.png b/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Hard_Shadow.png deleted file mode 100644 index 46354160b..000000000 Binary files a/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/Assets/Hard_Shadow.png and /dev/null differ diff --git a/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/icon.json b/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/icon.json deleted file mode 100644 index 6cc4bacff..000000000 --- a/apps/stage-pocket/ios/App/App/AppIcon_LiquidGlass.icon/icon.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "fill-specializations" : [ - { - "value" : { - "solid" : "srgb:0.98027,0.97642,0.98455,1.00000" - } - }, - { - "appearance" : "dark", - "value" : { - "solid" : "srgb:0.39461,0.38835,0.42874,1.00000" - } - } - ], - "groups" : [ - { - "blend-mode" : "normal", - "blur-material" : null, - "layers" : [ - { - "image-name" : "Ear_R.png", - "name" : "Ear R" - } - ], - "name" : "Ear R", - "shadow" : { - "kind" : "layer-color", - "opacity" : 0.5 - }, - "specular" : true, - "translucency" : { - "enabled" : false, - "value" : 0.5 - } - }, - { - "blend-mode" : "darken", - "blur-material" : 0, - "layers" : [ - { - "blend-mode" : "normal", - "fill" : "automatic", - "glass" : true, - "hidden" : false, - "image-name" : "Hard_Shadow.png", - "name" : "Hard Shadow" - } - ], - "lighting" : "individual", - "name" : "Hard Shadow", - "shadow" : { - "kind" : "layer-color", - "opacity" : 0.8 - }, - "specular" : false, - "translucency" : { - "enabled" : false, - "value" : 1 - } - }, - { - "blend-mode" : "normal", - "blur-material" : null, - "layers" : [ - { - "fill" : "none", - "glass-specializations" : [ - { - "value" : true - }, - { - "appearance" : "tinted", - "value" : true - } - ], - "hidden" : false, - "image-name" : "Body.png", - "name" : "Hair + Face" - } - ], - "lighting" : "individual", - "name" : "Body", - "shadow" : { - "kind" : "neutral", - "opacity" : 0.5 - }, - "specular" : true, - "translucency" : { - "enabled" : false, - "value" : 0.5 - } - }, - { - "blur-material" : null, - "layers" : [ - { - "glass" : true, - "hidden" : false, - "image-name" : "Ear_L.png", - "name" : "Ear L" - } - ], - "name" : "Ear L", - "shadow" : { - "kind" : "layer-color", - "opacity" : 0.5 - }, - "specular" : true, - "translucency" : { - "enabled" : false, - "value" : 0.5 - } - } - ], - "supported-platforms" : { - "circles" : [ - "watchOS" - ], - "squares" : "shared" - } -} \ No newline at end of file diff --git a/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x-dark.png b/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x-dark.png deleted file mode 100644 index 6d93c9151..000000000 Binary files a/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x-dark.png and /dev/null differ diff --git a/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x-light.png b/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x-light.png deleted file mode 100644 index 6bb43ff83..000000000 Binary files a/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x-light.png and /dev/null differ diff --git a/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png deleted file mode 100644 index 6bb43ff83..000000000 Binary files a/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png and /dev/null differ diff --git a/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 74671a801..000000000 --- a/apps/stage-pocket/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "images": [ - { - "filename": "AppIcon-512@2x.png", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ], - "filename": "AppIcon-512@2x-light.png", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "AppIcon-512@2x-dark.png", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/apps/stage-pocket/ios/App/App/Assets.xcassets/Contents.json b/apps/stage-pocket/ios/App/App/Assets.xcassets/Contents.json deleted file mode 100644 index 97a8662eb..000000000 --- a/apps/stage-pocket/ios/App/App/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info": { - "version": 1, - "author": "xcode" - } -} diff --git a/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json deleted file mode 100644 index b78149276..000000000 --- a/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images": [ - { - "idiom": "universal", - "filename": "splash-2732x2732-2.png", - "scale": "1x" - }, - { - "idiom": "universal", - "filename": "splash-2732x2732-1.png", - "scale": "2x" - }, - { - "idiom": "universal", - "filename": "splash-2732x2732.png", - "scale": "3x" - } - ], - "info": { - "version": 1, - "author": "xcode" - } -} diff --git a/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png deleted file mode 100644 index 33ea6c970..000000000 Binary files a/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png and /dev/null differ diff --git a/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png deleted file mode 100644 index 33ea6c970..000000000 Binary files a/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png and /dev/null differ diff --git a/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png deleted file mode 100644 index 33ea6c970..000000000 Binary files a/apps/stage-pocket/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png and /dev/null differ diff --git a/apps/stage-pocket/ios/App/App/Base.lproj/LaunchScreen.storyboard b/apps/stage-pocket/ios/App/App/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index e7ae5d780..000000000 --- a/apps/stage-pocket/ios/App/App/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/stage-pocket/ios/App/App/Base.lproj/Main.storyboard b/apps/stage-pocket/ios/App/App/Base.lproj/Main.storyboard deleted file mode 100644 index cdf6239aa..000000000 --- a/apps/stage-pocket/ios/App/App/Base.lproj/Main.storyboard +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift b/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift deleted file mode 100644 index acbd97deb..000000000 --- a/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift +++ /dev/null @@ -1,170 +0,0 @@ -import UIKit -import Capacitor -import WebKit - -class DevBridgeViewController: CAPBridgeViewController { - private let hostBridgeName = "airiHostBridge" - private lazy var webSocketBridge = HostWebSocketBridge( - sessionFactory: URLSessionHostWebSocketSession.init, - eventSink: { [weak self] payload in - self?.dispatchWebSocketBridgeEvent(payload) - } - ) - private lazy var hostBridgeMessageHandler = WeakScriptMessageHandler(delegate: self) - - override func capacitorDidLoad() { - super.capacitorDidLoad() - bridge?.registerPluginInstance(WebAuthenticationPlugin()) - configureTransparentBackground() - webView?.allowsBackForwardNavigationGestures = true - installWebSocketBridge() - } - - deinit { - bridge?.webView?.configuration.userContentController.removeScriptMessageHandler(forName: hostBridgeName) - webSocketBridge.dispose() - } - - #if DEBUG - override func viewDidLoad() { - super.viewDidLoad() - if let webView = bridge?.webView { - webView.navigationDelegate = self - print("[DevBridge] Navigation delegate set for WebView") - } else { - print("[DevBridge] Warning: WebView not available in viewDidLoad") - } - } - #endif - - private func installWebSocketBridge() { - guard let webView = bridge?.webView else { - print("[HostBridge] Warning: WebView not available during bridge installation") - return - } - - webView.configuration.userContentController.add(hostBridgeMessageHandler, name: hostBridgeName) - } - - private func configureTransparentBackground() { - view.isOpaque = false - view.backgroundColor = .clear - - guard let webView = bridge?.webView else { - return - } - - webView.isOpaque = false - webView.backgroundColor = .clear - webView.scrollView.backgroundColor = .clear - webView.superview?.backgroundColor = .clear - } - - private func dispatchWebSocketBridgeEvent(_ payload: String) { - guard let webView = bridge?.webView else { - return - } - - let script = "window.__airiHostBridge?.onNativeMessage(\(payload.javaScriptEscapedStringLiteral))" - DispatchQueue.main.async { - webView.evaluateJavaScript(script, completionHandler: nil) - } - } -} - -extension DevBridgeViewController: WKScriptMessageHandler { - func userContentController( - _ userContentController: WKUserContentController, - didReceive message: WKScriptMessage - ) { - guard message.name == hostBridgeName else { - return - } - - guard let payload = message.body as? String else { - print("[HostBridge] Warning: Unsupported message payload: \(type(of: message.body))") - return - } - - webSocketBridge.handleCommand(payload) - } -} - -#if DEBUG -extension DevBridgeViewController: WKNavigationDelegate { - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void - ) { - if let url = navigationAction.request.url { - print("[DevBridge] Navigation request to: \(url.absoluteString)") - } - decisionHandler(.allow) - } - - func webView( - _ webView: WKWebView, - didStartProvisionalNavigation navigation: WKNavigation! - ) { - print("[DevBridge] Started provisional navigation") - } - - func webView( - _ webView: WKWebView, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - let host = challenge.protectionSpace.host - let authMethod = challenge.protectionSpace.authenticationMethod - print( - "[DevBridge] Certificate challenge for host: \(host), method: \(authMethod)" - ) - - if authMethod == NSURLAuthenticationMethodServerTrust { - if let serverTrust = challenge.protectionSpace.serverTrust { - print( - "[DevBridge] Trusting certificate for development host: \(host)" - ) - completionHandler(.useCredential, URLCredential(trust: serverTrust)) - return - } else { - print( - "[DevBridge] Warning: No serverTrust available for host: \(host)" - ) - } - } - - print( - "[DevBridge] Using default certificate handling for host: \(host)" - ) - completionHandler(.performDefaultHandling, nil) - } - - func webView( - _ webView: WKWebView, - didFailProvisionalNavigation navigation: WKNavigation!, - withError error: Error - ) { - print("[DevBridge] Navigation failed: \(error.localizedDescription)") - if let nsError = error as NSError? { - print( - "[DevBridge] Error domain: \(nsError.domain), code: \(nsError.code)" - ) - if nsError.code == -1001 { - print( - "[DevBridge] Timeout error - check if Vite server is running and accessible." - ) - } - } - } - - func webView( - _ webView: WKWebView, - didFail navigation: WKNavigation!, - withError error: Error - ) { - print("[DevBridge] Navigation didFail: \(error.localizedDescription)") - } -} -#endif diff --git a/apps/stage-pocket/ios/App/App/HostWebSocketBridge.swift b/apps/stage-pocket/ios/App/App/HostWebSocketBridge.swift deleted file mode 100644 index 2d2cc0b10..000000000 --- a/apps/stage-pocket/ios/App/App/HostWebSocketBridge.swift +++ /dev/null @@ -1,156 +0,0 @@ -import Foundation - -protocol HostWebSocketSession { - func send(text: String) - func close(code: Int?, reason: String?) -} - -enum HostWebSocketEvent { - case open - case message(String) - case error(String) - case close(Int?, String?) -} - -private struct HostBridgeCommand: Decodable { - let kind: String - let id: String - let url: String? - let data: String? - let code: Int? - let reason: String? -} - -private struct HostBridgeEventPayload: Encodable { - let kind: String - let id: String - let data: String? - let message: String? - let code: Int? - let reason: String? -} - -final class HostWebSocketBridge { - typealias SessionFactory = - (_ url: String, _ emit: @escaping (HostWebSocketEvent) -> Void) throws -> HostWebSocketSession - - private let sessionFactory: SessionFactory - private let eventSink: (String) -> Void - private var sessions: [String: HostWebSocketSession] = [:] - private let decoder = JSONDecoder() - private let encoder = JSONEncoder() - - init( - sessionFactory: @escaping SessionFactory, - eventSink: @escaping (String) -> Void - ) { - self.sessionFactory = sessionFactory - self.eventSink = eventSink - } - - func handleCommand(_ payload: String) { - guard let data = payload.data(using: .utf8) else { - return - } - - guard let command = try? decoder.decode(HostBridgeCommand.self, from: data) else { - return - } - - DispatchQueue.main.async { - switch command.kind { - case "connect": - self.handleConnect(command) - case "send": - self.handleSend(command) - case "close": - self.handleClose(command) - default: - break - } - } - } - - func dispose() { - DispatchQueue.main.async { - self.sessions.values.forEach { $0.close(code: nil, reason: "Bridge disposed") } - self.sessions.removeAll() - } - } - - private func handleConnect(_ command: HostBridgeCommand) { - guard let url = command.url else { - emitEvent(kind: "error", id: command.id, message: "Missing websocket url") - emitEvent(kind: "close", id: command.id, reason: "Missing websocket url") - return - } - - print("[HostWebSocketBridge] connect id=\(command.id) url=\(url)") - - do { - let session = try sessionFactory(url) { [weak self] event in - self?.handleSessionEvent(id: command.id, event: event) - } - sessions[command.id] = session - } catch { - print("[HostWebSocketBridge] connect failed id=\(command.id) error=\(error.localizedDescription)") - let message = error.localizedDescription - emitEvent(kind: "error", id: command.id, message: message) - emitEvent(kind: "close", id: command.id, reason: message) - } - } - - private func handleSend(_ command: HostBridgeCommand) { - guard let data = command.data else { - return - } - - sessions[command.id]?.send(text: data) - } - - private func handleClose(_ command: HostBridgeCommand) { - sessions[command.id]?.close(code: command.code, reason: command.reason) - } - - private func handleSessionEvent(id: String, event: HostWebSocketEvent) { - DispatchQueue.main.async { - print("[HostWebSocketBridge] event id=\(id) \(String(describing: event))") - switch event { - case .open: - self.emitEvent(kind: "open", id: id) - case .message(let data): - self.emitEvent(kind: "message", id: id, data: data) - case .error(let message): - self.emitEvent(kind: "error", id: id, message: message) - case .close(let code, let reason): - self.sessions.removeValue(forKey: id) - self.emitEvent(kind: "close", id: id, code: code, reason: reason) - } - } - } - - private func emitEvent( - kind: String, - id: String, - data: String? = nil, - message: String? = nil, - code: Int? = nil, - reason: String? = nil - ) { - let payload = HostBridgeEventPayload( - kind: kind, - id: id, - data: data, - message: message, - code: code, - reason: reason - ) - - guard let encoded = try? encoder.encode(payload), - let string = String(data: encoded, encoding: .utf8) else { - return - } - - eventSink(string) - } -} diff --git a/apps/stage-pocket/ios/App/App/Info.plist b/apps/stage-pocket/ios/App/App/Info.plist deleted file mode 100644 index 1ee93ee65..000000000 --- a/apps/stage-pocket/ios/App/App/Info.plist +++ /dev/null @@ -1,70 +0,0 @@ - - - - - CAPACITOR_DEBUG - $(CAPACITOR_DEBUG) - CFBundleDevelopmentRegion - en - CFBundleDisplayName - AIRI - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleURLTypes - - - CFBundleURLName - ai.moeru.airi-pocket - CFBundleURLSchemes - - ai.moeru.airi-pocket - - - - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - LSRequiresIPhoneOS - - NSLocalNetworkUsageDescription - AIRI needs access to your local network to connect to your Tamagotchi host. - NSCameraUsageDescription - AIRI uses the camera to scan Tamagotchi connection QR codes. - NSMicrophoneUsageDescription - AIRI uses the microphone for voice interaction. - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - ITSAppUsesNonExemptEncryption - - - diff --git a/apps/stage-pocket/ios/App/App/URLSessionHostWebSocketSession.swift b/apps/stage-pocket/ios/App/App/URLSessionHostWebSocketSession.swift deleted file mode 100644 index 0851a5f05..000000000 --- a/apps/stage-pocket/ios/App/App/URLSessionHostWebSocketSession.swift +++ /dev/null @@ -1,219 +0,0 @@ -import Foundation -import Security - -final class URLSessionHostWebSocketSession: NSObject, HostWebSocketSession { - private let url: URL - private let emit: (HostWebSocketEvent) -> Void - private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) - private lazy var task = session.webSocketTask(with: url) - private var closed = false - - init(url: String, emit: @escaping (HostWebSocketEvent) -> Void) throws { - guard let parsedURL = URL(string: url) else { - throw HostWebSocketSessionError.invalidURL(url) - } - - guard isSupportedWebSocketURL(parsedURL) else { - throw HostWebSocketSessionError.unsupportedScheme(parsedURL.scheme) - } - - self.url = parsedURL - self.emit = emit - super.init() - - print("[URLSessionHostWebSocketSession] start url=\(parsedURL.absoluteString)") - task.resume() - receiveNextMessage() - } - - deinit { - session.invalidateAndCancel() - } - - func send(text: String) { - task.send(.string(text)) { [emit] error in - guard let error else { - return - } - - emit(.error(describeWebSocketError(error))) - } - } - - func close(code: Int?, reason: String?) { - print("[URLSessionHostWebSocketSession] close code=\(String(describing: code)) reason=\(String(describing: reason)) url=\(url.absoluteString)") - let closeCode = code.flatMap(URLSessionWebSocketTask.CloseCode.init(rawValue:)) - ?? .normalClosure - task.cancel(with: closeCode, reason: reason?.data(using: .utf8)) - } - - private func receiveNextMessage() { - task.receive { [weak self] result in - guard let self else { - return - } - - switch result { - case .success(.string(let text)): - self.emit(.message(text)) - self.receiveNextMessage() - case .success(.data): - self.emit(.error("Binary frames are not supported")) - self.receiveNextMessage() - case .failure: - break - @unknown default: - break - } - } - } - - private func closeIfNeeded(code: Int?, reason: String?) { - guard !closed else { - return - } - - closed = true - emit(.close(code, reason)) - session.invalidateAndCancel() - } -} - -extension URLSessionHostWebSocketSession: URLSessionWebSocketDelegate, URLSessionTaskDelegate { - func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didOpenWithProtocol negotiatedProtocol: String? - ) { - print("[URLSessionHostWebSocketSession] didOpen url=\(url.absoluteString) protocol=\(negotiatedProtocol ?? "nil")") - emit(.open) - } - - func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, - reason: Data? - ) { - let resolvedReason = reason.flatMap { String(data: $0, encoding: .utf8) } - print("[URLSessionHostWebSocketSession] didClose url=\(url.absoluteString) code=\(closeCode.rawValue) reason=\(resolvedReason ?? "nil")") - closeIfNeeded(code: Int(closeCode.rawValue), reason: resolvedReason) - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: Error? - ) { - guard let error else { - return - } - - if isCancelledWebSocketError(error) { - print("[URLSessionHostWebSocketSession] didComplete cancelled url=\(url.absoluteString)") - closeIfNeeded(code: nil, reason: nil) - return - } - - let message = describeWebSocketError(error) - print("[URLSessionHostWebSocketSession] didComplete error url=\(url.absoluteString) message=\(message)") - emit(.error(message)) - closeIfNeeded(code: nil, reason: message) - } - - func urlSession( - _ session: URLSession, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - print("[URLSessionHostWebSocketSession] challenge host=\(challenge.protectionSpace.host) method=\(challenge.protectionSpace.authenticationMethod)") - guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, - let trust = challenge.protectionSpace.serverTrust else { - completionHandler(.performDefaultHandling, nil) - return - } - - var trustError: CFError? - if SecTrustEvaluateWithError(trust, &trustError) { - print("[URLSessionHostWebSocketSession] challenge accepted by system host=\(challenge.protectionSpace.host)") - completionHandler(.useCredential, URLCredential(trust: trust)) - return - } - - if trustLooksLikeAiriServerCertificate(trust) { - print("[URLSessionHostWebSocketSession] challenge accepted by AIRI fallback host=\(challenge.protectionSpace.host)") - completionHandler(.useCredential, URLCredential(trust: trust)) - return - } - - print("[URLSessionHostWebSocketSession] challenge rejected host=\(challenge.protectionSpace.host) trustError=\(String(describing: trustError))") - completionHandler(.cancelAuthenticationChallenge, nil) - } -} - -private enum HostWebSocketSessionError: LocalizedError { - case invalidURL(String) - case unsupportedScheme(String?) - - var errorDescription: String? { - switch self { - case .invalidURL(let url): - return "Invalid websocket url: \(url)" - case .unsupportedScheme(let scheme): - return "Unsupported websocket url scheme: \(scheme ?? "nil")" - } - } -} - -private func isSupportedWebSocketURL(_ url: URL) -> Bool { - guard let scheme = url.scheme?.lowercased() else { - return false - } - - return scheme == "ws" || scheme == "wss" -} - -private func describeWebSocketError(_ error: Error) -> String { - let nsError = error as NSError - var details = ["\(nsError.localizedDescription) [\(nsError.domain):\(nsError.code)]"] - - if let failingURL = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL { - details.append("url=\(failingURL.absoluteString)") - } else if let failingURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String { - details.append("url=\(failingURL)") - } - - if let failureReason = nsError.localizedFailureReason, !failureReason.isEmpty { - details.append("reason=\(failureReason)") - } - - if let recoverySuggestion = nsError.localizedRecoverySuggestion, !recoverySuggestion.isEmpty { - details.append("suggestion=\(recoverySuggestion)") - } - - return details.joined(separator: " ") -} - -private func isCancelledWebSocketError(_ error: Error) -> Bool { - let nsError = error as NSError - return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled -} - -private func trustLooksLikeAiriServerCertificate(_ trust: SecTrust) -> Bool { - guard let leaf = SecTrustGetCertificateAtIndex(trust, 0), - certificateSummary(leaf) == "localhost" else { - return false - } - - let issuerIndex = SecTrustGetCertificateCount(trust) - 1 - guard issuerIndex >= 1, - let issuer = SecTrustGetCertificateAtIndex(trust, issuerIndex) else { - return false - } - - return certificateSummary(issuer) == "AIRI" -} - -private func certificateSummary(_ certificate: SecCertificate) -> String? { - SecCertificateCopySubjectSummary(certificate) as String? -} diff --git a/apps/stage-pocket/ios/App/App/WeakScriptMessageHandler.swift b/apps/stage-pocket/ios/App/App/WeakScriptMessageHandler.swift deleted file mode 100644 index f2693386f..000000000 --- a/apps/stage-pocket/ios/App/App/WeakScriptMessageHandler.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import WebKit - -final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { - weak var delegate: WKScriptMessageHandler? - - init(delegate: WKScriptMessageHandler) { - self.delegate = delegate - super.init() - } - - func userContentController( - _ userContentController: WKUserContentController, - didReceive message: WKScriptMessage - ) { - delegate?.userContentController(userContentController, didReceive: message) - } -} - -extension String { - var javaScriptEscapedStringLiteral: String { - let json = try? JSONSerialization.data(withJSONObject: [self]) - guard let json else { - return "\"\"" - } - - let serialized = String(bytes: json, encoding: .utf8) ?? "[\"\"]" - return String(serialized.dropFirst().dropLast()) - } -} diff --git a/apps/stage-pocket/ios/App/App/WebAuthenticationPlugin.swift b/apps/stage-pocket/ios/App/App/WebAuthenticationPlugin.swift deleted file mode 100644 index c3242e440..000000000 --- a/apps/stage-pocket/ios/App/App/WebAuthenticationPlugin.swift +++ /dev/null @@ -1,99 +0,0 @@ -import AuthenticationServices -import Capacitor -import Foundation - -@objc(WebAuthenticationPlugin) -final class WebAuthenticationPlugin: CAPPlugin, CAPBridgedPlugin { - let identifier = "WebAuthenticationPlugin" - let jsName = "WebAuthentication" - let pluginMethods: [CAPPluginMethod] = [ - CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise) - ] - - private var activeCall: CAPPluginCall? - private var authenticationSession: ASWebAuthenticationSession? - - @objc func authenticate(_ call: CAPPluginCall) { - DispatchQueue.main.async { [weak self] in - self?.startAuthentication(call) - } - } - - private func startAuthentication(_ call: CAPPluginCall) { - guard activeCall == nil else { - call.reject("An authentication session is already active.", "AUTHENTICATION_IN_PROGRESS") - return - } - - guard let urlValue = call.getString("url"), - let url = URL(string: urlValue), - ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { - call.reject("The authentication URL is invalid.", "INVALID_URL") - return - } - - guard let callbackScheme = call.getString("callbackScheme"), !callbackScheme.isEmpty else { - call.reject("The callback scheme is missing.", "INVALID_CALLBACK_SCHEME") - return - } - - activeCall = call - let session = ASWebAuthenticationSession( - url: url, - callbackURLScheme: callbackScheme - ) { [weak self] callbackURL, error in - DispatchQueue.main.async { - self?.finishAuthentication(callbackURL: callbackURL, error: error) - } - } - session.presentationContextProvider = self - authenticationSession = session - - if !session.start() { - finishAuthentication( - callbackURL: nil, - error: WebAuthenticationError.sessionDidNotStart - ) - } - } - - private func finishAuthentication(callbackURL: URL?, error: Error?) { - guard let call = activeCall else { - return - } - - activeCall = nil - authenticationSession = nil - - if let callbackURL { - call.resolve(["callbackUrl": callbackURL.absoluteString]) - return - } - - if let sessionError = error as? ASWebAuthenticationSessionError, - sessionError.code == .canceledLogin { - call.resolve() - return - } - - call.reject( - error?.localizedDescription ?? "The authentication session failed.", - "AUTHENTICATION_FAILED", - error - ) - } -} - -extension WebAuthenticationPlugin: ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return bridge?.viewController?.view.window ?? ASPresentationAnchor() - } -} - -private enum WebAuthenticationError: LocalizedError { - case sessionDidNotStart - - var errorDescription: String? { - return "The authentication session did not start." - } -} diff --git a/apps/stage-pocket/ios/App/CapApp-SPM/.gitignore b/apps/stage-pocket/ios/App/CapApp-SPM/.gitignore deleted file mode 100644 index 3b2981208..000000000 --- a/apps/stage-pocket/ios/App/CapApp-SPM/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/apps/stage-pocket/ios/App/CapApp-SPM/Package.resolved b/apps/stage-pocket/ios/App/CapApp-SPM/Package.resolved deleted file mode 100644 index 9ecef2e99..000000000 --- a/apps/stage-pocket/ios/App/CapApp-SPM/Package.resolved +++ /dev/null @@ -1,23 +0,0 @@ -{ - "pins" : [ - { - "identity" : "capacitor-swift-pm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", - "state" : { - "revision" : "0e862e6ff13852a710c8a484180ca4d6a2cc9761", - "version" : "8.2.0" - } - }, - { - "identity" : "osbarcodelib-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OutSystems/OSBarcodeLib-iOS.git", - "state" : { - "revision" : "1ae7a716331be720f9f1075ef033276014c341ec", - "version" : "2.1.1" - } - } - ], - "version" : 2 -} diff --git a/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift b/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift deleted file mode 100644 index 4b691b39c..000000000 --- a/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift +++ /dev/null @@ -1,33 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands -let package = Package( - name: "CapApp-SPM", - platforms: [.iOS(.v15)], - products: [ - .library( - name: "CapApp-SPM", - targets: ["CapApp-SPM"]) - ], - dependencies: [ - .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.1"), - .package(name: "CapacitorApp", path: "../../../../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.1/node_modules/@capacitor/app"), - .package(name: "CapacitorBarcodeScanner", path: "../../../../../node_modules/.pnpm/@capacitor+barcode-scanner@3.0.2_@capacitor+core@8.3.1/node_modules/@capacitor/barcode-scanner"), - .package(name: "CapacitorLocalNotifications", path: "../../../../../node_modules/.pnpm/@capacitor+local-notifications@8.0.2_@capacitor+core@8.3.1/node_modules/@capacitor/local-notifications"), - .package(name: "CapacitorNativeSettings", path: "../../../../../node_modules/.pnpm/capacitor-native-settings@8.1.0_@capacitor+core@8.3.1/node_modules/capacitor-native-settings") - ], - targets: [ - .target( - name: "CapApp-SPM", - dependencies: [ - .product(name: "Capacitor", package: "capacitor-swift-pm"), - .product(name: "Cordova", package: "capacitor-swift-pm"), - .product(name: "CapacitorApp", package: "CapacitorApp"), - .product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"), - .product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"), - .product(name: "CapacitorNativeSettings", package: "CapacitorNativeSettings") - ] - ) - ] -) diff --git a/apps/stage-pocket/ios/App/CapApp-SPM/README.md b/apps/stage-pocket/ios/App/CapApp-SPM/README.md deleted file mode 100644 index 5e22a2f8a..000000000 --- a/apps/stage-pocket/ios/App/CapApp-SPM/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# CapApp-SPM - -This SPM is used to host SPM dependencies for you Capacitor project - -Do not modify the contents of it or there may be unintended consequences. diff --git a/apps/stage-pocket/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/apps/stage-pocket/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift deleted file mode 100644 index 945afec8c..000000000 --- a/apps/stage-pocket/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift +++ /dev/null @@ -1 +0,0 @@ -public let isCapacitorApp = true diff --git a/apps/stage-pocket/ios/ExportOptions.plist b/apps/stage-pocket/ios/ExportOptions.plist deleted file mode 100644 index 87dddde8c..000000000 --- a/apps/stage-pocket/ios/ExportOptions.plist +++ /dev/null @@ -1,17 +0,0 @@ - - - - - method - app-store-connect - teamID - 433DLLA855 - signingStyle - manual - provisioningProfiles - - ai.moeru.airi-pocket - AIRI CI - - - diff --git a/apps/stage-pocket/ios/debug.xcconfig b/apps/stage-pocket/ios/debug.xcconfig deleted file mode 100644 index 53ce18dea..000000000 --- a/apps/stage-pocket/ios/debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -CAPACITOR_DEBUG = true diff --git a/apps/stage-pocket/package.json b/apps/stage-pocket/package.json deleted file mode 100644 index 63164fb71..000000000 --- a/apps/stage-pocket/package.json +++ /dev/null @@ -1,153 +0,0 @@ -{ - "name": "@proj-airi/stage-pocket", - "type": "module", - "private": true, - "description": "LLM powered virtual character", - "author": { - "name": "Moeru AI Project AIRI Team", - "email": "airi@moeru.ai", - "url": "https://github.com/moeru-ai" - }, - "license": "MIT", - "scripts": { - "build": "vite build", - "lint": "eslint .", - "lint:swift": "swiftlint ios", - "preview": "vite preview", - "typecheck": "vue-tsc --noEmit", - "dev:web": "vite", - "dev:ios": "cap-vite -- ios", - "dev:android": "cap-vite -- android" - }, - "dependencies": { - "@capacitor/android": "catalog:", - "@capacitor/app": "catalog:", - "@capacitor/barcode-scanner": "catalog:", - "@capacitor/core": "catalog:", - "@capacitor/ios": "catalog:", - "@capacitor/local-notifications": "catalog:", - "@date-fns/utc": "catalog:", - "@fontsource-variable/nunito": "catalog:", - "@formkit/auto-animate": "catalog:", - "@huggingface/transformers": "catalog:", - "@moeru/eventa": "catalog:", - "@moeru/std": "catalog:", - "@proj-airi/audio": "workspace:^", - "@proj-airi/ccc": "workspace:^", - "@proj-airi/drizzle-duckdb-wasm": "catalog:", - "@proj-airi/font-chillroundm": "workspace:^", - "@proj-airi/font-cjkfonts-allseto": "workspace:^", - "@proj-airi/font-xiaolai": "workspace:^", - "@proj-airi/i18n": "workspace:^", - "@proj-airi/pipelines-audio": "workspace:^", - "@proj-airi/server-sdk": "workspace:^", - "@proj-airi/stage-layouts": "workspace:^", - "@proj-airi/stage-pages": "workspace:^", - "@proj-airi/stage-shared": "workspace:^", - "@proj-airi/stage-ui": "workspace:^", - "@proj-airi/stage-ui-live2d": "workspace:^", - "@proj-airi/stage-ui-three": "workspace:^", - "@proj-airi/stream-kit": "workspace:^", - "@proj-airi/ui": "workspace:^", - "@proj-airi/ui-transitions": "workspace:^", - "@tresjs/cientos": "catalog:", - "@tresjs/core": "catalog:", - "@valibot/to-json-schema": "catalog:", - "@vueuse/core": "catalog:", - "@vueuse/shared": "catalog:", - "@xsai-ext/providers": "catalog:", - "@xsai-transformers/embed": "catalog:", - "@xsai/generate-speech": "catalog:", - "@xsai/generate-text": "catalog:", - "@xsai/model": "catalog:", - "@xsai/shared": "catalog:", - "@xsai/shared-chat": "catalog:", - "@xsai/stream-text": "catalog:", - "@xsai/stream-transcription": "catalog:", - "@xsai/utils-chat": "catalog:", - "animejs": "catalog:", - "capacitor-native-settings": "catalog:", - "colorjs.io": "catalog:", - "culori": "catalog:", - "d3": "catalog:", - "date-fns": "catalog:", - "dompurify": "catalog:", - "driver.js": "catalog:", - "drizzle-orm": "catalog:", - "embla-carousel-vue": "catalog:", - "gpuu": "catalog:", - "html2canvas": "catalog:", - "jszip": "catalog:", - "localforage": "catalog:", - "mediabunny": "catalog:", - "nanoid": "catalog:", - "node-vibrant": "catalog:", - "nprogress": "catalog:", - "onnxruntime-web": "catalog:", - "pinia": "catalog:", - "rehype-stringify": "catalog:", - "reka-ui": "catalog:", - "remark-parse": "catalog:", - "remark-rehype": "catalog:", - "shiki": "catalog:", - "splitpanes": "catalog:", - "three": "catalog:", - "unified": "catalog:", - "unspeech": "catalog:xsai", - "uuid": "catalog:", - "valibot": "catalog:", - "vaul-vue": "catalog:", - "vue": "catalog:", - "vue-demi": "catalog:", - "vue-i18n": "catalog:", - "vue-router": "catalog:", - "vue-sonner": "catalog:", - "web-haptics": "catalog:", - "workbox-window": "catalog:", - "xsschema": "catalog:", - "yauzl": "catalog:", - "zod": "catalog:" - }, - "devDependencies": { - "@capacitor/cli": "catalog:", - "@iconify-json/carbon": "catalog:", - "@iconify-json/eos-icons": "catalog:", - "@iconify-json/lucide": "catalog:", - "@iconify-json/mingcute": "catalog:", - "@iconify-json/ph": "catalog:", - "@iconify-json/simple-icons": "catalog:", - "@iconify-json/solar": "catalog:", - "@iconify-json/svg-spinners": "catalog:", - "@iconify-json/vscode-icons": "catalog:", - "@intlify/unplugin-vue-i18n": "catalog:", - "@proj-airi/cap-vite": "workspace:*", - "@proj-airi/iconify-meteocons": "catalog:", - "@proj-airi/lobe-icons": "catalog:", - "@proj-airi/unplugin-fetch": "catalog:", - "@proj-airi/unplugin-live2d-sdk": "catalog:", - "@shikijs/markdown-it": "catalog:", - "@types/audioworklet": "catalog:", - "@types/culori": "catalog:", - "@types/nprogress": "catalog:", - "@types/splitpanes": "catalog:", - "@types/three": "catalog:", - "@types/yauzl": "catalog:", - "@unocss/reset": "catalog:", - "@vitejs/plugin-vue": "catalog:", - "@vue-macros/volar": "catalog:", - "@vueuse/motion": "catalog:", - "csstype": "catalog:", - "hfup": "catalog:", - "less": "catalog:", - "unplugin-info": "catalog:", - "unplugin-yaml": "catalog:", - "vite": "catalog:", - "vite-bundle-visualizer": "catalog:", - "vite-plugin-mkcert": "catalog:", - "vite-plugin-pwa": "catalog:", - "vite-plugin-vue-devtools": "catalog:", - "vite-plugin-vue-layouts": "catalog:", - "vue-macros": "catalog:", - "vue-tsc": "catalog:" - } -} diff --git a/apps/stage-pocket/public/.assetsignore b/apps/stage-pocket/public/.assetsignore deleted file mode 100644 index 421c66e16..000000000 --- a/apps/stage-pocket/public/.assetsignore +++ /dev/null @@ -1,3 +0,0 @@ -**/node_modules -**/.DS_Store -**/.git diff --git a/apps/stage-pocket/public/_headers b/apps/stage-pocket/public/_headers deleted file mode 100644 index 7f40d0894..000000000 --- a/apps/stage-pocket/public/_headers +++ /dev/null @@ -1,4 +0,0 @@ -/assets/* - cache-control: max-age=31536000 - cache-control: immutable - diff --git a/apps/stage-pocket/public/_redirects b/apps/stage-pocket/public/_redirects deleted file mode 100644 index 79b7e45f8..000000000 --- a/apps/stage-pocket/public/_redirects +++ /dev/null @@ -1,7 +0,0 @@ -# i18n -/docs/ /docs/en/ 301 - -# Normalize old zh-hans locale path to zh-Hans -# This ensures legacy links like /docs/zh-hans/ still work -/docs/zh-hans/* /docs/zh-Hans/:splat 301 -/docs/zh-hans /docs/zh-Hans/ 301 diff --git a/apps/stage-pocket/public/apple-touch-icon.png b/apps/stage-pocket/public/apple-touch-icon.png deleted file mode 100644 index e3bfed969..000000000 Binary files a/apps/stage-pocket/public/apple-touch-icon.png and /dev/null differ diff --git a/apps/stage-pocket/public/favicon.ico b/apps/stage-pocket/public/favicon.ico deleted file mode 100644 index a47f8f675..000000000 Binary files a/apps/stage-pocket/public/favicon.ico and /dev/null differ diff --git a/apps/stage-pocket/public/favicon.svg b/apps/stage-pocket/public/favicon.svg deleted file mode 100644 index eae728aa1..000000000 --- a/apps/stage-pocket/public/favicon.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/stage-pocket/public/maskable_icon_x192.png b/apps/stage-pocket/public/maskable_icon_x192.png deleted file mode 100644 index 9a1728ebd..000000000 Binary files a/apps/stage-pocket/public/maskable_icon_x192.png and /dev/null differ diff --git a/apps/stage-pocket/public/maskable_icon_x512.png b/apps/stage-pocket/public/maskable_icon_x512.png deleted file mode 100644 index 2a56ae126..000000000 Binary files a/apps/stage-pocket/public/maskable_icon_x512.png and /dev/null differ diff --git a/apps/stage-pocket/public/open-graph.png b/apps/stage-pocket/public/open-graph.png deleted file mode 100644 index a7d26580c..000000000 Binary files a/apps/stage-pocket/public/open-graph.png and /dev/null differ diff --git a/apps/stage-pocket/public/web-app-manifest-192x192.png b/apps/stage-pocket/public/web-app-manifest-192x192.png deleted file mode 100644 index c046e5331..000000000 Binary files a/apps/stage-pocket/public/web-app-manifest-192x192.png and /dev/null differ diff --git a/apps/stage-pocket/public/web-app-manifest-512x512.png b/apps/stage-pocket/public/web-app-manifest-512x512.png deleted file mode 100644 index e92eb2fd7..000000000 Binary files a/apps/stage-pocket/public/web-app-manifest-512x512.png and /dev/null differ diff --git a/apps/stage-pocket/resources/icon-foreground.png b/apps/stage-pocket/resources/icon-foreground.png deleted file mode 100644 index 6bb43ff83..000000000 Binary files a/apps/stage-pocket/resources/icon-foreground.png and /dev/null differ diff --git a/apps/stage-pocket/resources/icon-only.png b/apps/stage-pocket/resources/icon-only.png deleted file mode 100644 index 6bb43ff83..000000000 Binary files a/apps/stage-pocket/resources/icon-only.png and /dev/null differ diff --git a/apps/stage-pocket/resources/splash.png b/apps/stage-pocket/resources/splash.png deleted file mode 100644 index 33ea6c970..000000000 Binary files a/apps/stage-pocket/resources/splash.png and /dev/null differ diff --git a/apps/stage-pocket/src/App.vue b/apps/stage-pocket/src/App.vue deleted file mode 100644 index ec4916488..000000000 --- a/apps/stage-pocket/src/App.vue +++ /dev/null @@ -1,211 +0,0 @@ - - - - - diff --git a/apps/stage-pocket/src/assets/backgrounds/fairy-forest.e17cbc2774.ko-fi.com.avif b/apps/stage-pocket/src/assets/backgrounds/fairy-forest.e17cbc2774.ko-fi.com.avif deleted file mode 100644 index 4fc190fe5..000000000 Binary files a/apps/stage-pocket/src/assets/backgrounds/fairy-forest.e17cbc2774.ko-fi.com.avif and /dev/null differ diff --git a/apps/stage-pocket/src/assets/icons/modules/games/factorio.png b/apps/stage-pocket/src/assets/icons/modules/games/factorio.png deleted file mode 100644 index 8162e4817..000000000 Binary files a/apps/stage-pocket/src/assets/icons/modules/games/factorio.png and /dev/null differ diff --git a/apps/stage-pocket/src/components/AudioWaveform.vue b/apps/stage-pocket/src/components/AudioWaveform.vue deleted file mode 100644 index cb795e5cd..000000000 --- a/apps/stage-pocket/src/components/AudioWaveform.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/apps/stage-pocket/src/components/DataGui/DualEndRange.vue b/apps/stage-pocket/src/components/DataGui/DualEndRange.vue deleted file mode 100644 index 8d132299d..000000000 --- a/apps/stage-pocket/src/components/DataGui/DualEndRange.vue +++ /dev/null @@ -1,204 +0,0 @@ - - - - - diff --git a/apps/stage-pocket/src/components/IconAnimation.vue b/apps/stage-pocket/src/components/IconAnimation.vue deleted file mode 100644 index 976762cb7..000000000 --- a/apps/stage-pocket/src/components/IconAnimation.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - diff --git a/apps/stage-pocket/src/components/onboarding/step-permissions.vue b/apps/stage-pocket/src/components/onboarding/step-permissions.vue deleted file mode 100644 index 1ea11e7dd..000000000 --- a/apps/stage-pocket/src/components/onboarding/step-permissions.vue +++ /dev/null @@ -1,47 +0,0 @@ - - - diff --git a/apps/stage-pocket/src/components/permissions/permission-card.vue b/apps/stage-pocket/src/components/permissions/permission-card.vue deleted file mode 100644 index 19e66edf1..000000000 --- a/apps/stage-pocket/src/components/permissions/permission-card.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - diff --git a/apps/stage-pocket/src/components/permissions/permissions-panel.vue b/apps/stage-pocket/src/components/permissions/permissions-panel.vue deleted file mode 100644 index 0fc74fc24..000000000 --- a/apps/stage-pocket/src/components/permissions/permissions-panel.vue +++ /dev/null @@ -1,147 +0,0 @@ - - - diff --git a/apps/stage-pocket/src/components/websocket-status-button.vue b/apps/stage-pocket/src/components/websocket-status-button.vue deleted file mode 100644 index 2b05bc257..000000000 --- a/apps/stage-pocket/src/components/websocket-status-button.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/apps/stage-pocket/src/composables/audio-input.ts b/apps/stage-pocket/src/composables/audio-input.ts deleted file mode 100644 index 64ac2a549..000000000 --- a/apps/stage-pocket/src/composables/audio-input.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useDevicesList, useUserMedia } from '@vueuse/core' -import { computed, ref, watch } from 'vue' - -export function useAudioInput() { - const devices = useDevicesList({ constraints: { audio: true }, requestPermissions: false }) - - const selectedAudioInputId = ref(devices.audioInputs.value[0]?.deviceId || '') - const selectedAudioInput = ref() - const audioInputs = computed(() => devices.audioInputs.value) - - const constraints = ref({ audio: true }) - const media = useUserMedia({ constraints, autoSwitch: true, enabled: false }) - - async function request() { - if (devices.permissionGranted.value) { - return - } - if (!devices.isSupported.value) { - return - } - - await devices.ensurePermissions() - } - - watch(selectedAudioInputId, () => { - if (selectedAudioInputId.value) { - constraints.value = { - audio: { - deviceId: { exact: selectedAudioInputId.value! }, - }, - } - } - }, { immediate: true }) - - watch(devices.audioInputs, () => { - selectedAudioInput.value = audioInputs.value.find(device => device.deviceId === selectedAudioInputId.value) - }, { immediate: true }) - - watch([devices.permissionGranted, audioInputs, selectedAudioInputId], async () => { - await request() - if (!devices.permissionGranted.value) { - return - } - if (audioInputs.value.length === 0) { - return - } - if (!selectedAudioInput.value) { - selectedAudioInput.value = audioInputs.value[0] - } - }, { immediate: true }) - - async function start() { - await request() - - if (!devices.permissionGranted.value) { - return - } - if (!selectedAudioInput.value) { - return - } - - if (media.enabled.value) { - media.restart() - } - - media.start() - } - - function stop() { - media.stop() - } - - return { - selectedAudioInputId, - selectedAudioInput, - audioInputs, - - start, - stop, - request, - media, - } -} diff --git a/apps/stage-pocket/src/composables/audio-record.ts b/apps/stage-pocket/src/composables/audio-record.ts deleted file mode 100644 index 8a642b568..000000000 --- a/apps/stage-pocket/src/composables/audio-record.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { MaybeRefOrGetter } from 'vue' - -import { until } from '@vueuse/core' -import { ref, toRef } from 'vue' - -export function useAudioRecord( - media: MaybeRefOrGetter, - start: () => Promise = () => Promise.resolve(), -) { - const audioRecorder = ref() - const mediaRef = toRef(media) - - async function startRecord() { - await start() - await until(mediaRef).toBeTruthy() - - if (!mediaRef.value) { - console.error('No media media available') - return - } - - audioRecorder.value = new MediaRecorder(mediaRef.value) - audioRecorder.value.start() - } - - function stopRecord() { - if (audioRecorder.value) { - audioRecorder.value.stop() - audioRecorder.value.ondataavailable = (event) => { - const audioBlob = event.data - const audioUrl = URL.createObjectURL(audioBlob) - const audioElement = new Audio(audioUrl) - audioElement.play() - } - } - } - - return { - startRecord, - stopRecord, - } -} diff --git a/apps/stage-pocket/src/composables/icon-animation.ts b/apps/stage-pocket/src/composables/icon-animation.ts deleted file mode 100644 index 3149a8e5b..000000000 --- a/apps/stage-pocket/src/composables/icon-animation.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useSettings } from '@proj-airi/stage-ui/stores/settings' -import { computed, onMounted, onUnmounted, ref } from 'vue' - -export function useIconAnimation(icon: string) { - const iconAnimationStarted = ref(false) - const showAnimationComponent = ref(false) - const animationIcon = ref(icon) - - const settingsStore = useSettings() - const showIconAnimation = computed(() => showAnimationComponent.value && !settingsStore.disableTransitions && settingsStore.usePageSpecificTransitions) - - onMounted(() => { - showAnimationComponent.value = true - requestAnimationFrame(() => { - iconAnimationStarted.value = true - }) - }) - - onUnmounted(() => { - iconAnimationStarted.value = false - showAnimationComponent.value = false - }) - - return { - iconAnimationStarted, - showIconAnimation, - animationIcon, - } -} diff --git a/apps/stage-pocket/src/main.ts b/apps/stage-pocket/src/main.ts deleted file mode 100644 index 4dd88df26..000000000 --- a/apps/stage-pocket/src/main.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { Plugin } from 'vue' -import type { Router, RouteRecordRaw } from 'vue-router' - -import Tres from '@tresjs/core' -import NProgress from 'nprogress' - -import { Capacitor } from '@capacitor/core' -import { autoAnimatePlugin } from '@formkit/auto-animate/vue' -import { isEnvTruthy } from '@proj-airi/stage-shared' -import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button' -import { browserAuthorizationHandler, registerAuthorizationHandler } from '@proj-airi/stage-ui/libs/auth' -import { setupSynced } from '@proj-airi/stage-ui/libs/pinia' -import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/product-signals' -import { MotionPlugin } from '@vueuse/motion' -import { createPinia } from 'pinia' -import { setupLayouts } from 'virtual:generated-layouts' -import { createApp } from 'vue' -import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router' -import { routes } from 'vue-router/auto-routes' - -import App from './App.vue' - -import { installDeepLinks } from './modules/deep-links' -import { i18n } from './modules/i18n' -import { WebAuthentication } from './modules/web-authentication' - -import '@proj-airi/font-cjkfonts-allseto/index.css' -import '@proj-airi/font-xiaolai/index.css' -import '@unocss/reset/tailwind.css' -import 'splitpanes/dist/splitpanes.css' -import 'vue-sonner/style.css' -import './styles/main.css' -import 'uno.css' - -configureAnalyticsAdapter(async (options) => { - const { createOpenpanelAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/openpanel') - return createOpenpanelAdapter(options) -}) - -if (Capacitor.isNativePlatform()) { - registerAuthorizationHandler(async ({ authorizationUrl, provider }) => { - const url = new URL(authorizationUrl) - if (provider) - url.searchParams.set('provider', provider) - - return await WebAuthentication.authenticate({ - callbackScheme: 'ai.moeru.airi-pocket', - url: url.toString(), - }) - }) -} -else { - registerAuthorizationHandler(browserAuthorizationHandler) -} - -const pinia = createPinia() -const synced = setupSynced() -pinia.use(synced.pinia) - -// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution -const routeRecords = setupLayouts(routes as RouteRecordRaw[]) - -let router: Router -if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE)) - router = createRouter({ routes: routeRecords, history: createWebHashHistory() }) -else - router = createRouter({ routes: routeRecords, history: createWebHistory() }) - -router.beforeEach((to, from) => { - if (to.path !== from.path) - NProgress.start() -}) - -router.afterEach(() => { - NProgress.done() -}) - -window.addEventListener('unhandledrejection', (event) => { - console.warn('Unhandled rejection:', event.reason) -}) - -installDeepLinks(router) - -createApp(App) - .use(synced.vue) - .use(MotionPlugin) - // TODO: Fix autoAnimatePlugin type error - .use(autoAnimatePlugin as unknown as Plugin) - .use(router) - .use(pinia) - .use(i18n) - .use(Tres) - .use(trackButtonPlugin) - .mount('#app') - -if (import.meta.env.DEV && !import.meta.env.SSR) { - function captureEvents(el: HTMLElement) { - // Force `pointer-events: auto` as DismissableLayer in Reka UI adds - // `pointer-events: none` to document body. - el.style.pointerEvents = 'auto' - - // We need to capture events inside elements like devtools to prevent them - // from leaking to other layers (like DismissableLayer in Reka UI). - // - // See: https://github.com/unovue/reka-ui/blob/14866201d179b8bae3c8b4346a1ca8eff1c5eaa4/packages/radix-vue/src/DismissableLayer/DismissableLayer.vue#L186-L188 - el.addEventListener('focus', e => e.stopPropagation(), { capture: true }) - el.addEventListener('blur', e => e.stopPropagation(), { capture: true }) - el.addEventListener('pointerdown', e => e.stopPropagation(), { capture: true }) - } - - const observer = new MutationObserver((mutationsList, observer) => { - for (const mutation of mutationsList) { - if (mutation.type === 'childList') { - const devtoolsContainer = document.getElementById('__vue-devtools-container__') - - if (devtoolsContainer) { - captureEvents(devtoolsContainer) - observer.disconnect() - } - } - } - }) - - observer.observe(document.body, { childList: true, subtree: true }) - - // Disconnect on timeout in case the MutationObserver is left here forever. - // `observer.disconnect()` is idempotent, so it's safe to call it multiple times. - setTimeout(() => observer.disconnect(), 15 * 1000) -} diff --git a/apps/stage-pocket/src/modules/deep-links.ts b/apps/stage-pocket/src/modules/deep-links.ts deleted file mode 100644 index 44c1b8cb2..000000000 --- a/apps/stage-pocket/src/modules/deep-links.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { URLOpenListenerEvent } from '@capacitor/app' -import type { Router } from 'vue-router' - -import { App } from '@capacitor/app' -import { completeOIDCSignIn } from '@proj-airi/stage-ui/libs/auth' - -export function installDeepLinks(router: Router): void { - App.addListener('appUrlOpen', async (event?: URLOpenListenerEvent) => { - if (!event?.url) - return - - try { - const url = new URL(event.url) - if (url.host === 'links' && url.pathname === '/auth/callback') { - if (await completeOIDCSignIn(event.url)) - await router.replace('/') - } - } - catch (error) { - console.error('Failed to handle deep link:', error) - } - }) -} diff --git a/apps/stage-pocket/src/modules/i18n.ts b/apps/stage-pocket/src/modules/i18n.ts deleted file mode 100644 index 438770ebe..000000000 --- a/apps/stage-pocket/src/modules/i18n.ts +++ /dev/null @@ -1,22 +0,0 @@ -import messages from '@proj-airi/i18n/locales' - -import { resolveSupportedLocale } from '@proj-airi/i18n' -import { createI18n } from 'vue-i18n' - -function getLocale() { - let language = localStorage.getItem('settings/language') - - if (!language) { - // Fallback to browser language - language = navigator.language || 'en' - } - - return resolveSupportedLocale(language, Object.keys(messages!)) -} - -export const i18n = createI18n({ - legacy: false, - locale: getLocale(), - fallbackLocale: 'en', - messages, -}) diff --git a/apps/stage-pocket/src/modules/microphone-permission.ts b/apps/stage-pocket/src/modules/microphone-permission.ts deleted file mode 100644 index 553dc4822..000000000 --- a/apps/stage-pocket/src/modules/microphone-permission.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerPlugin } from '@capacitor/core' - -interface MicrophonePermissionState { - granted: boolean -} - -interface MicrophonePermissionPlugin { - checkPermission: () => Promise -} - -/** Reads Android's native microphone permission state without triggering a permission request. */ -export const MicrophonePermission = registerPlugin('MicrophonePermission') diff --git a/apps/stage-pocket/src/modules/pwa.ts b/apps/stage-pocket/src/modules/pwa.ts deleted file mode 100644 index 5804fd1a5..000000000 --- a/apps/stage-pocket/src/modules/pwa.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerSW } from 'virtual:pwa-register' diff --git a/apps/stage-pocket/src/modules/server-channel-qr-probe.ts b/apps/stage-pocket/src/modules/server-channel-qr-probe.ts deleted file mode 100644 index e78da1804..000000000 --- a/apps/stage-pocket/src/modules/server-channel-qr-probe.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr' - -import { errorMessageFrom } from '@moeru/std' -import { Client, createTextProtocolConnector, WebSocketEventSource } from '@proj-airi/server-sdk' - -import { getHostWebSocketConnector } from './websocket-bridge' - -export async function probeServerChannelQrPayload(payload: ServerChannelQrPayload) { - if (!payload.urls.some(url => getHostWebSocketConnector(url))) { - throw new Error('AIRI host websocket bridge is unavailable') - } - - const errors: string[] = [] - - for (const url of payload.urls) { - const connector = getHostWebSocketConnector(url) - if (!connector) { - throw new Error('AIRI host websocket bridge is unavailable') - } - - const client = new Client({ - autoConnect: false, - autoReconnect: false, - connectTimeoutMs: 2_000, - name: WebSocketEventSource.StageWeb, - token: payload.authToken, - url, - connector: createTextProtocolConnector(connector), - }) - - try { - await client.connect({ timeout: 2_500 }) - client.close() - return url - } - catch (error) { - client.close() - errors.push(`${url}: ${errorMessageFrom(error) ?? 'Unknown websocket probe error'}`) - } - } - - throw new Error(`No candidate server channel URL was reachable. ${errors.join('; ')}`) -} diff --git a/apps/stage-pocket/src/modules/web-authentication.ts b/apps/stage-pocket/src/modules/web-authentication.ts deleted file mode 100644 index d1599d295..000000000 --- a/apps/stage-pocket/src/modules/web-authentication.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { registerPlugin } from '@capacitor/core' - -interface WebAuthenticationOptions { - callbackScheme: string - url: string -} - -interface WebAuthenticationResult { - callbackUrl?: string -} - -interface WebAuthenticationPlugin { - authenticate: (options: WebAuthenticationOptions) => Promise -} - -/** Opens an authorization URL with the native system browser session. */ -export const WebAuthentication = registerPlugin('WebAuthentication') diff --git a/apps/stage-pocket/src/modules/websocket-bridge.ts b/apps/stage-pocket/src/modules/websocket-bridge.ts deleted file mode 100644 index 1590148df..000000000 --- a/apps/stage-pocket/src/modules/websocket-bridge.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { ClientConnector, ClientEvents } from '@proj-airi/server-sdk' - -type HostBridgeCommand - = | { kind: 'connect', id: string, url: string } - | { kind: 'send', id: string, data: string } - | { kind: 'close', id: string, code?: number, reason?: string } - -type HostBridgeEvent - = | { kind: 'open', id: string } - | { kind: 'message', id: string, data: string } - | { kind: 'error', id: string, message: string } - | { kind: 'close', id: string, code?: number, reason?: string } - -declare global { - interface Window { - AiriHostBridge?: { - postMessage: (payload: string) => void - } - webkit?: { - messageHandlers?: { - airiHostBridge?: { - postMessage: (payload: string) => void - } - } - } - __airiHostBridge?: { - onNativeMessage?: (payload: string) => void - } - } -} - -const connections = new Map() - -function postBridgeMessage(command: HostBridgeCommand) { - if (window.AiriHostBridge) { - window.AiriHostBridge.postMessage(JSON.stringify(command)) - return - } - - if (window.webkit?.messageHandlers?.airiHostBridge) { - window.webkit.messageHandlers.airiHostBridge.postMessage(JSON.stringify(command)) - return - } - - throw new Error('AIRI host websocket bridge is unavailable') -} - -function dispatchNativeEvent(payload: string) { - const event = JSON.parse(payload) as HostBridgeEvent - const connection = connections.get(event.id) - if (!connection) { - return - } - - connection.handleNativeEvent(event) -} - -class HostBridgeConnection { - readonly id = crypto.randomUUID() - private opened = false - private settled = false - - constructor( - private readonly url: string, - private readonly events: ClientEvents, - private readonly resolve: () => void, - private readonly reject: (error: Error) => void, - ) { - connections.set(this.id, this) - - postBridgeMessage({ - kind: 'connect', - id: this.id, - url: this.url, - }) - } - - send(data: string) { - if (!this.opened) { - return false - } - - postBridgeMessage({ - kind: 'send', - id: this.id, - data, - }) - - return true - } - - close(code?: number, reason?: string) { - if (this.settled && !this.opened) { - return - } - - postBridgeMessage({ - kind: 'close', - id: this.id, - code, - reason, - }) - } - - handleNativeEvent(event: HostBridgeEvent) { - switch (event.kind) { - case 'open': - this.opened = true - this.settled = true - this.resolve() - break - - case 'message': - this.events.message(event.data) - break - - case 'error': - if (!this.settled) { - this.settled = true - connections.delete(this.id) - this.reject(new Error(event.message)) - return - } - - this.events.error(new Error(event.message)) - break - - case 'close': - connections.delete(this.id) - if (!this.settled) { - this.settled = true - this.reject(createCloseBeforeOpenError(event)) - return - } - - this.opened = false - this.events.close({ code: event.code, reason: event.reason }) - break - } - } -} - -function createCloseBeforeOpenError(event: Extract) { - const reason = event.reason ? ` ${event.reason}` : '' - const code = typeof event.code === 'number' ? ` with code ${event.code}` : '' - return new Error(`AIRI host websocket bridge closed before opening${code}.${reason}`) -} - -export function getHostWebSocketConnector(url: string): ClientConnector | undefined { - if (!window.AiriHostBridge && !window.webkit?.messageHandlers?.airiHostBridge) { - return undefined - } - - window.__airiHostBridge = window.__airiHostBridge ?? {} - window.__airiHostBridge.onNativeMessage = dispatchNativeEvent - - return { - connect(events) { - let connection: HostBridgeConnection | undefined - const opened = new Promise((resolve, reject) => { - connection = new HostBridgeConnection(url, events, resolve, reject) - }) - - return opened.then(() => { - const activeConnection = connection - if (!activeConnection) { - throw new Error('AIRI host websocket bridge connection was not created') - } - - return { - send: message => activeConnection.send(message), - close: (code?: number, reason?: string) => activeConnection.close(code, reason), - } - }) - }, - } -} diff --git a/apps/stage-pocket/src/pages/[...all].vue b/apps/stage-pocket/src/pages/[...all].vue deleted file mode 100644 index 5a92e2244..000000000 --- a/apps/stage-pocket/src/pages/[...all].vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/apps/stage-pocket/src/pages/devtools/audio-record.vue b/apps/stage-pocket/src/pages/devtools/audio-record.vue deleted file mode 100644 index 1d264e8b1..000000000 --- a/apps/stage-pocket/src/pages/devtools/audio-record.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - diff --git a/apps/stage-pocket/src/pages/devtools/background-gradient-blending.vue b/apps/stage-pocket/src/pages/devtools/background-gradient-blending.vue deleted file mode 100644 index dbe37f6e8..000000000 --- a/apps/stage-pocket/src/pages/devtools/background-gradient-blending.vue +++ /dev/null @@ -1,220 +0,0 @@ - - - - - diff --git a/apps/stage-pocket/src/pages/devtools/background-removal.vue b/apps/stage-pocket/src/pages/devtools/background-removal.vue deleted file mode 100644 index cccf52775..000000000 --- a/apps/stage-pocket/src/pages/devtools/background-removal.vue +++ /dev/null @@ -1,380 +0,0 @@ - - - diff --git a/apps/stage-pocket/src/pages/devtools/gesture-circle.vue b/apps/stage-pocket/src/pages/devtools/gesture-circle.vue deleted file mode 100644 index 80d6bf3a0..000000000 --- a/apps/stage-pocket/src/pages/devtools/gesture-circle.vue +++ /dev/null @@ -1,179 +0,0 @@ - - - - - -meta: - layout: plain - diff --git a/apps/stage-pocket/src/pages/devtools/notifications.vue b/apps/stage-pocket/src/pages/devtools/notifications.vue deleted file mode 100644 index bd4965fd5..000000000 --- a/apps/stage-pocket/src/pages/devtools/notifications.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - - - -meta: - layout: plain - diff --git a/apps/stage-pocket/src/pages/devtools/performance-playground.vue b/apps/stage-pocket/src/pages/devtools/performance-playground.vue deleted file mode 100644 index 4c1c316c7..000000000 --- a/apps/stage-pocket/src/pages/devtools/performance-playground.vue +++ /dev/null @@ -1,350 +0,0 @@ - - - - - -meta: - layout: settings - diff --git a/apps/stage-pocket/src/pages/devtools/use-magic-keys.vue b/apps/stage-pocket/src/pages/devtools/use-magic-keys.vue deleted file mode 100644 index 14229058b..000000000 --- a/apps/stage-pocket/src/pages/devtools/use-magic-keys.vue +++ /dev/null @@ -1,131 +0,0 @@ - - - diff --git a/apps/stage-pocket/src/pages/index.vue b/apps/stage-pocket/src/pages/index.vue deleted file mode 100644 index bb339c00f..000000000 --- a/apps/stage-pocket/src/pages/index.vue +++ /dev/null @@ -1,252 +0,0 @@ - - - - - -name: IndexScenePage -meta: - layout: stage - stageTransition: - name: bubble-wave-out - diff --git a/apps/stage-pocket/src/pages/settings/account/index.vue b/apps/stage-pocket/src/pages/settings/account/index.vue deleted file mode 100644 index 1449340af..000000000 --- a/apps/stage-pocket/src/pages/settings/account/index.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - -meta: - layout: settings - titleKey: settings.pages.account.title - subtitleKey: settings.title - descriptionKey: settings.pages.account.description - icon: i-solar:user-circle-bold-duotone - settingsEntry: false - order: 0 - stageTransition: - name: slide - diff --git a/apps/stage-pocket/src/pages/settings/connection/index.vue b/apps/stage-pocket/src/pages/settings/connection/index.vue deleted file mode 100644 index e25e0808f..000000000 --- a/apps/stage-pocket/src/pages/settings/connection/index.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - - - -meta: - layout: settings - titleKey: settings.pages.connection.title - subtitleKey: settings.title - descriptionKey: settings.pages.connection.description - icon: i-solar:wi-fi-router-bold-duotone - settingsEntry: true - order: 8 - stageTransition: - name: slide - diff --git a/apps/stage-pocket/src/pages/settings/connection/server-channel-qr-scanner.vue b/apps/stage-pocket/src/pages/settings/connection/server-channel-qr-scanner.vue deleted file mode 100644 index 189af20de..000000000 --- a/apps/stage-pocket/src/pages/settings/connection/server-channel-qr-scanner.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/apps/stage-pocket/src/pages/settings/system/developer.vue b/apps/stage-pocket/src/pages/settings/system/developer.vue deleted file mode 100644 index ad1ef395d..000000000 --- a/apps/stage-pocket/src/pages/settings/system/developer.vue +++ /dev/null @@ -1,157 +0,0 @@ - - - - - -meta: - layout: settings - stageTransition: - name: slide - diff --git a/apps/stage-pocket/src/pages/settings/system/index.vue b/apps/stage-pocket/src/pages/settings/system/index.vue deleted file mode 100644 index 2a2eef585..000000000 --- a/apps/stage-pocket/src/pages/settings/system/index.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - - - -meta: - layout: settings - titleKey: settings.pages.system.title - subtitleKey: settings.title - descriptionKey: settings.pages.system.description - icon: i-solar:filters-bold-duotone - settingsEntry: true - order: 9 - stageTransition: - name: slide - pageSpecificAvailable: true - diff --git a/apps/stage-pocket/src/pages/settings/system/permissions.vue b/apps/stage-pocket/src/pages/settings/system/permissions.vue deleted file mode 100644 index 564f6e7b8..000000000 --- a/apps/stage-pocket/src/pages/settings/system/permissions.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - - - -meta: - layout: settings - titleKey: settings.pages.system.permissions.title - subtitleKey: settings.title - stageTransition: - name: slide - diff --git a/apps/stage-pocket/src/stores/background.ts b/apps/stage-pocket/src/stores/background.ts deleted file mode 100644 index c7a34f62a..000000000 --- a/apps/stage-pocket/src/stores/background.ts +++ /dev/null @@ -1 +0,0 @@ -export { type BackgroundItem, BackgroundKind, useBackgroundStore } from '@proj-airi/stage-layouts/stores/background' diff --git a/apps/stage-pocket/src/styles/main.css b/apps/stage-pocket/src/styles/main.css deleted file mode 100755 index 8d884843e..000000000 --- a/apps/stage-pocket/src/styles/main.css +++ /dev/null @@ -1,72 +0,0 @@ -@import '@proj-airi/ui/main.css'; -@import './transitions.css'; -@import './vue-transitions.css'; - -:root { - --bg-color-light: rgb(255 255 255); - --bg-color-dark: rgb(18 18 18); - --bg-color: var(--bg-color-light); -} - -/** - Disable double-tap "zoom" option in browser on touch devices - - https://stackoverflow.com/a/54207844 - https://stackoverflow.com/questions/10614481/disable-double-tap-zoom-option-in-browser-on-touch-devices - */ -* { - touch-action: manipulation; -} - -html, -body, -#app { - height: 100%; - margin: 0; - padding: 0; - overscroll-behavior: none; -} - -html { - background: var(--bg-color); - transition: all 0.3s ease-in-out; -} - -html:has(.airi-native-transparent-surface), -body:has(.airi-native-transparent-surface), -#app:has(.airi-native-transparent-surface) { - background: transparent !important; -} - -html.dark { - --bg-color: var(--bg-color-dark); - color-scheme: dark; -} - -#nprogress { - pointer-events: none; -} - -#nprogress .bar { - background: color-mix(in srgb, oklch(95% var(--chromatic-chroma-900) calc(var(--chromatic-hue) + ${0})) 70%, oklch(50% 0 360)); - opacity: 0.75; - position: fixed; - z-index: 1031; - top: 0; - left: 0; - width: 100%; - height: 2px; -} - -.dark #nprogress .bar { - background: `color-mix(in srgb, oklch(95% var(--chromatic-chroma-900) calc(var(--chromatic-hue) + ${0})) 90%, oklch(90% 0 360))`; -} - -@media (display-mode: standalone) { - #nprogress { - display: none; - } - #nprogress .bar { - display: none; - } -} diff --git a/apps/stage-pocket/src/styles/transitions.css b/apps/stage-pocket/src/styles/transitions.css deleted file mode 100644 index 82ae842e3..000000000 --- a/apps/stage-pocket/src/styles/transitions.css +++ /dev/null @@ -1,18 +0,0 @@ -.slide-away-enter-active, -.slide-away-leave-active { - transition: - transform 0.3s ease-in-out, - opacity 0.3s ease-in-out; -} - -.slide-away-enter, -.slide-away-leave-to { - transform: translateY(-10px); - opacity: 0; -} - -.slide-away-enter-from, -.slide-away-leave { - transform: translateY(10px); - opacity: 0; -} diff --git a/apps/stage-pocket/src/styles/vue-transitions.css b/apps/stage-pocket/src/styles/vue-transitions.css deleted file mode 100644 index 2beab3d5c..000000000 --- a/apps/stage-pocket/src/styles/vue-transitions.css +++ /dev/null @@ -1,48 +0,0 @@ -.fade-slide-out-r-to-l-enter-active, -.fade-slide-out-r-to-l-leave-active { - transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; -} - -.fade-slide-out-r-to-l-enter-from, -.fade-slide-out-r-to-l-leave-to { - opacity: 0; - transform: translateX(10px); -} - -.fade-slide-out-r-to-l-enter-to, -.fade-slide-out-r-to-l-leave-from { - opacity: 1; - transform: translateX(0); -} - -.fade-slide-out-l-to-r-enter-active, -.fade-slide-out-l-to-r-leave-active { - transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; -} - -.fade-slide-out-l-to-r-enter-from, -.fade-slide-out-l-to-r-leave-to { - opacity: 0; - transform: translateX(-10px); -} - -.fade-slide-out-l-to-r-enter-to, -.fade-slide-out-l-to-r-leave-from { - opacity: 1; - transform: translateX(0); -} - -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.2s ease-in-out; -} - -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} - -.fade-enter-to, -.fade-leave-from { - opacity: 1; -} diff --git a/apps/stage-pocket/src/workers/vad/index.ts b/apps/stage-pocket/src/workers/vad/index.ts deleted file mode 100644 index 753421a38..000000000 --- a/apps/stage-pocket/src/workers/vad/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { createVADStates } from './manager' -export type { VADAudioOptions } from './manager' -export { createVAD, VAD } from './vad' diff --git a/apps/stage-pocket/src/workers/vad/manager.ts b/apps/stage-pocket/src/workers/vad/manager.ts deleted file mode 100644 index 8fc1108e2..000000000 --- a/apps/stage-pocket/src/workers/vad/manager.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { VADAudioOptions } from '@proj-airi/stage-ui/libs/audio/vad' -export { createVADStates } from '@proj-airi/stage-ui/libs/audio/vad' diff --git a/apps/stage-pocket/src/workers/vad/process.worklet.ts b/apps/stage-pocket/src/workers/vad/process.worklet.ts deleted file mode 100644 index 684543d0c..000000000 --- a/apps/stage-pocket/src/workers/vad/process.worklet.ts +++ /dev/null @@ -1,53 +0,0 @@ -// vad-worklet-processor.ts -// This file needs to be registered as an AudioWorklet - -/** - * Minimum chunk size for processing audio - */ -const MIN_CHUNK_SIZE = 512 - -/** - * Global state for audio buffer accumulation - */ -let globalPointer = 0 -const globalBuffer = new Float32Array(MIN_CHUNK_SIZE) - -/** - * VAD AudioWorklet Processor - processes audio chunks and sends them to the main thread - */ -class VADProcessor extends AudioWorkletProcessor { - process(inputs: Float32Array[][], _outputs: Float32Array[][], _parameters: Record) { - const buffer = inputs[0][0] - if (!buffer) - return true // buffer is null when the stream ends - - if (buffer.length > MIN_CHUNK_SIZE) { - // If the buffer is larger than the minimum chunk size, send the entire buffer - this.port.postMessage({ buffer }) - } - else { - const remaining = MIN_CHUNK_SIZE - globalPointer - if (buffer.length >= remaining) { - // If the buffer is larger than (or equal to) the remaining space in the global buffer, copy the remaining space - globalBuffer.set(buffer.subarray(0, remaining), globalPointer) - - // Send the global buffer - this.port.postMessage({ buffer: globalBuffer }) - - // Reset the global buffer and set the remaining buffer - globalBuffer.fill(0) - globalBuffer.set(buffer.subarray(remaining), 0) - globalPointer = buffer.length - remaining - } - else { - // If the buffer is smaller than the remaining space in the global buffer, copy the buffer to the global buffer - globalBuffer.set(buffer, globalPointer) - globalPointer += buffer.length - } - } - - return true - } -} - -registerProcessor('vad-audio-worklet-processor', VADProcessor) diff --git a/apps/stage-pocket/src/workers/vad/vad.ts b/apps/stage-pocket/src/workers/vad/vad.ts deleted file mode 100644 index 1cf21fd3c..000000000 --- a/apps/stage-pocket/src/workers/vad/vad.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { PreTrainedModel } from '@huggingface/transformers' -import type { BaseVAD, BaseVADConfig, VADEventCallback, VADEvents } from '@proj-airi/stage-ui/libs/audio/vad' - -import { AutoModel, Tensor } from '@huggingface/transformers' - -/** - * Voice Activity Detection processor - */ -export class VAD implements BaseVAD { - private config: BaseVADConfig - private model: PreTrainedModel | undefined - private state: Tensor - private sampleRateTensor: Tensor - private buffer: Float32Array - private bufferPointer: number = 0 - private isRecording: boolean = false - private postSpeechSamples: number = 0 - private prevBuffers: Float32Array[] = [] - private inferenceChain: Promise = Promise.resolve() - private eventListeners: Partial[]>> = {} - private isReady: boolean = false - - constructor(userConfig: Partial = {}) { - // Default configuration - const defaultConfig: BaseVADConfig = { - sampleRate: 16000, - speechThreshold: 0.3, - exitThreshold: 0.1, - minSilenceDurationMs: 400, - speechPadMs: 80, - minSpeechDurationMs: 250, - maxBufferDuration: 30, - newBufferSize: 512, - } - - this.config = { ...defaultConfig, ...userConfig } - - this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate) - this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], []) - this.state = new Tensor('float32', new Float32Array(2 * 1 * 128), [2, 1, 128]) - } - - /** - * Initialize the VAD model - */ - public async initialize(): Promise { - try { - this.emit('status', { type: 'info', message: 'Loading VAD model...' }) - - this.model = await AutoModel.from_pretrained('onnx-community/silero-vad', { - config: { model_type: 'custom' } as any, - dtype: 'fp32', // Full-precision - }) - - this.isReady = true - this.emit('status', { type: 'info', message: 'VAD model loaded successfully' }) - } - catch (error) { - this.emit('status', { type: 'error', message: `Failed to load VAD model: ${error}` }) - throw error - } - } - - /** - * Add event listener - */ - public on(event: K, callback: VADEventCallback): void { - if (!this.eventListeners[event]) { - this.eventListeners[event] = [] - } - this.eventListeners[event]!.push(callback as any) - } - - /** - * Remove event listener - */ - public off(event: K, callback: VADEventCallback): void { - if (!this.eventListeners[event]) - return - this.eventListeners[event] = this.eventListeners[event]!.filter(cb => cb !== callback) - } - - /** - * Emit event - */ - private emit(event: K, data: VADEvents[K]): void { - if (!this.eventListeners[event]) - return - for (const callback of this.eventListeners[event]!) { - callback(data) - } - } - - /** - * Process audio buffer for speech detection - */ - public async processAudio(inputBuffer: Float32Array): Promise { - if (!this.isReady) { - throw new Error('VAD model is not initialized. Call initialize() first.') - } - - const wasRecording = this.isRecording - - // Perform VAD on the input buffer - const isSpeech = await this.detectSpeech(inputBuffer) - - // Calculate derived constants - const sampleRateMs = this.config.sampleRate / 1000 - const minSilenceDurationSamples = this.config.minSilenceDurationMs * sampleRateMs - const speechPadSamples = this.config.speechPadMs * sampleRateMs - const minSpeechDurationSamples = this.config.minSpeechDurationMs * sampleRateMs - const maxPrevBuffers = Math.ceil(speechPadSamples / this.config.newBufferSize) - - // If not currently in speech and the current buffer isn't speech, - // store it in the previous buffers queue for potential padding - if (!wasRecording && !isSpeech) { - if (this.prevBuffers.length >= maxPrevBuffers) { - this.prevBuffers.shift() - } - this.prevBuffers.push(inputBuffer.slice(0)) - return - } - - // Check if we need to handle buffer overflow - const remaining = this.buffer.length - this.bufferPointer - if (inputBuffer.length >= remaining) { - // The buffer is full, process what we have - this.buffer.set(inputBuffer.subarray(0, remaining), this.bufferPointer) - this.bufferPointer += remaining - - // Process and reset with overflow - const overflow = inputBuffer.subarray(remaining) - this.processSpeechSegment(overflow) - return - } - else { - // Add input to the buffer - this.buffer.set(inputBuffer, this.bufferPointer) - this.bufferPointer += inputBuffer.length - } - - // Handle speech detection - if (isSpeech) { - if (!this.isRecording) { - // Speech just started - this.emit('speech-start', undefined) - this.emit('status', { type: 'info', message: 'Speech detected' }) - } - - // Update state - this.isRecording = true - this.postSpeechSamples = 0 - return - } - - // At this point, we were recording but the current buffer is not speech - this.postSpeechSamples += inputBuffer.length - - // Check if silence is long enough to consider speech ended - if (this.postSpeechSamples >= minSilenceDurationSamples) { - // Check if the speech segment is long enough to process - if (this.bufferPointer < minSpeechDurationSamples) { - // Too short, reset without processing - this.reset() - return - } - - // Process the speech segment - this.processSpeechSegment() - } - } - - /** - * Detect speech in an audio buffer - */ - private async detectSpeech(buffer: Float32Array): Promise { - const input = new Tensor('float32', buffer, [1, buffer.length]) - - const { stateN, output } = await (this.inferenceChain = this.inferenceChain.then(() => - this.model?.({ - input, - sr: this.sampleRateTensor, - state: this.state, - }), - )) - - // Update the state - this.state = stateN - // Get the speech probability - const speechProb = output.data[0] - - this.emit('debug', { message: 'VAD score', data: { probability: speechProb } }) - - // Apply thresholds - return ( - speechProb > this.config.speechThreshold - || (this.isRecording && speechProb >= this.config.exitThreshold) - ) - } - - /** - * Process a complete speech segment - */ - private processSpeechSegment(overflow?: Float32Array): void { - const sampleRateMs = this.config.sampleRate / 1000 - const speechPadSamples = this.config.speechPadMs * sampleRateMs - - // Calculate duration info - const duration = (this.bufferPointer / this.config.sampleRate) * 1000 - const overflowLength = overflow?.length ?? 0 - - // Create the final buffer with padding - const prevLength = this.prevBuffers.reduce((acc, b) => acc + b.length, 0) - const finalBuffer = new Float32Array(prevLength + this.bufferPointer + speechPadSamples) - - // Add previous buffers for pre-speech padding - let offset = 0 - for (const prev of this.prevBuffers) { - finalBuffer.set(prev, offset) - offset += prev.length - } - - // Add the main speech segment - finalBuffer.set(this.buffer.slice(0, this.bufferPointer + speechPadSamples), offset) - - // Emit the speech segment - this.emit('speech-end', undefined) - this.emit('speech-ready', { - buffer: finalBuffer, - duration, - }) - - // Reset for the next segment - if (overflow) { - this.buffer.set(overflow, 0) - } - this.reset(overflowLength) - } - - /** - * Reset the VAD state - */ - private reset(offset: number = 0): void { - this.buffer.fill(0, offset) - this.bufferPointer = offset - this.isRecording = false - this.postSpeechSamples = 0 - this.prevBuffers = [] - } - - /** - * Update configuration - */ - public updateConfig(newConfig: Partial): void { - this.config = { ...this.config, ...newConfig } - - // If buffer size changed, create a new buffer - if (newConfig.maxBufferDuration || newConfig.sampleRate) { - this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate) - this.bufferPointer = 0 - } - - // Update sample rate tensor if needed - if (newConfig.sampleRate) { - this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], []) - } - } -} - -/** - * Create a VAD processor with the given configuration - */ -export async function createVAD(config?: Partial): Promise { - const vad = new VAD(config) - await vad.initialize() - return vad -} diff --git a/apps/stage-pocket/tsconfig.json b/apps/stage-pocket/tsconfig.json deleted file mode 100644 index 55f0a59f6..000000000 --- a/apps/stage-pocket/tsconfig.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "jsx": "preserve", - "lib": [ - "DOM", - "ESNext", - "DOM.Iterable", - "DOM.AsyncIterable" - ], - "paths": { - "@proj-airi/stage-ui/*": [ - "../../packages/stage-ui/src/*" - ], - "@proj-airi/stage-layouts/*": [ - "../../packages/stage-layouts/src/*" - ] - }, - "resolveJsonModule": true, - "types": [ - "vitest", - "vite/client", - "vite-plugin-vue-layouts/client", - "vite-plugin-pwa/client", - "vue-macros/macros-global", - "@types/audioworklet", - "unplugin-info/client", - "node" - ], - "allowJs": true, - "strict": true, - "skipLibCheck": true - }, - "vueCompilerOptions": { - "plugins": [ - "@vue-macros/volar/define-models", - "@vue-macros/volar/define-slots" - ] - }, - "exclude": [ - "**/build/**", - "**/dist/**", - "**/public/**" - ] -} diff --git a/apps/stage-pocket/uno.config.ts b/apps/stage-pocket/uno.config.ts deleted file mode 100644 index 99045aec0..000000000 --- a/apps/stage-pocket/uno.config.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { mergeConfigs, presetWebFonts } from 'unocss' - -import { presetWebFontsFonts, sharedUnoConfig } from '../../uno.config' - -export default mergeConfigs([ - sharedUnoConfig(), - { - presets: [ - presetWebFonts({ - fonts: { - ...presetWebFontsFonts('fontsource'), - }, - timeouts: { - warning: 5000, - failure: 10000, - }, - }), - ], - rules: [ - ['transition-colors-none', { - 'transition-property': 'color, background-color, border-color, text-color', - 'transition-duration': '0s', - }], - - ['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }], - ['pb-safe', { 'padding-bottom': 'env(safe-area-inset-bottom)' }], - ['pl-safe', { 'padding-left': 'env(safe-area-inset-left)' }], - ['pr-safe', { 'padding-right': 'env(safe-area-inset-right)' }], - ['p-safe', { - 'padding-top': 'env(safe-area-inset-top)', - 'padding-bottom': 'env(safe-area-inset-bottom)', - 'padding-left': 'env(safe-area-inset-left)', - 'padding-right': 'env(safe-area-inset-right)', - }], - ], - shortcuts: [ - ['px-safe', 'pl-safe pr-safe'], - ['py-safe', 'pt-safe pb-safe'], - ], - }, -]) diff --git a/apps/stage-pocket/vite-env.d.ts b/apps/stage-pocket/vite-env.d.ts deleted file mode 100644 index 062516673..000000000 --- a/apps/stage-pocket/vite-env.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -/// -/// - -interface ImportMetaEnv { - readonly VITE_APP_TARGET_HUGGINGFACE_SPACE: string - readonly VITE_PLATFORM: 'ios' | 'android' | 'web' - // more env variables... -} diff --git a/apps/stage-pocket/vite.config-env.d.ts b/apps/stage-pocket/vite.config-env.d.ts deleted file mode 100644 index 8c0f41492..000000000 --- a/apps/stage-pocket/vite.config-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare namespace NodeJS { - export interface ProcessEnv { - VITE_SKIP_MKCERT?: string - VITE_CAP_SYNC_IOS_AFTER_BUILD?: string - } -} diff --git a/apps/stage-pocket/vite.config.ts b/apps/stage-pocket/vite.config.ts deleted file mode 100644 index ba161c690..000000000 --- a/apps/stage-pocket/vite.config.ts +++ /dev/null @@ -1,209 +0,0 @@ -/// - -import type { PluginOption } from 'vite' - -import process from 'node:process' - -import { execSync } from 'node:child_process' -import { join, resolve } from 'node:path' - -import VueI18n from '@intlify/unplugin-vue-i18n/vite' -import templateCompilerOptions from '@tresjs/core/template-compiler-options' -import Vue from '@vitejs/plugin-vue' -import Unocss from 'unocss/vite' -import Info from 'unplugin-info/vite' -import Yaml from 'unplugin-yaml/vite' -import mkcert from 'vite-plugin-mkcert' -import VueDevTools from 'vite-plugin-vue-devtools' -import Layouts from 'vite-plugin-vue-layouts' -import VueMacros from 'vue-macros/vite' -import VueRouter from 'vue-router/vite' - -import { tryCatch } from '@moeru/std' -import { Download } from '@proj-airi/unplugin-fetch/vite' -import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite' -import { defineConfig } from 'vite' - -// import { isEnvTruthy } from '@proj-airi/stage-shared' -function isEnvTruthy(value: string | undefined | null): boolean { - if (value == null) - return false - - return /^(?:1|true|t|yes|y|on)$/i.test(value.trim()) -} - -const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets')) -const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) - -export default defineConfig({ - optimizeDeps: { - exclude: [ - // Internal Packages - '@proj-airi/stage-ui/*', - '@proj-airi/drizzle-duckdb-wasm', - '@proj-airi/drizzle-duckdb-wasm/*', - - // Static Assets: Models, Images, etc. - 'public/assets/*', - - // Live2D SDK - '@framework/live2dcubismframework', - '@framework/math/cubismmatrix44', - '@framework/type/csmvector', - '@framework/math/cubismviewmatrix', - '@framework/cubismdefaultparameterid', - '@framework/cubismmodelsettingjson', - '@framework/effect/cubismbreath', - '@framework/effect/cubismeyeblink', - '@framework/model/cubismusermodel', - '@framework/motion/acubismmotion', - '@framework/motion/cubismmotionqueuemanager', - '@framework/type/csmmap', - '@framework/utils/cubismdebug', - '@framework/model/cubismmoc', - ], - }, - resolve: { - alias: { - '@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')), - '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), - '@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')), - '@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')), - '@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')), - '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), - }, - }, - server: { - host: '0.0.0.0', - port: 5273, - fs: { - // To mute errors like: - // The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list. - // - // See: https://vite.dev/config/server-options#server-fs-strict - strict: false, - }, - warmup: { - clientFiles: [ - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`, - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src'))}/*.vue`, - ], - }, - }, - build: { - sourcemap: true, - }, - worker: { - format: 'es', - rollupOptions: { - output: { - inlineDynamicImports: false, - }, - }, - }, - - plugins: [ - ...isEnvTruthy(process.env.VITE_SKIP_MKCERT ?? '') - ? [] - : [mkcert((() => { - // Workaround: plugin's bundled downloader has a feaxios bug, prefer system mkcert - const command = process.platform === 'win32' ? 'where' : 'which' - - const { data } = tryCatch(() => ({ mkcertPath: execSync(`${command} mkcert`, { stdio: 'pipe' }).toString().trim().split(/\r?\n/)[0] })) - return data - })())], - - Info(), - - Yaml(), - - VueMacros({ - plugins: { - vue: Vue({ - include: [/\.vue$/, /\.md$/], - ...templateCompilerOptions, - }), - vueJsx: false, - }, - betterDefine: false, - }), - - VueRouter({ - extensions: ['.vue', '.md'], - dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'), - importMode: 'async', - routesFolder: [ - resolve(import.meta.dirname, 'src', 'pages'), - { - src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'), - exclude: base => [ - ...base, - '**/settings/connection/index.vue', - '**/settings/modules/beat-sync.vue', - ], - }, - ], - exclude: ['**/components/**'], - }), - - // https://github.com/JohnCampionJr/vite-plugin-vue-layouts - Layouts({ - layoutsDirs: [ - resolve(import.meta.dirname, 'src', 'layouts'), - resolve(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src', 'layouts'), - ], - }), - - // https://github.com/antfu/unocss - // see uno.config.ts for config - Unocss(), - - // https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n - VueI18n({ - runtimeOnly: true, - compositionOnly: true, - fullInstall: true, - }), - - // https://github.com/webfansplz/vite-plugin-vue-devtools - VueDevTools(), - - DownloadLive2DSDK(), - Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - - ...isEnvTruthy(process.env.VITE_CAP_SYNC_IOS_AFTER_BUILD ?? '') - ? [{ - name: 'proj-airi:capacitor-sync', - closeBundle: { - sequential: true, - handler() { - if (this.meta.watchMode) { - execSync('cap sync ios', { stdio: 'inherit' }) - } - }, - }, - } as PluginOption] - : [], - - { - name: 'proj-airi:defines', - config(ctx) { - const define: Record = { - 'import.meta.env.RUNTIME_ENVIRONMENT': '\'capacitor\'', - } - if (ctx.mode === 'development') { - define['import.meta.env.URL_MODE'] = '\'server\'' - } - if (ctx.mode === 'production') { - define['import.meta.env.URL_MODE'] = '\'file\'' - } - - return { define } - }, - }, - ], -}) diff --git a/apps/stage-tamagotchi/ai.moeru.airi.desktop b/apps/stage-tamagotchi/ai.moeru.airi.desktop deleted file mode 100644 index 4b29b664b..000000000 --- a/apps/stage-tamagotchi/ai.moeru.airi.desktop +++ /dev/null @@ -1,10 +0,0 @@ -[Desktop Entry] -Name=AIRI -Exec=airi %U -Terminal=false -Type=Application -Icon=ai.moeru.airi -StartupWMClass=AIRI -Comment=AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering. -Categories=Utility; - diff --git a/apps/stage-tamagotchi/ai.moeru.airi.flatpak.yml b/apps/stage-tamagotchi/ai.moeru.airi.flatpak.yml deleted file mode 100644 index 0d526af3e..000000000 --- a/apps/stage-tamagotchi/ai.moeru.airi.flatpak.yml +++ /dev/null @@ -1,103 +0,0 @@ -app-id: ai.moeru.airi -runtime: org.freedesktop.Platform -runtime-version: '24.08' -sdk: org.freedesktop.Sdk -base: org.electronjs.Electron2.BaseApp -base-version: '24.08' -command: airi.sh - -finish-args: - # GUI environment - - --share=network - - --socket=x11 - - --socket=fallback-x11 - - --socket=wayland - - --device=dri - - --device=all - - --socket=pulseaudio - - --socket=system-bus - - --socket=session-bus - - --share=ipc - - # DBus - - --talk-name=org.freedesktop.Notifications - - --talk-name=org.freedesktop.portal.Desktop - - --system-talk-name=org.freedesktop.login1 - - --system-talk-name=org.freedesktop.UPower - # Filesystem permissions - - --filesystem=xdg-download:rw - - --filesystem=xdg-config/airi:create - # Electron uses ~/.config/airi when XDG_CONFIG_HOME is not set. - - --filesystem=~/.config/airi:create - -modules: - - name: AIRI-App - buildsystem: simple - build-commands: - - mkdir -p /app/bin - - mkdir -p /app/lib/airi - - cp -r payload/. /app/lib/airi/ - - install airi.sh /app/bin/airi.sh - - chmod +x /app/lib/airi/airi - # Desktop-file launches run Exec=airi directly inside the sandbox; the manifest - # `command` does not apply to them, so /app/bin/airi must be a real entry point - # that resolves to the zypak wrapper script. - - ln -s /app/bin/airi.sh /app/bin/airi - - install -Dm644 ai.moeru.airi.desktop /app/share/applications/ai.moeru.airi.desktop - - install -Dm644 ai.moeru.airi.metainfo.xml /app/share/metainfo/ai.moeru.airi.metainfo.xml - # build-export rejects icons larger than the hicolor size directory they land in, - # so Flatpak installs the 512x512 derivative while Electron keeps the 1024x1024 source. - - install -Dm644 build/icon-512.png /app/share/icons/hicolor/512x512/apps/ai.moeru.airi.png - sources: - - type: dir - only-arches: [x86_64] - path: dist/linux-unpacked - dest: payload - - type: dir - only-arches: [aarch64] - path: dist/linux-arm64-unpacked - dest: payload - - type: file - path: ai.moeru.airi.desktop - - type: file - path: ai.moeru.airi.metainfo.xml - - type: file - path: build/icon-512.png - dest: build - # First discovered by [@gg582](https://github.com/gg582) in https://github.com/moeru-ai/airi/pull/647 - # - # Without the correct zypak-wrapper, application launch will result in this error: - # content/browser/zygote_host/zygote_host_impl_linux.cc:132] No usable sandbox! ... If you want to live - # dangerously and need an immediate workaround, you can try using --no-sandbox. - # - # Though https://github.com/refi64/chromium-tar/blob/82ebd6a0473341fa75dd3bbb2f584da99f5ac92c/content/browser/zygote_host/zygote_host_impl_linux.cc#L104-L131 - # suggests that the error is related to missing switch of service_manager::switches::kDisableNamespaceSandbox and - # service_manager::switches::kDisableSetuidSandbox, even if we add --no-sandbox to the command, the application basically - # exits immediately without any error message and traces. - # - # When searching for solutions, I spotted the session-desktop issue below looks exactly the same and - # they managed to resolve it by renaming the executable command with -app suffix, however the wrapper - # called zypak-wrapper is the key to resolve this issue: - # https://github.com/session-foundation/session-desktop/issues/795 - # https://github.com/flathub/network.loki.Session/pull/32 - # - # Similar keywords appears in the official docs of Flatpak as well: - # https://docs.flatpak.org/en/latest/electron.html#launching-the-app - # - # With the correct way to research, I found several examples of Electron apps using zypak-wrapper: - # https://github.com/chaiNNer-org/chaiNNer/issues/1597#issuecomment-3272020725 - # https://github.com/vendillah/app.Chainner.chainner/blob/main/app.chainner.Chainner.yaml - # - # and bitwarden, as the most popular password manager app, it's architecture and bundle workflow looked - # almost the same as ours, most of the structure of this manifest is based on bitwarden's: - # https://github.com/bitwarden/clients/blob/b8d55c4db178de67f1778bc307f7c22aad01f4a9/apps/desktop/resources/com.bitwarden.desktop.devel.yaml - - type: script - dest-filename: airi.sh - commands: - - ulimit -c 0 - - export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID" - - | - if [ -n "$WAYLAND_DISPLAY" ]; then - export ELECTRON_OZONE_PLATFORM_HINT=auto - fi - - exec zypak-wrapper /app/lib/airi/airi "$@" diff --git a/apps/stage-tamagotchi/ai.moeru.airi.metainfo.xml b/apps/stage-tamagotchi/ai.moeru.airi.metainfo.xml deleted file mode 100644 index 06e1b2425..000000000 --- a/apps/stage-tamagotchi/ai.moeru.airi.metainfo.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - ai.moeru.airi - CC0-1.0 - MIT - AIRI - AI VTuber/Waifu chatbot inspired by Neuro-sama - -

- AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering. -

-
- ai.moeru.airi.desktop - https://airi.moeru.ai/docs/ - https://github.com/moeru-ai/airi/issues - Moeru AI Community - -
diff --git a/apps/stage-tamagotchi/build/entitlements.mac.plist b/apps/stage-tamagotchi/build/entitlements.mac.plist deleted file mode 100644 index eeeb956cf..000000000 --- a/apps/stage-tamagotchi/build/entitlements.mac.plist +++ /dev/null @@ -1,20 +0,0 @@ - - - - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-dyld-environment-variables - - com.apple.security.device.camera - - com.apple.security.device.microphone - - com.apple.security.device.audio-input - - com.apple.security.device.bluetooth - - - diff --git a/apps/stage-tamagotchi/build/icon-512.png b/apps/stage-tamagotchi/build/icon-512.png deleted file mode 100644 index c49a9a04e..000000000 Binary files a/apps/stage-tamagotchi/build/icon-512.png and /dev/null differ diff --git a/apps/stage-tamagotchi/build/icon.icns b/apps/stage-tamagotchi/build/icon.icns deleted file mode 100644 index 657f3ecef..000000000 Binary files a/apps/stage-tamagotchi/build/icon.icns and /dev/null differ diff --git a/apps/stage-tamagotchi/build/icon.ico b/apps/stage-tamagotchi/build/icon.ico deleted file mode 100644 index 896bc6789..000000000 Binary files a/apps/stage-tamagotchi/build/icon.ico and /dev/null differ diff --git a/apps/stage-tamagotchi/build/icon.icon/Assets/Body.png b/apps/stage-tamagotchi/build/icon.icon/Assets/Body.png deleted file mode 100644 index 11da46481..000000000 Binary files a/apps/stage-tamagotchi/build/icon.icon/Assets/Body.png and /dev/null differ diff --git a/apps/stage-tamagotchi/build/icon.icon/Assets/Ear_L.png b/apps/stage-tamagotchi/build/icon.icon/Assets/Ear_L.png deleted file mode 100644 index 911de7f26..000000000 Binary files a/apps/stage-tamagotchi/build/icon.icon/Assets/Ear_L.png and /dev/null differ diff --git a/apps/stage-tamagotchi/build/icon.icon/Assets/Ear_R.png b/apps/stage-tamagotchi/build/icon.icon/Assets/Ear_R.png deleted file mode 100644 index f56fc054d..000000000 Binary files a/apps/stage-tamagotchi/build/icon.icon/Assets/Ear_R.png and /dev/null differ diff --git a/apps/stage-tamagotchi/build/icon.icon/Assets/Hard_Shadow.png b/apps/stage-tamagotchi/build/icon.icon/Assets/Hard_Shadow.png deleted file mode 100644 index 46354160b..000000000 Binary files a/apps/stage-tamagotchi/build/icon.icon/Assets/Hard_Shadow.png and /dev/null differ diff --git a/apps/stage-tamagotchi/build/icon.icon/icon.json b/apps/stage-tamagotchi/build/icon.icon/icon.json deleted file mode 100644 index 316024d45..000000000 --- a/apps/stage-tamagotchi/build/icon.icon/icon.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "fill-specializations": [ - { - "value": { - "solid": "srgb:0.98027,0.97642,0.98455,1.00000" - } - }, - { - "appearance": "dark", - "value": { - "solid": "srgb:0.39461,0.38835,0.42874,1.00000" - } - } - ], - "groups": [ - { - "blend-mode": "normal", - "blur-material": null, - "layers": [ - { - "image-name": "Ear_R.png", - "name": "Ear R" - } - ], - "name": "Ear R", - "shadow": { - "kind": "layer-color", - "opacity": 0.5 - }, - "specular": true, - "translucency": { - "enabled": false, - "value": 0.5 - } - }, - { - "blend-mode": "darken", - "blur-material": 0, - "layers": [ - { - "blend-mode": "normal", - "fill": "automatic", - "glass": true, - "hidden": false, - "image-name": "Hard_Shadow.png", - "name": "Hard Shadow" - } - ], - "lighting": "individual", - "name": "Hard Shadow", - "shadow": { - "kind": "layer-color", - "opacity": 0.8 - }, - "specular": false, - "translucency": { - "enabled": false, - "value": 1 - } - }, - { - "blend-mode": "normal", - "blur-material": null, - "layers": [ - { - "fill": "none", - "glass-specializations": [ - { - "value": true - }, - { - "appearance": "tinted", - "value": true - } - ], - "hidden": false, - "image-name": "Body.png", - "name": "Hair + Face" - } - ], - "lighting": "individual", - "name": "Body", - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "specular": true, - "translucency": { - "enabled": false, - "value": 0.5 - } - }, - { - "blur-material": null, - "layers": [ - { - "glass": true, - "hidden": false, - "image-name": "Ear_L.png", - "name": "Ear L" - } - ], - "name": "Ear L", - "shadow": { - "kind": "layer-color", - "opacity": 0.5 - }, - "specular": true, - "translucency": { - "enabled": false, - "value": 0.5 - } - } - ], - "supported-platforms": { - "circles": [ - "watchOS" - ], - "squares": "shared" - } -} diff --git a/apps/stage-tamagotchi/build/icon.png b/apps/stage-tamagotchi/build/icon.png deleted file mode 100644 index 7aeb7eccb..000000000 Binary files a/apps/stage-tamagotchi/build/icon.png and /dev/null differ diff --git a/apps/stage-tamagotchi/dev-app-update.yml b/apps/stage-tamagotchi/dev-app-update.yml deleted file mode 100644 index 35378e1a1..000000000 --- a/apps/stage-tamagotchi/dev-app-update.yml +++ /dev/null @@ -1,6 +0,0 @@ -provider: github -owner: moeru-ai -repo: airi -updaterCacheDirName: electron-app-updater -allowPrerelease: true -channel: nightly diff --git a/apps/stage-tamagotchi/electron-builder.config.ts b/apps/stage-tamagotchi/electron-builder.config.ts deleted file mode 100644 index 6c0f77c8d..000000000 --- a/apps/stage-tamagotchi/electron-builder.config.ts +++ /dev/null @@ -1,253 +0,0 @@ -/* eslint-disable no-template-curly-in-string */ - -import type { Configuration } from 'electron-builder' - -import { execSync } from 'node:child_process' - -import { isMacOS } from 'std-env' - -function hasXcode26OrAbove() { - if (!isMacOS) - return false - try { - const output = execSync('xcodebuild -version') - .toString() - - .match(/Xcode (\d+)/) - if (!output) - return false - return Number.parseInt(output[1], 10) >= 26 - } - catch { - return false - } -} - -/** - * Determine whether to use the .icon format for the macOS app icon based on the - * Xcode version while building. - * This is friendly to developers whose macOS and/or Xcode versions are below 26. - */ -const useIconFormattedMacAppIcon = hasXcode26OrAbove() -if (!useIconFormattedMacAppIcon) { - console.warn('[electron-builder/config] Warning: Xcode version is below 26. Using .icns format for macOS app icon.') -} -else { - // NOTICE: This success-path message intentionally uses stderr via `console.warn`. - // The artifact metadata CLI imports this config and is used in GitHub Actions - // command substitution for `GITHUB_ENV`; writing this log to stdout would break - // machine-readable output such as `BUNDLE_NAME=$(...)`. - console.warn('[electron-builder/config] Xcode version is 26 or above. Using .icon format for macOS app icon.') -} - -export default { - appId: 'ai.moeru.airi', - productName: 'AIRI', - directories: { - output: 'dist', - buildResources: 'build', - }, - // // For self-publishing, testing, and distribution after modified the code without access to - // // an Apple Developer account, comment and uncomment the following lines. - // // Later on when you obtained one, you can set up the necessary certificates and provisioning - // // profiles to enable these security features. - // // - // // https://www.bigbinary.com/blog/code-sign-notorize-mac-desktop-app - // // https://kilianvalkhof.com/2019/electron/notarizing-your-electron-application/ - // afterSign: async (context) => { - // const { electronPlatformName, appOutDir } = context - // if (electronPlatformName !== 'darwin') - // return - // if (env.CI !== 'true') { - // console.warn('Skipping notarizing step. Packaging is not running in CI') - // return - // } - - // const appName = context.packager.appInfo.productFilename - // await notarize({ - // appPath: `${appOutDir}/${appName}.app`, - // teamId: env.APPLE_DEVELOPER_TEAM_ID!, - // appleId: env.APPLE_DEVELOPER_APPLE_ID!, - // appleIdPassword: env.APPLE_DEVELOPER_APPLE_APP_SPECIFIC_PASSWORD!, - // }) - // }, - files: [ - 'out/**', - 'resources/**', - 'package.json', - // NOTICE: Exclude npm `electron` package from app payload. - // Electron runtime is already provided by the outer app bundle; bundling a nested - // `node_modules/electron/dist/Electron.app` makes electron-builder deep-sign it and - // fails on non-code resources (for example `locale.pak`) with timestamp/signing errors. - '!**/node_modules/electron{,/**}', - '!**/.vscode/*', - '!src/**/*', - '!**/node_modules/**/{CHANGELOG.md,README.md,README,readme.md,readme}', - '!**/node_modules/**/{.turbo,test,src,__tests__,tests,example,examples}', - '**/node_modules/debug/**/*', - '**/node_modules/superjson/**/*', - '!electron.vite.config.{js,ts,mjs,cjs}', - '!vite.config.{js,ts,mjs,cjs}', - '!uno.config.{js,ts,mjs,cjs}', - '!{.eslintcache,eslint.config.ts,.yaml,dev-app-update.yml,CHANGELOG.md,README.md}', - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}', - '!{tsconfig.json}', - ], - asar: true, - asarUnpack: [ - '**/*.node', - ], - extraResources: [ - { - from: '../../engines/stage-tamagotchi-godot/out/${os}', - to: 'godot-stage', - filter: ['**/*'], - }, - ], - extraMetadata: { - name: 'ai.moeru.airi', - main: 'out/main/index.js', - homepage: 'https://airi.moeru.ai/docs/', - repository: 'https://github.com/moeru-ai/airi', - license: 'MIT', - }, - win: { - executableName: 'airi', - // NOTICE: Keep `channel: 'latest-${arch}'` for architecture-aware updater metadata. - // electron-builder expands `${arch}` at publish-time (for example: `latest-x64`, `latest-arm64`), - // and electron-updater later consumes that expanded channel to resolve platform-specific *.yml files. - // This prevents cross-arch lookups such as arm64 clients reading x64 metadata. - publish: { - provider: 'github', - owner: 'moeru-ai', - repo: 'airi', - channel: 'latest-${arch}', - }, - }, - nsis: { - artifactName: '${productName}-${version}-windows-${arch}-setup.${ext}', - shortcutName: '${productName}', - uninstallDisplayName: '${productName}', - createDesktopShortcut: 'always', - deleteAppDataOnUninstall: true, - oneClick: false, - allowToChangeInstallationDirectory: true, - runAfterFinish: true, - }, - mac: { - entitlementsInherit: 'build/entitlements.mac.plist', - // NOTICE: Same channel rule as Windows. Keep `${arch}` here so generated metadata resolves - // to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`). - publish: { - provider: 'github', - owner: 'moeru-ai', - repo: 'airi', - // NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands - // `${arch}` before it writes any publish metadata, and electron-updater later - // reuses that expanded channel string when deciding which `*.yml` file to fetch. - // - // Without this, the updater would look for `latest-mac.yml` for both x64 and arm64 macOS builds, - // which means the x64 build would be used for arm64 (Apple Silicon) users, causing suboptimal performance and higher resource usage. By embedding `${arch}` - // into the channel name, we ensure that the updater looks for `latest-x64-mac.yml` and `latest-arm64-mac.yml` respectively. - // - // This is how channel name was constructed: - // - // 1. `expandPublishConfig(...)` expands string values in the publish config. - // That is where `latest-${arch}` becomes `latest-x64` or `latest-arm64`. - // https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/PublishManager.ts#L521-L532 - // - // 2. The expanded publish config is written into `app-update.yml`. - // The packaged app therefore carries `channel: latest-x64` or - // `channel: latest-arm64`, not the literal template string. - // https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/PublishManager.ts#L93-L96 - // - // 3. electron-builder also uses that expanded channel when generating update - // metadata files. `getUpdateInfoFileName(channel, packager, arch)` builds the - // filename as: - // `${channel}${osSuffix}${getArchPrefixForUpdateFile(arch, packager)}.yml` - // https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/updateInfoBuilder.ts#L65-L68 - // - // 4. For macOS, `osSuffix` is `-mac` and `getArchPrefixForUpdateFile(...)` - // returns an empty string. So: - // `latest-x64` -> `latest-x64-mac.yml` - // `latest-arm64` -> `latest-arm64-mac.yml` - // This is the publish-time side of the behavior. - // - // 5. At runtime, electron-updater reads the embedded `app-update.yml` and takes - // its `channel` value. It does not reconstruct `latest-${arch}` itself; it - // consumes the already-expanded value from step 2. - // - // 6. `Provider.getChannelFilePrefix()` appends the platform suffix: - // - macOS -> `-mac` - // - Windows -> `` - // - Linux x64 -> `-linux` - // - Linux non-x64 -> `-linux-${arch}` - // https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/Provider.ts#L44-L52 - // - // 7. `getCustomChannelName(channel)` then returns: - // `${channel}${this.getChannelFilePrefix()}` - // So the updater turns: - // `latest-x64` -> `latest-x64-mac` - // `latest-arm64` -> `latest-arm64-mac` - // https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/Provider.ts#L58-L60 - // - // 8. GitHubProvider passes that channel name into `getChannelFilename(channel)`, - // which simply appends `.yml`, so the final lookup becomes: - // `latest-x64-mac.yml` - // `latest-arm64-mac.yml` - // https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/util.ts#L27-L29 - // https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/GitHubProvider.ts#L132-L145 - // - // Resulting filenames with this config: - // - macOS x64 -> `latest-x64-mac.yml` - // - macOS arm64 -> `latest-arm64-mac.yml` - // - Windows x64 -> `latest-x64.yml` - // - Linux x64 -> `latest-x64-linux.yml` - // - Linux arm64 -> `latest-arm64-linux-arm64.yml` - channel: 'latest-${arch}', - }, - extendInfo: { - NSMicrophoneUsageDescription: 'AIRI requires microphone access for voice interaction', - NSSpeechRecognitionUsageDescription: 'AIRI uses Apple Speech to transcribe voice interactions on this device', - NSCameraUsageDescription: 'AIRI requires camera access for vision understanding', - NSBluetoothAlwaysUsageDescription: 'AIRI uses Bluetooth to read game controller input', - }, - // For self-publishing, testing, and distribution after modified the code without access to - // an Apple Developer account, comment and uncomment the following 4 lines. - // Later on when you obtained one, you can set up the necessary certificates and provisioning - // profiles to enable these security features. - // hardenedRuntime: false, - hardenedRuntime: true, - // notarize: false, - notarize: true, - executableName: 'airi', - icon: useIconFormattedMacAppIcon ? 'icon.icon' : 'icon.icns', - }, - dmg: { - artifactName: '${productName}-${version}-darwin-${arch}.${ext}', - }, - linux: { - target: [ - 'deb', - 'rpm', - ], - // NOTICE: Same channel rule as Windows/macOS. Keep `${arch}` to avoid x64/arm64 feed collisions on Linux. - publish: { - provider: 'github', - owner: 'moeru-ai', - repo: 'airi', - channel: 'latest-${arch}', - }, - category: 'Utility', - synopsis: 'AI VTuber/Waifu chatbot app inspired by Neuro-sama.', - description: 'AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering.', - executableName: 'airi', - artifactName: '${productName}-${version}-linux-${arch}.${ext}', - icon: 'build/icons/icon.png', - }, - appImage: { - artifactName: '${productName}-${version}-linux-${arch}.${ext}', - }, - npmRebuild: false, - -} satisfies Configuration diff --git a/apps/stage-tamagotchi/electron.vite.config.ts b/apps/stage-tamagotchi/electron.vite.config.ts deleted file mode 100644 index 578a60979..000000000 --- a/apps/stage-tamagotchi/electron.vite.config.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { join, resolve } from 'node:path' - -import VueI18n from '@intlify/unplugin-vue-i18n/vite' -import templateCompilerOptions from '@tresjs/core/template-compiler-options' -import Vue from '@vitejs/plugin-vue' -import UnoCss from 'unocss/vite' -import Info from 'unplugin-info/vite' -import Yaml from 'unplugin-yaml/vite' -import Inspect from 'vite-plugin-inspect' -import VitePluginVueDevTools from 'vite-plugin-vue-devtools' -import Layouts from 'vite-plugin-vue-layouts' -import VueMacros from 'vue-macros/vite' -import VueRouter from 'vue-router/vite' - -import { Download } from '@proj-airi/unplugin-fetch' -import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk' -import { defineConfig } from 'electron-vite' - -const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets')) -const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) - -export default defineConfig({ - main: { - build: { - externalizeDeps: { - include: [ - // Native modules that have `__dirname` usages. Externalize to avoid bundling - // them into ESM and causing issues in runtime. - 'electron-click-drag-plugin', - 'uiohook-napi', - '@xsai-apple-speech/transcription-native', - ], - }, - }, - plugins: [ - { - // To replace `build.rolldownOptions`, as electron-vite still uses the deprecated - // `rollupOptions`, using `rollupOptions` and `rolldownOptions` at the same - // time may lead to unexpected merge results. Using `rollupOptions` to manipulate - // `manualChunks` also did not work. Therefore, it was transformed into a plugin - // declaration with the recommended `codeSplitting` option. - name: 'manual-chunks', - outputOptions(options) { - options.codeSplitting = { - groups: [ - { - name(moduleId) { - // https://github.com/lobehub/lobehub/blob/6ecba929b738e1259e15d17e7643941e015324ee/apps/desktop/electron.vite.config.ts#L54 - // Prevent debug package from being bundled into index.js to avoid side-effect pollution - if (moduleId.includes('node_modules/debug')) { - return 'vendor-debug' - } - }, - }, - { - name(moduleId) { - // https://github.com/lobehub/lobehub/blob/6ecba929b738e1259e15d17e7643941e015324ee/apps/desktop/electron.vite.config.ts#L54 - // Prevent debug package from being bundled into index.js to avoid side-effect pollution - if (moduleId.includes('node_modules/h3')) { - return 'vendor-h3' - } - }, - }, - ], - } - - return options - }, - }, - Info(), - ], - - resolve: { - alias: { - '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), - '@proj-airi/server-runtime/server': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'server', 'index.ts')), - '@proj-airi/server-runtime': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'index.ts')), - }, - }, - }, - - preload: { - build: { - lib: { - entry: { - 'index': resolve(join(import.meta.dirname, 'src', 'preload', 'index.ts')), - 'beat-sync': resolve(join(import.meta.dirname, 'src', 'preload', 'beat-sync.ts')), - }, - }, - }, - - plugins: [], - }, - - renderer: { - // Thanks to [@Maqsyo](https://github.com/Maqsyo) - // https://github.com/alex8088/electron-vite/issues/99#issuecomment-1862671727 - base: './', - - build: { - rolldownOptions: { - input: { - 'main': resolve(join(import.meta.dirname, 'src', 'renderer', 'index.html')), - 'beat-sync': resolve(join(import.meta.dirname, 'src', 'renderer', 'beat-sync.html')), - }, - }, - }, - - optimizeDeps: { - exclude: [ - // Internal Packages - '@proj-airi/stage-ui/*', - '@proj-airi/drizzle-duckdb-wasm', - '@proj-airi/drizzle-duckdb-wasm/*', - '@proj-airi/electron-screen-capture', - - // Static Assets: Models, Images, etc. - 'src/renderer/public/assets/*', - - // Live2D SDK - '@framework/live2dcubismframework', - '@framework/math/cubismmatrix44', - '@framework/type/csmvector', - '@framework/math/cubismviewmatrix', - '@framework/cubismdefaultparameterid', - '@framework/cubismmodelsettingjson', - '@framework/effect/cubismbreath', - '@framework/effect/cubismeyeblink', - '@framework/model/cubismusermodel', - '@framework/motion/acubismmotion', - '@framework/motion/cubismmotionqueuemanager', - '@framework/type/csmmap', - '@framework/utils/cubismdebug', - '@framework/model/cubismmoc', - ], - }, - - resolve: { - alias: { - '@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')), - '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), - // NOTICE: the @proj-airi/stage-ui alias resolves to a directory; rolldown - // concatenates sub-paths without a file extension, so bare .ts files at the - // stores/ root (e.g. mcp-tool-bridge.ts) are not found. Add explicit aliases - // for each such file that the renderer imports from @proj-airi/stage-ui. - '@proj-airi/stage-ui/stores/mcp-tool-bridge': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'stores', 'mcp-tool-bridge.ts')), - '@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')), - '@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')), - '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), - }, - }, - - server: { - fs: { - // To mute errors like: - // The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list. - // - // See: https://vite.dev/config/server-options#server-fs-strict - strict: false, - }, - warmup: { - clientFiles: [ - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`, - ], - }, - }, - - worker: { - format: 'es', - rollupOptions: { - output: { - inlineDynamicImports: false, - }, - }, - }, - - plugins: [ - Info(), - - { - name: 'proj-airi:defines', - config(ctx) { - const define: Record = { - 'import.meta.env.RUNTIME_ENVIRONMENT': '\'electron\'', - } - if (ctx.mode === 'development') { - define['import.meta.env.URL_MODE'] = '\'server\'' - } - if (ctx.mode === 'production') { - define['import.meta.env.URL_MODE'] = '\'file\'' - } - - return { define } - }, - }, - - Inspect(), - - Yaml(), - - VueMacros({ - plugins: { - vue: Vue({ - include: [/\.vue$/, /\.md$/], - ...templateCompilerOptions, - }), - vueJsx: false, - }, - betterDefine: false, - }), - - VueRouter({ - dts: resolve(import.meta.dirname, 'src/renderer/typed-router.d.ts'), - routesFolder: [ - { - src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'), - exclude: base => [ - ...base, - '**/settings/account/index.vue', - '**/settings/connection/index.vue', - '**/settings/data/index.vue', - '**/settings/models/index.vue', - '**/settings/system/general.vue', - '**/settings/modules/mcp.vue', - '**/devtools/index.vue', - '**/settings/index.vue', - ], - }, - resolve(import.meta.dirname, 'src', 'renderer', 'pages'), - ], - exclude: ['**/components/**'], - }), - - VitePluginVueDevTools(), - - // https://github.com/JohnCampionJr/vite-plugin-vue-layouts - Layouts({ - layoutsDirs: [ - resolve(import.meta.dirname, 'src', 'renderer', 'layouts'), - resolve(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src', 'layouts'), - ], - pagesDirs: [resolve(import.meta.dirname, 'src', 'renderer', 'pages')], - }), - - UnoCss(), - - // https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n - VueI18n({ - runtimeOnly: true, - compositionOnly: true, - fullInstall: true, - }), - - DownloadLive2DSDK(), - Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - ], - }, -}) diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json deleted file mode 100644 index 360cbedf0..000000000 --- a/apps/stage-tamagotchi/package.json +++ /dev/null @@ -1,224 +0,0 @@ -{ - "name": "@proj-airi/stage-tamagotchi", - "type": "module", - "version": "0.12.0-beta.5", - "private": true, - "description": "LLM powered virtual character", - "author": { - "name": "Moeru AI", - "email": "airi@moeru.ai", - "url": "https://github.com/moeru-ai" - }, - "homepage": "https://airi.moeru.ai/docs/", - "main": "./out/main/index.js", - "scripts": { - "lint": "eslint --cache .", - "typecheck": "vue-tsc --noEmit", - "app:dev": "pnpm run dev", - "app:build": "pnpm run build", - "start": "install-electron && electron-vite preview", - "start:xwayland": "install-electron && electron-vite preview -- --ozone-platform=x11", - "dev": "install-electron && electron-vite dev", - "dev:xwayland": "install-electron && electron-vite dev -- --ozone-platform=x11", - "build": "electron-vite build", - "postinstall": "electron-builder install-app-deps", - "build:unpack": "pnpm run build && electron-builder --dir", - "build:flatpak": "pnpm run build:unpack && flatpak-builder --user --install-deps-from=flathub ./flatpak ai.moeru.airi.flatpak.yml --force-clean", - "build:win": "pnpm run build && electron-builder --win", - "build:mac": "pnpm run build && electron-builder --mac", - "build:linux": "pnpm run build && electron-builder --linux", - "rename-artifacts": "mkdir -p bundle && tsx scripts/rename-artifacts.ts", - "merge-latest-mac": "tsx scripts/merge-latest-mac.ts", - "regenerate-windows-latest": "tsx scripts/regenerate-windows-latest.ts", - "artifacts-metadata": "tsx scripts/artifacts-metadata.ts", - "smoke:desktop-overlay-live-window": "NODE_OPTIONS='--experimental-websocket' tsx scripts/desktop-overlay-live-window-smoke.ts", - "update-test:generate": "tsx scripts/update-test/generate-manifest.ts", - "update-test:server": "tsx scripts/update-test/start-server.ts", - "update-test:matrix": "bash scripts/update-test/run-matrix.sh" - }, - "dependencies": { - "@date-fns/utc": "catalog:", - "@fontsource-variable/comfortaa": "catalog:", - "@fontsource-variable/dm-sans": "catalog:", - "@fontsource-variable/jura": "catalog:", - "@fontsource-variable/nunito": "catalog:", - "@fontsource-variable/quicksand": "catalog:", - "@fontsource-variable/urbanist": "catalog:", - "@fontsource/dm-mono": "catalog:", - "@fontsource/dm-serif-display": "catalog:", - "@fontsource/gugi": "catalog:", - "@fontsource/kiwi-maru": "catalog:", - "@fontsource/m-plus-rounded-1c": "catalog:", - "@formkit/auto-animate": "catalog:", - "@guiiai/logg": "catalog:", - "@huggingface/transformers": "catalog:", - "@intlify/core": "catalog:", - "@moeru/eventa": "catalog:", - "@moeru/std": "catalog:", - "@pinia/colada": "catalog:", - "@proj-airi/audio": "workspace:^", - "@proj-airi/ccc": "workspace:^", - "@proj-airi/drizzle-duckdb-wasm": "catalog:", - "@proj-airi/font-chillroundm": "workspace:^", - "@proj-airi/font-cjkfonts-allseto": "workspace:^", - "@proj-airi/font-xiaolai": "workspace:^", - "@proj-airi/i18n": "workspace:^", - "@proj-airi/input-gamepad-vueuse": "workspace:^", - "@proj-airi/model-driver-lipsync": "workspace:^", - "@proj-airi/pipelines-audio": "workspace:^", - "@proj-airi/plugin-sdk-tamagotchi": "workspace:^", - "@proj-airi/server-sdk": "workspace:*", - "@proj-airi/stage-layouts": "workspace:^", - "@proj-airi/stage-pages": "workspace:^", - "@proj-airi/stage-ui": "workspace:^", - "@proj-airi/stage-ui-live2d": "workspace:^", - "@proj-airi/stage-ui-mmd": "workspace:^", - "@proj-airi/stage-ui-spine": "workspace:^", - "@proj-airi/stage-ui-three": "workspace:^", - "@proj-airi/ui": "workspace:^", - "@shikijs/markdown-it": "catalog:", - "@tresjs/cientos": "catalog:", - "@tresjs/core": "catalog:", - "@unocss/reset": "catalog:", - "@vueuse/core": "catalog:", - "@vueuse/motion": "catalog:", - "@vueuse/shared": "catalog:", - "@xsai-apple-speech/transcription": "catalog:", - "@xsai-apple-speech/transcription-electron-plugin": "catalog:", - "@xsai-apple-speech/transcription-native": "catalog:", - "@xsai-ext/providers": "catalog:", - "@xsai-transformers/embed": "catalog:", - "@xsai-transformers/transcription": "catalog:", - "@xsai/generate-speech": "catalog:", - "@xsai/generate-text": "catalog:", - "@xsai/model": "catalog:", - "@xsai/shared": "catalog:", - "@xsai/shared-chat": "catalog:", - "@xsai/stream-text": "catalog:", - "@xsai/stream-transcription": "catalog:", - "@xsai/tool": "catalog:", - "@xsai/utils-chat": "catalog:", - "alien-signals": "catalog:", - "animejs": "catalog:", - "async-mutex": "catalog:", - "chess.js": "catalog:", - "colorjs.io": "catalog:", - "culori": "catalog:", - "date-fns": "catalog:", - "defu": "catalog:", - "destr": "catalog:", - "dompurify": "catalog:", - "electron-click-drag-plugin": "catalog:", - "embla-carousel-vue": "catalog:", - "es-toolkit": "catalog:", - "injeca": "catalog:", - "jszip": "catalog:", - "localforage": "catalog:", - "mediabunny": "catalog:", - "node-vibrant": "catalog:", - "nprogress": "catalog:", - "onnxruntime-web": "catalog:", - "pinia": "catalog:", - "popmotion": "catalog:", - "rehype-stringify": "catalog:", - "reka-ui": "catalog:", - "remark-parse": "catalog:", - "remark-rehype": "catalog:", - "replicate": "catalog:", - "semver": "catalog:", - "shiki": "catalog:", - "splitpanes": "catalog:", - "three": "catalog:", - "uiohook-napi": "catalog:", - "unified": "catalog:", - "unspeech": "catalog:xsai", - "uqr": "catalog:", - "uuid": "catalog:", - "valibot": "catalog:", - "vaul-vue": "catalog:", - "vue": "catalog:", - "vue-demi": "catalog:", - "vue-i18n": "catalog:", - "vue-router": "catalog:", - "vue-sonner": "catalog:", - "web-haptics": "catalog:", - "whatwg-mimetype": "catalog:", - "xsschema": "catalog:", - "zod": "catalog:" - }, - "optionalDependencies": { - "@xsai-apple-speech/transcription-native-darwin-arm64": "catalog:", - "@xsai-apple-speech/transcription-native-darwin-x64": "catalog:" - }, - "devDependencies": { - "@electron-toolkit/preload": "catalog:", - "@electron-toolkit/tsconfig": "catalog:", - "@electron-toolkit/utils": "catalog:", - "@electron/notarize": "catalog:", - "@iconify-json/carbon": "catalog:", - "@iconify-json/eos-icons": "catalog:", - "@iconify-json/lucide": "catalog:", - "@iconify-json/mingcute": "catalog:", - "@iconify-json/ph": "catalog:", - "@iconify-json/simple-icons": "catalog:", - "@iconify-json/solar": "catalog:", - "@iconify-json/svg-spinners": "catalog:", - "@iconify-json/vscode-icons": "catalog:", - "@iconify/utils": "catalog:", - "@intlify/unplugin-vue-i18n": "catalog:", - "@modelcontextprotocol/sdk": "catalog:", - "@pnpm/find-workspace-dir": "catalog:", - "@proj-airi/electron-eventa": "workspace:^", - "@proj-airi/electron-screen-capture": "workspace:^", - "@proj-airi/electron-vueuse": "workspace:^", - "@proj-airi/iconify-meteocons": "catalog:", - "@proj-airi/lobe-icons": "catalog:", - "@proj-airi/plugin-sdk": "workspace:^", - "@proj-airi/server-runtime": "workspace:^", - "@proj-airi/stage-shared": "workspace:^", - "@proj-airi/ui-transitions": "workspace:^", - "@proj-airi/unplugin-fetch": "catalog:", - "@proj-airi/unplugin-live2d-sdk": "catalog:", - "@types/audioworklet": "catalog:", - "@types/culori": "catalog:", - "@types/nprogress": "catalog:", - "@types/semver": "catalog:", - "@types/splitpanes": "catalog:", - "@types/three": "catalog:", - "@types/whatwg-mimetype": "catalog:", - "@types/yauzl": "catalog:", - "@vitejs/plugin-vue": "catalog:", - "@vue-macros/volar": "catalog:", - "builder-util-runtime": "catalog:", - "cac": "catalog:", - "crossws": "catalog:", - "csstype": "catalog:", - "drizzle-orm": "catalog:", - "electron": "catalog:", - "electron-builder": "catalog:", - "electron-updater": "catalog:", - "electron-vite": "catalog:", - "get-port-please": "catalog:", - "h3": "catalog:", - "less": "catalog:", - "mkcert": "catalog:", - "std-env": "catalog:", - "superjson": "catalog:", - "unocss-preset-scrollbar": "catalog:", - "unplugin-info": "catalog:", - "unplugin-yaml": "catalog:", - "vite": "catalog:", - "vite-bundle-visualizer": "catalog:", - "vite-plugin-mkcert": "catalog:", - "vite-plugin-vue-devtools": "catalog:", - "vite-plugin-vue-layouts": "catalog:", - "vue-macros": "catalog:", - "vue-tsc": "catalog:", - "yaml": "catalog:", - "yauzl": "catalog:" - }, - "build": { - "appId": "ai.moeru.airi", - "extends": "electron-builder.config.ts" - } -} diff --git a/apps/stage-tamagotchi/resources/icon-512.png b/apps/stage-tamagotchi/resources/icon-512.png deleted file mode 100644 index b6d6c51e6..000000000 Binary files a/apps/stage-tamagotchi/resources/icon-512.png and /dev/null differ diff --git a/apps/stage-tamagotchi/resources/icon.png b/apps/stage-tamagotchi/resources/icon.png deleted file mode 100644 index 7dcec3600..000000000 Binary files a/apps/stage-tamagotchi/resources/icon.png and /dev/null differ diff --git a/apps/stage-tamagotchi/resources/icon.svg b/apps/stage-tamagotchi/resources/icon.svg deleted file mode 100644 index e5a37b8a2..000000000 --- a/apps/stage-tamagotchi/resources/icon.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/stage-tamagotchi/resources/tray-icon-macos.png b/apps/stage-tamagotchi/resources/tray-icon-macos.png deleted file mode 100644 index 4bcfae563..000000000 Binary files a/apps/stage-tamagotchi/resources/tray-icon-macos.png and /dev/null differ diff --git a/apps/stage-tamagotchi/scripts/artifacts-metadata.ts b/apps/stage-tamagotchi/scripts/artifacts-metadata.ts deleted file mode 100644 index f0d0eb43a..000000000 --- a/apps/stage-tamagotchi/scripts/artifacts-metadata.ts +++ /dev/null @@ -1,106 +0,0 @@ -import process from 'node:process' - -import { cac } from 'cac' - -import { getElectronBuilderConfig, getFilenames, getVersion } from './utils' - -async function main() { - const cli = cac('name-of-artifact') - .option( - '--release', - 'Rename with version from package.json', - { default: false }, - ) - .option( - '--get-filename ', - 'Get the release artifact filename for a specific extension (e.g., deb, rpm, dmg, exe)', - { default: '', type: [String] }, - ) - .option( - '--get-output-filename ', - 'Get the build output filename for a specific extension (pre-rename)', - { default: '', type: [String] }, - ) - .option( - '--auto-tag', - 'Automatically tag the release with the latest git ref', - { default: false }, - ) - .option( - '--tag ', - 'Tag to use for the release', - { default: '', type: [String] }, - ) - .option( - '--get-bundle-name', - 'Get the bundle name', - { default: false }, - ) - .option( - '--get-product-name', - 'Get the product name', - { default: false }, - ) - .option( - '--get-version', - 'Get the version', - { default: false }, - ) - - const args = cli.parse() - - const argOptions = args.options as { - release: boolean - autoTag: boolean - tag: string[] - getBundleName: boolean - getProductName: boolean - getVersion: boolean - getFilename: string[] - getOutputFilename: string[] - } - - const target = args.args[0] - if (argOptions.getBundleName) { - const filenames = await getFilenames(target, argOptions) - console.info(filenames[0].releaseArtifactFilename) - return - } - if (argOptions.getFilename && argOptions.getFilename[0]) { - const ext = String(argOptions.getFilename[0]).trim() - const filenames = await getFilenames(target, argOptions) - const match = filenames.find(f => f.extension === ext) - if (!match) { - console.error(`No artifact found for extension: ${ext}`) - process.exit(1) - } - console.info(match.releaseArtifactFilename) - return - } - if (argOptions.getOutputFilename && argOptions.getOutputFilename[0]) { - const ext = String(argOptions.getOutputFilename[0]).trim() - const filenames = await getFilenames(target, argOptions) - const match = filenames.find(f => f.extension === ext) - if (!match) { - console.error(`No artifact found for extension: ${ext}`) - process.exit(1) - } - console.info(match.outputFilename) - return - } - if (argOptions.getProductName) { - const electronBuilderConfig = await getElectronBuilderConfig() - console.info(electronBuilderConfig.productName) - return - } - if (argOptions.getVersion) { - const version = await getVersion({ release: argOptions.release, autoTag: argOptions.autoTag, tag: argOptions.tag }) - console.info(version) - } -} - -main() - .catch((error) => { - console.error('Error during generating name:', error) - process.exit(1) - }) diff --git a/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.test.ts b/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.test.ts deleted file mode 100644 index aedaa4ae3..000000000 --- a/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { EventEmitter } from 'node:events' - -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { CdpClient } from './desktop-overlay-live-window-smoke' - -afterEach(() => { - vi.restoreAllMocks() -}) - -function createMockSocket() { - const socket = new EventEmitter() as EventEmitter & { - send: ReturnType - close: ReturnType - addEventListener: (event: string, listener: (...args: any[]) => void) => void - } - socket.send = vi.fn() - socket.close = vi.fn(() => { - socket.emit('close') - }) - socket.addEventListener = (event, listener) => { - socket.on(event, listener) - } - return socket -} - -describe('cdpClient', () => { - it('rejects pending requests when the socket closes', async () => { - const socket = createMockSocket() - const client = new CdpClient(socket as never) - - const pending = client.send('Runtime.evaluate', { expression: '1 + 1' }) - expect(socket.send).toHaveBeenCalledTimes(1) - - client.close() - - await expect(pending).rejects.toThrow('CDP socket closed before completing request 1') - expect(socket.close).toHaveBeenCalledTimes(1) - }) -}) diff --git a/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.ts b/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.ts deleted file mode 100644 index 6ea6623fe..000000000 --- a/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.ts +++ /dev/null @@ -1,566 +0,0 @@ -import type { Buffer } from 'node:buffer' -import type { ChildProcessWithoutNullStreams } from 'node:child_process' - -import { spawn } from 'node:child_process' -import { createWriteStream } from 'node:fs' -import { access, mkdir, writeFile } from 'node:fs/promises' -import { createServer } from 'node:net' -import { dirname, resolve } from 'node:path' -import { env, exit, kill as killProcess } from 'node:process' -import { fileURLToPath } from 'node:url' - -import { errorMessageFromValue } from '@proj-airi/stage-shared' - -import { desktopOverlayPollHeartbeatMarker } from '../src/shared/desktop-overlay-heartbeat' -import { selectDesktopOverlaySmokeCandidateId } from '../src/shared/desktop-overlay-live-window-smoke' - -interface DebugTarget { - id: string - title: string - type: string - url: string - webSocketDebuggerUrl?: string -} - -interface McpResult { - content?: unknown[] - structuredContent?: Record - isError?: boolean -} - -interface McpApplyResult { - started: Array<{ name: string }> - failed: Array<{ name: string, error: string }> - skipped: Array<{ name: string, reason: string }> -} - -interface McpToolDescriptor { - name: string - serverName: string - toolName: string -} - -interface McpRuntimeStatus { - servers: Array<{ - name: string - state: 'running' | 'stopped' | 'error' - lastError?: string - }> -} - -const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const repoDir = resolve(packageDir, '../..') -const runId = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-') -const reportDir = resolve(repoDir, '.temp', `desktop-overlay-live-window-smoke-${runId}`) -const userDataDir = resolve(reportDir, 'stage-user-data') -const mcpSessionRoot = resolve(reportDir, 'computer-use-session') -const stageLogPath = resolve(reportDir, 'stage-tamagotchi.log') -const mcpConfigPath = resolve(userDataDir, 'mcp.json') -const requiredWorkspaceBuildOutputs = [ - 'packages/electron-screen-capture/dist/main.mjs', - 'packages/electron-vueuse/dist/main/index.mjs', - 'packages/server-runtime/dist/server.mjs', -] - -const smokeHtml = ` - - - AIRI Desktop Overlay Live Window Smoke - - - -

AIRI Desktop Overlay Live Window Smoke

- - -` - -const smokeUrl = `data:text/html;charset=utf-8,${encodeURIComponent(smokeHtml)}` - -function assert(condition: boolean, message: string): asserts condition { - if (!condition) - throw new Error(message) -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -async function findAvailablePort(): Promise { - return await new Promise((resolvePort, reject) => { - const server = createServer() - server.listen(0, '127.0.0.1', () => { - const address = server.address() - server.close(() => { - if (typeof address === 'object' && address?.port) { - resolvePort(address.port) - } - else { - reject(new Error('failed to allocate debug port')) - } - }) - }) - server.on('error', reject) - }) -} - -async function waitFor( - label: string, - probe: () => Promise | T | undefined, - timeoutMs: number, - intervalMs: number, -): Promise { - const start = Date.now() - let lastError: unknown - - while ((Date.now() - start) < timeoutMs) { - try { - const value = await probe() - if (value !== undefined) - return value - } - catch (error) { - lastError = error - } - await sleep(intervalMs) - } - - const suffix = lastError instanceof Error ? `: ${lastError.message}` : '' - throw new Error(`${label} timed out after ${timeoutMs}ms${suffix}`) -} - -export class CdpClient { - private socket?: WebSocket - private nextId = 1 - private pending = new Map) => void - reject: (error: Error) => void - }>() - - constructor(socket: WebSocket) { - this.socket = socket - this.socket.addEventListener('message', (event) => { - const payload = JSON.parse(String(event.data)) as Record - const id = typeof payload.id === 'number' ? payload.id : undefined - if (id === undefined) - return - - const pending = this.pending.get(id) - if (!pending) - return - - this.pending.delete(id) - if (payload.error) { - pending.reject(new Error(JSON.stringify(payload.error))) - } - else { - pending.resolve(payload) - } - }) - this.socket.addEventListener('close', () => { - this.failPending('CDP socket closed') - }) - this.socket.addEventListener('error', () => { - this.failPending('CDP socket errored') - }) - } - - static async connect(url: string): Promise { - const socket = new WebSocket(url) - await new Promise((resolveOpen, reject) => { - socket.addEventListener('open', () => resolveOpen(), { once: true }) - socket.addEventListener('error', () => reject(new Error(`failed to connect CDP target: ${url}`)), { once: true }) - }) - return new CdpClient(socket) - } - - async send(method: string, params?: Record): Promise> { - if (!this.socket) { - throw new Error('CDP socket is closed') - } - - const id = this.nextId++ - const promise = new Promise>((resolveMessage, reject) => { - this.pending.set(id, { resolve: resolveMessage, reject }) - }) - - this.socket.send(JSON.stringify({ id, method, params: params ?? {} })) - return await promise - } - - async evaluate(expression: string): Promise { - const response = await this.send('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - const result = response.result - if (!isRecord(result)) - throw new Error('Runtime.evaluate missing result') - - const exceptionDetails = result.exceptionDetails - if (exceptionDetails) { - throw new Error(JSON.stringify(exceptionDetails)) - } - - const remoteObject = result.result - if (!isRecord(remoteObject)) - throw new Error('Runtime.evaluate missing remote object') - - return remoteObject.value as T - } - - close() { - this.failPending('CDP socket closed') - this.socket?.close() - this.socket = undefined - } - - private failPending(reason: string) { - if (this.pending.size === 0) { - return - } - - for (const [id, pending] of this.pending.entries()) { - this.pending.delete(id) - pending.reject(new Error(`${reason} before completing request ${id}`)) - } - } -} - -async function fetchJson(url: string): Promise { - const response = await fetch(url) - if (!response.ok) - throw new Error(`${url} returned ${response.status}`) - return await response.json() as T -} - -async function prepareMcpConfig() { - await mkdir(userDataDir, { recursive: true }) - await mkdir(mcpSessionRoot, { recursive: true }) - - const mcpEnv: Record = { - PATH: env.PATH || '', - HOME: env.HOME || '', - SHELL: env.SHELL || '', - LANG: env.LANG || 'en_US.UTF-8', - TMPDIR: env.TMPDIR || '', - COMPUTER_USE_EXECUTOR: env.COMPUTER_USE_SMOKE_EXECUTOR || env.COMPUTER_USE_EXECUTOR || 'macos-local', - COMPUTER_USE_APPROVAL_MODE: env.COMPUTER_USE_SMOKE_APPROVAL_MODE || env.COMPUTER_USE_APPROVAL_MODE || 'never', - COMPUTER_USE_OPENABLE_APPS: env.COMPUTER_USE_OPENABLE_APPS || 'Terminal,Cursor,Google Chrome', - COMPUTER_USE_SESSION_TAG: `desktop-overlay-live-window-smoke-${runId}`, - COMPUTER_USE_SESSION_ROOT: mcpSessionRoot, - } - - for (const optionalEnvName of ['PNPM_HOME', 'COREPACK_HOME']) { - const value = env[optionalEnvName]?.trim() - if (value) { - mcpEnv[optionalEnvName] = value - } - } - - const config = { - mcpServers: { - computer_use: { - command: 'pnpm', - args: ['-F', '@proj-airi/computer-use-mcp', 'start'], - cwd: repoDir, - enabled: true, - env: mcpEnv, - }, - }, - } - - await writeFile(mcpConfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8') -} - -async function ensureSmokePrerequisites() { - if (typeof WebSocket !== 'function') { - throw new TypeError('APP_START_FAILED: WebSocket is unavailable in this Node runtime. Run through the package script or set NODE_OPTIONS=--experimental-websocket.') - } - - const missingOutputs: string[] = [] - for (const relativePath of requiredWorkspaceBuildOutputs) { - try { - await access(resolve(repoDir, relativePath)) - } - catch { - missingOutputs.push(relativePath) - } - } - - if (missingOutputs.length === 0) - return - - throw new Error([ - 'APP_START_FAILED: required workspace build outputs are missing.', - `Missing: ${missingOutputs.join(', ')}`, - 'Build stage-tamagotchi dependencies manually before this smoke. The smoke command does not auto-build them to avoid saturating the local machine.', - 'Suggested command: pnpm -F \'@proj-airi/stage-tamagotchi^...\' --if-present build', - ].join(' ')) -} - -async function waitForRemoteDebug(debugPort: number): Promise { - const version = await waitFor('Electron remote debug endpoint', async () => { - const data = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${debugPort}/json/version`) - return data.webSocketDebuggerUrl - }, 120_000, 500) - - return version -} - -async function findOverlayTarget(debugPort: number): Promise { - return await waitFor('desktop overlay debug target', async () => { - const targets = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`) - return targets.find(target => target.type === 'page' && target.url.includes('#/desktop-overlay')) - }, 120_000, 500) -} - -async function connectOverlayClient(debugPort: number): Promise { - const overlayTarget = await findOverlayTarget(debugPort) - if (!overlayTarget.webSocketDebuggerUrl) - throw new Error('APP_START_FAILED: overlay target missing webSocketDebuggerUrl') - - const client = await CdpClient.connect(overlayTarget.webSocketDebuggerUrl) - - await waitFor('overlay smoke bridge', async () => { - return await client.evaluate('Boolean(window.__AIRI_DESKTOP_OVERLAY_SMOKE__?.callMcpTool)') - ? true - : undefined - }, 60_000, 500) - - return client -} - -async function callOverlayMcpTool(client: CdpClient, name: string, args: Record = {}): Promise { - const result = await client.evaluate(`window.__AIRI_DESKTOP_OVERLAY_SMOKE__.callMcpTool(${JSON.stringify({ name, arguments: args })})`) - if (result.isError) { - throw new Error(`${name} returned isError=true`) - } - return result -} - -async function ensureOverlayMcpServerReady(client: CdpClient): Promise { - const applyResult = await client.evaluate('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.applyAndRestartMcp()') - const failedComputerUse = applyResult.failed.find(item => item.name === 'computer_use') - if (failedComputerUse) { - throw new Error(`computer_use failed to start: ${failedComputerUse.error}`) - } - - await waitFor('computer_use MCP runtime', async () => { - const status = await client.evaluate('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.getMcpRuntimeStatus()') - const computerUse = status.servers.find(server => server.name === 'computer_use') - if (computerUse?.state === 'error') { - throw new Error(`computer_use runtime error: ${computerUse.lastError ?? 'unknown error'}`) - } - return computerUse?.state === 'running' ? true : undefined - }, 30_000, 500) - - await waitFor('computer_use desktop tools', async () => { - const tools = await client.evaluate('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.listMcpTools()') - const names = new Set(tools.map(tool => tool.name)) - return names.has('computer_use::desktop_get_state') - && names.has('computer_use::desktop_observe') - && names.has('computer_use::desktop_click_target') - ? true - : undefined - }, 30_000, 500) -} - -function requireStructuredContent(result: McpResult, label: string): Record { - if (!isRecord(result.structuredContent)) - throw new Error(`${label} missing structuredContent`) - - if (result.structuredContent.status && result.structuredContent.status !== 'ok') - throw new Error(`${label} expected status=ok, got ${String(result.structuredContent.status)}`) - - return result.structuredContent -} - -function requireRunState(result: McpResult, label: string): Record { - const structuredContent = requireStructuredContent(result, label) - if (!isRecord(structuredContent.runState)) - throw new Error(`${label} missing runState`) - return structuredContent.runState -} - -function startStage(debugPort: number, heartbeatLines: string[]): ChildProcessWithoutNullStreams { - const stageProcess = spawn('pnpm', ['-F', '@proj-airi/stage-tamagotchi', 'dev'], { - cwd: repoDir, - detached: true, - env: { - ...env, - APP_REMOTE_DEBUG: 'true', - APP_REMOTE_DEBUG_PORT: String(debugPort), - APP_REMOTE_DEBUG_NO_OPEN: 'true', - APP_USER_DATA_PATH: userDataDir, - AIRI_DESKTOP_OVERLAY: '1', - AIRI_DESKTOP_OVERLAY_POLL_HEARTBEAT: '1', - }, - stdio: 'pipe', - }) - - const stageLogStream = createWriteStream(stageLogPath, { flags: 'a' }) - const capture = (chunk: Buffer) => { - const text = chunk.toString('utf-8') - stageLogStream.write(text) - for (const line of text.split(/\r?\n/u)) { - if (line.includes(desktopOverlayPollHeartbeatMarker)) { - heartbeatLines.push(line) - } - } - } - stageProcess.stdout.on('data', capture) - stageProcess.stderr.on('data', capture) - stageProcess.on('close', () => stageLogStream.end()) - - return stageProcess -} - -async function stopStage(stageProcess: ChildProcessWithoutNullStreams | undefined) { - if (!stageProcess || stageProcess.exitCode !== null) - return - - const signalStageProcessGroup = (signal: NodeJS.Signals) => { - try { - if (stageProcess.pid) { - killProcess(-stageProcess.pid, signal) - return - } - } - catch { - // Fall back to the pnpm wrapper process if process-group signalling is - // unavailable. The smoke starts a detached group to make this reliable on - // macOS, but the fallback keeps the helper safe on other local setups. - } - - stageProcess.kill(signal) - } - - signalStageProcessGroup('SIGTERM') - await Promise.race([ - new Promise(resolve => stageProcess.once('exit', resolve)), - sleep(5_000).then(() => signalStageProcessGroup('SIGKILL')), - ]) -} - -function rejectWhenStageExits(stageProcess: ChildProcessWithoutNullStreams): Promise { - return new Promise((_, reject) => { - stageProcess.once('exit', (code, signal) => { - reject(new Error(`stage-tamagotchi exited with code=${String(code)} signal=${String(signal)}`)) - }) - }) -} - -async function main() { - let stageProcess: ChildProcessWithoutNullStreams | undefined - let overlayClient: CdpClient | undefined - let stoppingStage = false - const heartbeatLines: string[] = [] - - try { - await ensureSmokePrerequisites() - await mkdir(reportDir, { recursive: true }) - await prepareMcpConfig() - - const debugPort = await findAvailablePort() - stageProcess = startStage(debugPort, heartbeatLines) - const stageExited = rejectWhenStageExits(stageProcess) - stageProcess.once('exit', (code, signal) => { - if (!stoppingStage && code !== null && code !== 0) - console.error(`APP_START_FAILED: stage-tamagotchi exited with code=${code} signal=${String(signal)}`) - }) - - await Promise.race([ - waitForRemoteDebug(debugPort), - stageExited, - ]).catch((error) => { - throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`) - }) - - overlayClient = await Promise.race([ - connectOverlayClient(debugPort), - stageExited, - ]).catch((error) => { - throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`) - }) - - // NOTICE: - // Vite's dev optimizer can trigger one renderer reload shortly after the - // Electron window first exposes the smoke bridge. Reconnect once after a - // short settle window so the following MCP calls do not race a closing CDP - // target. This is local smoke harness discipline, not product runtime. - await sleep(5_000) - overlayClient.close() - overlayClient = await Promise.race([ - connectOverlayClient(debugPort), - stageExited, - ]).catch((error) => { - throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`) - }) - - const readiness = await overlayClient.evaluate<{ state: 'booting' | 'ready' | 'degraded', error?: string }>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.getReadiness()') - if (readiness.state !== 'ready') { - throw new Error(`OVERLAY_READINESS_DEGRADED: state=${readiness.state}${readiness.error ? ` error=${readiness.error}` : ''}`) - } - - try { - await ensureOverlayMcpServerReady(overlayClient) - await callOverlayMcpTool(overlayClient, 'computer_use::desktop_ensure_chrome', { url: smokeUrl }) - await sleep(750) - await callOverlayMcpTool(overlayClient, 'computer_use::desktop_observe', { includeChrome: true }) - const preClickRunState = requireRunState( - await callOverlayMcpTool(overlayClient, 'computer_use::desktop_get_state'), - 'computer_use::desktop_get_state before click', - ) - const candidateId = selectDesktopOverlaySmokeCandidateId(preClickRunState) - await callOverlayMcpTool(overlayClient, 'computer_use::desktop_click_target', { - candidateId, - button: 'left', - clickCount: 1, - }) - const postClickRunState = requireRunState( - await callOverlayMcpTool(overlayClient, 'computer_use::desktop_get_state'), - 'computer_use::desktop_get_state after click', - ) - const pointerIntent = postClickRunState.lastPointerIntent - assert(isRecord(pointerIntent), 'computer_use::desktop_get_state missing lastPointerIntent after click') - assert(pointerIntent.candidateId === candidateId, `lastPointerIntent candidate mismatch: expected ${candidateId}, got ${String(pointerIntent.candidateId)}`) - } - catch (error) { - throw new Error(`MCP_CALL_FAILED: ${errorMessageFromValue(error)}`) - } - - const heartbeat = await waitFor('overlay poll heartbeat', () => { - return heartbeatLines.find(line => line.includes('snapshotId=') && line.includes('pointerIntent=yes')) - }, 30_000, 250).catch((error) => { - throw new Error(`HEARTBEAT_TIMEOUT: ${errorMessageFromValue(error)}`) - }) - - console.info(JSON.stringify({ - ok: true, - reportDir, - stageLogPath, - heartbeat, - }, null, 2)) - } - finally { - overlayClient?.close() - stoppingStage = true - await stopStage(stageProcess) - } -} - -if (import.meta.main) { - main().catch((error) => { - console.error(errorMessageFromValue(error)) - console.error(`stage log: ${stageLogPath}`) - exit(1) - }) -} diff --git a/apps/stage-tamagotchi/scripts/merge-latest-mac.ts b/apps/stage-tamagotchi/scripts/merge-latest-mac.ts deleted file mode 100644 index 0f4dcf8b7..000000000 --- a/apps/stage-tamagotchi/scripts/merge-latest-mac.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { existsSync, readdirSync, statSync } from 'node:fs' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import { cwd, exit } from 'node:process' - -import { findWorkspaceDir } from '@pnpm/find-workspace-dir' -import { cac } from 'cac' - -import * as yaml from 'yaml' - -interface UpdateInfoFile { - url: string - sha2?: string - sha512?: string - size?: number -} - -interface UpdateInfo { - files?: UpdateInfoFile[] - path?: string - sha2?: string - sha512?: string - [key: string]: unknown -} - -type Platform = 'x64' | 'arm64' | 'both' | 'none' - -const regexpIsLatestMacMetadata = /^latest(?:-[^-]+)?-mac\.yml$/i - -function getUrls(updateInfo: UpdateInfo): string[] { - const urls: string[] = [] - - if (Array.isArray(updateInfo.files)) { - for (const file of updateInfo.files) { - if (typeof file?.url === 'string') { - urls.push(file.url) - } - } - } - - if (typeof updateInfo.path === 'string') { - urls.push(updateInfo.path) - } - - return urls -} - -export const regexpContainsArm64 = /arm64/i -export const regexpHasArm64 = /(^|[-_/])arm64([-.]|$)/i -export const regexpHasX64 = /(^|[-_/])x64([-.]|$)/i -export const regexpIsMacZip = /-mac\.zip$/i -export const regexpIsArm64MacZip = /-arm64-mac\.zip$/i - -function isArm64MacZip(url: string): boolean { - return regexpIsArm64MacZip.test(url) -} - -function isX64MacZip(url: string): boolean { - return regexpIsMacZip.test(url) && !regexpContainsArm64.test(url) -} - -function getMacZipUrls(updateInfo: UpdateInfo): string[] { - return getUrls(updateInfo).filter(url => regexpIsMacZip.test(url)) -} - -function assertContainsMacZip(updateInfo: UpdateInfo, platform: Exclude, filePath: string) { - const zipUrls = getMacZipUrls(updateInfo) - - if (platform === 'arm64' && !zipUrls.some(isArm64MacZip)) { - throw new Error(`arm64 update info is missing an arm64 mac zip entry: ${filePath}`) - } - - if (platform === 'x64' && !zipUrls.some(isX64MacZip)) { - throw new Error(`x64 update info is missing an x64 mac zip entry: ${filePath}`) - } -} - -function assertMergedContainsBothMacZips(updateInfo: UpdateInfo) { - const zipUrls = getMacZipUrls(updateInfo) - const hasArm64 = zipUrls.some(isArm64MacZip) - const hasX64 = zipUrls.some(isX64MacZip) - - if (!hasArm64 || !hasX64) { - throw new Error(`Merged latest-mac.yml must contain both arm64 and x64 mac zip entries, received: ${zipUrls.join(', ') || '(none)'}`) - } -} - -function detectPlatform(updateInfo: UpdateInfo): Platform { - const urls = getUrls(updateInfo) - - const hasArm64 = urls.some(url => regexpHasArm64.test(url)) - const hasX64FromName = urls.some(url => regexpHasX64.test(url)) - const hasMacZip = urls.some(url => regexpIsMacZip.test(url) && !regexpContainsArm64.test(url)) - const hasX64 = hasX64FromName || hasMacZip - - if (hasX64 && hasArm64) { - return 'both' - } - if (hasX64) { - return 'x64' - } - if (hasArm64) { - return 'arm64' - } - return 'none' -} - -function mergeFiles(arm64: UpdateInfo, x64: UpdateInfo): UpdateInfo { - const arm64Files = Array.isArray(arm64.files) ? arm64.files : [] - const x64Files = Array.isArray(x64.files) ? x64.files : [] - - const byUrl = new Map() - for (const file of [...arm64Files, ...x64Files]) { - if (file?.url) { - byUrl.set(file.url, file) - } - } - - return { - ...arm64, - files: [...byUrl.values()], - path: undefined, - sha2: undefined, - sha512: undefined, - } -} - -async function readUpdateInfo(filePath: string): Promise { - const raw = await readFile(filePath, 'utf8') - return yaml.parse(raw) as UpdateInfo -} - -function collectLatestMacFiles(rootDir: string): string[] { - const results: string[] = [] - // eslint-disable-next-line no-console - console.debug('merge-latest-mac: scan context', { - cwd: cwd(), - rootDir, - }) - if (!existsSync(rootDir)) { - console.warn('merge-latest-mac: scan directory missing', rootDir) - return results - } - if (!statSync(rootDir).isDirectory()) { - return results - } - - const entries = readdirSync(rootDir, { withFileTypes: true }) - // eslint-disable-next-line no-console - console.debug('merge-latest-mac: scan directory entries', { - rootDir, - entries: entries.map(entry => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isFile: entry.isFile(), - })), - }) - - for (const entry of entries) { - const fullPath = resolve(rootDir, entry.name) - if (entry.isDirectory()) { - results.push(...collectLatestMacFiles(fullPath)) - continue - } - if (entry.isFile() && regexpIsLatestMacMetadata.test(entry.name)) { - results.push(fullPath) - } - } - - return results -} - -async function main() { - const cli = cac('merge-latest-mac') - .option('--input ', 'Input latest-mac yml file', { default: [], type: [String] }) - .option('--dir ', 'Scan directory for latest-mac*.yml files', { default: '' }) - .option('--output ', 'Output file path', { default: '' }) - - const args = cli.parse() - const inputs = (args.options.input as string[]).filter(Boolean) - const dir = String(args.options.dir || '').trim() - - let files: string[] = [] - const workspaceRoot = await findWorkspaceDir(cwd()) || cwd() - if (inputs.length > 0) { - for (const input of inputs) { - const resolved = resolve(input) - const fallback = resolve(workspaceRoot, input) - const target = existsSync(resolved) ? resolved : fallback - if (!existsSync(target)) { - continue - } - if (statSync(target).isDirectory()) { - files.push(...collectLatestMacFiles(target)) - } - else { - files.push(target) - } - } - } - else { - const scanDir = dir - ? (existsSync(resolve(dir)) ? resolve(dir) : resolve(workspaceRoot, dir)) - : resolve('bundle') - files = collectLatestMacFiles(scanDir) - } - - console.info('merge-latest-mac: found candidates', files) - if (files.length === 0) { - throw new Error('No latest-mac*.yml files found') - } - - const entries: { filePath: string, updateInfo: UpdateInfo, platform: Platform }[] = [] - for (const filePath of files) { - if (!existsSync(filePath)) { - console.warn('merge-latest-mac: missing file', filePath) - continue - } - const updateInfo = await readUpdateInfo(filePath) - const platform = detectPlatform(updateInfo) - console.info('merge-latest-mac: detected platform', { filePath, platform }) - - if (platform === 'arm64' || platform === 'x64') { - assertContainsMacZip(updateInfo, platform, filePath) - } - - entries.push({ filePath, updateInfo, platform }) - } - - if (entries.length === 0) { - throw new Error('No readable latest-mac*.yml files found') - } - - const outputPath = String(args.options.output || '').trim() - || resolve(dir || 'bundle', 'latest-mac.yml') - await mkdir(dirname(outputPath), { recursive: true }) - - const mergedEntry = entries.find(entry => entry.platform === 'both') - if (mergedEntry) { - assertMergedContainsBothMacZips(mergedEntry.updateInfo) - await writeFile(outputPath, yaml.stringify(mergedEntry.updateInfo), 'utf8') - return - } - - const x64Entries = entries.filter(entry => entry.platform === 'x64') - const arm64Entries = entries.filter(entry => entry.platform === 'arm64') - - if (x64Entries.length === 0 && arm64Entries.length === 0) { - throw new Error('No x64 or arm64 update info found') - } - - if (x64Entries.length === 0) { - await writeFile(outputPath, yaml.stringify(arm64Entries[0].updateInfo), 'utf8') - return - } - - if (arm64Entries.length === 0) { - await writeFile(outputPath, yaml.stringify(x64Entries[0].updateInfo), 'utf8') - return - } - - const merged = mergeFiles(arm64Entries[0].updateInfo, x64Entries[0].updateInfo) - assertMergedContainsBothMacZips(merged) - await writeFile(outputPath, yaml.stringify(merged), 'utf8') -} - -main().catch((error) => { - console.error(error) - exit(1) -}) diff --git a/apps/stage-tamagotchi/scripts/regenerate-windows-latest.test.ts b/apps/stage-tamagotchi/scripts/regenerate-windows-latest.test.ts deleted file mode 100644 index e840837ba..000000000 --- a/apps/stage-tamagotchi/scripts/regenerate-windows-latest.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import process from 'node:process' - -import { Buffer } from 'node:buffer' -import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import * as yaml from 'yaml' - -import { hashFile, regenerateWindowsLatest } from './regenerate-windows-latest' - -describe('regenerateWindowsLatest', () => { - let originalCwd: string - - beforeEach(() => { - originalCwd = process.cwd() - }) - - afterEach(() => { - process.chdir(originalCwd) - }) - - it('resolves workspace-root-relative paths from the package cwd and rewrites latest.yml from the signed installer', async () => { - const root = await mkdtemp(join(tmpdir(), 'airi-regenerate-windows-latest-')) - const workspaceRoot = join(root, 'repo') - const packageDir = join(workspaceRoot, 'apps', 'stage-tamagotchi') - const bundleDir = join(packageDir, 'bundle') - - await mkdir(bundleDir, { recursive: true }) - await writeFile(join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8') - await writeFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'), 'signed-binary-content', 'utf8') - await writeFile(join(bundleDir, 'latest.yml'), yaml.stringify({ - version: 'stale-version', - path: 'stale.exe', - sha512: 'stale-sha512', - releaseDate: '2026-01-02T03:04:05.000Z', - stagingPercentage: 25, - files: [{ url: 'stale.exe', sha512: 'stale-sha512', size: 10 }], - }), 'utf8') - - process.chdir(packageDir) - - const nextUpdateInfo = await regenerateWindowsLatest({ - input: 'apps/stage-tamagotchi/bundle/AIRI-1.2.3-windows-x64-setup.exe', - output: 'apps/stage-tamagotchi/bundle/latest.yml', - version: '1.2.3', - }) - - const expectedHashes = await hashFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe')) - expect(nextUpdateInfo).toMatchObject({ - version: '1.2.3', - path: 'AIRI-1.2.3-windows-x64-setup.exe', - sha512: expectedHashes.sha512, - sha2: expectedHashes.sha256, - releaseDate: '2026-01-02T03:04:05.000Z', - stagingPercentage: 25, - files: [ - { - url: 'AIRI-1.2.3-windows-x64-setup.exe', - sha512: expectedHashes.sha512, - }, - ], - }) - expect(nextUpdateInfo.files[0]?.size).toBe(Buffer.byteLength('signed-binary-content')) - - const persisted = yaml.parse(await readFile(join(bundleDir, 'latest.yml'), 'utf8')) - expect(persisted).toMatchObject(nextUpdateInfo) - }) - - it('also works with package-relative paths', async () => { - const root = await mkdtemp(join(tmpdir(), 'airi-regenerate-windows-latest-')) - const packageDir = join(root, 'apps', 'stage-tamagotchi') - const bundleDir = join(packageDir, 'bundle') - - await mkdir(bundleDir, { recursive: true }) - await writeFile(join(bundleDir, 'AIRI-9.9.9-windows-x64-setup.exe'), 'another-signed-binary', 'utf8') - process.chdir(packageDir) - - await regenerateWindowsLatest({ - input: 'bundle/AIRI-9.9.9-windows-x64-setup.exe', - output: 'bundle/latest.yml', - version: '9.9.9', - releaseDate: '2026-03-23T00:00:00.000Z', - }) - - const persisted = yaml.parse(await readFile(resolve(bundleDir, 'latest.yml'), 'utf8')) - const expectedHashes = await hashFile(join(bundleDir, 'AIRI-9.9.9-windows-x64-setup.exe')) - expect(persisted).toMatchObject({ - version: '9.9.9', - path: 'AIRI-9.9.9-windows-x64-setup.exe', - sha512: expectedHashes.sha512, - sha2: expectedHashes.sha256, - releaseDate: '2026-03-23T00:00:00.000Z', - files: [ - { - url: 'AIRI-9.9.9-windows-x64-setup.exe', - sha512: expectedHashes.sha512, - }, - ], - }) - }) -}) diff --git a/apps/stage-tamagotchi/scripts/regenerate-windows-latest.ts b/apps/stage-tamagotchi/scripts/regenerate-windows-latest.ts deleted file mode 100644 index 23b595e4a..000000000 --- a/apps/stage-tamagotchi/scripts/regenerate-windows-latest.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { createHash } from 'node:crypto' -import { createReadStream, existsSync } from 'node:fs' -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' -import { basename, dirname, resolve } from 'node:path' -import { cwd, exit } from 'node:process' - -import { findWorkspaceDir } from '@pnpm/find-workspace-dir' -import { cac } from 'cac' - -import * as yaml from 'yaml' - -interface UpdateFileInfo { - url: string - sha512: string - size?: number -} - -interface WindowsUpdateInfo { - version: string - files: UpdateFileInfo[] - path: string - sha512: string - sha2?: string - releaseDate?: string - [key: string]: unknown -} - -export async function hashFile(filePath: string): Promise<{ sha512: string, sha256: string }> { - return await new Promise((resolveHash, reject) => { - const sha512 = createHash('sha512') - const sha256 = createHash('sha256') - const stream = createReadStream(filePath) - - stream.on('data', (chunk) => { - sha512.update(chunk) - sha256.update(chunk) - }) - stream.on('error', reject) - stream.on('end', () => { - resolveHash({ - sha512: sha512.digest('base64'), - sha256: sha256.digest('hex'), - }) - }) - }) -} - -export async function readExistingUpdateInfo(filePath: string): Promise> { - try { - const raw = await readFile(filePath, 'utf8') - return (yaml.parse(raw) ?? {}) as Partial - } - catch { - return {} - } -} - -export async function resolveFromWorkspace(inputPath: string): Promise { - const resolved = resolve(inputPath) - if (existsSync(resolved)) { - return resolved - } - - const workspaceRoot = await findWorkspaceDir(cwd()) - if (workspaceRoot) { - const workspaceResolved = resolve(workspaceRoot, inputPath) - if (existsSync(workspaceResolved)) { - return workspaceResolved - } - } - - return resolved -} - -export interface RegenerateWindowsLatestOptions { - input: string - output: string - version: string - releaseDate?: string -} - -export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOptions): Promise { - const input = String(options.input || '').trim() - const output = String(options.output || '').trim() - const version = String(options.version || '').trim() - const releaseDate = String(options.releaseDate || '').trim() - - if (!input) { - throw new Error('--input is required') - } - if (!output) { - throw new Error('--output is required') - } - if (!version) { - throw new Error('--version is required') - } - - const inputPath = await resolveFromWorkspace(input) - const outputPath = await resolveFromWorkspace(output) - const fileStats = await stat(inputPath) - const { sha512, sha256 } = await hashFile(inputPath) - const existing = await readExistingUpdateInfo(outputPath) - const url = basename(inputPath) - - const nextUpdateInfo: WindowsUpdateInfo = { - ...existing, - version, - files: [ - { - url, - sha512, - size: fileStats.size, - }, - ], - path: url, - sha512, - sha2: sha256, - releaseDate: releaseDate || existing.releaseDate || new Date().toISOString(), - } - - await mkdir(dirname(outputPath), { recursive: true }) - await writeFile(outputPath, yaml.stringify(nextUpdateInfo), 'utf8') - - return nextUpdateInfo -} - -async function main() { - const cli = cac('regenerate-windows-latest') - .option('--input ', 'Signed Windows installer path', { type: [String] }) - .option('--output ', 'Output latest-x64.yml path', { default: 'bundle/latest-x64.yml' }) - .option('--version ', 'Version to write into latest-x64.yml', { type: [String] }) - .option('--release-date ', 'Release date to write into latest-x64.yml', { type: [String] }) - - const args = cli.parse() - - const input = String(args.options.input?.[0] || '').trim() - const output = String(args.options.output || '').trim() - const version = String(args.options.version?.[0] || '').trim() - const releaseDate = String(args.options.releaseDate?.[0] || '').trim() - - await regenerateWindowsLatest({ - input, - output, - version, - releaseDate, - }) -} - -if (import.meta.main) { - main().catch((error) => { - console.error(error) - exit(1) - }) -} diff --git a/apps/stage-tamagotchi/scripts/rename-artifacts.ts b/apps/stage-tamagotchi/scripts/rename-artifacts.ts deleted file mode 100644 index 3bd71b708..000000000 --- a/apps/stage-tamagotchi/scripts/rename-artifacts.ts +++ /dev/null @@ -1,95 +0,0 @@ -import process from 'node:process' - -import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs' -import { join } from 'node:path' - -import { cac } from 'cac' - -import packageJSON from '../package.json' assert { type: 'json' } - -import { getElectronBuilderConfig, getFilenames, getVersion } from './utils' - -async function main() { - const cli = cac('rename-artifact') - .option( - '--release', - 'Rename with version from package.json', - { default: false }, - ) - .option( - '--auto-tag', - 'Automatically tag the release with the latest git ref', - { default: false }, - ) - .option( - '--tag ', - 'Tag to use for the release', - { default: '', type: [String] }, - ) - - const args = cli.parse() - - let version = packageJSON.version - const electronBuilderConfig = await getElectronBuilderConfig() - const target = args.args[0] - const productName = electronBuilderConfig.productName - const dirname = import.meta.dirname - - const beforeVersion = version - const beforeProductName = productName - - const argOptions = args.options as { - release: boolean - autoTag: boolean - tag: string[] - } - - version = await getVersion(argOptions) - - console.info('target:', target) - console.info('dirname', dirname) - console.info('version from:', beforeVersion, 'to:', version) - console.info('product name from:', beforeProductName, 'to:', productName) - - if (!target) { - throw new Error(' is required') - } - - const srcPrefix = join(dirname, '..', 'dist') - console.info('source directory:', srcPrefix) - const bundlePrefix = join(dirname, '..', 'bundle') - console.info('bundle directory:', bundlePrefix) - - console.info('renaming directory from:', srcPrefix) - console.info('renaming directory to:', bundlePrefix) - console.info(readdirSync(srcPrefix)) - - mkdirSync(bundlePrefix, { recursive: true }) - - const filenames = await getFilenames(target, argOptions) - console.info(filenames, 'is the target filename') - - for (const filename of filenames) { - const renameFrom = join(srcPrefix, filename.outputFilename) - const renameTo = join(bundlePrefix, filename.releaseArtifactFilename) - console.info('renaming, from:', renameFrom, 'to:', renameTo) - if (!existsSync(renameFrom)) { - const message = `missing artifact: ${renameFrom}` - if (filename.optional) { - console.warn(message) - continue - } - throw new Error(message) - } - renameSync(renameFrom, renameTo) - } -} - -main() - .then(() => { - console.info('Renaming completed successfully.') - }) - .catch((error) => { - console.error('Error during renaming:', error) - process.exit(1) - }) diff --git a/apps/stage-tamagotchi/scripts/update-readme-download-links.ts b/apps/stage-tamagotchi/scripts/update-readme-download-links.ts deleted file mode 100644 index 5ff2ffac6..000000000 --- a/apps/stage-tamagotchi/scripts/update-readme-download-links.ts +++ /dev/null @@ -1,83 +0,0 @@ -import process from 'node:process' - -import { readdir, readFile, writeFile } from 'node:fs/promises' -import { resolve } from 'node:path' - -import { getFilenames } from './utils' - -const ROOT_DIR = resolve(import.meta.dirname, '..', '..', '..') -const DOCS_DIR = resolve(ROOT_DIR, 'docs') - -// GitHub releases download URLs -const GITHUB_WINDOWS_RE = /https:\/\/github\.com\/moeru-ai\/airi\/releases\/download\/v[^/]+\/AIRI-[^")\s]+-windows-x64-setup\.exe/g -const GITHUB_MACOS_RE = /https:\/\/github\.com\/moeru-ai\/airi\/releases\/download\/v[^/]+\/AIRI-[^")\s]+-darwin-arm64\.dmg/g - -// Aliyun OSS mirror download URLs (used by zh-CN README) -const OSS_WINDOWS_RE = /https:\/\/static-cn-proj-airi\.oss-cn-shanghai\.aliyuncs\.com\/artifacts\/apps\/desktop\/versions\/v[^/]+\/AIRI-[^")\s]+-windows-x64-setup\.exe/g -const OSS_MACOS_RE = /https:\/\/static-cn-proj-airi\.oss-cn-shanghai\.aliyuncs\.com\/artifacts\/apps\/desktop\/versions\/v[^/]+\/AIRI-[^")\s]+-darwin-arm64\.dmg/g - -async function main() { - const version = process.argv[2] - if (!version) { - console.error('Usage: tsx update-readme-download-links.ts ') - console.error('Example: tsx update-readme-download-links.ts v0.9.0-alpha.7') - process.exit(1) - } - - const cleanVersion = version.replace(/^v/, '') - const releaseOptions = { release: true, autoTag: false, tag: [cleanVersion] } - - const windowsFilenames = await getFilenames('x86_64-pc-windows-msvc', releaseOptions) - const macosFilenames = await getFilenames('aarch64-apple-darwin', releaseOptions) - - const windowsExe = windowsFilenames.find(f => f.extension === 'exe')?.releaseArtifactFilename - const macosDmg = macosFilenames.find(f => f.extension === 'dmg')?.releaseArtifactFilename - - if (!windowsExe || !macosDmg) { - console.error('Failed to determine artifact filenames') - process.exit(1) - } - - console.info(`Windows: ${windowsExe}`) - console.info(`macOS: ${macosDmg}`) - - function updateContent(content: string): string { - return content - .replace(GITHUB_WINDOWS_RE, `https://github.com/moeru-ai/airi/releases/download/v${cleanVersion}/${windowsExe}`) - .replace(GITHUB_MACOS_RE, `https://github.com/moeru-ai/airi/releases/download/v${cleanVersion}/${macosDmg}`) - .replace(OSS_WINDOWS_RE, `https://github.com/moeru-ai/airi/releases/download/v${cleanVersion}/${windowsExe}`) - .replace(OSS_MACOS_RE, `https://github.com/moeru-ai/airi/releases/download/v${cleanVersion}/${macosDmg}`) - } - - const readmeFiles: string[] = [ - resolve(ROOT_DIR, 'README.md'), - ] - - const docsFiles = await readdir(DOCS_DIR) - for (const file of docsFiles) { - if (file.startsWith('README') && file.endsWith('.md')) { - readmeFiles.push(resolve(DOCS_DIR, file)) - } - } - - let updatedCount = 0 - for (const filePath of readmeFiles) { - const content = await readFile(filePath, 'utf-8') - const updated = updateContent(content) - if (content !== updated) { - await writeFile(filePath, updated, 'utf-8') - console.info(`Updated: ${filePath}`) - updatedCount++ - } - else { - console.info(`No changes: ${filePath}`) - } - } - - console.info(`\nDone. Updated ${updatedCount} file(s).`) -} - -main().catch((error) => { - console.error('Error:', error) - process.exit(1) -}) diff --git a/apps/stage-tamagotchi/scripts/update-test/README.md b/apps/stage-tamagotchi/scripts/update-test/README.md deleted file mode 100644 index d189a4f7a..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# AIRI Electron Updater Local Test Harness - -This directory provides a local mocked update-server workflow for Stage Tamagotchi. - -It is intended to verify AIRI's updater path: - -- explicit `UPDATE_SERVER_URL` override mode -- lane switching (`stable`, `beta`, `alpha`, `nightly`) via `AIRI_UPDATE_CHANNEL` -- developer-only updater diagnostics inspection - -## Files - -- `generate-manifest.ts`: generate local `latest-*.yml` metadata and placeholder artifacts -- `start-server.ts`: serve generated fixtures over HTTP -- `setup.sh`: prepare the local fixture directories -- `run-test.sh`: thin orchestration wrapper -- `dev-app-update.local.yml`: optional generic-provider template for development-only experiments - -## Quick Start - -From the repo root: - -```bash -bash apps/stage-tamagotchi/scripts/update-test/setup.sh -pnpm -F @proj-airi/stage-tamagotchi update-test:generate \ - --root scripts/update-test/fixtures/server \ - --channel stable \ - --target aarch64-apple-darwin \ - --version 9.9.9-update-test.1 -pnpm -F @proj-airi/stage-tamagotchi update-test:server \ - --port 8787 \ - --root scripts/update-test/fixtures/server -``` - -Then, in another terminal: - -```bash -cd apps/stage-tamagotchi -UPDATE_SERVER_URL=http://127.0.0.1:8787/stable pnpm run dev -# optional lane override: -# AIRI_UPDATE_CHANNEL=beta UPDATE_SERVER_URL=http://127.0.0.1:8787/beta pnpm run dev -``` - -## Verification Flow - -1. Open the About page. -2. Click `Check for updates`. -3. Confirm the update becomes available. -4. Click `Download update`. -5. Confirm the updater reaches the `downloaded` state. -6. Open `Settings > System > Developer`. -7. Enable `Inspect updater diagnostics`. -8. Open `Devtools > Updater`. -9. Confirm: - - `overrideActive=true` - - `feedUrl` points to `http://127.0.0.1:8787/stable` - - `platform`, `arch`, and `channel` match the current runtime - -## Helper Wrapper - -You can also print the workflow commands with: - -```bash -bash apps/stage-tamagotchi/scripts/update-test/run-test.sh -``` - -For automated matrix checks (lane x runtime feed mode + bundle-version test matrix), run: - -```bash -pnpm -F @proj-airi/stage-tamagotchi update-test:matrix -``` - -This script: - -- runs Vitest updater matrix tests (including bundled version: stable/beta/alpha) -- generates local fixtures for `stable`, `beta`, `alpha`, `nightly` -- runs packaged app checks for two runtime modes: - - `UPDATE_SERVER_URL` override mode - - no override (GitHub lane resolution mode) -- captures logs and summaries under `scripts/update-test/artifacts/` -- writes a green/red matrix report at `scripts/update-test/artifacts//summary.md` - -Environment variables supported by the wrapper: - -- `PORT` -- `CHANNEL` -- `TARGET` -- `VERSION` -- `AIRI_UPDATE_CHANNEL` (at app launch time; independent from `CHANNEL`) -- `RUN_SECONDS` (matrix app runtime per case; default `18`) -- `LOG_DIR` (matrix artifact directory override) - -Common targets: - -- Apple Silicon macOS: `aarch64-apple-darwin` -- Intel macOS: `x86_64-apple-darwin` -- Windows x64: `x86_64-pc-windows-msvc` -- Linux x64: `x86_64-unknown-linux-gnu` - -## Notes - -- The generated artifact is a placeholder file meant for update discovery and early download flow verification. -- Real signed installer execution remains a separate manual verification step. -- The first pass is manual-first by design. A Playwright `_electron` smoke layer can be added on top later. -- When invoking the package scripts through `pnpm -F @proj-airi/stage-tamagotchi`, treat `--root` as relative to `apps/stage-tamagotchi`, not the workspace root. diff --git a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-darwin-arm64.dmg b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-darwin-arm64.dmg deleted file mode 100644 index 62b4b683b..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-darwin-arm64.dmg +++ /dev/null @@ -1 +0,0 @@ -mock-update-stable-9.9.9-update-test.1 \ No newline at end of file diff --git a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-windows-x64-setup.exe b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-windows-x64-setup.exe deleted file mode 100644 index 62b4b683b..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-windows-x64-setup.exe +++ /dev/null @@ -1 +0,0 @@ -mock-update-stable-9.9.9-update-test.1 \ No newline at end of file diff --git a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-arm64-mac.yml b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-arm64-mac.yml deleted file mode 100644 index d6827ca69..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-arm64-mac.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: 9.9.9-update-test.1 -files: - - url: AIRI-9.9.9-update-test.1-darwin-arm64.dmg - sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A== - size: 38 -path: AIRI-9.9.9-update-test.1-darwin-arm64.dmg -sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A== -releaseDate: 2026-04-05T17:39:00.050Z -releaseNotes: Mock update for AIRI local updater verification. diff --git a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-x64.yml b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-x64.yml deleted file mode 100644 index 7ffa1f7db..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-x64.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: 9.9.9-update-test.1 -files: - - url: AIRI-9.9.9-update-test.1-windows-x64-setup.exe - sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A== - size: 38 -path: AIRI-9.9.9-update-test.1-windows-x64-setup.exe -sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A== -releaseDate: 2026-04-05T14:51:10.149Z -releaseNotes: Mock update for AIRI local updater verification. diff --git a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts deleted file mode 100644 index e1bf2855b..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { mkdtemp, readFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { afterEach, describe, expect, it } from 'vitest' - -import * as yaml from 'yaml' - -import { generateManifestFixtures, resolveLatestFilenameForTarget } from './generate-manifest' - -describe('generateManifestFixtures', () => { - const roots: string[] = [] - - afterEach(async () => { - await Promise.all(roots.map(async (root) => { - await import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })) - })) - roots.length = 0 - }) - - it('maps targets to the expected latest-yml filenames', () => { - expect(resolveLatestFilenameForTarget('x86_64-pc-windows-msvc')).toBe('latest-x64.yml') - expect(resolveLatestFilenameForTarget('aarch64-apple-darwin')).toBe('latest-arm64-mac.yml') - expect(resolveLatestFilenameForTarget('x86_64-apple-darwin')).toBe('latest-x64-mac.yml') - expect(resolveLatestFilenameForTarget('x86_64-unknown-linux-gnu')).toBe('latest-x64-linux.yml') - expect(resolveLatestFilenameForTarget('aarch64-unknown-linux-gnu')).toBe('latest-arm64-linux-arm64.yml') - }) - - it('generates a channel directory, manifest, and placeholder artifact', async () => { - const root = await mkdtemp(join(tmpdir(), 'airi-update-test-')) - roots.push(root) - - const result = await generateManifestFixtures({ - rootDir: root, - channel: 'stable', - target: 'x86_64-pc-windows-msvc', - version: '9.9.9-test.1', - releaseNotes: 'Mock update for AIRI local updater verification.', - artifactContent: 'mock-installer-binary', - }) - - expect(result.channelDir).toBe(join(root, 'stable')) - expect(result.latestFilename).toBe('latest-x64.yml') - expect(result.artifactFilename).toBe('AIRI-9.9.9-test.1-windows-x64-setup.exe') - - const manifest = yaml.parse(await readFile(result.manifestPath, 'utf8')) - expect(manifest).toMatchObject({ - version: '9.9.9-test.1', - path: 'AIRI-9.9.9-test.1-windows-x64-setup.exe', - releaseNotes: 'Mock update for AIRI local updater verification.', - files: [ - { - url: 'AIRI-9.9.9-test.1-windows-x64-setup.exe', - }, - ], - }) - - expect(typeof manifest.sha512).toBe('string') - expect(typeof manifest.releaseDate).toBe('string') - expect(manifest.files[0]?.size).toBeGreaterThan(0) - await expect(readFile(result.artifactPath, 'utf8')).resolves.toBe('mock-installer-binary') - }) - - it.each(['stable', 'beta', 'alpha', 'nightly'] as const)('supports channel fixtures for %s', async (channel) => { - const root = await mkdtemp(join(tmpdir(), 'airi-update-test-')) - roots.push(root) - - const result = await generateManifestFixtures({ - rootDir: root, - channel, - target: 'aarch64-apple-darwin', - version: '9.9.9-test.2', - releaseNotes: 'Mock update lane fixture', - artifactContent: `mock-installer-${channel}`, - }) - - expect(result.channelDir).toBe(join(root, channel)) - expect(result.latestFilename).toBe('latest-arm64-mac.yml') - await expect(readFile(result.artifactPath, 'utf8')).resolves.toBe(`mock-installer-${channel}`) - }) -}) diff --git a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts deleted file mode 100644 index 891425d82..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { Buffer } from 'node:buffer' -import { createHash } from 'node:crypto' -import { mkdir, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { exit } from 'node:process' - -import { cac } from 'cac' - -import * as yaml from 'yaml' - -import { getFilenames } from '../utils' - -export type UpdateTestChannel = 'stable' | 'beta' | 'alpha' | 'nightly' | 'canary' - -export interface GenerateManifestFixturesOptions { - rootDir: string - channel: UpdateTestChannel - target: string - version: string - releaseNotes: string - artifactContent?: string -} - -export interface GenerateManifestFixturesResult { - channelDir: string - manifestPath: string - artifactPath: string - latestFilename: string - artifactFilename: string -} - -export function resolveLatestFilenameForTarget(target: string) { - switch (target) { - case 'x86_64-pc-windows-msvc': - return 'latest-x64.yml' - case 'x86_64-unknown-linux-gnu': - return 'latest-x64-linux.yml' - case 'aarch64-unknown-linux-gnu': - return 'latest-arm64-linux-arm64.yml' - case 'x86_64-apple-darwin': - return 'latest-x64-mac.yml' - case 'aarch64-apple-darwin': - return 'latest-arm64-mac.yml' - default: - throw new Error(`Unsupported update-test target: ${target}`) - } -} - -function encodeBase64Sha512(content: string) { - return createHash('sha512').update(content).digest('base64') -} - -async function resolveArtifactFilename(target: string, version: string) { - const filenames = await getFilenames(target, { - release: true, - autoTag: false, - tag: [version], - }) - - const artifact = filenames.find(entry => !entry.optional && entry.extension !== 'blockmap') - if (!artifact) - throw new Error(`Unable to determine artifact filename for target: ${target}`) - - return artifact.releaseArtifactFilename -} - -export async function generateManifestFixtures(options: GenerateManifestFixturesOptions): Promise { - const channelDir = join(options.rootDir, options.channel) - const latestFilename = resolveLatestFilenameForTarget(options.target) - const artifactFilename = await resolveArtifactFilename(options.target, options.version) - const manifestPath = join(channelDir, latestFilename) - const artifactPath = join(channelDir, artifactFilename) - const artifactContent = options.artifactContent ?? `mock-update-${options.channel}-${options.version}` - - await mkdir(dirname(manifestPath), { recursive: true }) - await writeFile(artifactPath, artifactContent, 'utf8') - - const sha512 = encodeBase64Sha512(artifactContent) - const releaseDate = new Date().toISOString() - const size = Buffer.byteLength(artifactContent) - - const manifest = { - version: options.version, - files: [ - { - url: artifactFilename, - sha512, - size, - }, - ], - path: artifactFilename, - sha512, - releaseDate, - releaseNotes: options.releaseNotes, - } - - await writeFile(manifestPath, yaml.stringify(manifest), 'utf8') - - return { - channelDir, - manifestPath, - artifactPath, - latestFilename, - artifactFilename, - } -} - -async function main() { - const cli = cac('generate-update-test-manifest') - .option('--root ', 'Root directory for generated server fixtures', { default: 'scripts/update-test/fixtures/server' }) - .option('--channel ', 'Channel to generate', { default: 'stable' }) - .option('--target ', 'Target triple to generate fixtures for', { default: 'x86_64-pc-windows-msvc' }) - .option('--version ', 'Version to publish in the generated manifest', { default: '9.9.9-update-test.1' }) - .option('--release-notes ', 'Release notes content', { default: 'Mock update for AIRI local updater verification.' }) - - const parsed = cli.parse() - const result = await generateManifestFixtures({ - rootDir: String(parsed.options.root), - channel: String(parsed.options.channel) as UpdateTestChannel, - target: String(parsed.options.target), - version: String(parsed.options.version), - releaseNotes: String(parsed.options.releaseNotes), - }) - - // eslint-disable-next-line no-console - console.log(`Generated ${result.latestFilename} in ${result.channelDir}`) - // eslint-disable-next-line no-console - console.log(`Artifact: ${result.artifactFilename}`) -} - -if (import.meta.main) { - main().catch((error) => { - console.error(error) - exit(1) - }) -} diff --git a/apps/stage-tamagotchi/scripts/update-test/run-matrix.sh b/apps/stage-tamagotchi/scripts/update-test/run-matrix.sh deleted file mode 100755 index 0f054e14e..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/run-matrix.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" -APP_BIN="${APP_DIR}/dist/mac-arm64/airi.app/Contents/MacOS/airi" - -PORT="${PORT:-8787}" -RUN_SECONDS="${RUN_SECONDS:-18}" -LOG_DIR="${LOG_DIR:-${SCRIPT_DIR}/artifacts/matrix-$(date +%Y%m%d-%H%M%S)}" -SUMMARY_TSV="${LOG_DIR}/summary.tsv" -SUMMARY_MD="${LOG_DIR}/summary.md" - -LANES=(stable beta alpha nightly) -RUNTIME_MODES=(override github) - -mkdir -p "${LOG_DIR}" -printf "mode\tlane\tstatus\treason\tsummary\n" > "${SUMMARY_TSV}" - -if [[ ! -x "${APP_BIN}" ]]; then - echo "Packaged app not found: ${APP_BIN}" - echo "Build first: rm -rf apps/stage-tamagotchi/dist && pnpm -F @proj-airi/stage-tamagotchi build:mac" - exit 1 -fi - -cd "${REPO_ROOT}" - -echo "==> Running updater matrix unit tests (includes bundle-version matrix)" -pnpm exec vitest run \ - apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts \ - apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts - -echo "==> Preparing fixture directories" -bash "${SCRIPT_DIR}/setup.sh" - -echo "==> Generating local update fixtures for lanes: ${LANES[*]}" -for lane in "${LANES[@]}"; do - pnpm -F @proj-airi/stage-tamagotchi update-test:generate \ - --root scripts/update-test/fixtures/server \ - --channel "${lane}" \ - --target aarch64-apple-darwin \ - --version "9.9.9-${lane}.1" \ - --release-notes "mock ${lane}" -done - -echo "==> Starting local update-test server on port ${PORT}" -pnpm -F @proj-airi/stage-tamagotchi update-test:server \ - --port "${PORT}" \ - --root scripts/update-test/fixtures/server \ - > "${LOG_DIR}/server.log" 2>&1 & -SERVER_PID=$! - -cleanup() { - kill "${SERVER_PID}" >/dev/null 2>&1 || true -} -trap cleanup EXIT - -for lane in "${LANES[@]}"; do - for _ in {1..40}; do - if curl -fsS "http://127.0.0.1:${PORT}/${lane}/latest-arm64-mac.yml" >/dev/null 2>&1; then - break - fi - sleep 0.2 - done -done - -run_case() { - local mode="$1" - local lane="$2" - local log_file="${LOG_DIR}/${mode}-${lane}.log" - local app_pid="" - - echo "==> Running mode=${mode}, lane=${lane}" - if [[ "${mode}" == "override" ]]; then - UPDATE_SERVER_URL="http://127.0.0.1:${PORT}/${lane}" AIRI_UPDATE_CHANNEL="${lane}" "${APP_BIN}" > "${log_file}" 2>&1 & - app_pid=$! - else - AIRI_UPDATE_CHANNEL="${lane}" "${APP_BIN}" > "${log_file}" 2>&1 & - app_pid=$! - fi - - sleep "${RUN_SECONDS}" - kill "${app_pid}" >/dev/null 2>&1 || true - for _ in {1..20}; do - if ! kill -0 "${app_pid}" >/dev/null 2>&1; then - break - fi - sleep 0.2 - done - if kill -0 "${app_pid}" >/dev/null 2>&1; then - kill -KILL "${app_pid}" >/dev/null 2>&1 || true - fi - wait "${app_pid}" 2>/dev/null || true - - local matched - local status="GREEN" - local reason="ok" - matched="$(rg -n "auto-updater|applied generic feed override|checkForUpdates\\(\\) failed|No published versions on GitHub|update-available|update-not-available" "${log_file}" || true)" - if [[ -z "${matched}" ]]; then - status="RED" - reason="no-updater-log" - echo " [warn] no updater logs matched in ${log_file}" - else - echo "${matched}" > "${LOG_DIR}/${mode}-${lane}.summary.log" - if rg -q "checkForUpdates\\(\\) failed|No published versions on GitHub|No GitHub release found|Cannot find channel|HttpError: 404|\\[error\\]" "${LOG_DIR}/${mode}-${lane}.summary.log"; then - status="RED" - reason="updater-error" - fi - echo " [ok] summary: ${LOG_DIR}/${mode}-${lane}.summary.log" - fi - printf "%s\t%s\t%s\t%s\t%s\n" "${mode}" "${lane}" "${status}" "${reason}" "${LOG_DIR}/${mode}-${lane}.summary.log" >> "${SUMMARY_TSV}" -} - -for mode in "${RUNTIME_MODES[@]}"; do - for lane in "${LANES[@]}"; do - run_case "${mode}" "${lane}" - done -done - -{ - echo "| mode | lane | status | reason | summary |" - echo "|---|---|---|---|---|" - tail -n +2 "${SUMMARY_TSV}" | while IFS=$'\t' read -r mode lane status reason summary; do - echo "| ${mode} | ${lane} | ${status} | ${reason} | ${summary} |" - done -} > "${SUMMARY_MD}" - -echo -echo "Matrix run complete." -echo "Artifacts:" -echo "- ${LOG_DIR}" -echo "- ${LOG_DIR}/server.log" -echo "- ${LOG_DIR}/*.summary.log" -echo "- ${SUMMARY_MD}" diff --git a/apps/stage-tamagotchi/scripts/update-test/run-test.sh b/apps/stage-tamagotchi/scripts/update-test/run-test.sh deleted file mode 100755 index 559daf864..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/run-test.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" -PORT="${PORT:-8787}" -CHANNEL="${CHANNEL:-stable}" -TARGET="${TARGET:-x86_64-pc-windows-msvc}" -VERSION="${VERSION:-9.9.9-update-test.1}" - -bash "${SCRIPT_DIR}/setup.sh" - -pnpm exec tsx "${SCRIPT_DIR}/generate-manifest.ts" \ - --root "${SCRIPT_DIR}/fixtures/server" \ - --channel "${CHANNEL}" \ - --target "${TARGET}" \ - --version "${VERSION}" - -echo -echo "Start the local update server in another terminal:" -echo "pnpm exec tsx ${SCRIPT_DIR}/start-server.ts --port ${PORT} --root ${SCRIPT_DIR}/fixtures/server" -echo -echo "Launch AIRI against the mocked update server:" -echo "cd ${APP_DIR}" -echo "UPDATE_SERVER_URL=http://127.0.0.1:${PORT}/${CHANNEL} pnpm run dev" -echo -echo "Verify:" -echo "1. About page shows an available update." -echo "2. Download reaches the downloaded state." -echo "3. Settings > System > Developer enables updater diagnostics." -echo "4. Devtools > Updater shows overrideActive=true and the local feed URL." diff --git a/apps/stage-tamagotchi/scripts/update-test/setup.sh b/apps/stage-tamagotchi/scripts/update-test/setup.sh deleted file mode 100755 index 24703880c..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/setup.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -mkdir -p "${SCRIPT_DIR}/fixtures/server/stable" -mkdir -p "${SCRIPT_DIR}/fixtures/server/beta" -mkdir -p "${SCRIPT_DIR}/fixtures/server/alpha" -mkdir -p "${SCRIPT_DIR}/fixtures/server/nightly" -mkdir -p "${SCRIPT_DIR}/fixtures/server/canary" - -chmod +x "${SCRIPT_DIR}/setup.sh" "${SCRIPT_DIR}/run-test.sh" "${SCRIPT_DIR}/run-matrix.sh" - -echo "Prepared update-test fixtures in ${SCRIPT_DIR}/fixtures/server" diff --git a/apps/stage-tamagotchi/scripts/update-test/start-server.ts b/apps/stage-tamagotchi/scripts/update-test/start-server.ts deleted file mode 100644 index 8b8d054d4..000000000 --- a/apps/stage-tamagotchi/scripts/update-test/start-server.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* eslint-disable no-console */ -import process, { exit } from 'node:process' - -import { readFile } from 'node:fs/promises' -import { createServer } from 'node:http' -import { extname, join, normalize } from 'node:path' - -import { cac } from 'cac' - -const CONTENT_TYPES: Record = { - '.exe': 'application/vnd.microsoft.portable-executable', - '.yml': 'text/yaml; charset=utf-8', - '.yaml': 'text/yaml; charset=utf-8', - '.zip': 'application/zip', - '.dmg': 'application/octet-stream', - '.deb': 'application/vnd.debian.binary-package', - '.rpm': 'application/x-rpm', - '.txt': 'text/plain; charset=utf-8', -} - -function getContentType(pathname: string) { - return CONTENT_TYPES[extname(pathname)] ?? 'application/octet-stream' -} - -export async function startUpdateTestServer(options: { port: number, rootDir: string }) { - const server = createServer(async (request, response) => { - const pathname = request.url?.split('?')[0] || '/' - - const safePath = normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, '') - const filePath = join(options.rootDir, safePath === '/' ? '/index.html' : safePath) - - try { - const body = await readFile(filePath) - response.writeHead(200, { 'content-type': getContentType(filePath) }) - response.end(body) - } - catch { - response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) - response.end(`Not found: ${pathname}`) - } - }) - - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(options.port, '127.0.0.1', () => resolve()) - }) - - return server -} - -async function main() { - const cli = cac('update-test-server') - .option('--port ', 'Port to listen on', { default: '8787' }) - .option('--root ', 'Server root directory', { default: 'scripts/update-test/fixtures/server' }) - - const parsed = cli.parse() - const port = Number(parsed.options.port) - const rootDir = String(parsed.options.root) - - const server = await startUpdateTestServer({ port, rootDir }) - - console.log(`Update test server listening on http://127.0.0.1:${port}`) - console.log(`stable: http://127.0.0.1:${port}/stable`) - console.log(`nightly: http://127.0.0.1:${port}/nightly`) - console.log(`canary: http://127.0.0.1:${port}/canary`) - - const close = async () => { - await new Promise((resolve, reject) => { - server.close(error => error ? reject(error) : resolve()) - }) - exit(0) - } - - process.on('SIGINT', () => { - void close() - }) - process.on('SIGTERM', () => { - void close() - }) -} - -if (import.meta.main) { - main().catch((error) => { - console.error(error) - exit(1) - }) -} diff --git a/apps/stage-tamagotchi/scripts/utils.ts b/apps/stage-tamagotchi/scripts/utils.ts deleted file mode 100644 index 5e9699b00..000000000 --- a/apps/stage-tamagotchi/scripts/utils.ts +++ /dev/null @@ -1,486 +0,0 @@ -import type { Configuration } from 'electron-builder' - -import process from 'node:process' - -import { x } from 'tinyexec' - -import packageJSON from '../package.json' with { type: 'json' } - -export async function getVersion(options: { release: boolean, autoTag: boolean, tag: string[] }) { - if (!options.release || !options.tag) { - // Otherwise, fetch from the latest git ref - const res = await x('git', ['log', '-1', '--pretty=format:"%H"']) - - const date = new Date().toISOString().split('T')[0].replace(/-/g, '') - - return `nightly-${date}-${String(res.stdout.replace(/"/g, '')).trim().substring(0, 7)}` - } - - // If --release is specified, use the version from package.json - let version = packageJSON.version - - // If --tag is specified, use the provided tag - if (options.tag[0] !== 'true') { - version = String(options.tag[0]).replace(/^v/, '').trim() - } - // Otherwise, even for --tag option (true / enabled), ignore the input - else { - version = '' - } - - if (version) { - return version - } - - // If no version is provided and --auto-tag is not specified, throw an error - if (!options.autoTag) { - throw new Error('Tag cannot be empty when --release is specified') - } - - // Now, only auto-tag & release && non-specific tag is the only possibility, - // fetch the latest git ref - try { - const res = await x('git', ['describe', '--tags', '--abbrev=0']) - - return String(res.stdout).replace(/^v/, '').trim() - } - catch { - // If no tags exist, fall back to package.json version - console.warn('No git tags found, falling back to package.json version') - return packageJSON.version - } -} - -export async function getElectronBuilderConfig(): Promise { - const config = await import ('../electron-builder.config') - return config.default -} - -export function applyTemplateOfArtifactName( - template: string, - productName: string, - version: string, - arch: string, - ext: string, -): string { - return template - // eslint-disable-next-line no-template-curly-in-string - .replace('${productName}', productName) - // eslint-disable-next-line no-template-curly-in-string - .replace('${version}', version) - // eslint-disable-next-line no-template-curly-in-string - .replace('${arch}', arch) - // eslint-disable-next-line no-template-curly-in-string - .replace('${ext}', ext) -} - -interface FilenameOutputEntry { - target: string - extension: string - outputFilename: string - releaseArtifactFilename: string - productName: string - version: string - optional?: boolean -} - -export function mapArchFor( - target: string, - ext: string, -): string { - switch (true) { - case target === 'aarch64-unknown-linux-gnu': - if (ext === 'rpm') { - return 'aarch64' - } - if (ext === 'deb') { - return 'arm64' - } - - return 'arm64' - case target === 'x86_64-unknown-linux-gnu': - if (ext === 'rpm') { - return 'x86_64' - } - if (ext === 'deb') { - return 'amd64' - } - - return 'x64' - case target === 'aarch64-apple-darwin': - return 'arm64' - case target === 'x86_64-apple-darwin': - return 'x64' - case target === 'x86_64-pc-windows-msvc': - return 'x64' - default: - return 'x64' - } -} - -function getLatestUpdateFilename(target: string): string | null { - switch (target) { - case 'x86_64-pc-windows-msvc': - return `latest-${mapArchFor(target, 'yml')}.yml` - case 'x86_64-unknown-linux-gnu': - return `latest-${mapArchFor(target, 'yml')}-linux.yml` - case 'aarch64-unknown-linux-gnu': - return `latest-${mapArchFor(target, 'yml')}-linux-${mapArchFor(target, 'yml')}.yml` - case 'aarch64-apple-darwin': - case 'x86_64-apple-darwin': - return `latest-${mapArchFor(target, 'yml')}-mac.yml` - default: - return null - } -} - -function getMacZipFilename(productName: string, version: string, target: string): string { - const arch = mapArchFor(target, 'zip') - const archPrefix = arch === 'x64' ? '' : `${arch}-` - return `${productName}-${version}-${archPrefix}mac.zip` -} - -export async function getFilenames(target: string, options: { release: boolean, autoTag: boolean, tag: string[] }): Promise { - const electronBuilder = await getElectronBuilderConfig() - const version = await getVersion(options) - - if (!target) { - throw new Error(' is required') - } - - const beforeVersion = packageJSON.version - const productName = electronBuilder.productName! - - switch (target) { - case 'x86_64-pc-windows-msvc': - - return [ - { - target: 'x86_64-pc-windows-msvc', - extension: 'exe', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.nsis!.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'exe'), - 'exe', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.nsis!.artifactName!, - productName, - version, - mapArchFor(target, 'exe'), - 'exe', - ), - productName, - version, - }, - { - target: 'x86_64-pc-windows-msvc', - extension: getLatestUpdateFilename(target)!, - outputFilename: getLatestUpdateFilename(target)!, - releaseArtifactFilename: getLatestUpdateFilename(target)!, - productName, - version, - optional: true, - }, - ] - case 'x86_64-unknown-linux-gnu': - { - const artifacts: FilenameOutputEntry[] = [] - if (electronBuilder.linux?.artifactName) { - if ( - (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb')) - || electronBuilder.linux.target === 'deb' - ) { - artifacts.push( - { - target: 'x86_64-unknown-linux-gnu', - extension: 'deb', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'deb'), - 'deb', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'deb'), - 'deb', - ), - productName, - version, - }, - ) - } - - if ( - (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm')) - || electronBuilder.linux.target === 'rpm' - ) { - artifacts.push( - { - target: 'x86_64-unknown-linux-gnu', - extension: 'rpm', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'rpm'), - 'rpm', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'rpm'), - 'rpm', - ), - productName, - version, - }, - ) - } - - // Flatpak artifact (built outside electron-builder, but we follow linux template) - artifacts.push( - { - target: 'x86_64-unknown-linux-gnu', - extension: 'flatpak', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'flatpak'), - 'flatpak', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'flatpak'), - 'flatpak', - ), - productName, - version, - }, - ) - } - - const latestUpdateFilename = getLatestUpdateFilename(target) - if (latestUpdateFilename) { - artifacts.push({ - target: 'x86_64-unknown-linux-gnu', - extension: latestUpdateFilename, - outputFilename: latestUpdateFilename, - releaseArtifactFilename: latestUpdateFilename, - productName, - version, - optional: true, - }) - } - - return artifacts - } - case 'aarch64-unknown-linux-gnu': - { - const artifacts: FilenameOutputEntry[] = [] - if (electronBuilder.linux?.artifactName) { - if ( - (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb')) - || electronBuilder.linux.target === 'deb' - ) { - artifacts.push( - { - target: 'aarch64-unknown-linux-gnu', - extension: 'deb', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'deb'), - 'deb', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'deb'), - 'deb', - ), - productName, - version, - }, - ) - } - - if ( - (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm')) - || electronBuilder.linux.target === 'rpm' - ) { - artifacts.push( - { - target: 'aarch64-unknown-linux-gnu', - extension: 'rpm', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'rpm'), - 'rpm', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'rpm'), - 'rpm', - ), - productName, - version, - }, - ) - } - - // Flatpak artifact (built outside electron-builder, but we follow linux template) - artifacts.push( - { - target: 'aarch64-unknown-linux-gnu', - extension: 'flatpak', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'flatpak'), - 'flatpak', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'flatpak'), - 'flatpak', - ), - productName, - version, - }, - ) - } - - const latestUpdateFilename = getLatestUpdateFilename(target) - if (latestUpdateFilename) { - artifacts.push({ - target: 'aarch64-unknown-linux-gnu', - extension: latestUpdateFilename, - outputFilename: latestUpdateFilename, - releaseArtifactFilename: latestUpdateFilename, - productName, - version, - optional: true, - }) - } - - return artifacts - } - case 'aarch64-apple-darwin': - { - const artifacts: FilenameOutputEntry[] = [ - { - target: 'aarch64-apple-darwin', - extension: 'dmg', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.dmg!.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'dmg'), - 'dmg', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.dmg!.artifactName!, - productName, - version, - mapArchFor(target, 'dmg'), - 'dmg', - ), - productName, - version, - }, - ] - - artifacts.push( - { - target: 'aarch64-apple-darwin', - extension: 'zip', - outputFilename: getMacZipFilename(productName, beforeVersion, target), - releaseArtifactFilename: getMacZipFilename(productName, version, target), - productName, - version, - }, - { - target: 'aarch64-apple-darwin', - extension: getLatestUpdateFilename(target)!, - outputFilename: getLatestUpdateFilename(target)!, - releaseArtifactFilename: getLatestUpdateFilename(target)!, - productName, - version, - optional: true, - }, - ) - - return artifacts - } - case 'x86_64-apple-darwin': - { - const artifacts: FilenameOutputEntry[] = [ - { - target: 'x86_64-apple-darwin', - extension: 'dmg', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.dmg!.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'dmg'), - 'dmg', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.dmg!.artifactName!, - productName, - version, - mapArchFor(target, 'dmg'), - 'dmg', - ), - productName, - version, - }, - ] - - artifacts.push( - { - target: 'x86_64-apple-darwin', - extension: 'zip', - outputFilename: getMacZipFilename(productName, beforeVersion, target), - releaseArtifactFilename: getMacZipFilename(productName, version, target), - productName, - version, - }, - { - target: 'x86_64-apple-darwin', - extension: getLatestUpdateFilename(target)!, - outputFilename: getLatestUpdateFilename(target)!, - releaseArtifactFilename: getLatestUpdateFilename(target)!, - productName, - version, - optional: true, - }, - ) - - return artifacts - } - default: - console.error('Target is not supported') - process.exit(1) - } -} diff --git a/apps/stage-tamagotchi/src/main/app/debugger.ts b/apps/stage-tamagotchi/src/main/app/debugger.ts deleted file mode 100644 index 1b1ec1334..000000000 --- a/apps/stage-tamagotchi/src/main/app/debugger.ts +++ /dev/null @@ -1,62 +0,0 @@ -import http from 'node:http' - -import { env } from 'node:process' - -import { app, shell } from 'electron' - -/** Enables Electron's CDP endpoint before the app ready event. */ -export function setupDebugger() { - if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) { - const remoteDebugPort = Number(env.APP_REMOTE_DEBUG_PORT || '9222') - if (Number.isNaN(remoteDebugPort) || !Number.isInteger(remoteDebugPort) || remoteDebugPort < 0 || remoteDebugPort > 65535) { - throw new Error(`Invalid remote debug port: ${env.APP_REMOTE_DEBUG_PORT}`) - } - - app.commandLine.appendSwitch('remote-debugging-port', String(remoteDebugPort)) - app.commandLine.appendSwitch('remote-allow-origins', `http://localhost:${remoteDebugPort}`) - } -} - -/** - * Opens the inspector for the first available Electron renderer target. - * - * Developers may keep CDP enabled without opening the system browser by - * setting `APP_REMOTE_DEBUG_NO_OPEN=true`. - */ -export function openDebugger() { - if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) { - const remoteDebugEndpoint = `http://localhost:${env.APP_REMOTE_DEBUG_PORT || '9222'}` - - http.get(`${remoteDebugEndpoint}/json`, (res) => { - let data = '' - res.on('data', chunk => data += chunk) - res.on('end', () => { - try { - const targets = JSON.parse(data) - if (targets.length <= 0) { - console.warn('[Remote Debugging] No targets found') - return - } - - let wsUrl = targets[0].webSocketDebuggerUrl - if (!wsUrl.startsWith('ws://')) { - console.warn('[Remote Debugging] Invalid WebSocket URL:', wsUrl) - return - } - - wsUrl = wsUrl.substring(5) - const inspectorUrl = `${remoteDebugEndpoint}/devtools/inspector.html?ws=${wsUrl}` - console.info(`Inspect remotely: ${inspectorUrl}`) - - if (!/^true$/i.test(env.APP_REMOTE_DEBUG_NO_OPEN || '')) - void shell.openExternal(inspectorUrl) - } - catch (err) { - console.error('[Remote Debugging] Failed to parse metadata from /json:', err) - } - }) - }).on('error', (err) => { - console.error('[Remote Debugging] Failed to fetch metadata from /json:', err) - }) - } -} diff --git a/apps/stage-tamagotchi/src/main/app/file-logger.ts b/apps/stage-tamagotchi/src/main/app/file-logger.ts deleted file mode 100644 index 143228be0..000000000 --- a/apps/stage-tamagotchi/src/main/app/file-logger.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * File Logger for Electron Main Process - * - * Sets up file-based logging by creating timestamped log files in the userData directory. - * - * Log file naming: airi-tamagotchi-{timestamp}.log - * - No rotation needed due to unique timestamp per session - * - Unique timestamp per session avoids cross-process log file sharing - * - Easy to identify and debug specific sessions - * - * @example - * ```typescript - * const fileLogger = await setupFileLogger() - * setGlobalFormat(Format.Pretty) - * setGlobalLogLevel(LogLevel.Log) - * - * setGlobalHookPostLog((log, formatted) => { - * if (fileLogger.logFileFd !== null) { - * void fileLogger.appendLog(formatted) - * } - * }) - * ``` - */ - -import { mkdir, open, stat } from 'node:fs/promises' -import { join } from 'node:path' - -import { errorMessageFromValue } from '@proj-airi/stage-shared' -import { app } from 'electron' - -// ============================================================================ -// Constants -// ============================================================================ - -const LOG_FILE_PREFIX = 'airi-tamagotchi' - -// ============================================================================ -// Public Types -// ============================================================================ - -/** - * Handle for the file logger, providing access to the log file and append operations. - */ -export interface FileLoggerHandle { - /** Path to the current session's log file, or null if initialization failed */ - logFilePath: string | null - /** File descriptor for the current session's log file, or null if initialization failed */ - logFileFd: number | null - /** - * Appends a log entry to the file. - * @param content - The formatted log content to append - */ - appendLog: (content: string) => Promise - /** - * Closes the log file and releases resources. - */ - close: () => Promise -} - -export const nullFileLoggerHandle: FileLoggerHandle = { - logFilePath: null, - logFileFd: null, - appendLog: async () => {}, - close: async () => {}, -} - -// ============================================================================ -// Internal Functions -// ============================================================================ - -/** - * Extracts a human-readable error message from an unknown error object. - */ -function getErrorMessage(error: unknown): string { - return errorMessageFromValue(error) -} - -/** - * Generates the log file path for the current session. - * Format: {userData}/logs/airi-tamagotchi-{timestamp}.log - */ -function createLogFilePath(logsDir: string, timestamp: number): string { - return join(logsDir, `${LOG_FILE_PREFIX}-${timestamp}.log`) -} - -/** - * Ensures the logs directory exists. - * Returns the logs directory path if successful, null otherwise. - */ -async function ensureLogsDirectory(): Promise { - try { - const logsDir = join(app.getPath('userData'), 'logs') - await mkdir(logsDir, { recursive: true }) - return logsDir - } - catch (error) { - const message = getErrorMessage(error) - console.error(`[FileLogger] Failed to create logs directory: ${message}`) - return null - } -} - -/** - * Checks if the current log file exists and returns its size. - */ -async function getLogFileSize(filePath: string): Promise { - try { - const stats = await stat(filePath) - return stats.size - } - catch { - return null - } -} - -// ============================================================================ -// Public API -// ============================================================================ - -/** - * Sets up the file logger by creating a timestamped log file. - * - * Returns a {@link FileLoggerHandle} that provides: - * - `logFilePath`: Path to the log file (null if failed) - * - `logFileFd`: File descriptor (null if failed) - * - `appendLog(content)`: Async function to append logs to the file - * - `close()`: Async function to close the file - * - * Note: This function only creates the file. The caller is responsible for - * registering the `setGlobalHookPostLog` hook from @guiiai/logg. - */ -export async function setupFileLogger(): Promise { - const timestamp = Date.now() - - const logsDir = await ensureLogsDirectory() - if (!logsDir) { - return nullFileLoggerHandle - } - - const logFilePath = createLogFilePath(logsDir, timestamp) - - try { - const fileHandle = await open(logFilePath, 'a') - const logFileFd = fileHandle.fd - let isFileClosed = false - - // Write initialization message - const sessionStartMessage = `[FileLogger] Initialized - logging to: ${logFilePath}\n` - await fileHandle.appendFile(sessionStartMessage) - - console.info(`[FileLogger] Session logs: ${logFilePath}`) - - async function appendLog(content: string) { - if (isFileClosed) { - return - } - - const normalizedContent = content.endsWith('\n') ? content : `${content}\n` - - try { - await fileHandle.appendFile(normalizedContent) - } - catch (error) { - const message = getErrorMessage(error) - console.error(`[FileLogger] Failed to write log: ${message}`) - } - } - - async function close() { - if (isFileClosed) { - return - } - - try { - await fileHandle.close() - isFileClosed = true - console.info('[FileLogger] File closed successfully') - } - catch (error) { - const message = getErrorMessage(error) - console.error(`[FileLogger] Failed to close log file: ${message}`) - } - - const size = await getLogFileSize(logFilePath) - const sizeInfo = size !== null ? ` (${(size / 1024).toFixed(2)} KB)` : '' - console.info(`[FileLogger] Session log file: ${logFilePath}${sizeInfo}`) - } - - return { logFilePath, logFileFd, appendLog, close } - } - catch (error) { - const message = getErrorMessage(error) - console.error(`[FileLogger] Failed to create log file - logging to console only: ${message}`) - return nullFileLoggerHandle - } -} diff --git a/apps/stage-tamagotchi/src/main/app/ozone.test.ts b/apps/stage-tamagotchi/src/main/app/ozone.test.ts deleted file mode 100644 index 664e9d584..000000000 --- a/apps/stage-tamagotchi/src/main/app/ozone.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { resolveIsWayland } from './ozone' - -describe('resolveIsWayland', () => { - it('resolves to true when --ozone-platform is explicitly wayland', () => { - expect(resolveIsWayland({ - explicitOzonePlatform: 'wayland', - env: {}, - })).toBe(true) - }) - - it('resolves to false when --ozone-platform is explicitly x11 even in Wayland environment', () => { - expect(resolveIsWayland({ - explicitOzonePlatform: 'x11', - env: { - WAYLAND_DISPLAY: 'wayland-0', - XDG_SESSION_TYPE: 'wayland', - }, - })).toBe(false) - }) - - it('treats --ozone-platform=auto as unresolved and falls back to environment', () => { - expect(resolveIsWayland({ - explicitOzonePlatform: 'auto', - env: { - WAYLAND_DISPLAY: 'wayland-0', - }, - })).toBe(true) - - expect(resolveIsWayland({ - explicitOzonePlatform: 'auto', - env: { - XDG_SESSION_TYPE: 'x11', - }, - })).toBe(false) - }) - - it('resolves based on --ozone-platform-hint when not auto', () => { - expect(resolveIsWayland({ - ozonePlatformHint: 'wayland', - env: {}, - })).toBe(true) - - expect(resolveIsWayland({ - ozonePlatformHint: 'x11', - env: { - WAYLAND_DISPLAY: 'wayland-0', - }, - })).toBe(false) - }) - - it('treats --ozone-platform-hint=auto as unresolved and falls back to environment', () => { - expect(resolveIsWayland({ - ozonePlatformHint: 'auto', - env: { - WAYLAND_DISPLAY: 'wayland-0', - }, - })).toBe(true) - - expect(resolveIsWayland({ - ozonePlatformHint: 'auto', - env: {}, - })).toBe(false) - }) - - it('falls back to environment variables when no flags are present', () => { - expect(resolveIsWayland({ - env: { - WAYLAND_DISPLAY: 'wayland-0', - }, - })).toBe(true) - - expect(resolveIsWayland({ - env: { - XDG_SESSION_TYPE: 'wayland', - }, - })).toBe(true) - - expect(resolveIsWayland({ - env: { - XDG_SESSION_TYPE: 'x11', - }, - })).toBe(false) - - expect(resolveIsWayland({ - env: {}, - })).toBe(false) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/app/ozone.ts b/apps/stage-tamagotchi/src/main/app/ozone.ts deleted file mode 100644 index fe56ebbab..000000000 --- a/apps/stage-tamagotchi/src/main/app/ozone.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Resolves whether the application is running under the Wayland Ozone backend. - * - * Checks explicit command-line switches before falling back to session environment variables. - * Treats 'auto' as an unresolved platform selection and resolves it using session environment variables. - */ -export function resolveIsWayland(params: { - explicitOzonePlatform?: string - ozonePlatformHint?: string - env?: Record -}): boolean { - if (params.explicitOzonePlatform && params.explicitOzonePlatform !== 'auto') { - return params.explicitOzonePlatform === 'wayland' - } - - if (params.ozonePlatformHint && params.ozonePlatformHint !== 'auto') { - return params.ozonePlatformHint === 'wayland' - } - - return Boolean(params.env?.WAYLAND_DISPLAY || params.env?.XDG_SESSION_TYPE === 'wayland') -} diff --git a/apps/stage-tamagotchi/src/main/app/single-instance.test.ts b/apps/stage-tamagotchi/src/main/app/single-instance.test.ts deleted file mode 100644 index 24097c682..000000000 --- a/apps/stage-tamagotchi/src/main/app/single-instance.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { App, BrowserWindow } from 'electron' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const windowMock = vi.hoisted(() => ({ - toggleWindowShow: vi.fn(), -})) - -vi.mock('../windows/shared/window', () => ({ - toggleWindowShow: windowMock.toggleWindowShow, -})) - -function createMockApp(hasSingleInstanceLock: boolean): MockApp { - return { - on: vi.fn(), - quit: vi.fn(), - requestSingleInstanceLock: vi.fn(() => hasSingleInstanceLock), - } as unknown as MockApp -} - -function createMockWindow() { - return {} as BrowserWindow -} - -describe('installSingleInstanceGuard', async () => { - const { installSingleInstanceGuard } = await import('./single-instance') - - beforeEach(() => { - vi.clearAllMocks() - }) - - /** - * @example - * const installed = installSingleInstanceGuard({ app, getWindow }) - * expect(installed).toBe(false) - */ - it('quits the secondary process when another AIRI instance already owns the lock', () => { - const app = createMockApp(false) - - const installed = installSingleInstanceGuard({ - app, - getWindow: vi.fn(() => undefined), - }) - - expect(installed).toBe(false) - expect(app.requestSingleInstanceLock).toHaveBeenCalledOnce() - expect(app.quit).toHaveBeenCalledOnce() - expect(app.on).not.toHaveBeenCalled() - }) - - /** - * @example - * secondInstanceHandler() - * expect(toggleWindowShow).toHaveBeenCalledWith(window) - */ - it('shows the main window when Windows forwards a second launch to the primary process', () => { - const app = createMockApp(true) - const window = createMockWindow() - - const installed = installSingleInstanceGuard({ - app, - getWindow: vi.fn(() => window), - }) - - expect(installed).toBe(true) - expect(app.on).toHaveBeenCalledWith('second-instance', expect.any(Function)) - - const secondInstanceHandler = app.on.mock.calls[0]?.[1] as () => void - secondInstanceHandler() - - expect(windowMock.toggleWindowShow).toHaveBeenCalledWith(window) - }) -}) -type MockApp = App & { - on: ReturnType - quit: ReturnType - requestSingleInstanceLock: ReturnType -} diff --git a/apps/stage-tamagotchi/src/main/app/single-instance.ts b/apps/stage-tamagotchi/src/main/app/single-instance.ts deleted file mode 100644 index b0bcd56cf..000000000 --- a/apps/stage-tamagotchi/src/main/app/single-instance.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { App, BrowserWindow } from 'electron' - -import { toggleWindowShow } from '../windows/shared/window' - -interface SingleInstanceGuardOptions { - app: App - getWindow: () => BrowserWindow | undefined -} - -/** - * Focuses the main AIRI window after a duplicate launch. - * - * Use when: - * - Electron forwards a second process launch to the primary instance - * - The app should show the already-running UI instead of starting another runtime - * - * Expects: - * - `getWindow` returns the main user-facing window when it has been created - * - * Returns: - * - N/A - */ -function focusMainWindow(getWindow: SingleInstanceGuardOptions['getWindow']) { - const window = getWindow() - if (!window) { - return - } - - toggleWindowShow(window) -} - -/** - * Installs Electron's single-instance guard for the desktop runtime. - * - * Use when: - * - Only one AIRI desktop process should own local runtime resources - * - Fixed localhost services such as the server channel must not bind twice - * - * Expects: - * - The guard is installed before `app.whenReady()` starts runtime services - * - * Returns: - * - `true` for the primary process, `false` after requesting shutdown for a secondary process - */ -export function installSingleInstanceGuard(options: SingleInstanceGuardOptions) { - const hasSingleInstanceLock = options.app.requestSingleInstanceLock() - if (!hasSingleInstanceLock) { - options.app.quit() - return false - } - - options.app.on('second-instance', () => { - focusMainWindow(options.getWindow) - }) - - return true -} diff --git a/apps/stage-tamagotchi/src/main/configs/artistry.ts b/apps/stage-tamagotchi/src/main/configs/artistry.ts deleted file mode 100644 index bd44ad67d..000000000 --- a/apps/stage-tamagotchi/src/main/configs/artistry.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { any, array, number, object, optional, string } from 'valibot' - -import { createConfig } from '../libs/electron/persistence' - -export const artistryConfigSchema = object({ - artistryProvider: optional(string(), 'none'), - artistryGlobals: optional(object({ - comfyuiServerUrl: optional(string(), 'http://localhost:8188'), - comfyuiSavedWorkflows: optional(array(any()), []), - comfyuiActiveWorkflow: optional(string(), ''), - replicateApiKey: optional(string(), ''), - replicateDefaultModel: optional(string(), 'black-forest-labs/flux-schnell'), - replicateAspectRatio: optional(string(), '16:9'), - replicateInferenceSteps: optional(number(), 4), - nanobananaApiKey: optional(string(), ''), - nanobananaModel: optional(string(), 'gemini-3.1-flash-image-preview'), - nanobananaResolution: optional(string(), '1K'), - }), {}), -}) - -export function createArtistryConfig() { - const config = createConfig('artistry', 'options.json', artistryConfigSchema) - config.setup() - - return config -} diff --git a/apps/stage-tamagotchi/src/main/configs/global.ts b/apps/stage-tamagotchi/src/main/configs/global.ts deleted file mode 100644 index eb54ecdaf..000000000 --- a/apps/stage-tamagotchi/src/main/configs/global.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { array, object, optional, picklist, string } from 'valibot' - -import { createConfig } from '../libs/electron/persistence' - -const shortcutAcceleratorSchema = object({ - modifiers: array(picklist(['cmd-or-ctrl', 'cmd', 'ctrl', 'alt', 'shift', 'super'])), - key: string(), -}) - -export const globalAppConfigSchema = object({ - language: optional(string()), - spotlightShortcutAccelerator: optional(shortcutAcceleratorSchema), - updateChannel: optional(picklist(['latest', 'stable', 'alpha', 'beta', 'nightly', 'canary'])), -}) - -export function createGlobalAppConfig() { - const config = createConfig('app', 'options.json', globalAppConfigSchema) - config.setup() - - return config -} diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts deleted file mode 100644 index 6fac27939..000000000 --- a/apps/stage-tamagotchi/src/main/index.ts +++ /dev/null @@ -1,397 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { FileLoggerHandle } from './app/file-logger' - -import process, { env, platform } from 'node:process' - -import { dirname } from 'node:path' -import { fileURLToPath } from 'node:url' - -import messages from '@proj-airi/i18n/locales' - -import { electronApp, optimizer } from '@electron-toolkit/utils' -import { Format, LogLevel, setGlobalFormat, setGlobalHookPostLog, setGlobalLogLevel, useLogg } from '@guiiai/logg' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { hasSelectedScreenCaptureSource, initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main' -import { app, ipcMain, session } from 'electron' -import { noop } from 'es-toolkit' -import { createLoggLogger, injeca, lifecycle } from 'injeca' -import { isLinux } from 'std-env' - -import icon from '../../resources/icon.png?asset' - -import { openDebugger, setupDebugger } from './app/debugger' -import { nullFileLoggerHandle, setupFileLogger } from './app/file-logger' -import { resolveIsWayland } from './app/ozone' -import { installSingleInstanceGuard } from './app/single-instance' -import { createArtistryConfig } from './configs/artistry' -import { createGlobalAppConfig } from './configs/global' -import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle' -import { setElectronMainDirname } from './libs/electron/location' -import { createI18n } from './libs/i18n' -import { setupAppleSpeechTranscriptionService } from './services/airi/apple-speech-transcription' -import { setupServerChannel } from './services/airi/channel-server' -import { setupGodotStageManager } from './services/airi/godot-stage' -import { setupBuiltInServer } from './services/airi/http-server' -import { setupMcpStdioManager } from './services/airi/mcp-servers' -import { setupExtensionHost } from './services/airi/plugins' -import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge' -import { setupAutoUpdater } from './services/electron/auto-updater' -import { setupGlobalShortcutService } from './services/electron/global-shortcut' -import { setupPermissionHandlers } from './services/electron/media-permissions' -import { setupTray } from './tray' -import { setupAboutWindowReusable } from './windows/about' -import { setupBeatSync } from './windows/beat-sync' -import { setupCaptionWindowManager } from './windows/caption' -import { setupChatWindowReusableFunc } from './windows/chat' -import { isDesktopOverlayEnabled, setupDesktopOverlayWindow } from './windows/desktop-overlay' -import { setupDevtoolsWindow } from './windows/devtools' -import { setupEditorWindowManager } from './windows/editor' -import { setupMainWindow } from './windows/main' -import { setupNoticeWindowManager } from './windows/notice' -import { setupOnboardingWindowManager } from './windows/onboarding' -import { setupSettingsWindowReusableFunc } from './windows/settings' -import { setupSpotlightWindowManager } from './windows/spotlight' -import { setupWidgetsWindowManager } from './windows/widgets' - -// TODO: once we refactored eventa to support window-namespaced contexts, -// we can remove the setMaxListeners call below since eventa will be able to dispatch and -// manage events within eventa's context system. -ipcMain.setMaxListeners(100) - -setElectronMainDirname(dirname(fileURLToPath(import.meta.url))) -setGlobalFormat(Format.Pretty) -setGlobalLogLevel(LogLevel.Log) -setupDebugger() - -const log = useLogg('main').useGlobalConfig() - -const appUserDataPath = env.APP_USER_DATA_PATH?.trim() -if (appUserDataPath) { - app.setPath('userData', appUserDataPath) -} - -// Thanks to [@blurymind](https://github.com/blurymind), -// -// When running Electron on Linux, navigator.gpu.requestAdapter() fails. -// In order to enable WebGPU and process the shaders fast enough, we need the following -// command line switches to be set. -// -// https://github.com/electron/electron/issues/41763#issuecomment-2051725363 -// https://github.com/electron/electron/issues/41763#issuecomment-3143338995 -if (isLinux) { - // NOTICE: - // All enabled features must be joined into a single comma-separated string - // instead of calling appendSwitch('enable-features', ...) once per feature. - // Root cause: Chromium's commandLine stores switches by key, so each - // appendSwitch('enable-features', ...) call overwrites the previous value and - // only the last feature survives. - // Source: Chromium base::CommandLine behavior; see - // https://github.com/electron/electron/issues/41763 for the WebGPU setup this supports. - // Removal condition: never for the join itself; this block can be deleted once - // WebGPU works on Linux Electron without manual feature switches. - const enabledFeatures = [ - 'SharedArrayBuffer', - ] - - app.commandLine.appendSwitch('enable-unsafe-webgpu') - - // Check explicit command-line switches before falling back to session environment variables. - // When running with XWayland (e.g. '--ozone-platform=x11'), session variables like WAYLAND_DISPLAY - // are still inherited from the Wayland desktop, but Chromium uses the explicitly specified Ozone backend. - // Treat explicit 'auto' as an unresolved platform selection and resolve using session environment variables. - const isWayland = resolveIsWayland({ - explicitOzonePlatform: app.commandLine.getSwitchValue('ozone-platform'), - ozonePlatformHint: app.commandLine.getSwitchValue('ozone-platform-hint'), - env, - }) - - if (isWayland) { - enabledFeatures.push('GlobalShortcutsPortal', 'UseOzonePlatform', 'WaylandWindowDecorations') - if (!app.commandLine.hasSwitch('ozone-platform-hint')) { - app.commandLine.appendSwitch('ozone-platform-hint', 'auto') - } - } - else { - // NOTICE: - // Vulkan must only be enabled on non-Wayland sessions, otherwise GPU - // initialization fails or rendering glitches appear. - // Root cause: Vulkan is incompatible with '--ozone-platform=wayland' in - // Chromium's surface factory; the Wayland Ozone backend cannot present - // Vulkan surfaces. - // Source: Chromium Ozone/Wayland surface factory; workaround tracked via - // https://github.com/electron/electron/issues/41763 (WebGPU on Linux). - // Removal condition: when Chromium/Electron supports Vulkan with the Wayland - // Ozone backend, drop the isWayland guard and always push 'Vulkan'. - enabledFeatures.push('Vulkan') - } - - app.commandLine.appendSwitch('enable-features', enabledFeatures.join(',')) -} - -app.dock?.setIcon(icon) -electronApp.setAppUserModelId('ai.moeru.airi') - -// Track the real user-facing AIRI window because the process also owns hidden utility windows. -// The second-instance handler should restore the main UI instead of accidentally surfacing internals. -let userFacingMainWindow: BrowserWindow | undefined -const shouldStartMainProcess = installSingleInstanceGuard({ app, getWindow: () => userFacingMainWindow }) - -if (shouldStartMainProcess) { - initScreenCaptureForMain() -} - -let fileLogger: FileLoggerHandle = nullFileLoggerHandle -let skipFileLogging = false - -app.whenReady().then(async () => { - if (!shouldStartMainProcess) { - return - } - - setupPermissionHandlers(session.defaultSession, hasSelectedScreenCaptureSource) - - // Initialize file logger and register the hook - fileLogger = await setupFileLogger() - - // Register the global hook for file logging - setGlobalHookPostLog((_, formatted) => { - if (skipFileLogging || fileLogger.logFileFd === null) - return - void fileLogger.appendLog(formatted) - }) - - injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig())) - - const appConfig = injeca.provide('configs:app', () => createGlobalAppConfig()) - const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig()) - const electronApp = injeca.provide('host:electron:app', () => app) - const autoUpdater = injeca.provide('services:auto-updater', { - dependsOn: { appConfig }, - build: ({ dependsOn }) => setupAutoUpdater({ - enabled: import.meta.env.VITE_DISTRIBUTION !== 'steam', - getStoredUpdateLane: () => dependsOn.appConfig.get()?.updateChannel, - setStoredUpdateLane: (lane) => { - const currentConfig = dependsOn.appConfig.get() - dependsOn.appConfig.update({ - language: currentConfig?.language ?? 'en', - updateChannel: lane, - }) - }, - }), - }) - - const i18n = injeca.provide('libs:i18n', { - dependsOn: { appConfig }, - build: ({ dependsOn }) => createI18n({ messages, locale: dependsOn.appConfig.get()?.language }), - }) - - const serverChannel = injeca.provide('modules:channel-server', { - dependsOn: { app: electronApp, lifecycle }, - build: async ({ dependsOn }) => setupServerChannel(dependsOn), - }) - - const airiHttpServer = injeca.provide('modules:airi-http-server', { - build: async () => setupBuiltInServer({ servers: [] }), - }) - - const godotStageManager = injeca.provide('modules:godot-stage-manager', { - build: async () => setupGodotStageManager(), - }) - - const appleSpeechTranscription = injeca.provide('modules:apple-speech-transcription', { - dependsOn: { lifecycle }, - build: ({ dependsOn }) => setupAppleSpeechTranscriptionService(dependsOn), - }) - - const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', { - build: async () => setupMcpStdioManager(), - }) - - const widgetsManager = injeca.provide('windows:widgets', { - dependsOn: { serverChannel, i18n }, - build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn), - }) - - const pluginHost = injeca.provide('modules:plugin-host', { - dependsOn: { serverChannel, widgetsManager }, - build: ({ dependsOn }) => setupExtensionHost(dependsOn), - }) - - const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService()) - - // Beat Sync uses a background renderer because Web Audio processing needs a DOM runtime. - const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync()) - - const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow()) - - const onboardingWindowManager = injeca.provide('windows:onboarding', { - dependsOn: { serverChannel, i18n }, - build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn), - }) - - const noticeWindow = injeca.provide('windows:notice', { - dependsOn: { i18n, serverChannel }, - build: ({ dependsOn }) => setupNoticeWindowManager(dependsOn), - }) - - const aboutWindow = injeca.provide('windows:about', { - dependsOn: { autoUpdater, i18n, serverChannel }, - build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn), - }) - - const chatWindow = injeca.provide('windows:chat', { - dependsOn: { widgetsManager, serverChannel, mcpStdioManager, i18n }, - build: ({ dependsOn }) => setupChatWindowReusableFunc(dependsOn), - }) - - const spotlightWindow = injeca.provide('windows:spotlight', { - dependsOn: { serverChannel, i18n, chatWindow, globalShortcut, appConfig }, - build: ({ dependsOn }) => setupSpotlightWindowManager(dependsOn), - }) - - const editorWindow = injeca.provide('windows:editor', { - dependsOn: { serverChannel, i18n }, - build: ({ dependsOn }) => setupEditorWindowManager(dependsOn), - }) - - const settingsWindow = injeca.provide('windows:settings', { - dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow }, - build: async ({ dependsOn }) => - setupSettingsWindowReusableFunc({ - ...dependsOn, - getMainWindow: () => userFacingMainWindow, - }), - }) - - const mainWindow = injeca.provide('windows:main', { - dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription }, - build: async ({ dependsOn }) => setupMainWindow({ - ...dependsOn, - onWindowCreated: (window) => { - userFacingMainWindow = window - }, - }), - }) - - const captionWindow = injeca.provide('windows:caption', { - dependsOn: { mainWindow, serverChannel, i18n }, - build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn), - }) - - const tray = injeca.provide('app:tray', { - dependsOn: { mainWindow, settingsWindow, captionWindow, widgetsWindow: widgetsManager, serverChannel, beatSyncBgWindow: beatSync, aboutWindow, i18n }, - build: async ({ dependsOn }) => setupTray(dependsOn), - }) - - // Desktop grounding overlay — gated by AIRI_DESKTOP_OVERLAY=1 - if (isDesktopOverlayEnabled()) { - const desktopOverlay = injeca.provide('windows:desktop-overlay', { - dependsOn: { mcpStdioManager, serverChannel, i18n }, - build: async ({ dependsOn }) => setupDesktopOverlayWindow(dependsOn), - }) - - // NOTICE: Separate invoke ensures the overlay is eagerly built. - // Without this, injeca.start() would skip it because no other - // provider depends on 'windows:desktop-overlay'. - injeca.invoke({ - dependsOn: { desktopOverlay }, - callback: noop, - }) - } - - injeca.invoke({ - dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, godotStageManager, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, spotlightWindow, artistryConfig }, - callback: async (deps) => { - const { context } = createContext(ipcMain) - await setupArtistryBridge({ - widgetsManager: deps.widgetsWindow, - context, - artistryConfig: deps.artistryConfig, - }) - }, - }) - - injeca.start().catch(err => console.error(err)) - - // Lifecycle - emitAppReady() - - // Extra - openDebugger() - - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils - app.on('browser-window-created', (_, window) => optimizer.watchWindowShortcuts(window)) -}).catch((err) => { - log.withError(err).error('Error during app initialization') -}) - -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. -app.on('window-all-closed', () => { - emitAppWindowAllClosed() - - if (platform !== 'darwin') { - app.quit() - } -}) - -let appExiting = false - -// Clean up server and intervals when app quits -async function handleAppExit() { - if (appExiting) - return - - appExiting = true - - let exitedNormally = true - - /** - * Safely execute fn and log any errors that occur, marking the exit as abnormal - * if an error is caught. - * - * @param operation - A verb phrase describing the operation. - * @param fn - Any function to execute. It can be either sync or async. - * @returns A promise that resolves when the operation is complete. - */ - async function logIfError(operation: string, fn: () => unknown): Promise { - try { - await fn() - } - catch (error) { - exitedNormally = false - log.withError(error).error(`[app-exit] Failed to ${operation}:`) - } - } - - await Promise.all([ - logIfError('execute onAppBeforeQuit hooks', () => emitAppBeforeQuit()), - logIfError('stop injeca', () => injeca.stop()), - ]) - - // Prevent the global log hook from trying to write to the file after close() is called, - // which would cause a recursive failure if close() itself throws. - skipFileLogging = true - await logIfError('flush file logs', () => fileLogger.close()) // Ensure all logs are flushed - - if (!exitedNormally) { - app.exit(1) - } - else { - app.quit() - } -} - -process.on('SIGINT', () => handleAppExit()) - -app.on('before-quit', (event) => { - if (appExiting) - return - - event.preventDefault() - handleAppExit() -}) diff --git a/apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts b/apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts deleted file mode 100644 index 357985dbe..000000000 --- a/apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts +++ /dev/null @@ -1,33 +0,0 @@ -const onAppReadyHooks = [] as (() => Promise | void)[] -const onAppBeforeQuitHooks = [] as (() => Promise | void)[] -const onAppWindowAllClosedHooks = [] as (() => Promise | void)[] - -export function onAppReady(fn: () => Promise | void) { - onAppReadyHooks.push(fn) -} - -export async function emitAppReady() { - for (const fn of onAppReadyHooks) { - await fn() - } -} - -export function onAppBeforeQuit(fn: () => Promise | void) { - onAppBeforeQuitHooks.push(fn) -} - -export async function emitAppBeforeQuit() { - for (const fn of onAppBeforeQuitHooks) { - await fn() - } -} - -export function onAppWindowAllClosed(fn: () => Promise | void) { - onAppWindowAllClosedHooks.push(fn) -} - -export async function emitAppWindowAllClosed() { - for (const fn of onAppWindowAllClosedHooks) { - await fn() - } -} diff --git a/apps/stage-tamagotchi/src/main/libs/electron/location.test.ts b/apps/stage-tamagotchi/src/main/libs/electron/location.test.ts deleted file mode 100644 index 5800282b0..000000000 --- a/apps/stage-tamagotchi/src/main/libs/electron/location.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { withHashRoute } from './location' - -vi.mock(import('@electron-toolkit/utils'), () => { - return { - is: { - dev: true, - }, - } -}) - -describe('withHashRoute', () => { - it('should use string url construct URL with hash route correctly', () => { - const result = withHashRoute('http://localhost:5173', '/test/inner-test') - expect(result).toEqual({ url: 'http://localhost:5173/#/test/inner-test' }) - }) - - it('should use object url construct URL with hash route correctly', () => { - const result = withHashRoute({ url: 'http://localhost:5173' }, '/test/inner-test') - expect(result).toEqual({ url: 'http://localhost:5173/#/test/inner-test' }) - }) - - it('should use file url construct URL with hash route correctly', () => { - const result = withHashRoute({ url: 'file:////home/workspace/project/index.html' }, '/test/inner-test') - expect(result).toEqual({ url: `file:////home/workspace/project/index.html#/test/inner-test` }) - }) - - it('adds query options before the hash route for development URLs', () => { - expect(withHashRoute({ url: 'http://localhost:5173' }, '/about', { - query: { 'synced-leader': 'false' }, - })).toEqual({ - url: 'http://localhost:5173/?synced-leader=false#/about', - }) - }) - - it('passes query options to Electron for packaged renderer URLs', () => { - expect(withHashRoute({ file: '/opt/airi/renderer/index.html' }, '/settings', { - query: { 'synced-leader': 'false' }, - })).toEqual({ - file: '/opt/airi/renderer/index.html', - options: { - hash: '/settings', - query: { 'synced-leader': 'false' }, - }, - }) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/libs/electron/location.ts b/apps/stage-tamagotchi/src/main/libs/electron/location.ts deleted file mode 100644 index 1c1c721a1..000000000 --- a/apps/stage-tamagotchi/src/main/libs/electron/location.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { BrowserWindow, LoadFileOptions, LoadURLOptions } from 'electron' - -import { join } from 'node:path' -import { env } from 'node:process' - -import { is } from '@electron-toolkit/utils' - -let electronMainDirname: string = '' - -export function setElectronMainDirname(dirname: string) { - electronMainDirname = dirname -} - -export function getElectronMainDirname() { - return electronMainDirname -} - -export function baseUrl(parentOfIndexHtml: string, filename?: string) { - if (is.dev && env.ELECTRON_RENDERER_URL) { - if (!filename) { - return { url: env.ELECTRON_RENDERER_URL } - } - - const url = new URL(env.ELECTRON_RENDERER_URL) - const paths = url.pathname.split('/') - paths.pop() - paths.push(filename) - url.pathname = paths.join('/') - return { url: url.toString() } - } - else { - return { file: join(parentOfIndexHtml, filename ?? 'index.html') } - } -} - -export async function load(window: BrowserWindow, url: string | { url: string, options?: LoadURLOptions } | { file: string, options?: LoadFileOptions }) { - try { - if (typeof url === 'object' && 'url' in url) { - return await window.loadURL(url.url, url.options) - } - if (typeof url === 'object' && 'file' in url) { - return await window.loadFile(url.file, url.options) - } - - return await window.loadURL(url) - } - catch (error) { - if (!(error instanceof Error)) { - throw error - } - - // Electron navigation error shape - // https://github.com/electron/electron/blob/8d05285a1f39c759985b17c89a449e4a6b3960df/lib/browser/api/web-contents.ts#L354-L359 - if (!('code' in error) || !('errno' in error)) { - throw error - } - if (error.code === 'ERR_ABORTED' && error.errno === -3) { - if (typeof url === 'object' && 'url' in url) { - const parsedURL = new URL(url.url) - if (parsedURL.hash) { - // When targeting /#/ hash route, Electron may throw - // - // ``` - // Error: ERR_ABORTED (-3) loading 'http://localhost:5173/#/notice/fade-on-hover?id=fade-on-hover' - // ``` - // - // and this will cause the `load(...)` promise to reject, while `#${hash content}` is in fact the correct URL expected by - // electron, but from `new URL(...)` standard API, the output URL with hash will include at least one `/` before `#${hash content}`, - // which causes the mismatch and thus the error. - // - // This is more likely a URL scheme standard mismatch between Electron and Node.js, and currently we can only catch and - // ignore this error, since the URL with hash is actually loaded correctly in Electron, and the error is just a false alarm. - // - // Navigation started: {url: 'http://localhost:5173/#/notice/fade-on-hover?id=fade-on-hover', isSameDocument: false, isMainFrame: true, isInPlace: false} - // Navigation started: {url: 'http://localhost:5173/#/notice/fade-on-hover?id=fade-on-hover', isSameDocument: false, isMainFrame: true, isInPlace: false} - // Navigation started: {url: 'http://localhost:5173/#/notice/fade-on-hover?id=fade-on-hover', isSameDocument: false, isMainFrame: true, isInPlace: false} - // - // https://github.com/electron/electron/issues/17526 - // https://github.com/electron/electron/blob/8d05285a1f39c759985b17c89a449e4a6b3960df/lib/browser/api/web-contents.ts#L370-L387 - console.warn('Electron navigation error with hash route, ignoring:', error, 'url:', url.url) - - return - } - } - } - - throw error - } - finally { - window.webContents.removeAllListeners('did-start-navigation') - } -} - -/** - * Adds a hash route and optional query to an Electron renderer location. - * - * @example - * withHashRoute({ url: 'http://localhost:5173' }, '/about', { - * query: { 'synced-leader': 'false' }, - * }) - * // => { url: 'http://localhost:5173/?synced-leader=false#/about' } - */ -export function withHashRoute( - baseUrl: string | { url: string } | { file: string }, - hashRoute: string, - options: Pick = {}, -) { - if (typeof baseUrl === 'object' && 'url' in baseUrl) { - // trim `/` suffix - const baseURLinURL = new URL(baseUrl.url) - - const pathname = baseURLinURL.pathname - const trimmedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname - baseURLinURL.pathname = trimmedPathname - - for (const [key, value] of Object.entries(options.query ?? {})) - baseURLinURL.searchParams.set(key, value) - - baseURLinURL.hash = hashRoute - - return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions } - } - if (typeof baseUrl === 'object' && 'file' in baseUrl) { - return { file: `${baseUrl.file}`, options: { hash: hashRoute, ...options } } satisfies { file: string, options?: LoadFileOptions } - } - - // trim `/` suffix - const baseURLinURL = new URL(baseUrl) - - const pathname = baseURLinURL.pathname - const trimmedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname - baseURLinURL.pathname = trimmedPathname - - for (const [key, value] of Object.entries(options.query ?? {})) - baseURLinURL.searchParams.set(key, value) - - baseURLinURL.hash = hashRoute - - return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions } -} diff --git a/apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts b/apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts deleted file mode 100644 index 2115de1ff..000000000 --- a/apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { number, object } from 'valibot' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -/** - * @example - * describe('createConfig', () => { - * it('persists configuration data', async () => { - * // assertions - * }) - * }) - */ -describe('createConfig', () => { - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - vi.restoreAllMocks() - }) - - /** - * @example - * it('uses a unique temp file per save to avoid concurrent rename collisions', async () => { - * await vi.waitFor(() => { - * expect(renameMock).toHaveBeenCalledTimes(2) - * }) - * }) - * - * Failed to save config Error: ENOENT: no such file or directory, rename '/path/to/the/electron/app/data/app-config.json.tmp' -> '/path/to/the/electron/app/data/app-config.json' - * at async rename (node:internal/fs/promises:785:10) - * at async file://./airi/apps/stage-tamagotchi/out/main/index.js:3327:4 { - * errno: -2, - * code: 'ENOENT', - * syscall: 'rename', - * path: '/path/to/the/electron/app/data/app-config.json.tmp', - * dest: '/path/to/the/electron/app/data/app-config.json' - * } - * - * ROOT CAUSE: - * - * If concurrent save calls share one temporary file path, one rename removes the file first. - * This causes a second rename attempt to fail with ENOENT, and the save path logs an error. - * - * We fixed this by asserting each save operation writes and renames a distinct temp file path. - */ - it('uses a unique temp file per save to avoid concurrent rename collisions', async () => { - const appMock = { - getPath: vi.fn(() => '/tmp/airi-user-data'), - } - const mkdirMock = vi.fn(async () => {}) - const existingTempFiles = new Set() - const renameMock = vi.fn(async (from: string) => { - if (!existingTempFiles.has(from)) { - const error = new Error(`ENOENT: no such file or directory, rename '${from}'`) as NodeJS.ErrnoException - error.code = 'ENOENT' - throw error - } - existingTempFiles.delete(from) - }) - const writeCoordinator = { - calls: 0, - waitFor: Promise.resolve(), - release: () => {}, - } - const writeFileMock = vi.fn(async (path: string) => { - existingTempFiles.add(path) - writeCoordinator.calls += 1 - if (writeCoordinator.calls === 2) { - writeCoordinator.release() - } - await writeCoordinator.waitFor - }) - - writeCoordinator.waitFor = new Promise((resolve) => { - writeCoordinator.release = resolve - }) - - vi.doMock('electron', () => ({ - app: appMock, - })) - vi.doMock('es-toolkit', () => ({ - throttle: (handler: (...args: unknown[]) => unknown) => handler, - })) - vi.doMock('node:fs', () => ({ - existsSync: () => false, - readFileSync: () => '', - })) - vi.doMock('node:fs/promises', () => ({ - copyFile: vi.fn(async () => {}), - mkdir: mkdirMock, - rename: renameMock, - writeFile: writeFileMock, - })) - - const { createConfig } = await import('./persistence') - const schema = object({ value: number() }) - const config = createConfig('windows-widgets', 'config.json', schema, { default: { value: 0 } }) - const saveErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - config.setup() - config.update({ value: 1 }) - config.update({ value: 2 }) - - /** - * @example - * expect(renameMock).toHaveBeenCalledTimes(2) - * expect(saveErrorSpy).not.toHaveBeenCalledWith('Failed to save config', expect.anything()) - * expect(new Set(renameMock.mock.calls.map(([from]) => from)).size).toBe(2) - */ - await vi.waitFor(() => { - expect(renameMock).toHaveBeenCalledTimes(2) - }) - - expect(saveErrorSpy).not.toHaveBeenCalledWith('Failed to save config', expect.anything()) - expect(new Set(renameMock.mock.calls.map(([from]) => from)).size).toBe(2) - saveErrorSpy.mockRestore() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts b/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts deleted file mode 100644 index 7837f18c9..000000000 --- a/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { BaseIssue, BaseSchema, InferIssue, InferOutput } from 'valibot' - -import { randomUUID } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import { copyFile, mkdir, rename, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' - -import { safeDestr } from 'destr' -import { app } from 'electron' -import { throttle } from 'es-toolkit' -import { safeParse } from 'valibot' - -type ConfigStatus = 'ok' | 'missing' | 'invalid' | 'read-error' - -export interface ConfigDiagnostics { - status: ConfigStatus - path: string - issues?: BaseIssue[] - error?: unknown - raw?: string - healed?: boolean - value?: T -} - -export interface CreateConfigOptions { - default?: T - autoHeal?: boolean - onValidationFailure?: (diagnostics: ConfigDiagnostics) => void - onReadError?: (diagnostics: ConfigDiagnostics) => void -} - -const persistenceMap = new Map() -const diagnosticsMap = new Map>() - -function createConfigPath(namespace: string, filename: string) { - return join(app.getPath('userData'), `${namespace}-${filename}`) -} - -async function ensureConfigDirectory(path: string) { - await mkdir(dirname(path), { recursive: true }) -} - -type PersistedSchema = BaseSchema> - -function parseWithSchema( - raw: string, - schema: TSchema, -): { value?: InferOutput, issues?: InferIssue[] } { - const parsed = safeDestr(raw) - const result = safeParse(schema, parsed) - if (result.success) { - return { value: result.output } - } - return { issues: result.issues } -} - -export interface Config { - setup: () => ConfigDiagnostics> - get: () => InferOutput | undefined - update: (newData: InferOutput) => void - getDiagnostics: () => ConfigDiagnostics> | undefined -} - -export function createConfig( - namespace: string, - filename: string, - schema: TSchema, - options?: CreateConfigOptions>, -): Config { - const key = `${namespace}:${filename}` - const autoHeal = options?.autoHeal ?? Boolean(options?.default) - - const configPath = () => createConfigPath(namespace, filename) - - const recordDiagnostics = (diagnostics: ConfigDiagnostics>) => { - diagnosticsMap.set(key, diagnostics) - return diagnostics - } - - const save = throttle(async () => { - try { - const path = configPath() - await ensureConfigDirectory(path) - const tmpPath = `${path}.${randomUUID()}.tmp` - await writeFile(tmpPath, JSON.stringify(persistenceMap.get(key))) - await rename(tmpPath, path) - } - catch (error) { - console.error('Failed to save config', error) - } - }, 250) - - const writeHealingConfig = async (value: InferOutput) => { - try { - const path = configPath() - await ensureConfigDirectory(path) - if (existsSync(path)) { - await copyFile(path, `${path}.bak`).catch(err => console.warn('Failed to create backup for config:', path, err)) - } - await writeFile(path, JSON.stringify(value)) - return true - } - catch (error) { - console.error('Failed to heal config', error) - return false - } - } - - const setup = () => { - const path = configPath() - if (!existsSync(path)) { - const diagnostics = recordDiagnostics({ - status: 'missing', - path, - value: options?.default, - }) - persistenceMap.set(key, options?.default) - return diagnostics - } - - try { - const raw = readFileSync(path, { encoding: 'utf-8' }) - const parsed = parseWithSchema(raw, schema) - if (parsed.value !== undefined) { - const diagnostics = recordDiagnostics({ - status: 'ok', - path, - value: parsed.value, - }) - persistenceMap.set(key, parsed.value) - return diagnostics - } - - const fallback = options?.default - const diagnostics = recordDiagnostics({ - status: 'invalid', - path, - issues: parsed.issues, - raw, - value: fallback, - }) - options?.onValidationFailure?.(diagnostics) - persistenceMap.set(key, fallback) - - if (autoHeal && fallback !== undefined) { - void writeHealingConfig(fallback).then((healed) => { - if (healed) { - diagnosticsMap.set(key, { ...diagnostics, healed }) - } - }) - } - return diagnostics - } - catch (error) { - const fallback = options?.default - const diagnostics = recordDiagnostics({ - status: 'read-error', - path, - error, - value: fallback, - }) - options?.onReadError?.(diagnostics) - persistenceMap.set(key, fallback) - return diagnostics - } - } - - const update = (newData: InferOutput) => { - persistenceMap.set(key, newData) - save() - } - - const get = () => persistenceMap.get(key) as InferOutput | undefined - - const getDiagnostics = () => diagnosticsMap.get(key) as ConfigDiagnostics> | undefined - - return { - setup, - get, - update, - getDiagnostics, - } -} diff --git a/apps/stage-tamagotchi/src/main/libs/electron/url.ts b/apps/stage-tamagotchi/src/main/libs/electron/url.ts deleted file mode 100644 index e9443ff80..000000000 --- a/apps/stage-tamagotchi/src/main/libs/electron/url.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { env } from 'node:process' - -/** - * Checks whether a URL belongs to an AIRI-owned local renderer page. - * - * Use when: - * - Electron main-process policies need to distinguish AIRI pages from remote content - * - Packaged and development renderer URLs must share the same trust decision - * - * Expects: - * - Packaged pages use file URLs - * - Development pages share the exact origin configured by Electron Vite - * - * Returns: - * - Whether the URL uses the packaged file scheme or the configured renderer origin - */ -export function isLocalAppURL(rawURL: string | undefined): boolean { - if (!rawURL) - return false - - try { - const url = new URL(rawURL) - if (url.protocol === 'file:') - return true - - if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !env.ELECTRON_RENDERER_URL) - return false - - const rendererURL = new URL(env.ELECTRON_RENDERER_URL) - return url.origin === rendererURL.origin - } - catch { - return false - } -} diff --git a/apps/stage-tamagotchi/src/main/libs/electron/window-manager/index.ts b/apps/stage-tamagotchi/src/main/libs/electron/window-manager/index.ts deleted file mode 100644 index e2e28d4e1..000000000 --- a/apps/stage-tamagotchi/src/main/libs/electron/window-manager/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './reusable' diff --git a/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts b/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts deleted file mode 100644 index 50cff8f03..000000000 --- a/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import { isRendererUnavailable } from '@proj-airi/electron-vueuse/main' - -export function createReusableWindow(setupFn: () => BrowserWindow | Promise): { getWindow: () => Promise } { - let window: BrowserWindow | undefined - let windowSetupFnPromise: Promise | undefined - - const ensureWindow = async () => { - if (window && !isRendererUnavailable(window)) - return window - - if (windowSetupFnPromise) - return windowSetupFnPromise - - windowSetupFnPromise = Promise.resolve(setupFn()).then((created) => { - window = created - windowSetupFnPromise = undefined - - created.on?.('closed', () => { - if (window === created) - window = undefined - }) - - return created - }).catch((error) => { - windowSetupFnPromise = undefined - throw error - }) - - return windowSetupFnPromise - } - - return { - getWindow: async () => ensureWindow(), - } -} diff --git a/apps/stage-tamagotchi/src/main/libs/i18n/index.ts b/apps/stage-tamagotchi/src/main/libs/i18n/index.ts deleted file mode 100644 index a1c4378c3..000000000 --- a/apps/stage-tamagotchi/src/main/libs/i18n/index.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { - CoreOptions, - IsEmptyObject, - LocaleDetector, - NamedValue, - PickupPaths, - RemovedIndexResources, - TranslateOptions, -} from '@intlify/core' - -import { useLogg } from '@guiiai/logg' -import { createCoreContext, translate } from '@intlify/core' -import { effect, signal } from 'alien-signals' -import { isString } from 'es-toolkit' - -type ResolveResourceKeys< - // eslint-disable-next-line ts/no-empty-object-type - Schema extends Record = {}, - // eslint-disable-next-line ts/no-empty-object-type - DefineLocaleMessageSchema extends Record = {}, - DefinedLocaleMessage extends - RemovedIndexResources = RemovedIndexResources, - SchemaPaths = IsEmptyObject extends false - ? PickupPaths<{ [K in keyof Schema]: Schema[K] }> - : never, - DefineMessagesPaths = IsEmptyObject extends false - ? PickupPaths<{ - [K in keyof DefinedLocaleMessage]: DefinedLocaleMessage[K] - }> - : never, -> = SchemaPaths | DefineMessagesPaths - -interface TranslationFunction< - // eslint-disable-next-line ts/no-empty-object-type - Schema extends Record = {}, - // eslint-disable-next-line ts/no-empty-object-type - DefineLocaleMessageSchema extends Record = {}, - ResourceKeys = ResolveResourceKeys, -> { - /** - * @param {Key | ResourceKeys} key - A translation key - * @returns {string} A translated message, if the key is not found, return the key - */ - (key: Key | ResourceKeys): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {number} plural - A plural choice number - * @returns {string} A translated message, if the key is not found, return the key - */ - (key: Key | ResourceKeys, plural: number): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {number} plural - A plural choice number - * @param {TranslateOptions} options - A translate options, about details see {@link TranslateOptions} - * @returns {string} A translated message, if the key is not found, return the key - */ - (key: Key | ResourceKeys, plural: number, options: TranslateOptions): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {string} defaultMsg - A default message, if the key is not found - * @returns {string} A translated message, if the key is not found, return the `defaultMsg` argument - */ - (key: Key | ResourceKeys, defaultMsg: string): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {string} defaultMsg - A default message, if the key is not found - * @param {TranslateOptions} options - A translate options, about details see {@link TranslateOptions} - * @returns {string} A translated message, if the key is not found, return the `defaultMsg` argument - */ - ( - key: Key | ResourceKeys, - defaultMsg: string, - options: TranslateOptions - ): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {unknown[]} list - A list for list interpolation - * @returns {string} A translated message, if the key is not found, return the key - */ - (key: Key | ResourceKeys, list: unknown[]): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {unknown[]} list - A list for list interpolation - * @param {number} plural - A plural choice number - * @returns {string} A translated message, if the key is not found, return the key - */ - (key: Key | ResourceKeys, list: unknown[], plural: number): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {unknown[]} list - A list for list interpolation - * @param {string} defaultMsg - A default message, if the key is not found - * @returns {string} A translated message, if the key is not found, return the `defaultMsg` argument - */ - (key: Key | ResourceKeys, list: unknown[], defaultMsg: string): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {unknown[]} list - A list for list interpolation - * @param {TranslateOptions} options - A translate options, about details see {@link TranslateOptions} - * @returns {string} A translated message, if the key is not found, return the key - */ - (key: Key | ResourceKeys, list: unknown[], options: TranslateOptions): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {NamedValue} named - A named value for named interpolation - * @returns {string} A translated message, if the key is not found, return the key - */ - (key: Key | ResourceKeys, named: NamedValue): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {NamedValue} named - A named value for named interpolation - * @param {number} plural - A plural choice number - * @returns {string} A translated message, if the key is not found, return the key - */ - (key: Key | ResourceKeys, named: NamedValue, plural: number): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {NamedValue} named - A named value for named interpolation - * @param {string} defaultMsg - A default message, if the key is not found - * @returns {string} A translated message, if the key is not found, return the `defaultMsg` argument - */ - (key: Key | ResourceKeys, named: NamedValue, defaultMsg: string): string - /** - * @param {Key | ResourceKeys} key - A translation key - * @param {NamedValue} named - A named value for named interpolation - * @param {TranslateOptions} options - A translate options, about details see {@link TranslateOptions} - * @returns {string} A translated message, if the key is not found, return the key - */ - ( - key: Key | ResourceKeys, - named: NamedValue, - options: TranslateOptions - ): string -} - -export interface I18n = Record> { - t: TranslationFunction - locale: - (() => (string | LocaleDetector | undefined)) | ((value: string | LocaleDetector | undefined) => void) -} - -export function createI18n = Record>(options: CoreOptions): I18n { - const log = useLogg('i18n').useGlobalConfig() - - const locale = signal(options.locale) - - const context = createCoreContext({ - fallbackLocale: options.fallbackLocale, - fallbackWarn: false, - missingWarn: false, - warnHtmlMessage: false, - fallbackFormat: true, - ...options, - }) - - const t: TranslationFunction = ( - key: string, - ...args: unknown[] - ) => { - if (context == null) { - log.error('cannot initialize core context for i18n') - - return key - } - - const ret = Reflect.apply(translate, null, [context, key, ...args]) - return isString(ret) ? ret : key - } - - effect(() => { - locale() - - if (context != null) { - const l = locale() - if (l != null) { - context.locale = l - } - } - }) - - return { - t, - locale, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/apple-speech-transcription/index.ts b/apps/stage-tamagotchi/src/main/services/airi/apple-speech-transcription/index.ts deleted file mode 100644 index 336b157c9..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/apple-speech-transcription/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Lifecycle } from 'injeca' - -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { setupAppleSpeechTranscription } from '@xsai-apple-speech/transcription-electron-plugin/main' -import { ipcMain } from 'electron' -import { isMacOS } from 'std-env' - -/** - * Registers the app-wide Apple Speech transport and its native Provider. - * - * The Electron main process owns native work. Renderer Providers communicate - * with it through the Eventa handlers registered by the xsAI plugin. - * Non-macOS hosts return an inactive service without loading the native package. - * - * Call stack: - * - * setupAppleSpeechTranscriptionService - * -> {@link createContext} - * -> {@link setupAppleSpeechTranscription} - * -> {@link createAppleSpeechProvider} - */ -export async function setupAppleSpeechTranscriptionService(options: { lifecycle: Lifecycle }) { - if (!isMacOS) - return { dispose: () => Promise.resolve() } - - const { createAppleSpeechProvider } = await import('@xsai-apple-speech/transcription-native') - const eventa = createContext(ipcMain) - const setup = setupAppleSpeechTranscription({ - context: eventa.context, - provider: createAppleSpeechProvider(), - }) - let disposal: Promise | undefined - - const dispose = () => { - disposal ??= (async () => { - // Stop accepting native work before the transport cancels remaining invokes. - await setup.dispose() - eventa.dispose() - })() - return disposal - } - - options.lifecycle.appHooks.onStop(dispose) - - return { dispose } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/auth.ts b/apps/stage-tamagotchi/src/main/services/airi/auth.ts deleted file mode 100644 index c72536427..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/auth.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { BrowserWindow } from 'electron' - -import { useLogg } from '@guiiai/logg' -import { defineInvokeHandler } from '@moeru/eventa' -import { errorMessageFrom } from '@moeru/std' -import { - generateCodeChallenge, - generateCodeVerifier, - generateState, -} from '@proj-airi/stage-shared/auth' -import { shell } from 'electron' - -import { - electronAuthCallback, - electronAuthCallbackError, - electronAuthLogout, - electronAuthStartLogin, -} from '../../../shared/eventa' -import { startLoopbackServer } from './http-server/http/auth' - -const log = useLogg('auth-service').useGlobalConfig() - -type MainContext = ReturnType['context'] - -// OIDC configuration for the Electron client. -const OIDC_CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID || 'airi-stage-electron' -const OIDC_SCOPES = 'openid profile email offline_access' -const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://api.airi.build' -const OIDC_AUTHORIZE_PATH = '/api/auth/oauth2/authorize' -const OIDC_TOKEN_PATH = '/api/auth/oauth2/token' - -// Active loopback server cleanup handle -let closeLoopback: (() => void) | null = null -let signingInFlight = false - -/** - * Create the auth service IPC handlers for a given window context. - */ -export function createAuthService(params: { - context: MainContext - window: BrowserWindow -}): void { - defineInvokeHandler(params.context, electronAuthStartLogin, async (_, options) => { - if (params.window.webContents.id !== options?.raw.ipcMainEvent.sender.id) { - return - } - - if (signingInFlight) { - log.withFields({ windowId: params.window.webContents.id }).warn('Replacing in-flight OIDC login attempt with a new request') - closeLoopback?.() - closeLoopback = null - signingInFlight = false - } - - signingInFlight = true - - try { - // Clean up any previous in-flight login - closeLoopback?.() - - const codeVerifier = generateCodeVerifier() - const codeChallenge = await generateCodeChallenge(codeVerifier) - const state = generateState() - - // Start loopback server to receive the callback - const loopback = await startLoopbackServer(state) - closeLoopback = loopback.close - - // Use the server-side relay as redirect_uri. The relay page serves HTML - // that forwards the authorization code to the loopback via JS fetch(). - // The loopback port is encoded in the state parameter as "{port}:{state}". - const redirectUri = `${SERVER_URL}/api/auth/oidc/electron-callback` - const stateWithPort = `${loopback.port}:${state}` - - // Build authorization URL - // NOTICE: prompt=login forces the authorization server to show the login - // page even if the system browser has an existing session cookie. Without - // this, the OIDC flow auto-completes silently using the stale cookie. - const url = new URL(OIDC_AUTHORIZE_PATH, SERVER_URL) - url.searchParams.set('response_type', 'code') - url.searchParams.set('client_id', OIDC_CLIENT_ID) - url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', OIDC_SCOPES) - url.searchParams.set('state', stateWithPort) - url.searchParams.set('code_challenge', codeChallenge) - url.searchParams.set('code_challenge_method', 'S256') - url.searchParams.set('prompt', 'login') - url.searchParams.set('resource', SERVER_URL) - - // Open system browser - await shell.openExternal(url.toString()) - - // Wait for the callback in the background - loopback.result - .then(async ({ code }) => { - const tokens = await exchangeCode(code, codeVerifier, redirectUri) - params.context.emit(electronAuthCallback, tokens) - log.log('OIDC token exchange successful') - }) - .catch((err) => { - log.withError(err).error('OIDC signing in failed') - params.context.emit(electronAuthCallbackError, { error: errorMessageFrom(err) ?? 'OIDC signing in failed' }) - }) - .finally(() => { - closeLoopback = null - signingInFlight = false - }) - } - catch (err) { - closeLoopback = null - signingInFlight = false - log.withError(err).error('Failed to start OIDC signing in flow') - params.context.emit(electronAuthCallbackError, { error: errorMessageFrom(err) ?? 'OIDC signing in failed' }) - } - }) - - defineInvokeHandler(params.context, electronAuthLogout, async (_, options) => { - if (params.window.webContents.id !== options?.raw.ipcMainEvent.sender.id) { - return - } - - closeLoopback?.() - closeLoopback = null - signingInFlight = false - }) -} - -// --- Internal helpers --- - -interface TokenExchangeResult { - accessToken: string - refreshToken?: string - idToken?: string - expiresIn: number -} - -async function exchangeCode(code: string, codeVerifier: string, redirectUri: string): Promise { - const body = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri, - client_id: OIDC_CLIENT_ID, - code_verifier: codeVerifier, - resource: SERVER_URL, - }) - - const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }) - - if (!response.ok) { - const text = await response.text() - throw new Error(`Token exchange failed (${response.status}): ${text}`) - } - - const data = await response.json() as Record - return { - accessToken: data.access_token as string, - refreshToken: data.refresh_token as string | undefined, - idToken: data.id_token as string | undefined, - expiresIn: data.expires_in as number, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/channel-server/config.test.ts b/apps/stage-tamagotchi/src/main/services/airi/channel-server/config.test.ts deleted file mode 100644 index 7c5bb8c73..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/channel-server/config.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { ensureServerChannelConfigDefaults } from './config' - -describe('ensureServerChannelConfigDefaults', () => { - it('keeps an existing auth token', () => { - const generateToken = vi.fn(() => 'generated-token') - - const result = ensureServerChannelConfigDefaults({ - authToken: 'existing-token', - hostname: '0.0.0.0', - tlsConfig: null, - }, generateToken) - - expect(result.changed).toBe(false) - expect(result.config).toEqual({ - authToken: 'existing-token', - hostname: '0.0.0.0', - tlsConfig: null, - }) - expect(generateToken).not.toHaveBeenCalled() - }) - - it('generates a token when the config is missing one', () => { - const generateToken = vi.fn(() => 'generated-token') - - const result = ensureServerChannelConfigDefaults({ - authToken: '', - hostname: '', - tlsConfig: null, - }, generateToken) - - expect(result.changed).toBe(true) - expect(result.config).toEqual({ - authToken: 'generated-token', - hostname: '127.0.0.1', - tlsConfig: null, - }) - expect(generateToken).toHaveBeenCalledOnce() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/channel-server/config.ts b/apps/stage-tamagotchi/src/main/services/airi/channel-server/config.ts deleted file mode 100644 index 474fcd359..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/channel-server/config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ElectronServerChannelConfig } from '../../../../shared/eventa' - -export function ensureServerChannelConfigDefaults( - config: Partial, - generateToken: () => string, -) { - const nextConfig: ElectronServerChannelConfig = { - authToken: config.authToken?.trim() || generateToken(), - hostname: config.hostname?.trim() || '127.0.0.1', - tlsConfig: config.tlsConfig || null, - } - - const previousConfig: ElectronServerChannelConfig = { - authToken: config.authToken?.trim() || '', - hostname: config.hostname?.trim() || '127.0.0.1', - tlsConfig: config.tlsConfig || null, - } - - return { - changed: JSON.stringify(previousConfig) !== JSON.stringify(nextConfig), - config: nextConfig, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts b/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts deleted file mode 100644 index cf92c8a23..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts +++ /dev/null @@ -1,509 +0,0 @@ -import type { Server, ServerOptions } from '@proj-airi/server-runtime/server' -import type { Lifecycle } from 'injeca' - -import type { ElectronServerChannelConfig } from '../../../../shared/eventa' - -import { randomUUID, X509Certificate } from 'node:crypto' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { isIP } from 'node:net' -import { join } from 'node:path' -import { env, platform } from 'node:process' - -import { useLogg } from '@guiiai/logg' -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { errorMessageFrom } from '@moeru/std' -import { createServer, getLocalIPs } from '@proj-airi/server-runtime/server' -import { createServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr' -import { Mutex } from 'async-mutex' -import { app, ipcMain, session } from 'electron' -import { createCA, createCert } from 'mkcert' -import { x } from 'tinyexec' -import { nullable, object, optional, string } from 'valibot' -import { z } from 'zod' - -import { - electronApplyServerChannelConfig, - electronGetServerChannelConfig, - electronGetServerChannelQrPayload, -} from '../../../../shared/eventa' -import { createConfig } from '../../../libs/electron/persistence' -import { ensureServerChannelConfigDefaults } from './config' - -const channelServerConfigSchema = object({ - hostname: optional(string()), - authToken: optional(string()), - tlsConfig: optional(nullable(object({ - cert: optional(string()), - key: optional(string()), - passphrase: optional(string()), - }))), -}) - -const channelServerInvokeConfigSchema = z.object({ - hostname: z.string().optional(), - authToken: z.string().optional(), - tlsConfig: z.object({ }).nullable().optional(), -}).strict() - -const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, { - default: { - hostname: '127.0.0.1', - authToken: '', - tlsConfig: null, - }, - autoHeal: true, -}) -let serverChannelServiceRegistered = false -let serverChannelCertificateTrustConfigured = false - -interface ServerChannelCertificateVerifyRequest { - hostname: string - verificationResult: string - errorCode: number - certificate: { - subject: { - commonName: string - } - issuer: { - commonName: string - country: string - locality: string - organizations: string[] - } - } -} - -function getServerChannelPort() { - return env.SERVER_CHANNEL_PORT ? Number.parseInt(env.SERVER_CHANNEL_PORT) : 6121 -} - -const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) - -function isLoopbackHost(host: string) { - return LOOPBACK_HOSTS.has(host) -} - -function getServerChannelQrHosts(config: ElectronServerChannelConfig, serverChannel: Server) { - if (config.hostname === '0.0.0.0') { - return Array.from(new Set(serverChannel.getConnectionHost())) - .filter(host => !isLoopbackHost(host)) - .sort() - } - - if (isLoopbackHost(config.hostname)) { - return [] - } - - return [config.hostname] -} - -function createServerChannelUrl(protocol: 'ws' | 'wss', host: string) { - const urlHost = isIP(host) === 6 ? `[${host}]` : host - // TODO: Deduplicate the server channel websocket path with `packages/server-runtime/src/index.ts` - // and `packages/server-sdk/src/client.ts` so this does not rely on three separate `/ws` literals. - return `${protocol}://${urlHost}:${getServerChannelPort()}/ws` -} - -function getServerChannelQrPayload(config: ElectronServerChannelConfig, serverChannel: Server) { - const protocol = config.tlsConfig ? 'wss' : 'ws' - const urls = getServerChannelQrHosts(config, serverChannel) - .map(host => createServerChannelUrl(protocol, host)) - - if (!urls.length) { - throw new Error('No reachable private LAN address is available for the current server channel host.') - } - - return createServerChannelQrPayload({ - type: 'airi:server-channel', - version: 1, - urls, - authToken: config.authToken, - }) -} - -async function getChannelServerConfig(): Promise { - const config = channelServerConfigStore.get() || { hostname: '127.0.0.1', authToken: '', tlsConfig: null } - - return { - hostname: config.hostname || '127.0.0.1', - authToken: config.authToken || '', - tlsConfig: config.tlsConfig || null, - } -} - -function getServerRuntimeBaseOptions() { - return { - port: getServerChannelPort(), - hostname: '127.0.0.1', - } -} - -async function resolveServerRuntimeOptions(config: ServerOptions): Promise { - return { - ...getServerRuntimeBaseOptions(), - auth: { - token: 'authToken' in config && typeof config.authToken === 'string' ? config.authToken : '', - }, - hostname: 'hostname' in config && typeof config.hostname === 'string' - ? config.hostname || '127.0.0.1' - : '127.0.0.1', - tlsConfig: config.tlsConfig ? await getOrCreateCertificate() : null, - } -} - -async function normalizeChannelServerOptions(payload: unknown, fallback?: ElectronServerChannelConfig) { - if (!fallback) { - fallback = await getChannelServerConfig() - } - - const parsed = channelServerInvokeConfigSchema.safeParse(payload) - if (!parsed.success) { - return fallback - } - - const normalizedConfig = { - hostname: parsed.data.hostname ?? fallback.hostname, - authToken: parsed.data.authToken ?? fallback.authToken, - tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig, - } - - return ensureServerChannelConfigDefaults(normalizedConfig, randomUUID).config -} - -function getCertificateDomains(): string[] { - const localIPs = getLocalIPs() - const hostname = channelServerConfigStore.get()?.hostname || env.SERVER_RUNTIME_HOSTNAME - return Array.from(new Set([ - 'localhost', - '127.0.0.1', - '::1', - ...(hostname ? [hostname] : []), - ...localIPs, - ])) -} - -function getCertificatePaths() { - const userDataPath = app.getPath('userData') - - return { - certPath: join(userDataPath, 'websocket-cert.pem'), - keyPath: join(userDataPath, 'websocket-key.pem'), - caCertPath: join(userDataPath, 'websocket-ca-cert.pem'), - caKeyPath: join(userDataPath, 'websocket-ca-key.pem'), - } -} - -function withCertificateChain(cert: string, caCert?: string) { - return caCert ? `${cert.trim()}\n${caCert.trim()}\n` : cert -} - -function certHasAllDomains(certPem: string, domains: string[]): boolean { - try { - const cert = new X509Certificate(certPem) - const san = cert.subjectAltName || '' - const entries = san.split(',').map(part => part.trim()) - const values = entries - .map((entry) => { - if (entry.startsWith('DNS:')) - return entry.slice(4).trim() - if (entry.startsWith('IP Address:')) - return entry.slice(11).trim() - return '' - }) - .filter(Boolean) - - const sanSet = new Set(values) - return domains.every(domain => sanSet.has(domain)) - } - catch { - return false - } -} - -function isTrustedServerChannelCertificate(request: ServerChannelCertificateVerifyRequest): boolean { - if (!['CERT_AUTHORITY_INVALID', 'ERR_CERT_AUTHORITY_INVALID'].includes(request.verificationResult) - && request.errorCode !== -202) { - return false - } - - if (!getCertificateDomains().includes(request.hostname)) { - return false - } - - const issuer = request.certificate.issuer - return request.certificate.subject.commonName === 'localhost' - && issuer.commonName === 'AIRI' - && issuer.country === 'US' - && issuer.locality === 'Local' - && issuer.organizations.includes('AIRI') -} - -function configureServerChannelCertificateTrust() { - if (serverChannelCertificateTrustConfigured) { - return - } - - session.defaultSession.setCertificateVerifyProc((request, callback) => { - if (isTrustedServerChannelCertificate(request)) { - callback(0) - return - } - - callback(-3) - }) - - serverChannelCertificateTrustConfigured = true -} - -async function installCACertificate(caCert: string) { - const { caCertPath } = getCertificatePaths() - const log = useLogg('main/server-runtime').useGlobalConfig() - writeFileSync(caCertPath, caCert) - - try { - if (platform === 'darwin') { - await x('security', ['add-trusted-cert', '-d', '-r', 'trustRoot', '-k', join(app.getPath('home'), 'Library/Keychains/login.keychain-db'), caCertPath], { nodeOptions: { stdio: 'ignore' } }) - } - else if (platform === 'win32') { - await x('certutil', ['-addstore', '-f', 'Root', caCertPath], { nodeOptions: { stdio: 'ignore' } }) - } - else if (platform === 'linux') { - const caDir = '/usr/local/share/ca-certificates' - const caFileName = 'airi-websocket-ca.crt' - try { - writeFileSync(join(caDir, caFileName), caCert) - await x('update-ca-certificates', [], { nodeOptions: { stdio: 'ignore' } }) - } - catch { - const userCaDir = join(env.HOME || '', '.local/share/ca-certificates') - try { - if (!existsSync(userCaDir)) { - await x('mkdir', ['-p', userCaDir], { nodeOptions: { stdio: 'ignore' } }) - } - writeFileSync(join(userCaDir, caFileName), caCert) - } - catch { - // Ignore errors - } - } - } - } - catch (error) { - log.withError(error).warn(`Failed to install AIRI WebSocket CA certificate from ${caCertPath}`) - } -} - -async function generateCertificate() { - const { caCertPath, caKeyPath } = getCertificatePaths() - - let ca: { key: string, cert: string } - - if (existsSync(caCertPath) && existsSync(caKeyPath)) { - ca = { - cert: readFileSync(caCertPath, 'utf-8'), - key: readFileSync(caKeyPath, 'utf-8'), - } - } - else { - ca = await createCA({ - organization: 'AIRI', - countryCode: 'US', - state: 'Development', - locality: 'Local', - validity: 365, - }) - writeFileSync(caCertPath, ca.cert) - writeFileSync(caKeyPath, ca.key) - } - - await installCACertificate(ca.cert) - - const domains = getCertificateDomains() - - const cert = await createCert({ - ca: { key: ca.key, cert: ca.cert }, - domains, - validity: 365, - }) - - return { - cert: cert.cert, - key: cert.key, - } -} - -async function getOrCreateCertificate() { - const { certPath, keyPath, caCertPath } = getCertificatePaths() - const expectedDomains = getCertificateDomains() - - if (existsSync(certPath) && existsSync(keyPath)) { - const cert = readFileSync(certPath, 'utf-8') - const key = readFileSync(keyPath, 'utf-8') - if (certHasAllDomains(cert, expectedDomains)) { - const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined - return { cert: withCertificateChain(cert, caCert), key } - } - } - - const { cert, key } = await generateCertificate() - writeFileSync(certPath, cert) - writeFileSync(keyPath, key) - - const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined - return { cert: withCertificateChain(cert, caCert), key } -} - -export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise { - channelServerConfigStore.setup() - configureServerChannelCertificateTrust() - - const storedConfig = await getChannelServerConfig() - const { changed: storedConfigChanged, config: normalizedStoredConfig } = ensureServerChannelConfigDefaults(storedConfig, randomUUID) - if (storedConfigChanged) { - channelServerConfigStore.update(normalizedStoredConfig) - } - - const serverChannel = createServer(await resolveServerRuntimeOptions(normalizedStoredConfig)) - - const mutex = new Mutex() - - params.lifecycle.appHooks.onStart(async () => { - const release = await mutex.acquire() - - const log = useLogg('main/server-runtime').useGlobalConfig() - - try { - await serverChannel.start() - log.log('WebSocket server started') - } - catch (error) { - log.withError(error).error('Error starting WebSocket server') - } - finally { - release() - } - }) - params.lifecycle.appHooks.onStop(async () => { - const release = await mutex.acquire() - - const log = useLogg('main/server-runtime').useGlobalConfig() - if (!serverChannel) { - return - } - - try { - await serverChannel.stop() - log.log('WebSocket server closed') - } - catch (error) { - log.withError(error).error('Error closing WebSocket server') - } - finally { - release() - } - }) - - return { - getConnectionHost() { - return serverChannel.getConnectionHost() - }, - async start() { - const release = await mutex.acquire() - try { - await serverChannel.start() - } - finally { - release() - } - }, - async restart() { - const release = await mutex.acquire() - try { - await serverChannel.stop() - await serverChannel.start() - } - finally { - release() - } - }, - async stop() { - const release = await mutex.acquire() - try { - await serverChannel.stop() - } - finally { - release() - } - }, - async updateConfig(config) { - const release = await mutex.acquire() - try { - await serverChannel.updateConfig(config) - } - finally { - release() - } - }, - } -} - -export async function createServerChannelService(params: { serverChannel: Server }) { - if (serverChannelServiceRegistered) { - return - } - serverChannelServiceRegistered = true - - const { context } = createContext(ipcMain) - - defineInvokeHandler(context, electronGetServerChannelConfig, async () => { - return await getChannelServerConfig() - }) - - defineInvokeHandler(context, electronGetServerChannelQrPayload, async () => { - const config = await getChannelServerConfig() - return getServerChannelQrPayload(config, params.serverChannel) - }) - - defineInvokeHandler(context, electronApplyServerChannelConfig, async (req) => { - const current = await getChannelServerConfig() - const next = await normalizeChannelServerOptions(req, current) - const tlsChanged = JSON.stringify(next.tlsConfig) !== JSON.stringify(current.tlsConfig) - const hostnameChanged = next.hostname !== current.hostname - const authTokenChanged = next.authToken !== current.authToken - const runtimeChanged = tlsChanged || hostnameChanged || authTokenChanged - - try { - if (runtimeChanged) { - const nextRuntimeOptions = await resolveServerRuntimeOptions(next) - - await params.serverChannel.updateConfig(nextRuntimeOptions) - await params.serverChannel.restart() - } - else { - await params.serverChannel.start() - } - - channelServerConfigStore.update(next) - return next - } - catch (error) { - useLogg('main/server-runtime').withError(error).error('Failed to apply server channel configuration') - if (runtimeChanged) { - const previousRuntimeOptions = await resolveServerRuntimeOptions(current) - - try { - await params.serverChannel.updateConfig(previousRuntimeOptions) - await params.serverChannel.restart() - } - catch (rollbackError) { - useLogg('main/server-runtime').withError(rollbackError).error('Failed to restore previous server channel configuration') - } - } - - throw new Error(errorMessageFrom(error) ?? 'Failed to apply server channel configuration') - } - }) -} - -export type { Server as ServerChannel } diff --git a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts deleted file mode 100644 index d61135810..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts +++ /dev/null @@ -1,972 +0,0 @@ -import type { ChildProcessByStdio } from 'node:child_process' -import type { Readable } from 'node:stream' - -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { - StageViewErrorPayload, - StageViewPatch, - StageViewRequestAckPayload, - StageViewSnapshotPayload, -} from '@proj-airi/stage-shared/godot-stage' -import type { BrowserWindow } from 'electron' -import type { WebSocketMessage, WebSocketPeer } from 'h3' -import type { InferOutput } from 'valibot' - -import type { - ElectronGodotStageSceneInputPayload, - ElectronGodotStageStatus, -} from '../../../../shared/eventa' - -import process from 'node:process' - -import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { access, mkdir, stat, writeFile } from 'node:fs/promises' -import { basename, dirname, join, resolve } from 'node:path' - -import { useLogg } from '@guiiai/logg' -import { defineInvokeHandler } from '@moeru/eventa' -import { errorMessageFrom } from '@moeru/std' -import { - parseStageViewErrorPayload, - parseStageViewPatchPayload, - parseStageViewSnapshotPayload, -} from '@proj-airi/stage-shared/godot-stage' -import { Mutex } from 'async-mutex' -import { plugin as ws } from 'crossws/server' -import { safeDestr } from 'destr' -import { app } from 'electron' -import { getRandomPort } from 'get-port-please' -import { defineWebSocketHandler, H3, serve } from 'h3' -import { instance, literal, object, optional, safeParse, string, unknown as unknownSchema } from 'valibot' - -import { - electronGodotStageApplySceneInput, - electronGodotStageApplyViewPatch, - electronGodotStageGetStatus, - electronGodotStageGetViewSnapshot, - electronGodotStageRequestViewSnapshot, - electronGodotStageStart, - electronGodotStageStatusChanged, - electronGodotStageStop, - electronGodotStageViewSnapshotChanged, - electronGodotStageViewStateError, -} from '../../../../shared/eventa' -import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' -import { getElectronMainDirname } from '../../../libs/electron/location' - -type GodotStageProcess = ChildProcessByStdio -type MainContext = ReturnType['context'] - -const DEFAULT_GODOT_REMOTE_DEBUG_URI = 'tcp://127.0.0.1:6007' - -interface Deferred { - promise: Promise - reject: (error?: unknown) => void - resolve: (value: PromiseLike | T) => void -} - -interface GodotStageSceneApplyPayload { - format: 'vrm' - modelId: string - name: string - path: string -} - -interface GodotStageSocketRuntime { - port: number - server: ReturnType - token: string -} - -interface ListenerChannel { - publish: (payload: T) => void - subscribe: (callback: (payload: T) => void) => () => void -} - -const godotStageSceneInputPayloadSchema = object({ - data: instance(Uint8Array), - fileName: string(), - format: literal('vrm'), - modelId: string(), - name: string(), -}) - -const godotStageSocketEnvelopeSchema = object({ - payload: optional(unknownSchema()), - type: string(), -}) - -const godotStagePayloadMessageSchema = object({ - message: string(), -}) - -/** - * Godot sidecar lifecycle controller owned by Electron main. - * - * Use when: - * - Renderer windows need to start or stop the external Godot stage - * - The selected model should be materialized and forwarded to the Godot runtime - * - * Expects: - * - Production: pre-exported binary in `extraResources/godot-stage/` - * - Dev: `GODOT4` env var points to a local Godot 4.x .NET/Mono executable - * - The current workspace contains `engines/stage-tamagotchi-godot/project.godot` (dev mode only) - * - * Returns: - * - Lifecycle helpers, scene-input forwarding, and status subscriptions - */ -export interface GodotStageManager { - applySceneInput: (payload: ElectronGodotStageSceneInputPayload) => Promise - applyViewPatch: (payload: StageViewPatch) => Promise - getStatus: () => ElectronGodotStageStatus - getViewSnapshot: () => null | StageViewSnapshotPayload - requestViewSnapshot: () => Promise - start: () => Promise - stop: () => Promise - subscribe: (callback: (status: ElectronGodotStageStatus) => void) => () => void - subscribeViewError: (callback: (payload: StageViewErrorPayload) => void) => () => void - subscribeViewSnapshot: (callback: (snapshot: StageViewSnapshotPayload) => void) => () => void -} - -interface GodotBinaryResolution { - executable: string - mode: 'engine' | 'exported' -} - -type GodotStageSocketEnvelope = InferOutput - -/** - * Creates the shared Godot stage manager. - * - * Call stack: - * - * setupGodotStageManager - * -> {@link createGodotStageManager} - * -> renderer invoke handlers - * -> Godot sidecar process + websocket bridge - */ -export function createGodotStageManager(): GodotStageManager { - const log = useLogg('main/godot-stage').useGlobalConfig() - const lifecycleMutex = new Mutex() - const statusListeners = createListenerChannel( - error => log.withError(error).warn('failed to publish Godot stage status change'), - ) - const viewErrorListeners = createListenerChannel( - error => log.withError(error).warn('failed to publish Godot stage view-state error'), - ) - const viewSnapshotListeners = createListenerChannel( - error => log.withError(error).warn('failed to publish Godot stage view-state snapshot'), - ) - let currentStatus = createInitialStatus() - let currentProcess: GodotStageProcess | undefined - let currentProcessExit = createDeferred() - let currentReady: Deferred | undefined - let currentSocketRuntime: GodotStageSocketRuntime | undefined - let currentSocketPeer: undefined | WebSocketPeer - let currentViewSnapshot: null | StageViewSnapshotPayload = null - let expectedProcessExit = false - - function setStatus(next: Partial & Pick) { - currentStatus = { - ...currentStatus, - ...next, - updatedAt: Date.now(), - } - statusListeners.publish(currentStatus) - } - - function broadcastViewSnapshot(snapshot: StageViewSnapshotPayload) { - currentViewSnapshot = snapshot - viewSnapshotListeners.publish(snapshot) - } - - function broadcastViewError(payload: StageViewErrorPayload) { - viewErrorListeners.publish(payload) - } - - function broadcastInvalidViewPayloadError(error: unknown) { - broadcastViewError({ - code: 'invalid-payload', - message: errorMessageFrom(error) ?? 'Invalid Godot stage view-state payload.', - }) - } - - function clearProcessState() { - currentProcess = undefined - currentSocketPeer = undefined - currentViewSnapshot = null - currentProcessExit.resolve() - currentProcessExit = createDeferred() - } - - async function stopSocketRuntime() { - const runtime = currentSocketRuntime - currentSocketRuntime = undefined - currentSocketPeer = undefined - - if (!runtime) { - return - } - - await runtime.server.close(true).catch(() => {}) - } - - async function stopProcessAfterFailedStart() { - if (!currentProcess) { - return - } - - const activeProcess = currentProcess - const exitPromise = currentProcessExit.promise - expectedProcessExit = true - - // Startup failed after spawning Godot; release the child process before - // allowing the renderer to retry and create another stage runtime. - activeProcess.kill() - - await waitForProcessExit(exitPromise, 2_000) - } - - function sendSocketMessage(type: string, payload?: unknown) { - if (!currentSocketPeer) { - return false - } - - currentSocketPeer.send(createSocketEnvelope(type, payload)) - return true - } - - function sendViewRequest(type: 'host.view.patch' | 'host.view.request_snapshot', payload: Record = {}) { - if (currentStatus.state !== 'running') { - throw new Error('Godot stage is not running.') - } - - const requestId = randomUUID() - if (!sendSocketMessage(type, { requestId, ...payload })) { - throw new Error('Godot stage bridge is not connected.') - } - - return { requestId } - } - - function sendSceneInputToGodot(payload: GodotStageSceneApplyPayload) { - if (!sendSocketMessage('host.scene.apply', payload)) { - throw new Error('Godot stage bridge is not connected.') - } - } - - function handleSocketMessage(message: GodotStageSocketEnvelope) { - switch (message.type) { - case 'scene.applied': { - if (currentStatus.state === 'running' && currentStatus.lastError) { - setStatus({ - lastError: undefined, - pid: currentProcess?.pid ?? null, - state: 'running', - }) - } - return - } - case 'scene.error': { - const error = getPayloadMessage(message.payload) ?? 'Godot stage failed to apply scene input.' - setStatus({ - lastError: error, - pid: currentProcess?.pid ?? null, - state: currentStatus.state, - }) - return - } - case 'stage.fatal': { - const error = getPayloadMessage(message.payload) ?? 'Godot stage reported a fatal startup error.' - setStatus({ - lastError: error, - pid: currentProcess?.pid ?? null, - state: 'error', - }) - currentReady?.reject(new Error(error)) - currentReady = undefined - currentProcess?.kill() - return - } - case 'stage.ready': { - setStatus({ - lastError: undefined, - pid: currentProcess?.pid ?? null, - state: 'running', - }) - currentReady?.resolve() - currentReady = undefined - - return - } - case 'stage.view.error': { - try { - broadcastViewError(parseStageViewErrorPayload(message.payload)) - } - catch (error) { - broadcastInvalidViewPayloadError(error) - } - return - } - case 'stage.view.snapshot': { - try { - broadcastViewSnapshot(parseStageViewSnapshotPayload(message.payload)) - } - catch (error) { - broadcastInvalidViewPayloadError(error) - } - return - } - default: { - log.withFields({ type: message.type }).debug('received unknown Godot stage message') - } - } - } - - async function startSocketRuntime() { - if (currentSocketRuntime) { - return currentSocketRuntime - } - - const host = '127.0.0.1' - const port = await getRandomPort(host) - const token = randomUUID() - const appServer = new H3() - - appServer.get('/ws', defineWebSocketHandler({ - close: (peer) => { - if (currentSocketPeer?.id === peer.id) { - currentSocketPeer = undefined - } - }, - message: (_peer, message) => { - try { - handleSocketMessage(parseSocketMessage(message)) - } - catch (error) { - log.withError(error).warn('failed to parse Godot websocket message') - } - }, - open: (peer) => { - const requestUrl = peer.request.url ?? '' - const url = new URL(requestUrl, `ws://${host}:${port}`) - if (url.searchParams.get('token') !== token) { - peer.close?.() - return - } - - currentSocketPeer = peer - log.withFields({ peer: peer.id }).debug('Godot websocket connected') - }, - })) - - const server = serve(appServer, { - gracefulShutdown: { - forceTimeout: 0.25, - gracefulTimeout: 0.25, - }, - hostname: host, - manual: true, - // @ts-expect-error - h3 does not extend the crossws response type. - plugins: [ws({ resolve: async req => (await appServer.fetch(req)).crossws })], - port, - reusePort: false, - silent: true, - }) - - await server.serve() - - currentSocketRuntime = { - port, - server, - token, - } - - return currentSocketRuntime - } - - function attachProcessListeners(processHandle: GodotStageProcess) { - pipeProcessLog(processHandle.stdout, message => log.log(message)) - pipeProcessLog(processHandle.stderr, message => log.warn(message)) - - processHandle.on('error', (error) => { - if (currentProcess !== processHandle) { - log.withError(error).debug('ignored stale Godot stage process error') - return - } - - const message = errorMessageFrom(error) ?? 'Failed to spawn Godot stage process.' - setStatus({ - lastError: message, - pid: processHandle.pid ?? null, - state: 'error', - }) - currentReady?.reject(error) - currentReady = undefined - }) - - processHandle.on('close', (code, signal) => { - if (currentProcess !== processHandle) { - log.withFields({ - code, - pid: processHandle.pid ?? null, - signal, - }).debug('ignored stale Godot stage process close') - return - } - - const exitMessage = signal - ? `Godot stage exited with signal ${signal}.` - : `Godot stage exited with code ${code ?? 0}.` - - clearProcessState() - void stopSocketRuntime() - - if (expectedProcessExit) { - setStatus({ - lastError: undefined, - pid: null, - state: 'stopped', - }) - } - else { - setStatus({ - lastError: exitMessage, - pid: null, - state: 'error', - }) - } - - currentReady?.reject(new Error(exitMessage)) - currentReady = undefined - expectedProcessExit = false - }) - } - - return { - async applySceneInput(payload) { - await lifecycleMutex.runExclusive(async () => { - if (currentStatus.state !== 'running') { - throw new Error('Godot stage is not running.') - } - - const sceneInputPayload = parseSceneInputPayload(payload) - - const fileName = normalizeFileName(sceneInputPayload.fileName) - const modelDirectory = join(resolveGodotStageStorageRoot(), 'models', sceneInputPayload.modelId) - const materializedPath = join(modelDirectory, fileName) - - await mkdir(modelDirectory, { recursive: true }) - await writeFile(materializedPath, sceneInputPayload.data) - - sendSceneInputToGodot({ - format: sceneInputPayload.format, - modelId: sceneInputPayload.modelId, - name: sceneInputPayload.name, - path: materializedPath, - }) - }) - }, - async applyViewPatch(payload) { - return await lifecycleMutex.runExclusive(async () => { - const patch = parseStageViewPatchPayload(payload) - return sendViewRequest('host.view.patch', { patch }) - }) - }, - getStatus() { - return currentStatus - }, - getViewSnapshot() { - return currentViewSnapshot - }, - async requestViewSnapshot() { - return await lifecycleMutex.runExclusive(async () => { - return sendViewRequest('host.view.request_snapshot') - }) - }, - async start() { - return await lifecycleMutex.runExclusive(async () => { - let spawnedProcess: GodotStageProcess | undefined - - try { - if (currentProcess && currentStatus.state === 'running') { - return currentStatus - } - - if (currentProcess && currentStatus.state === 'starting' && currentReady) { - await currentReady.promise - return currentStatus - } - - if (currentProcess) { - const activeProcess = currentProcess - await stopProcessAfterFailedStart() - - if (currentProcess === activeProcess) { - throw new Error('Previous Godot stage process is still shutting down. Retry after it exits.') - } - } - - await stopSocketRuntime() - - const socketRuntime = await startSocketRuntime() - const godotBinary = await resolveGodotBinary() - const websocketUrl = `ws://127.0.0.1:${socketRuntime.port}/ws?token=${socketRuntime.token}` - const readyDeferred = createDeferred() - const readyTimeout = setTimeout(() => { - readyDeferred.reject(new Error('Godot stage did not report ready in time.')) - }, 20_000) - - currentReady = readyDeferred - expectedProcessExit = false - setStatus({ - lastError: undefined, - pid: null, - state: 'starting', - }) - - let spawnArgs: string[] - let spawnCwd: string | undefined - const debugLaunchOptions = resolveGodotStageDebugLaunchOptions() - const sidecarArgs = [ - ...debugLaunchOptions.engineArgs, - '--', - `--airi-ws-url=${websocketUrl}`, - `--airi-storage-root=${resolveGodotStageStorageRoot()}`, - ] - - if (godotBinary.mode === 'engine') { - const godotProjectPath = await resolveGodotProjectPath() - spawnArgs = ['--path', godotProjectPath, ...sidecarArgs] - spawnCwd = godotProjectPath - } - else { - spawnArgs = sidecarArgs - } - - log.withFields({ - executable: godotBinary.executable, - mode: godotBinary.mode, - remoteDebugUri: debugLaunchOptions.remoteDebugUri, - }).log('spawning Godot stage') - - const processHandle = spawn( - godotBinary.executable, - spawnArgs, - { - cwd: spawnCwd, - env: resolveGodotStageProcessEnv(), - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: false, - }, - ) - - spawnedProcess = processHandle - currentProcess = processHandle - attachProcessListeners(processHandle) - - setStatus({ - lastError: undefined, - pid: processHandle.pid ?? null, - state: 'starting', - }) - - try { - await readyDeferred.promise - } - finally { - if (currentReady === readyDeferred) - currentReady = undefined - clearTimeout(readyTimeout) - } - - return currentStatus - } - catch (error) { - if (spawnedProcess && currentProcess === spawnedProcess) { - await stopProcessAfterFailedStart() - } - await stopSocketRuntime() - setStatus({ - lastError: errorMessageFrom(error) ?? 'Failed to start Godot stage.', - pid: null, - state: 'error', - }) - throw error - } - }) - }, - async stop() { - return await lifecycleMutex.runExclusive(async () => { - if (!currentProcess) { - await stopSocketRuntime() - setStatus({ - lastError: undefined, - pid: null, - state: 'stopped', - }) - return currentStatus - } - - const activeProcess = currentProcess - const exitPromise = currentProcessExit.promise - - expectedProcessExit = true - setStatus({ - lastError: undefined, - pid: activeProcess.pid ?? null, - state: 'stopping', - }) - - try { - sendSocketMessage('host.shutdown') - - const exited = await waitForProcessExit(exitPromise, 2_000) - - if (!exited) { - activeProcess.kill() - await exitPromise.catch(() => {}) - } - } - catch (error) { - setStatus({ - lastError: errorMessageFrom(error) ?? 'Failed to stop Godot stage.', - pid: activeProcess.pid ?? null, - state: 'error', - }) - throw error - } - finally { - await stopSocketRuntime() - } - - setStatus({ - lastError: undefined, - pid: null, - state: 'stopped', - }) - - return currentStatus - }) - }, - subscribe(callback) { - const unsubscribe = statusListeners.subscribe(callback) - callback(currentStatus) - return unsubscribe - }, - subscribeViewError(callback) { - return viewErrorListeners.subscribe(callback) - }, - subscribeViewSnapshot(callback) { - return viewSnapshotListeners.subscribe(callback) - }, - } -} - -/** - * Registers Godot stage invoke handlers for one Electron window context. - * - * Call stack: - * - * createGodotStageService - * -> renderer invoke/eventa handlers - * -> {@link GodotStageManager} - */ -export function createGodotStageService(params: { - context: MainContext - manager: GodotStageManager - window: BrowserWindow -}) { - const unsubscribe = params.manager.subscribe((status) => { - if (!params.window.isDestroyed()) { - params.context.emit(electronGodotStageStatusChanged, status) - } - }) - const unsubscribeViewSnapshot = params.manager.subscribeViewSnapshot((snapshot) => { - if (!params.window.isDestroyed()) { - params.context.emit(electronGodotStageViewSnapshotChanged, snapshot) - } - }) - const unsubscribeViewError = params.manager.subscribeViewError((payload) => { - if (!params.window.isDestroyed()) { - params.context.emit(electronGodotStageViewStateError, payload) - } - }) - - const cleanups: Array<() => void> = [ - unsubscribe, - unsubscribeViewSnapshot, - unsubscribeViewError, - defineInvokeHandler(params.context, electronGodotStageStart, () => params.manager.start()), - defineInvokeHandler(params.context, electronGodotStageStop, () => params.manager.stop()), - defineInvokeHandler(params.context, electronGodotStageGetStatus, () => params.manager.getStatus()), - defineInvokeHandler(params.context, electronGodotStageApplySceneInput, payload => params.manager.applySceneInput(payload)), - defineInvokeHandler(params.context, electronGodotStageGetViewSnapshot, () => params.manager.getViewSnapshot()), - defineInvokeHandler(params.context, electronGodotStageApplyViewPatch, payload => params.manager.applyViewPatch(payload)), - defineInvokeHandler(params.context, electronGodotStageRequestViewSnapshot, () => params.manager.requestViewSnapshot()), - ] - - const cleanup = () => { - for (const fn of cleanups) { - fn() - } - } - - params.window.on('closed', cleanup) - return cleanup -} - -/** - * Creates and wires the shared Godot stage manager into app lifecycle hooks. - * - * Use when: - * - Electron main needs one app-wide Godot sidecar lifecycle owner - * - * Expects: - * - App shutdown to call the registered `onAppBeforeQuit` hook - * - * Returns: - * - The ready-to-use Godot stage manager - */ -export function setupGodotStageManager() { - const manager = createGodotStageManager() - - onAppBeforeQuit(async () => { - await manager.stop() - }) - - return manager -} - -function createDeferred(): Deferred { - let resolve!: Deferred['resolve'] - let reject!: Deferred['reject'] - - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - - return { - promise, - reject, - resolve, - } -} - -function createInitialStatus(): ElectronGodotStageStatus { - return { - pid: null, - state: 'stopped', - updatedAt: Date.now(), - } -} - -function createListenerChannel(onListenerError: (error: unknown) => void): ListenerChannel { - const listeners = new Set<(payload: T) => void>() - - return { - publish(payload) { - for (const listener of listeners) { - try { - listener(payload) - } - catch (error) { - onListenerError(error) - } - } - }, - subscribe(callback) { - listeners.add(callback) - - return () => { - listeners.delete(callback) - } - }, - } -} - -function createSocketEnvelope(type: string, payload?: unknown) { - return JSON.stringify({ payload, type }) -} - -function getPayloadMessage(payload: unknown) { - const result = safeParse(godotStagePayloadMessageSchema, payload) - if (!result.success) { - return undefined - } - - return result.output.message -} - -function normalizeFileName(fileName: string) { - const normalized = basename(fileName.trim()) - return normalized || 'model.bin' -} - -function parseSceneInputPayload(payload: unknown): ElectronGodotStageSceneInputPayload { - const result = safeParse(godotStageSceneInputPayloadSchema, payload) - if (!result.success) - throw new Error('Invalid Godot stage scene input payload.') - - return result.output -} - -function parseSocketMessage(message: WebSocketMessage): GodotStageSocketEnvelope { - const parsed = safeDestr(message.text(), { strict: true }) - const result = safeParse(godotStageSocketEnvelopeSchema, parsed) - if (!result.success) - throw new Error('Invalid Godot stage WebSocket envelope.') - - return result.output -} - -function pipeProcessLog(stream: Readable, write: (message: string) => void) { - stream.on('data', (data) => { - const message = data.toString('utf-8').trim() - if (message) { - write(message) - } - }) -} - -// Packaged builds ship a pre-exported sidecar under Electron resources. -async function resolveExportedGodotBinary(): Promise { - const platform = process.platform - let binaryName: string - - if (platform === 'win32') { - binaryName = 'godot-stage.exe' - } - else if (platform === 'darwin') { - binaryName = join('godot-stage.app', 'Contents', 'MacOS', 'godot-stage') - } - else { - binaryName = 'godot-stage' - } - - const binaryPath = join(process.resourcesPath, 'godot-stage', binaryName) - - try { - await access(binaryPath) - return binaryPath - } - catch { - return undefined - } -} - -async function resolveGodotBinary(): Promise { - if (app.isPackaged) { - const exported = await resolveExportedGodotBinary() - if (exported) { - return { executable: exported, mode: 'exported' } - } - - throw new Error( - 'Godot stage exported binary not found. ' - + `Expected at: ${join(process.resourcesPath, 'godot-stage')}`, - ) - } - - const envPath = process.env.GODOT4?.trim() - if (!envPath) { - throw new Error( - 'GODOT4 is required to start Godot Stage in development mode.\n' - + 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable, then restart the Electron dev app.\n' - + 'Examples:\n' - + ' PowerShell: $env:GODOT4 = "C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe"\n' - + ' Bash: export GODOT4="/path/to/godot"', - ) - } - - await validateConfiguredGodotEnginePath(envPath) - return { executable: envPath, mode: 'engine' } -} - -// Dev builds run the Godot engine against the workspace project.godot. -async function resolveGodotProjectPath() { - let currentDirectory = getElectronMainDirname() - - while (true) { - const projectPath = resolve(currentDirectory, 'engines', 'stage-tamagotchi-godot') - - try { - await access(join(projectPath, 'project.godot')) - return projectPath - } - catch {} - - const parentDirectory = dirname(currentDirectory) - if (parentDirectory === currentDirectory) { - break - } - - currentDirectory = parentDirectory - } - - throw new Error(`Unable to locate engines/stage-tamagotchi-godot/project.godot from ${getElectronMainDirname()}.`) -} - -function resolveGodotStageDebugLaunchOptions() { - const remoteDebugEnabled = ['1', 'on', 'true', 'yes'].includes( - (process.env.GODOT_STAGE_REMOTE_DEBUG ?? '').trim().toLowerCase(), - ) - const remoteDebugUri = remoteDebugEnabled - ? process.env.GODOT_STAGE_REMOTE_DEBUG_URI?.trim() || DEFAULT_GODOT_REMOTE_DEBUG_URI - : undefined - - // Godot engine/debugger flags must stay before `--`; StageRoot arguments stay - // after it and are assembled next to the WebSocket URL. - return { - engineArgs: remoteDebugUri ? ['--remote-debug', remoteDebugUri] : [], - remoteDebugUri, - } -} - -function resolveGodotStageProcessEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - AIRI_GODOT_STAGE_DEV_MODE: app.isPackaged - ? process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '0' - : process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '1', - } -} - -function resolveGodotStageStorageRoot() { - return join(app.getPath('userData'), 'godot-stage') -} - -async function validateConfiguredGodotEnginePath(executable: string) { - let executableStats - try { - executableStats = await stat(executable) - } - catch (error) { - throw new Error( - 'GODOT4 points to a missing Godot executable.\n' - + `Configured path: ${executable}\n` - + 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable before starting dev mode.\n' - + `Original error: ${errorMessageFrom(error) ?? 'unknown error'}`, - ) - } - - if (!executableStats.isFile()) { - throw new Error( - 'GODOT4 must point to the Godot executable file, not a directory or app bundle.\n' - + `Configured path: ${executable}\n` - + 'Examples:\n' - + ' Windows: C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe\n' - + ' macOS: /Applications/Godot_mono.app/Contents/MacOS/Godot\n' - + ' Linux: /path/to/Godot_v4.x-stable_mono_linux.x86_64', - ) - } -} - -function waitForProcessExit(exitPromise: Promise, timeoutMs: number) { - return Promise.race([ - exitPromise.then(() => true, () => false), - new Promise(resolve => setTimeout(resolve, timeoutMs, false)), - ]) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts deleted file mode 100644 index 2acfd24af..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { HTTPError } from 'h3' - -export interface HttpErrorInput { - status: number - code: string - message: string - reason?: string - details?: unknown - expose?: boolean -} - -export interface H3HttpErrorOptions { - headers?: HeadersInit -} - -/** - * Unified HTTP error shape for AIRI local HTTP server modules. - * - * Use when: - * - Returning typed errors from internal server modules - * - Keeping stable error code + reason metadata for logging/debugging - * - * Expects: - * - `code` is a stable machine-readable value - * - `message` is safe for clients when `expose` is true - * - * Returns: - * - Error instance with status and structured metadata - */ -export class HttpError extends Error { - readonly status: number - readonly code: string - readonly reason?: string - readonly details?: unknown - readonly expose: boolean - - constructor(input: HttpErrorInput) { - super(input.message) - this.name = 'HttpError' - this.status = input.status - this.code = input.code - this.reason = input.reason - this.details = input.details - this.expose = input.expose ?? false - } -} - -/** - * Converts a local `HttpError` into an h3-compatible throwable error. - * - * Use when: - * - A route catches internal `HttpError` values and must throw an h3 HTTP error - * - * Expects: - * - `error.message` is only exposed when `error.expose` is true - * - * Returns: - * - `HTTPError` preserving status while controlling client-visible message - */ -export function toH3HttpError(error: HttpError, options: H3HttpErrorOptions = {}) { - return HTTPError.status(error.status, error.expose ? error.message : defaultHttpMessage(error.status), { - headers: options.headers, - }) -} - -function defaultHttpMessage(status: number) { - if (status === 400) - return 'Bad Request' - if (status === 401) - return 'Unauthorized' - if (status === 403) - return 'Forbidden' - if (status === 404) - return 'Not Found' - if (status >= 500) - return 'Internal Server Error' - return 'Request Failed' -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.test.ts deleted file mode 100644 index f992005e3..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' - -import { startLoopbackServer } from './index' - -/** - * @example - * const server = await startLoopbackServer('expected-state') - */ -describe('startLoopbackServer', () => { - const servers: Array>> = [] - - afterEach(async () => { - for (const server of servers.splice(0)) { - server.close() - await server.result.catch(() => {}) - } - }) - - /** @example A callback with the expected state resolves the authorization code. */ - it('returns the code from a callback with the expected state', async () => { - const server = await startLoopbackServer('state-1') - servers.push(server) - - const response = await fetch(`http://127.0.0.1:${server.port}/callback?code=ok&state=state-1`) - expect(response.status).toBe(200) - - await expect(server.result).resolves.toEqual({ code: 'ok' }) - }) - - /** @example A forged callback cannot consume the one-shot server before the valid callback. */ - it('rejects a mismatched state without settling the login attempt', async () => { - const server = await startLoopbackServer('expected-state') - servers.push(server) - - const forgedResponse = await fetch(`http://127.0.0.1:${server.port}/callback?code=forged&state=wrong-state`) - expect(forgedResponse.status).toBe(400) - - const validResponse = await fetch(`http://127.0.0.1:${server.port}/callback?code=valid&state=expected-state`) - expect(validResponse.status).toBe(200) - - await expect(server.result).resolves.toEqual({ code: 'valid' }) - }) - - /** @example The web relay receives ordinary CORS without the obsolete PNA response header. */ - it('keeps standard CORS for the relay without private-network access headers', async () => { - const server = await startLoopbackServer('state-1') - servers.push(server) - - const response = await fetch(`http://127.0.0.1:${server.port}/callback?code=ok&state=state-1`, { - headers: { - Origin: 'https://accounts.airi.build', - }, - }) - - expect(response.status).toBe(200) - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*') - expect(response.headers.get('Access-Control-Allow-Private-Network')).toBeNull() - await expect(server.result).resolves.toEqual({ code: 'ok' }) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts deleted file mode 100644 index d0076b49c..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { eventHandler, getQuery, H3, handleCors } from 'h3' - -import { createH3Server } from '../../server' - -/** - * Validated authorization data returned by the temporary loopback server. - */ -export interface LoopbackCallbackResult { - /** Authorization code accepted only after the OIDC state matches. */ - code: string -} - -/** - * Starts a temporary loopback callback server for the Electron OIDC flow. - * - * Use when: - * - Exchanging authorization code from system browser callback - * - * Expects: - * - `expectedState` is the high-entropy state generated for this login attempt - * - Callback request on `GET /callback?code=...&state=...` - * - One-shot lifecycle; the first callback with matching state closes the server - * - * Returns: - * - Random bound port, callback result promise, and manual cancellation method - */ -export async function startLoopbackServer(expectedState: string): Promise<{ - port: number - result: Promise - close: () => void -}> { - const host = '127.0.0.1' - let settled = false - let timeout: ReturnType | undefined - - let resolveResult!: (value: LoopbackCallbackResult) => void - let rejectResult!: (reason: Error) => void - - const result = new Promise((resolve, reject) => { - resolveResult = resolve - rejectResult = reject - }) - - const app = new H3() - const loopbackServer = createH3Server({ app, host }) - const corsOptions = { - origin: '*', - methods: '*', - preflight: { - statusCode: 204, - }, - } as const - - // NOTICE: - // Standard CORS lets configured web relay origins read successful handoff responses. - // A simple cross-origin GET is still sent regardless of CORS response headers, so OIDC state validation is the authorization boundary. - // Source/context: `https://developer.chrome.com/blog/local-network-access`. - // Removal condition: the relay moves to same-origin transport or top-level navigation only. - - /** - * Settles the one-shot callback result and stops the loopback listener. - */ - const finish = (callback: () => void) => { - if (settled) { - return - } - - settled = true - if (timeout) { - clearTimeout(timeout) - timeout = undefined - } - callback() - void loopbackServer.stop() - } - - app.options('/callback', eventHandler(async (event) => { - const corsResponse = handleCors(event, corsOptions) - if (corsResponse !== false) { - return corsResponse - } - - return new Response(null, { status: 204 }) - })) - - app.get('/callback', eventHandler(async (event) => { - const corsResponse = handleCors(event, corsOptions) - if (corsResponse !== false) { - return corsResponse - } - - const query = getQuery(event) - const state = typeof query.state === 'string' ? query.state : '' - if (!state || state !== expectedState) { - return new Response('

Invalid state

', { - status: 400, - headers: { 'Content-Type': 'text/html; charset=utf-8' }, - }) - } - - const error = typeof query.error === 'string' ? query.error : undefined - if (error) { - const description = typeof query.error_description === 'string' && query.error_description.length > 0 - ? query.error_description - : error - finish(() => { - rejectResult(new Error(description)) - }) - return new Response('

Authentication failed

You can close this window.

', { - status: 200, - headers: { 'Content-Type': 'text/html; charset=utf-8' }, - }) - } - - const code = typeof query.code === 'string' ? query.code : '' - if (!code) { - return new Response('

Missing parameters

', { - status: 400, - headers: { 'Content-Type': 'text/html; charset=utf-8' }, - }) - } - - finish(() => { - resolveResult({ code }) - }) - - return new Response('

Authentication successful!

You can close this window and return to the app.

', { - status: 200, - headers: { 'Content-Type': 'text/html; charset=utf-8' }, - }) - })) - - const address = await loopbackServer.start() - - timeout = setTimeout(() => { - finish(() => { - rejectResult(new Error('Sign-in timed out — no callback received')) - }) - }, 5 * 60 * 1000) - - return { - port: address.port, - result, - close: () => { - finish(() => { - rejectResult(new Error('OIDC sign-in attempt cancelled')) - }) - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/index.test.ts deleted file mode 100644 index 8d5c5fdb8..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/index.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { setupBuiltInServer } from './index' - -describe('setupBuiltInServer', () => { - it('starts registered adapters', async () => { - const auth = { key: 'auth', start: vi.fn(async () => {}), stop: vi.fn(async () => {}) } - const assets = { key: 'assets', start: vi.fn(async () => {}), stop: vi.fn(async () => {}) } - - const service = setupBuiltInServer({ - authServer: auth, - staticAssetServer: assets, - }) - - await service.start() - - expect(auth.start).toHaveBeenCalledOnce() - expect(assets.start).toHaveBeenCalledOnce() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts deleted file mode 100644 index e8b9e5178..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ServerManager } from './server-manager/types' - -import { createHttpServerManager } from './server-manager' - -export interface BuiltInServer { - start: () => Promise - stop: () => Promise -} - -/** - * Composes AIRI local HTTP servers behind one lifecycle service. - * - * Use when: - * - Main process needs one start/stop entrypoint for local HTTP services - * - * Expects: - * - Each server follows the `ServerManager` lifecycle contract - * - * Returns: - * - A lifecycle service with ordered startup/shutdown behavior - */ -export function setupBuiltInServer(params: { - authServer?: ServerManager - staticAssetServer?: ServerManager - servers?: ServerManager[] -}): BuiltInServer { - const servers = [ - ...(params.authServer ? [params.authServer] : []), - ...(params.staticAssetServer ? [params.staticAssetServer] : []), - ...(params.servers ?? []), - ] - const manager = createHttpServerManager(servers) - - return { - start: manager.start, - stop: manager.stop, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/server-manager/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/server-manager/index.test.ts deleted file mode 100644 index bffa77e41..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/server-manager/index.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { createHttpServerManager } from './index' - -describe('createHttpServerManager', () => { - it('starts and stops registered servers in order', async () => { - const startA = vi.fn(async () => {}) - const stopA = vi.fn(async () => {}) - const startB = vi.fn(async () => {}) - const stopB = vi.fn(async () => {}) - - const manager = createHttpServerManager([ - { key: 'a', start: startA, stop: stopA }, - { key: 'b', start: startB, stop: stopB }, - ]) - - await manager.start() - await manager.stop() - - expect(startA).toHaveBeenCalledOnce() - expect(startB).toHaveBeenCalledOnce() - expect(stopB).toHaveBeenCalledOnce() - expect(stopA).toHaveBeenCalledOnce() - }) - - it('serializes concurrent start and stop calls', async () => { - let releaseStartA: (() => void) | undefined - - const startA = vi.fn(async () => { - await new Promise((resolve) => { - releaseStartA = resolve - }) - }) - const stopA = vi.fn(async () => {}) - const startB = vi.fn(async () => {}) - const stopB = vi.fn(async () => {}) - - const manager = createHttpServerManager([ - { key: 'a', start: startA, stop: stopA }, - { key: 'b', start: startB, stop: stopB }, - ]) - - const firstStart = manager.start() - const secondStart = manager.start() - await Promise.resolve() - - expect(startA).toHaveBeenCalledOnce() - expect(startB).toHaveBeenCalledTimes(0) - - releaseStartA?.() - await Promise.all([firstStart, secondStart]) - - expect(startA).toHaveBeenCalledOnce() - expect(startB).toHaveBeenCalledOnce() - - await Promise.all([manager.stop(), manager.stop()]) - - expect(stopB).toHaveBeenCalledOnce() - expect(stopA).toHaveBeenCalledOnce() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/server-manager/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/server-manager/index.ts deleted file mode 100644 index f32ac4244..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/server-manager/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ServerManager } from './types' - -import { Mutex } from 'async-mutex' - -/** - * Creates an ordered lifecycle manager for AIRI local HTTP servers. - * - * Use when: - * - Multiple standalone HTTP servers must boot together - * - Shutdown order must run in reverse startup order - * - * Expects: - * - The `servers` list order defines startup order - * - `start`/`stop` may be called multiple times safely - * - * Returns: - * - An idempotent manager with `start` and `stop` - */ -export function createHttpServerManager(servers: ServerManager[]) { - let started = false - const lifecycleMutex = new Mutex() - - return { - async start() { - await lifecycleMutex.runExclusive(async () => { - if (started) { - return - } - - for (const server of servers) { - await server.start() - } - - started = true - }) - }, - async stop() { - await lifecycleMutex.runExclusive(async () => { - if (!started) { - return - } - - for (const server of [...servers].reverse()) { - await server.stop() - } - - started = false - }) - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/server-manager/types.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/server-manager/types.ts deleted file mode 100644 index deb29a7a1..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/server-manager/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Shared lifecycle contract for AIRI local HTTP sub-servers. - * - * Use when: - * - Registering standalone local HTTP services under `services/airi/http-server` - * - Composing startup/shutdown order in the server manager - * - * Expects: - * - `start` to be idempotent - * - `stop` to be safe to call after partial startup - * - * Returns: - * - Promise lifecycle completion for each server action - */ -export interface ServerManager { - key: string - start: () => Promise - stop: () => Promise -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/server.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/server.ts deleted file mode 100644 index e569bfd8b..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/server.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Mutex } from 'async-mutex' -import { getRandomPort } from 'get-port-please' -import { serve } from 'h3' - -export interface BuiltInServerAddress { - host: string - port: number - baseUrl: string -} - -/** - * Creates a reusable local HTTP server lifecycle around an h3 app. - * - * Use when: - * - A local HTTP module needs host/port assignment and start/stop lifecycle - * - Callers may trigger concurrent start/stop operations - * - * Expects: - * - `app` is a valid h3 app/handler accepted by `serve` - * - Caller manages route registration before first `start` - * - * Returns: - * - Idempotent lifecycle with serialized start/stop and runtime address getter - */ -export function createH3Server(options: { - app: Parameters[0] - host?: string - port?: number - silent?: boolean -}) { - const host = options.host ?? '127.0.0.1' - const silent = options.silent ?? true - const lifecycleMutex = new Mutex() - - let server: ReturnType | undefined - let address: BuiltInServerAddress | undefined - - return { - async start(): Promise { - return await lifecycleMutex.runExclusive(async () => { - if (address) { - return address - } - - const port = options.port ?? await getRandomPort(host) - server = serve(options.app, { hostname: host, port, silent }) - - address = { - host, - port, - baseUrl: `http://${host}:${port}`, - } - - return address - }) - }, - async stop(): Promise { - await lifecycleMutex.runExclusive(async () => { - address = undefined - if (!server) { - return - } - - const activeServer = server - server = undefined - await activeServer.close().catch(() => {}) - }) - }, - getAddress() { - return address - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts deleted file mode 100644 index 4b53a221a..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { afterEach, describe, expect, it } from 'vitest' - -import { createStaticAssetService } from './index' -import { createStaticAssetSessionStore } from './session-store' - -describe('createStaticAssetService', () => { - const servers: Array> = [] - const tempRoots: string[] = [] - - afterEach(async () => { - while (servers.length > 0) { - const server = servers.pop() - if (!server) { - continue - } - await server.stop() - } - - for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }) - } - tempRoots.length = 0 - }) - - it('accepts pathPrefix relative to /ui route segment', async () => { - const rootDir = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) - tempRoots.push(rootDir) - await mkdir(join(rootDir, 'ui', 'assets'), { recursive: true }) - await writeFile(join(rootDir, 'ui', 'assets', 'app.js'), 'console.log("ok")\n') - - const extensionId = 'airi-plugin-game-chess' - const version = '1.0.0' - const sessionStore = createStaticAssetSessionStore() - const validateInputs: string[] = [] - const server = createStaticAssetService({ - getManifestEntryByExtensionId: () => new Map([ - [extensionId, { rootDir, version }], - ]), - sessionStore: { - ...sessionStore, - validateRequest(input) { - validateInputs.push(input.assetPath) - return sessionStore.validateRequest(input) - }, - }, - }) - servers.push(server) - await server.start() - - const session = server.createSession({ - extensionId, - version, - ownerSessionId: 'session-1', - pathPrefix: '', - ttlMs: 60_000, - }) - - const baseUrl = server.getBaseUrl() - expect(baseUrl).toBeTruthy() - - const response = await fetch(`${baseUrl}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, { - headers: { - cookie: `${session.cookieName}=${session.cookieValue}`, - }, - }) - const responseBody = await response.text() - expect({ - status: response.status, - validateInputs, - responseBody, - }).toEqual({ - status: 200, - validateInputs: ['assets/app.js'], - responseBody: 'console.log("ok")\n', - }) - }) - - it('serves HEAD requests with valid cookie auth refresh and empty body', async () => { - let refreshedSessionId: string | undefined - const { extensionId, server } = await createStartedAssetServer({ - onRefreshSession: (assetSessionId) => { - refreshedSessionId = assetSessionId - }, - }) - - const session = server.createSession({ - extensionId, - version: '1.0.0', - ownerSessionId: 'session-1', - pathPrefix: '', - ttlMs: 60_000, - }) - - const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, { - headers: { - cookie: `${session.cookieName}=${session.cookieValue}`, - }, - method: 'HEAD', - }) - - expect(response.status).toBe(200) - expect(refreshedSessionId).toBe(session.assetSessionId) - expect(response.headers.get('cache-control')).toBe('no-store') - expect(response.headers.get('referrer-policy')).toBe('no-referrer') - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - expect(await response.text()).toBe('') - }) - - it('returns 405 with security headers for POST requests', async () => { - const { extensionId, server } = await createStartedAssetServer() - - const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/session-1/ui/assets/app.js`, { - method: 'POST', - }) - - expect(response.status).toBe(405) - expect(response.headers.get('cache-control')).toBe('no-store') - expect(response.headers.get('referrer-policy')).toBe('no-referrer') - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - }) - - it('returns 401 with security headers when cookie is missing through the real server', async () => { - const { extensionId, server } = await createStartedAssetServer() - const session = server.createSession({ - extensionId, - version: '1.0.0', - ownerSessionId: 'session-1', - pathPrefix: '', - ttlMs: 60_000, - }) - - const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`) - - expect(response.status).toBe(401) - expect(response.headers.get('cache-control')).toBe('no-store') - expect(response.headers.get('referrer-policy')).toBe('no-referrer') - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - }) - - it('rejects a previously valid URL and cookie after revocation', async () => { - const { extensionId, server } = await createStartedAssetServer() - const session = server.createSession({ - extensionId, - version: '1.0.0', - ownerSessionId: 'session-1', - pathPrefix: '', - ttlMs: 60_000, - }) - const url = `${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js` - const headers = { - cookie: `${session.cookieName}=${session.cookieValue}`, - } - - const validResponse = await fetch(url, { headers }) - await validResponse.arrayBuffer() - server.revokeSession(session.assetSessionId) - const revokedResponse = await fetch(url, { headers }) - - expect(validResponse.status).toBe(200) - expect(revokedResponse.status).toBe(401) - expect(revokedResponse.headers.get('cache-control')).toBe('no-store') - expect(revokedResponse.headers.get('referrer-policy')).toBe('no-referrer') - expect(revokedResponse.headers.get('x-content-type-options')).toBe('nosniff') - }) - - it('returns 404 for missing in-root assets with a valid session', async () => { - const { extensionId, server } = await createStartedAssetServer() - const session = server.createSession({ - extensionId, - version: '1.0.0', - ownerSessionId: 'session-1', - pathPrefix: '', - ttlMs: 60_000, - }) - - const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/missing.js`, { - headers: { - cookie: `${session.cookieName}=${session.cookieValue}`, - }, - }) - - expect(response.status).toBe(404) - expect(response.headers.get('cache-control')).toBe('no-store') - expect(response.headers.get('referrer-policy')).toBe('no-referrer') - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - }) - - it('uses the same manifest entry for auth and asset resolution within one request', async () => { - const firstRootDir = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) - const secondRootDir = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) - tempRoots.push(firstRootDir, secondRootDir) - await mkdir(join(firstRootDir, 'ui', 'assets'), { recursive: true }) - await mkdir(join(secondRootDir, 'ui', 'assets'), { recursive: true }) - await writeFile(join(firstRootDir, 'ui', 'assets', 'app.js'), 'console.log("first")\n') - await writeFile(join(secondRootDir, 'ui', 'assets', 'app.js'), 'console.log("second")\n') - - const extensionId = 'airi-plugin-game-chess' - const version = '1.0.0' - const manifestEntries = [ - new Map([[extensionId, { rootDir: firstRootDir, version }]]), - new Map([[extensionId, { rootDir: secondRootDir, version }]]), - ] - let manifestReadCount = 0 - const server = createStaticAssetService({ - getManifestEntryByExtensionId: () => manifestEntries[Math.min(manifestReadCount++, manifestEntries.length - 1)], - }) - servers.push(server) - await server.start() - - const session = server.createSession({ - extensionId, - version, - ownerSessionId: 'session-1', - pathPrefix: '', - ttlMs: 60_000, - }) - - const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, { - headers: { - cookie: `${session.cookieName}=${session.cookieValue}`, - }, - }) - - expect(response.status).toBe(200) - expect(await response.text()).toBe('console.log("first")\n') - expect(manifestReadCount).toBe(1) - }) - - async function createStartedAssetServer(options: { - onRefreshSession?: (assetSessionId: string) => void - } = {}) { - const rootDir = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) - tempRoots.push(rootDir) - await mkdir(join(rootDir, 'ui', 'assets'), { recursive: true }) - await writeFile(join(rootDir, 'ui', 'assets', 'app.js'), 'console.log("ok")\n') - - const extensionId = 'airi-plugin-game-chess' - const version = '1.0.0' - const sessionStore = createStaticAssetSessionStore() - const server = createStaticAssetService({ - getManifestEntryByExtensionId: () => new Map([ - [extensionId, { rootDir, version }], - ]), - sessionStore: { - ...sessionStore, - refreshSession(assetSessionId) { - options.onRefreshSession?.(assetSessionId) - return sessionStore.refreshSession(assetSessionId) - }, - }, - }) - servers.push(server) - await server.start() - - return { extensionId, rootDir, server, sessionStore, version } - } -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts deleted file mode 100644 index ec731c8c8..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts +++ /dev/null @@ -1,221 +0,0 @@ -import type { ServerManager } from '../server-manager/types' -import type { StaticAssetSessionStore } from './types' - -import { AsyncLocalStorage } from 'node:async_hooks' -import { realpath, stat } from 'node:fs/promises' -import { resolve } from 'node:path' - -import { H3 } from 'h3' - -import { HttpError } from '../errors' -import { createH3Server } from '../server' -import { - normalizeStaticAssetPath, - resolveStaticAssetFilePath, -} from './paths' -import { createStaticAssetRoute } from './route' -import { createStaticAssetSessionStore } from './session-store' - -export interface StaticAssetManifestEntry { - rootDir: string - version: string -} - -export interface StaticAssetService extends ServerManager { - getBaseUrl: () => string | undefined - createSession: StaticAssetSessionStore['createSession'] - revokeSession: StaticAssetSessionStore['revokeSession'] - revokeByOwnerSessionId: StaticAssetSessionStore['revokeByOwnerSessionId'] - revokeByExtensionId: StaticAssetSessionStore['revokeByExtensionId'] - revokeAll: StaticAssetSessionStore['revokeAll'] -} - -/** - * Creates the low-level extension static asset transport server. - * - * Use when: - * - Main process must serve plugin iframe assets via local loopback HTTP - * - Cookie-backed session auth is required for all plugin asset requests - * - A higher-level plugin asset service needs an HTTP transport adapter - * - * Expects: - * - `getManifestEntryByExtensionId` returns up-to-date extension root/version map - * - * Returns: - * - Lifecycle service with session create/revoke APIs and local base URL getter - */ -export function createStaticAssetService(options: { - getManifestEntryByExtensionId: () => Map - host?: string - sessionStore?: StaticAssetSessionStore - getType?: (ext: string) => string | undefined -}): StaticAssetService { - const host = options.host ?? '127.0.0.1' - const sessionStore = options.sessionStore ?? createStaticAssetSessionStore() - const getType = options.getType ?? defaultStaticAssetMimeTypeResolver - - const app = new H3() - const serverLifecycle = createH3Server({ app, host }) - const manifestEntryRequestCache = new AsyncLocalStorage>() - const getManifestEntryForRequest = (extensionId: string) => { - const cache = manifestEntryRequestCache.getStore() - if (!cache) { - return options.getManifestEntryByExtensionId().get(extensionId) - } - - if (!cache.has(extensionId)) { - cache.set(extensionId, options.getManifestEntryByExtensionId().get(extensionId)) - } - - return cache.get(extensionId) - } - - const staticAssetRoute = createStaticAssetRoute({ - getType, - authorize: async ({ extensionId, assetSessionId, assetPath, cookieValue }) => { - const entry = getManifestEntryForRequest(extensionId) - if (!entry) { - return { - ok: false, - error: new HttpError({ - status: 401, - code: 'EXTENSION_ASSET_EXTENSION_NOT_REGISTERED', - message: 'Unauthorized', - reason: 'extension manifest entry does not exist for requested extensionId', - }), - } - } - - return sessionStore.validateRequest({ - extensionId, - version: entry.version, - assetSessionId, - assetPath, - cookieValue, - }) - }, - refreshSession: sessionStore.refreshSession, - resolveAsset: async ({ extensionId, assetPath }) => { - const entry = getManifestEntryForRequest(extensionId) - if (!entry) { - return { - ok: false, - error: new HttpError({ - status: 404, - code: 'EXTENSION_ASSET_EXTENSION_NOT_FOUND', - message: 'Not Found', - reason: 'extension manifest entry does not exist for requested extensionId', - }), - } - } - - const normalizedAssetPath = normalizeStaticAssetPath(assetPath) - if (!normalizedAssetPath) { - return { - ok: false, - error: new HttpError({ - status: 400, - code: 'EXTENSION_ASSET_PATH_INVALID', - message: 'Bad Request', - reason: 'asset path could not be normalized', - }), - } - } - - const fullAssetPath = `ui/${normalizedAssetPath}` - const resolvedRoot = await realpath(entry.rootDir) - const candidatePath = resolve(resolvedRoot, fullAssetPath) - const filePath = await resolveStaticAssetFilePath(entry.rootDir, fullAssetPath) - if (!filePath) { - try { - await stat(candidatePath) - } - catch { - return { - ok: false, - error: new HttpError({ - status: 404, - code: 'EXTENSION_ASSET_NOT_FOUND', - message: 'Not Found', - reason: 'resolved file does not exist', - }), - } - } - - return { - ok: false, - error: new HttpError({ - status: 400, - code: 'EXTENSION_ASSET_PATH_RESOLVE_FAILED', - message: 'Bad Request', - reason: 'resolved asset path is outside extension root', - }), - } - } - - try { - const fileStats = await stat(filePath) - if (!fileStats.isFile()) { - return { - ok: false, - error: new HttpError({ - status: 404, - code: 'EXTENSION_ASSET_NOT_FILE', - message: 'Not Found', - reason: 'resolved path exists but is not a file', - }), - } - } - - return { - ok: true, - filePath, - size: fileStats.size, - mtime: fileStats.mtimeMs, - } - } - catch { - return { - ok: false, - error: new HttpError({ - status: 404, - code: 'EXTENSION_ASSET_NOT_FOUND', - message: 'Not Found', - reason: 'resolved file does not exist', - }), - } - } - }, - }) - - app.use('/_airi/extensions/**', event => manifestEntryRequestCache.run(new Map(), () => staticAssetRoute(event))) - - return { - key: 'static-assets', - async start() { - await serverLifecycle.start() - }, - async stop() { - await serverLifecycle.stop() - }, - getBaseUrl() { - return serverLifecycle.getAddress()?.baseUrl - }, - createSession: sessionStore.createSession, - revokeSession: sessionStore.revokeSession, - revokeByOwnerSessionId: sessionStore.revokeByOwnerSessionId, - revokeByExtensionId: sessionStore.revokeByExtensionId, - revokeAll: sessionStore.revokeAll, - } -} - -const staticAssetMimeTypeOverrides: Record = { - '.wasm': 'application/wasm', - '.avif': 'image/avif', - '.heic': 'image/heic', - '.heif': 'image/heif', -} - -function defaultStaticAssetMimeTypeResolver(ext: string) { - return staticAssetMimeTypeOverrides[ext.toLowerCase()] -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts deleted file mode 100644 index cff0a8bee..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { afterEach, describe, expect, it } from 'vitest' - -import { - buildMountedStaticAssetPath, - normalizeStaticAssetPath, - parseStaticAssetRequestPath, - resolveStaticAssetFilePath, -} from './paths' - -describe('static asset paths', () => { - const tempRoots: string[] = [] - - afterEach(async () => { - for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }) - } - tempRoots.length = 0 - }) - - it('normalizes valid asset paths and rejects traversal-like segments', () => { - expect(normalizeStaticAssetPath('dist/ui/index.html')).toBe('dist/ui/index.html') - expect(normalizeStaticAssetPath('./dist/ui/index.html')).toBeUndefined() - expect(normalizeStaticAssetPath('../secret.txt')).toBeUndefined() - expect(normalizeStaticAssetPath('dist/../ui/index.html')).toBeUndefined() - }) - - it('parses session-scoped mounted plugin request path and rejects malformed routes', () => { - expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html')).toEqual({ - extensionId: 'airi-plugin-game-chess', - assetSessionId: 'asset-session-1', - assetPath: 'dist/ui/index.html', - }) - expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/ui/dist/ui/index.html')).toBeUndefined() - expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/../../etc/passwd')).toBeUndefined() - expect(parseStaticAssetRequestPath('/_airi/extensions//sessions/asset-session-1/ui/index.html')).toBeUndefined() - expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess//sessions/asset-session-1/ui/index.html')).toBeUndefined() - expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions//ui/index.html')).toBeUndefined() - expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1//ui/index.html')).toBeUndefined() - expect(parseStaticAssetRequestPath('/_airi/extensions/p/sessions/s/ui/safe%2F..%2Fsecret.txt')).toBeUndefined() - expect(parseStaticAssetRequestPath('/_airi/extensions/p%/sessions/s/ui/index.html')).toBeUndefined() - }) - - it('builds session-scoped mounted asset path with encoded segments', () => { - expect(buildMountedStaticAssetPath({ - extensionId: 'airi-plugin-game-chess', - assetSessionId: 'asset-session-1', - assetPath: 'dist/ui/index.html', - })).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html') - expect(buildMountedStaticAssetPath({ - extensionId: 'airi-plugin-game-chess', - assetSessionId: 'asset-session-1', - assetPath: 'dist/ui/file name.html', - })).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/file%20name.html') - expect(buildMountedStaticAssetPath({ - extensionId: 'bad/id', - assetSessionId: 'asset-session-1', - assetPath: 'dist/ui/index.html', - })).toBeUndefined() - expect(buildMountedStaticAssetPath({ - extensionId: 'airi-plugin-game-chess', - assetSessionId: 'bad session', - assetPath: 'dist/ui/index.html', - })).toBeUndefined() - }) - - it('resolves only files inside plugin root', async () => { - const root = await mkdtemp(join(tmpdir(), 'airi-plugin-assets-')) - tempRoots.push(root) - - await mkdir(join(root, 'dist', 'ui'), { recursive: true }) - await writeFile(join(root, 'dist', 'ui', 'index.html'), '') - - await expect(resolveStaticAssetFilePath(root, 'dist/ui/index.html')).resolves.toContain('dist/ui/index.html') - await expect(resolveStaticAssetFilePath(root, '../outside.txt')).resolves.toBeUndefined() - }) - - it('rejects symlinked plugin asset files that resolve outside plugin root', async () => { - const root = await mkdtemp(join(tmpdir(), 'airi-plugin-assets-')) - const outsideRoot = await mkdtemp(join(tmpdir(), 'airi-plugin-assets-outside-')) - tempRoots.push(root, outsideRoot) - - const outsideFile = join(outsideRoot, 'secret.txt') - await writeFile(outsideFile, 'secret') - await symlink(outsideFile, join(root, 'link-name')) - - await expect(resolveStaticAssetFilePath(root, 'link-name')).resolves.toBeUndefined() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts deleted file mode 100644 index 6e2c4f97f..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { realpath } from 'node:fs/promises' -import { resolve, sep } from 'node:path' - -/** - * Parsed session-scoped plugin asset request route. - * - * @param extensionId Plugin extension identifier from the mounted route. - * @param assetSessionId Asset session identifier from the mounted route. - */ -export interface ParsedStaticAssetRequest { - /** Plugin extension identifier validated as one safe route segment. */ - extensionId: string - /** Asset session identifier validated as one safe route segment. */ - assetSessionId: string - /** Normalized plugin asset path relative to the mounted UI asset root. */ - assetPath: string -} - -const pathPrefix = '/_airi/extensions/' -const segmentPattern = /^[\w.+-]+$/ - -function decodePathSegment(segment: string): string | undefined { - try { - return decodeURIComponent(segment) - } - catch { - return undefined - } -} - -function isSafeRouteSegment(segment: string): boolean { - return !segment.includes('/') - && !segment.includes('\\') - && segmentPattern.test(segment) -} - -/** - * Normalizes plugin asset paths into safe forward-slash relative paths. - * - * Use when: - * - Accepting route asset paths before resolving files - * - Building mounted asset URLs from plugin-owned asset paths - * - * Expects: - * - Input may contain URL-encoded path segments - * - Decoded segments must not introduce separators or traversal segments - * - * Returns: - * - A slash-joined relative path, or `undefined` for empty, malformed, or traversal-like input - * - * Before: - * - "dist\\ui\\file%20name.html" - * - "safe%2F..%2Fsecret.txt" - * - * After: - * - "dist/ui/file name.html" - * - undefined - */ -export function normalizeStaticAssetPath(value: string): string | undefined { - const normalized = value.trim().replaceAll('\\', '/') - if (!normalized) { - return undefined - } - - const segments: string[] = [] - for (const rawSegment of normalized - .split('/') - ) { - const decodedSegment = decodePathSegment(rawSegment)?.trim() - if (decodedSegment == null) { - return undefined - } - - if (!decodedSegment) { - continue - } - - if (decodedSegment.includes('/') || decodedSegment.includes('\\')) { - return undefined - } - - segments.push(decodedSegment) - } - - if (segments.length === 0) { - return undefined - } - - if (segments.some(segment => segment === '.' || segment === '..')) { - return undefined - } - - return segments.join('/') -} - -/** - * Parses one session-scoped mounted plugin asset request path. - * - * Use when: - * - Handling `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/:assetPath` requests - * - Rejecting malformed plugin asset routes before file resolution - * - * Expects: - * - `pathname` is the URL pathname without query or hash - * - Route identity segments are safe single path segments - * - * Returns: - * - Parsed route fields with a normalized asset path, or `undefined` when the route is invalid - */ -export function parseStaticAssetRequestPath(pathname: string): ParsedStaticAssetRequest | undefined { - if (!pathname.startsWith(pathPrefix)) { - return undefined - } - - const rawRemainder = pathname.slice(pathPrefix.length) - if (!rawRemainder) { - return undefined - } - - const segments = rawRemainder.split('/') - if (segments.length < 5) { - return undefined - } - - if (segments.includes('')) { - return undefined - } - - const extensionId = decodePathSegment(segments[0] ?? '') - const sessionsSegment = decodePathSegment(segments[1] ?? '') - const assetSessionId = decodePathSegment(segments[2] ?? '') - const mountSegment = decodePathSegment(segments[3] ?? '') - const rawAssetPath = segments.slice(4).join('/') - - if ( - extensionId == null - || sessionsSegment == null - || assetSessionId == null - || mountSegment == null - || !isSafeRouteSegment(extensionId) - || sessionsSegment !== 'sessions' - || !isSafeRouteSegment(assetSessionId) - || mountSegment !== 'ui' - ) { - return undefined - } - - const assetPath = normalizeStaticAssetPath(rawAssetPath) - if (!assetPath) { - return undefined - } - - return { - extensionId, - assetSessionId, - assetPath, - } -} - -/** - * Resolves a normalized plugin asset path to a real file inside one plugin root. - * - * Use when: - * - Serving mounted plugin assets from disk - * - Preventing traversal and symlink escapes from the plugin asset root - * - * Expects: - * - `rootDir` exists and can be resolved with `realpath` - * - `assetPath` is route-relative user input and may still need normalization - * - * Returns: - * - The candidate file's real path when it exists inside `rootDir` - * - `undefined` when input is invalid, missing, or resolves outside `rootDir` - */ -export async function resolveStaticAssetFilePath(rootDir: string, assetPath: string) { - const normalizedAssetPath = normalizeStaticAssetPath(assetPath) - if (!normalizedAssetPath) { - return undefined - } - - const resolvedRoot = await realpath(rootDir) - const resolvedCandidate = resolve(resolvedRoot, normalizedAssetPath) - let realCandidate: string - try { - realCandidate = await realpath(resolvedCandidate) - } - catch { - return undefined - } - - const normalizedRootPrefix = `${resolvedRoot}${sep}` - if (realCandidate !== resolvedRoot && !realCandidate.startsWith(normalizedRootPrefix)) { - return undefined - } - - return realCandidate -} - -/** - * Builds a session-scoped mounted plugin asset route path. - * - * Use when: - * - Converting a validated plugin asset path into a mounted HTTP route - * - Emitting URLs for `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/:assetPath` - * - * Expects: - * - `extensionId` and `assetSessionId` are safe single route segments - * - `assetPath` is a plugin-relative asset path accepted by {@link normalizeStaticAssetPath} - * - * Returns: - * - Encoded mounted route path, or `undefined` when any input is unsafe - */ -export function buildMountedStaticAssetPath(input: { - extensionId: string - assetSessionId: string - assetPath: string -}) { - if (!isSafeRouteSegment(input.extensionId) || !isSafeRouteSegment(input.assetSessionId)) { - return undefined - } - - const normalizedAssetPath = normalizeStaticAssetPath(input.assetPath) - if (!normalizedAssetPath) { - return undefined - } - - const encodedExtensionId = encodeURIComponent(input.extensionId) - const encodedAssetSessionId = encodeURIComponent(input.assetSessionId) - const encodedAssetPath = normalizedAssetPath - .split('/') - .map(segment => encodeURIComponent(segment)) - .join('/') - - return `${pathPrefix}${encodedExtensionId}/sessions/${encodedAssetSessionId}/ui/${encodedAssetPath}` -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts deleted file mode 100644 index ea19b4f4b..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { createServer } from 'node:http' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { H3 } from 'h3' -import { toNodeHandler } from 'h3/node' -import { afterEach, describe, expect, it } from 'vitest' - -import { HttpError } from '../errors' -import { createStaticAssetRoute } from './route' -import { createStaticAssetSessionCookieName } from './session-store' - -describe('createStaticAssetRoute', () => { - let server: ReturnType | undefined - const tempRoots: string[] = [] - - afterEach(async () => { - for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }) - } - tempRoots.length = 0 - - await new Promise((resolve) => { - if (!server) { - resolve() - return - } - - server.close(() => resolve()) - server = undefined - }) - }) - - it('returns 401 when cookie is missing', async () => { - const app = new H3() - app.get('/_airi/extensions/**', createStaticAssetRoute({ - authorize: async () => ({ - ok: false, - error: new HttpError({ - status: 401, - code: 'COOKIE_MISSING', - message: 'Unauthorized', - }), - }), - refreshSession: () => undefined, - resolveAsset: async () => ({ - ok: false, - error: new HttpError({ - status: 404, - code: 'NOT_FOUND', - message: 'Not Found', - }), - }), - })) - - server = createServer(toNodeHandler(app)) - await new Promise(resolve => server!.listen(0, '127.0.0.1', () => resolve())) - const address = server.address() - const port = typeof address === 'object' && address ? address.port : 0 - - const response = await fetch(`http://127.0.0.1:${port}/_airi/extensions/a/sessions/s1/ui/index.html`) - expect(response.status).toBe(401) - expect(response.headers.get('cache-control')).toBe('no-store') - expect(response.headers.get('referrer-policy')).toBe('no-referrer') - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - }) - - it('returns 405 with security headers when method is not allowed', async () => { - const app = new H3() - app.use('/_airi/extensions/**', createStaticAssetRoute({ - authorize: async () => ({ - ok: false, - error: new HttpError({ - status: 401, - code: 'COOKIE_MISSING', - message: 'Unauthorized', - }), - }), - refreshSession: () => undefined, - resolveAsset: async () => ({ - ok: false, - error: new HttpError({ - status: 404, - code: 'NOT_FOUND', - message: 'Not Found', - }), - }), - })) - - server = createServer(toNodeHandler(app)) - await new Promise(resolve => server!.listen(0, '127.0.0.1', () => resolve())) - const address = server.address() - const port = typeof address === 'object' && address ? address.port : 0 - - const response = await fetch(`http://127.0.0.1:${port}/_airi/extensions/a/sessions/s1/ui/index.html`, { - method: 'POST', - }) - expect(response.status).toBe(405) - expect(response.headers.get('cache-control')).toBe('no-store') - expect(response.headers.get('referrer-policy')).toBe('no-referrer') - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - }) - - it('uses custom getType resolver and sets nosniff', async () => { - const root = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) - tempRoots.push(root) - - const wasmFilePath = join(root, 'module.wasm') - await writeFile(wasmFilePath, new Uint8Array([0, 97, 115, 109])) - - let authorizeCalled = false - let authorizedCookieValue: string | undefined - let refreshedSessionId: string | undefined - const app = new H3() - app.get('/_airi/extensions/**', createStaticAssetRoute({ - authorize: async ({ cookieValue }) => { - authorizeCalled = true - authorizedCookieValue = cookieValue - return { - ok: true, - session: { - assetSessionId: 's1', - cookieName: createStaticAssetSessionCookieName('s1'), - cookieValue: 'test-token', - cookiePath: '/_airi/extensions/a/sessions/s1/ui', - expiresAt: Date.now() + 1000, - }, - } - }, - refreshSession: (assetSessionId) => { - refreshedSessionId = assetSessionId - return undefined - }, - resolveAsset: async () => ({ - ok: true, - filePath: wasmFilePath, - size: 4, - mtime: Date.now(), - }), - getType: ext => ext === '.wasm' ? 'application/wasm' : undefined, - })) - - server = createServer(toNodeHandler(app)) - await new Promise(resolve => server!.listen(0, '127.0.0.1', () => resolve())) - const address = server.address() - const port = typeof address === 'object' && address ? address.port : 0 - - const response = await fetch(`http://127.0.0.1:${port}/_airi/extensions/a/sessions/s1/ui/module.wasm`, { - headers: { cookie: `${createStaticAssetSessionCookieName('s1')}=test-token` }, - }) - if (!authorizeCalled) { - throw new Error(`Expected authorize to be called. response=${response.status} body=${await response.text()}`) - } - expect(response.status).toBe(200) - expect(authorizedCookieValue).toBe('test-token') - expect(refreshedSessionId).toBe('s1') - expect(response.headers.get('content-type')).toBe('application/wasm') - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - }) - - it('serves HEAD requests with auth refresh and no response body', async () => { - const root = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) - tempRoots.push(root) - - const wasmFilePath = join(root, 'module.wasm') - await writeFile(wasmFilePath, new Uint8Array([0, 97, 115, 109])) - - let authorizeCalled = false - let refreshedSessionId: string | undefined - const app = new H3() - app.use('/_airi/extensions/**', createStaticAssetRoute({ - authorize: async ({ cookieValue }) => { - authorizeCalled = true - expect(cookieValue).toBe('test-token') - return { - ok: true, - session: { - assetSessionId: 's1', - cookieName: createStaticAssetSessionCookieName('s1'), - cookieValue: 'test-token', - cookiePath: '/_airi/extensions/a/sessions/s1/ui', - expiresAt: Date.now() + 1000, - }, - } - }, - refreshSession: (assetSessionId) => { - refreshedSessionId = assetSessionId - return undefined - }, - resolveAsset: async () => ({ - ok: true, - filePath: wasmFilePath, - size: 4, - mtime: Date.now(), - }), - getType: ext => ext === '.wasm' ? 'application/wasm' : undefined, - })) - - server = createServer(toNodeHandler(app)) - await new Promise(resolve => server!.listen(0, '127.0.0.1', () => resolve())) - const address = server.address() - const port = typeof address === 'object' && address ? address.port : 0 - - const response = await fetch(`http://127.0.0.1:${port}/_airi/extensions/a/sessions/s1/ui/module.wasm`, { - headers: { cookie: `${createStaticAssetSessionCookieName('s1')}=test-token` }, - method: 'HEAD', - }) - expect(response.status).toBe(200) - expect(authorizeCalled).toBe(true) - expect(refreshedSessionId).toBe('s1') - expect(response.headers.get('cache-control')).toBe('no-store') - expect(response.headers.get('referrer-policy')).toBe('no-referrer') - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - expect(await response.text()).toBe('') - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts deleted file mode 100644 index c1cdbfa6c..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { StaticAssetResolveResult, StaticAssetSession, StaticAssetSessionValidationResult } from './types' - -import { readFile } from 'node:fs/promises' - -import { eventHandler, getCookie, getRequestURL, serveStatic } from 'h3' - -import { HttpError, toH3HttpError } from '../errors' -import { normalizeStaticAssetPath, parseStaticAssetRequestPath } from './paths' -import { createStaticAssetSessionCookieName } from './session-store' - -const staticAssetSecurityHeaders = { - 'Cache-Control': 'no-store', - 'Referrer-Policy': 'no-referrer', - 'X-Content-Type-Options': 'nosniff', -} - -export interface StaticAssetRouteOptions { - authorize: (params: { - extensionId: string - assetSessionId: string - assetPath: string - cookieValue: string | undefined - }) => Promise - refreshSession: (assetSessionId: string) => StaticAssetSession | undefined - resolveAsset: (params: { extensionId: string, assetPath: string }) => Promise - getType?: (ext: string) => string | undefined -} - -/** - * Creates the secured extension static asset route handler. - * - * Use when: - * - Serving plugin iframe assets under `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/**assetPath` - * - * Expects: - * - Cookie-backed asset session data to be present and valid - * - `resolveAsset` to map request params into a validated local file - * - * Returns: - * - H3 event handler that enforces cookie auth before static file response - */ -export function createStaticAssetRoute(options: StaticAssetRouteOptions) { - return eventHandler(async (event) => { - try { - Object.entries(staticAssetSecurityHeaders).forEach(([key, value]) => { - event.res.headers.set(key, value) - }) - - if (event.req.method !== 'GET' && event.req.method !== 'HEAD') { - throw new HttpError({ - status: 405, - code: 'EXTENSION_ASSET_METHOD_NOT_ALLOWED', - message: 'Method Not Allowed', - }) - } - - const requestPath = parseStaticAssetRequestPath(getRequestURL(event).pathname) - const extensionId = requestPath?.extensionId ?? '' - const assetSessionId = requestPath?.assetSessionId ?? '' - const assetPath = normalizeStaticAssetPath(requestPath?.assetPath ?? '') - - if (!extensionId || !assetSessionId || !assetPath) { - throw new HttpError({ - status: 401, - code: 'EXTENSION_ASSET_REQUEST_INVALID', - message: 'Unauthorized', - reason: 'required extensionId, assetSessionId, or assetPath is missing', - }) - } - - const cookieValue = getCookie(event, createStaticAssetSessionCookieName(assetSessionId)) - const auth = await options.authorize({ - extensionId, - assetSessionId, - assetPath, - cookieValue, - }) - if (!auth.ok) { - throw auth.error - } - - options.refreshSession(assetSessionId) - - let resolved: Awaited> | undefined - const resolveOnce = async () => { - if (!resolved) { - resolved = await options.resolveAsset({ extensionId, assetPath }) - } - return resolved - } - - return await serveStatic(event, { - getType: options.getType, - getContents: async () => { - const item = await resolveOnce() - if (!item.ok) { - throw item.error - } - return await readFile(item.filePath) - }, - getMeta: async () => { - const item = await resolveOnce() - if (!item.ok) { - throw item.error - } - - return { - size: item.size, - mtime: item.mtime, - } - }, - }) - } - catch (error) { - if (error instanceof HttpError) { - throw toH3HttpError(error, { - headers: staticAssetSecurityHeaders, - }) - } - - throw error - } - }) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts deleted file mode 100644 index 2231917b0..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -import type { StaticAssetSession } from './types' - -import { describe, expect, it, vi } from 'vitest' - -import { createStaticAssetSessionStore } from './session-store' - -function tryMutateCookieValue(session: StaticAssetSession, cookieValue: string) { - try { - Object.assign(session, { cookieValue }) - } - catch { - // Frozen snapshots reject mutation; the assertion that follows verifies store state. - } -} - -/** - * @example - * describe('createStaticAssetSessionStore', () => {}) - */ -describe('createStaticAssetSessionStore', () => { - /** - * @example - * it('creates and validates cookie-backed asset session', () => {}) - */ - it('creates and validates cookie-backed asset session', () => { - const now = vi.fn(() => 1000) - const store = createStaticAssetSessionStore({ now }) - const session = store.createSession({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-1', - pathPrefix: '', - ttlMs: 30_000, - }) - - expect(session.assetSessionId).toBeTruthy() - expect(session.cookieName).toContain(session.assetSessionId) - expect(session.cookieValue).toBeTruthy() - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: session.cookieValue, - }).ok).toBe(true) - }) - - /** - * @example - * it('rejects mismatched extension and revoked sessions', () => {}) - */ - it('rejects mismatched extension and revoked sessions', () => { - const now = vi.fn(() => 1000) - const store = createStaticAssetSessionStore({ now }) - const session = store.createSession({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-1', - pathPrefix: '', - ttlMs: 30_000, - }) - - expect(store.validateRequest({ - extensionId: 'other-plugin', - version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'index.html', - cookieValue: session.cookieValue, - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_EXTENSION_MISMATCH', - }, - }) - - expect(store.revokeByOwnerSessionId('plugin-session-1')).toHaveLength(1) - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'index.html', - cookieValue: session.cookieValue, - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_SESSION_NOT_FOUND', - }, - }) - }) - - /** - * @example - * it('rejects invalid cookie, version, path, and expired requests', () => {}) - */ - it('rejects invalid cookie, version, path, and expired requests', () => { - const now = vi.fn(() => 1000) - const store = createStaticAssetSessionStore({ now }) - const session = store.createSession({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-1', - pathPrefix: 'assets/', - ttlMs: 30_000, - }) - - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: undefined, - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_COOKIE_MISSING', - }, - }) - - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: 'wrong-cookie', - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_COOKIE_MISMATCH', - }, - }) - - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.2.0', - assetSessionId: session.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: session.cookieValue, - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_VERSION_MISMATCH', - }, - }) - - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: '', - cookieValue: session.cookieValue, - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_PATH_EMPTY', - }, - }) - - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'other/index.js', - cookieValue: session.cookieValue, - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH', - }, - }) - - now.mockReturnValue(31_001) - - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: session.cookieValue, - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_SESSION_EXPIRED', - }, - }) - }) - - /** - * @example - * it('refreshes and revokes sessions by id, extension, and all records', () => {}) - */ - it('refreshes and revokes sessions by id, extension, and all records', () => { - const now = vi.fn(() => 1000) - const store = createStaticAssetSessionStore({ now }) - const firstSession = store.createSession({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-1', - pathPrefix: '', - ttlMs: 30_000, - }) - const secondSession = store.createSession({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-2', - pathPrefix: '', - ttlMs: 30_000, - }) - const thirdSession = store.createSession({ - extensionId: 'airi-plugin-game-go', - version: '0.1.0', - ownerSessionId: 'plugin-session-3', - pathPrefix: '', - ttlMs: 30_000, - }) - - now.mockReturnValue(2000) - - expect(store.refreshSession(firstSession.assetSessionId)).toMatchObject({ - assetSessionId: firstSession.assetSessionId, - expiresAt: 32_000, - }) - expect(store.revokeSession(firstSession.assetSessionId)).toMatchObject({ - assetSessionId: firstSession.assetSessionId, - }) - expect(store.revokeByExtensionId('airi-plugin-game-chess')).toEqual([secondSession]) - expect(store.revokeAll()).toEqual([thirdSession]) - }) - - /** - * @example - * it('returns immutable snapshots that cannot mutate internal session state', () => {}) - */ - it('returns immutable snapshots that cannot mutate internal session state', () => { - const now = vi.fn(() => 1000) - const store = createStaticAssetSessionStore({ now }) - const createdSession = store.createSession({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-1', - pathPrefix: '', - ttlMs: 30_000, - }) - const originalCookieValue = createdSession.cookieValue - - tryMutateCookieValue(createdSession, 'mutated-create-cookie') - - const firstValidation = store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: createdSession.assetSessionId, - assetPath: 'index.html', - cookieValue: originalCookieValue, - }) - expect(firstValidation.ok).toBe(true) - expect(Object.isFrozen(createdSession)).toBe(true) - if (!firstValidation.ok) { - throw firstValidation.error - } - - tryMutateCookieValue(firstValidation.session, 'mutated-validation-cookie') - - const secondValidation = store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: createdSession.assetSessionId, - assetPath: 'index.html', - cookieValue: originalCookieValue, - }) - expect(secondValidation.ok).toBe(true) - expect(Object.isFrozen(firstValidation.session)).toBe(true) - if (!secondValidation.ok) { - throw secondValidation.error - } - - now.mockReturnValue(2000) - const refreshedSession = store.refreshSession(createdSession.assetSessionId) - expect(refreshedSession).toBeTruthy() - if (!refreshedSession) { - throw new Error('Expected refreshed session') - } - - tryMutateCookieValue(refreshedSession, 'mutated-refresh-cookie') - - const thirdValidation = store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: createdSession.assetSessionId, - assetPath: 'index.html', - cookieValue: originalCookieValue, - }) - expect(thirdValidation.ok).toBe(true) - expect(Object.isFrozen(refreshedSession)).toBe(true) - - const revokedSessions = store.revokeByOwnerSessionId('plugin-session-1') - expect(revokedSessions).toHaveLength(1) - const revokedSession = revokedSessions[0] - expect(revokedSession).toBeTruthy() - if (!revokedSession) { - throw new Error('Expected revoked session') - } - - tryMutateCookieValue(revokedSession, 'mutated-revoked-cookie') - - expect(createdSession.cookieValue).toBe(originalCookieValue) - expect(Object.isFrozen(revokedSession)).toBe(true) - }) - - /** - * @example - * it('rejects invalid TTL values during session creation', () => {}) - */ - it('rejects invalid TTL values during session creation', () => { - const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) - const input = { - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-1', - pathPrefix: '', - } - - for (const ttlMs of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1]) { - expect(() => store.createSession({ - ...input, - ttlMs, - })).toThrow(RangeError) - } - }) - - /** - * @example - * it('rejects traversal-like asset paths and prefixes at the store boundary', () => {}) - */ - it('rejects traversal-like asset paths and prefixes at the store boundary', () => { - const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) - const session = store.createSession({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-1', - pathPrefix: 'assets/', - ttlMs: 30_000, - }) - - for (const assetPath of [ - 'assets/../secret.js', - 'assets\\..\\secret.js', - 'assets%2Fsecret.js', - ]) { - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath, - cookieValue: session.cookieValue, - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH', - }, - }) - } - - expect(() => store.createSession({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-1', - pathPrefix: '../', - ttlMs: 30_000, - })).toThrow(RangeError) - }) - - /** - * @example - * it('applies directory and exact-file prefix semantics', () => {}) - */ - it('applies directory and exact-file prefix semantics', () => { - const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) - const directorySession = store.createSession({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-1', - pathPrefix: 'assets/', - ttlMs: 30_000, - }) - const exactSession = store.createSession({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - ownerSessionId: 'plugin-session-2', - pathPrefix: 'assets', - ttlMs: 30_000, - }) - - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: directorySession.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: directorySession.cookieValue, - }).ok).toBe(true) - - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: exactSession.assetSessionId, - assetPath: 'assets', - cookieValue: exactSession.cookieValue, - }).ok).toBe(true) - - expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: exactSession.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: exactSession.cookieValue, - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH', - }, - }) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts deleted file mode 100644 index a31cc2b95..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { - StaticAssetSession, - StaticAssetSessionCreateInput, - StaticAssetSessionStore, - StaticAssetSessionValidateInput, - StaticAssetSessionValidationResult, -} from './types' - -import { Buffer } from 'node:buffer' -import { randomBytes, timingSafeEqual } from 'node:crypto' - -import { HttpError } from '../errors' -import { normalizeStaticAssetPath } from './paths' - -interface StaticAssetSessionRecord { - assetSessionId: string - extensionId: string - version: string - ownerSessionId: string - pathPrefix: string - ttlMs: number - cookieName: string - cookieValue: string - cookiePath: string - expiresAt: number -} - -/** - * Normalizes asset path prefixes used to constrain a session. - * - * Before: - * - " assets\\ " - * - * After: - * - "assets/" - */ -function normalizePathPrefix(pathPrefix: string) { - const normalizedInput = pathPrefix.trim().replaceAll('\\', '/') - if (!normalizedInput) { - return '' - } - - const isDirectoryPrefix = normalizedInput.endsWith('/') - const normalized = normalizeStaticAssetPath(normalizedInput) - if (!normalized) { - throw new RangeError('Extension asset session pathPrefix must be empty or a safe plugin asset path') - } - - return isDirectoryPrefix ? `${normalized}/` : normalized -} - -/** - * Normalizes requested asset paths before comparing them to session prefixes. - * - * Before: - * - " assets\\index.js " - * - * After: - * - "assets/index.js" - */ -function normalizeAssetPath(assetPath: string) { - return normalizeStaticAssetPath(assetPath.trim().replaceAll('\\', '/')) -} - -function createOpaqueToken() { - // Node's base64url alphabet is route/cookie friendly while staying opaque. - return randomBytes(18).toString('base64url') -} - -/** - * Creates the cookie name for a cookie-backed extension asset session. - * - * Use when: - * - Issuing extension asset session cookies - * - Reading extension asset session cookies from static asset route requests - * - * Expects: - * - `assetSessionId` is the opaque id returned by the session store - * - * Returns: - * - Stable cookie name shared by session creation and route validation - */ -export function createStaticAssetSessionCookieName(assetSessionId: string) { - return `airi_extension_asset_session_${assetSessionId}` -} - -function createCookiePath(extensionId: string, assetSessionId: string) { - return `/_airi/extensions/${encodeURIComponent(extensionId)}/sessions/${encodeURIComponent(assetSessionId)}/ui` -} - -function createSessionSnapshot(record: StaticAssetSessionRecord): StaticAssetSession { - return Object.freeze({ - assetSessionId: record.assetSessionId, - cookieName: record.cookieName, - cookieValue: record.cookieValue, - cookiePath: record.cookiePath, - expiresAt: record.expiresAt, - }) -} - -function cookieValuesMatch(expected: string, actual: string) { - const expectedBuffer = Buffer.from(expected, 'utf8') - const actualBuffer = Buffer.from(actual, 'utf8') - if (expectedBuffer.length !== actualBuffer.length) { - return false - } - - return timingSafeEqual(expectedBuffer, actualBuffer) -} - -function unauthorized(code: string, reason: string) { - return { - ok: false as const, - error: new HttpError({ - status: 401, - code, - message: 'Unauthorized', - reason, - }), - } -} - -/** - * Creates an in-memory cookie-backed session store for extension static assets. - * - * Use when: - * - Main process needs short-lived cookie auth for plugin iframe asset loading - * - Asset sessions must be revoked by asset id, owner plugin session, extension, or shutdown - * - * Expects: - * - Session ids and cookie values are opaque and stored server-side only - * - Callers set returned cookies on the returned cookie path - * - * Returns: - * - Create, validate, refresh, and revoke operations for extension asset sessions - */ -export function createStaticAssetSessionStore(options: { now?: () => number } = {}): StaticAssetSessionStore { - const now = options.now ?? (() => Date.now()) - const records = new Map() - - const dropIfExpired = (assetSessionId: string, record: StaticAssetSessionRecord) => { - if (record.expiresAt > now()) { - return false - } - - records.delete(assetSessionId) - return true - } - - const readActiveRecord = (assetSessionId: string, expiredCode: string): { ok: false, result: { ok: false, error: HttpError } } | { ok: true, record: StaticAssetSessionRecord } => { - const record = records.get(assetSessionId) - if (!record) { - return { - ok: false as const, - result: unauthorized('EXTENSION_ASSET_SESSION_NOT_FOUND', 'asset session was not found in session store'), - } - } - - if (dropIfExpired(assetSessionId, record)) { - return { - ok: false as const, - result: unauthorized(expiredCode, 'asset session has expired'), - } - } - - return { - ok: true as const, - record, - } - } - - const createSession = (input: StaticAssetSessionCreateInput) => { - if (!Number.isFinite(input.ttlMs) || input.ttlMs <= 0) { - throw new RangeError('Extension asset session ttlMs must be a finite positive number') - } - - const assetSessionId = createOpaqueToken() - const record: StaticAssetSessionRecord = { - assetSessionId, - extensionId: input.extensionId, - version: input.version, - ownerSessionId: input.ownerSessionId, - pathPrefix: normalizePathPrefix(input.pathPrefix), - ttlMs: input.ttlMs, - cookieName: createStaticAssetSessionCookieName(assetSessionId), - cookieValue: createOpaqueToken(), - cookiePath: createCookiePath(input.extensionId, assetSessionId), - expiresAt: now() + input.ttlMs, - } - - records.set(assetSessionId, record) - - return createSessionSnapshot(record) - } - - const validateRequest = (input: StaticAssetSessionValidateInput): StaticAssetSessionValidationResult => { - const active = readActiveRecord(input.assetSessionId, 'EXTENSION_ASSET_SESSION_EXPIRED') - if (!active.ok) { - return active.result - } - - const { record } = active - if (!input.cookieValue) { - return unauthorized('EXTENSION_ASSET_COOKIE_MISSING', 'asset session cookie is missing') - } - - if (!cookieValuesMatch(record.cookieValue, input.cookieValue)) { - return unauthorized('EXTENSION_ASSET_COOKIE_MISMATCH', 'asset session cookie does not match') - } - - if (record.extensionId !== input.extensionId) { - return unauthorized('EXTENSION_ASSET_EXTENSION_MISMATCH', 'asset session extensionId does not match request extensionId') - } - - if (record.version !== input.version) { - return unauthorized('EXTENSION_ASSET_VERSION_MISMATCH', 'asset session version does not match request version') - } - - const isEmptyAssetPath = !input.assetPath.trim() - if (isEmptyAssetPath) { - return unauthorized('EXTENSION_ASSET_PATH_EMPTY', 'asset path is empty') - } - - const normalizedAssetPath = normalizeAssetPath(input.assetPath) - if (!normalizedAssetPath) { - return unauthorized('EXTENSION_ASSET_PATH_PREFIX_MISMATCH', 'asset path is outside allowed prefix') - } - - if (record.pathPrefix) { - const isDirectoryPrefix = record.pathPrefix.endsWith('/') - const isAllowed = isDirectoryPrefix - ? normalizedAssetPath.startsWith(record.pathPrefix) - : normalizedAssetPath === record.pathPrefix - - if (!isAllowed) { - return unauthorized('EXTENSION_ASSET_PATH_PREFIX_MISMATCH', 'asset path is outside allowed prefix') - } - } - - return { ok: true, session: createSessionSnapshot(record) } - } - - const revokeWhere = (predicate: (record: StaticAssetSessionRecord) => boolean) => { - const revoked: StaticAssetSession[] = [] - for (const [assetSessionId, record] of records.entries()) { - if (predicate(record)) { - records.delete(assetSessionId) - revoked.push(createSessionSnapshot(record)) - } - } - return revoked - } - - return { - createSession, - validateRequest, - refreshSession(assetSessionId) { - const active = readActiveRecord(assetSessionId, 'EXTENSION_ASSET_SESSION_EXPIRED') - if (!active.ok) { - return undefined - } - - active.record.expiresAt = now() + active.record.ttlMs - return createSessionSnapshot(active.record) - }, - revokeSession(assetSessionId) { - const record = records.get(assetSessionId) - if (!record) { - return undefined - } - - records.delete(assetSessionId) - return createSessionSnapshot(record) - }, - revokeByOwnerSessionId(ownerSessionId) { - return revokeWhere(record => record.ownerSessionId === ownerSessionId) - }, - revokeByExtensionId(extensionId) { - return revokeWhere(record => record.extensionId === extensionId) - }, - revokeAll() { - return revokeWhere(() => true) - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts deleted file mode 100644 index 43df18669..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { HttpError } from '../errors' - -/** - * Input required to create a cookie-backed static asset session. - */ -export interface StaticAssetSessionCreateInput { - /** Plugin extension id that owns the served static assets. */ - extensionId: string - /** Extension version expected by requests using this asset session. */ - version: string - /** Parent plugin session id used for owner-scoped revocation. */ - ownerSessionId: string - /** - * Required allowed asset path prefix for this session. - * - * Empty string allows all UI assets, a trailing slash means directory prefix, - * and no trailing slash means exact asset path. - */ - pathPrefix: string - /** Session lifetime in milliseconds from creation or refresh time. */ - ttlMs: number -} - -/** - * Cookie data returned after creating a static asset session. - */ -export interface StaticAssetSession { - /** Opaque server-side session id embedded in extension asset routes. */ - readonly assetSessionId: string - /** Cookie name callers set on the asset route path. */ - readonly cookieName: string - /** Opaque cookie value required to validate asset requests. */ - readonly cookieValue: string - /** Cookie path scope for browser requests. */ - readonly cookiePath: string - /** Unix timestamp in milliseconds when the session expires. */ - readonly expiresAt: number -} - -/** - * Request data required to validate a cookie-backed static asset session. - */ -export interface StaticAssetSessionValidateInput { - /** Plugin extension id from the requested route. */ - extensionId: string - /** Extension version from the requested route. */ - version: string - /** Opaque session id from the requested route. */ - assetSessionId: string - /** Static asset path being requested. */ - assetPath: string - /** Cookie value provided by the request, if any. */ - cookieValue: string | undefined -} - -/** - * Result of validating a cookie-backed static asset request. - */ -export type StaticAssetSessionValidationResult - = | { ok: true, session: StaticAssetSession } - | { ok: false, error: HttpError } - -/** - * In-memory store for cookie-backed extension static asset sessions. - */ -export interface StaticAssetSessionStore { - /** Creates a new cookie-backed static asset session. */ - createSession: (input: StaticAssetSessionCreateInput) => StaticAssetSession - /** Validates route and cookie data for a static asset request. */ - validateRequest: (input: StaticAssetSessionValidateInput) => StaticAssetSessionValidationResult - /** Extends an existing session using its original TTL. */ - refreshSession: (assetSessionId: string) => StaticAssetSession | undefined - /** Revokes one static asset session by id. */ - revokeSession: (assetSessionId: string) => StaticAssetSession | undefined - /** Revokes all static asset sessions owned by a plugin session. */ - revokeByOwnerSessionId: (ownerSessionId: string) => StaticAssetSession[] - /** Revokes all static asset sessions for one extension. */ - revokeByExtensionId: (extensionId: string) => StaticAssetSession[] - /** Revokes every static asset session. */ - revokeAll: () => StaticAssetSession[] -} - -export type StaticAssetResolveResult - = | { ok: true, filePath: string, size: number, mtime: number } - | { ok: false, error: HttpError } diff --git a/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts b/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts deleted file mode 100644 index e98df79c8..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { BrowserWindow } from 'electron' -import type { ProvidedBy } from 'injeca' - -import type { globalAppConfigSchema } from '../../../configs/global' -import type { Config } from '../../../libs/electron/persistence' -import type { I18n } from '../../../libs/i18n' - -import { defineInvokeHandler } from '@moeru/eventa' -import { injeca } from 'injeca' - -import { i18nGetLocale, i18nSetLocale } from '../../../../shared/eventa' - -export async function createI18nService(params: { context: ReturnType['context'], window: BrowserWindow, i18n: I18n }) { - const { config } = await injeca.resolve({ config: 'configs:app' } as { config: ProvidedBy> }) - params.i18n.locale(config.get()?.language || 'en') - - defineInvokeHandler(params.context, i18nSetLocale, (locale) => { - const current = config.get() - config.update({ ...current, language: locale as string }) - params.i18n.locale(locale) - }) - - defineInvokeHandler(params.context, i18nGetLocale, () => { - return config.get()?.language - }) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts deleted file mode 100644 index 1508d6013..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const appMock = vi.hoisted(() => ({ - getPath: vi.fn(), - getVersion: vi.fn(), -})) - -const shellMock = vi.hoisted(() => ({ - showItemInFolder: vi.fn(), -})) - -const clientMocks = vi.hoisted(() => ({ - close: vi.fn(), - connect: vi.fn(), - listTools: vi.fn(), -})) - -vi.mock('electron', () => ({ - app: appMock, - shell: shellMock, -})) - -vi.mock('@guiiai/logg', () => ({ - useLogg: vi.fn(() => ({ - useGlobalConfig: () => ({ - debug: vi.fn(), - warn: vi.fn(), - withError: vi.fn(() => ({ warn: vi.fn() })), - withFields: vi.fn(() => ({ debug: vi.fn(), warn: vi.fn() })), - }), - })), -})) - -vi.mock('../../../libs/bootkit/lifecycle', () => ({ - onAppBeforeQuit: vi.fn(), -})) - -vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ - Client: class { - close = clientMocks.close - connect = clientMocks.connect - listTools = clientMocks.listTools - }, -})) - -vi.mock('@modelcontextprotocol/sdk/client/stdio.js', async () => { - const { PassThrough } = await import('node:stream') - - return { - StdioClientTransport: class { - stderr = new PassThrough() - - constructor(readonly server: unknown) {} - - close = vi.fn(async () => undefined) - }, - } -}) - -describe('createMcpStdioManager', () => { - beforeEach(() => { - vi.clearAllMocks() - appMock.getPath.mockReturnValue('/tmp/airi-user-data') - appMock.getVersion.mockReturnValue('0.10.0') - clientMocks.close.mockResolvedValue(undefined) - clientMocks.listTools.mockResolvedValue({ tools: [] }) - }) - - it('includes stderr captured during connect failures in MCP server test results', async () => { - const { createMcpStdioManager } = await import('./index') - const manager = createMcpStdioManager() - - clientMocks.connect.mockImplementationOnce(async (transport: { stderr: NodeJS.WritableStream }) => { - transport.stderr.write('Missing required environment variable: API_KEY\n') - throw new Error('connect failed') - }) - - const result = await manager.testServer({ - name: 'broken-server', - config: { - command: 'broken-mcp-server', - }, - }) - - expect(result.ok).toBe(false) - expect(result.error).toContain('connect failed') - expect(result.error).toContain('Missing required environment variable: API_KEY') - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts deleted file mode 100644 index e6ed20685..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts +++ /dev/null @@ -1,493 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' - -import type { - ElectronMcpCallToolPayload, - ElectronMcpCallToolResult, - ElectronMcpStdioApplyResult, - ElectronMcpStdioConfigFile, - ElectronMcpStdioConfigText, - ElectronMcpStdioRuntimeStatus, - ElectronMcpStdioServerConfig, - ElectronMcpStdioServerRuntimeStatus, - ElectronMcpStdioTestPayload, - ElectronMcpStdioTestResult, - ElectronMcpToolDescriptor, -} from '../../../../shared/eventa' - -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' - -import { useLogg } from '@guiiai/logg' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' -import { defineInvokeHandler } from '@moeru/eventa' -import { app, shell } from 'electron' - -import { - electronMcpApplyAndRestart, - electronMcpCallTool, - electronMcpGetRuntimeStatus, - electronMcpListTools, - electronMcpOpenConfigFile, - electronMcpReadConfigText, - electronMcpTestServer, - electronMcpWriteConfigText, -} from '../../../../shared/eventa' -import { parseElectronMcpConfigText } from '../../../../shared/mcp-config' -import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' - -interface McpServerSession { - client: Client - transport: StdioClientTransport - config: ElectronMcpStdioServerConfig -} - -export interface McpStdioManager { - ensureConfigFile: () => Promise<{ path: string }> - openConfigFile: () => Promise<{ path: string }> - applyAndRestart: () => Promise - listTools: () => Promise - callTool: (payload: ElectronMcpCallToolPayload) => Promise - stopAll: () => Promise - getRuntimeStatus: () => ElectronMcpStdioRuntimeStatus - readConfigText: () => Promise - writeConfigText: (text: string) => Promise - testServer: (payload: ElectronMcpStdioTestPayload) => Promise -} - -const defaultMcpConfig: ElectronMcpStdioConfigFile = { - mcpServers: {}, -} -const toolNameSeparator = '::' -const mcpRequestTimeoutMsec = 10_000 -const mcpRequestMaxTotalTimeoutMsec = 15_000 -const mcpTestStderrMaxChars = 16_000 - -function stringifyError(error: unknown) { - if (error instanceof Error) { - return error.message - } - - return String(error) -} - -function getConfigPath() { - return join(app.getPath('userData'), 'mcp.json') -} - -function parseQualifiedToolName(name: string) { - const separatorIndex = name.indexOf(toolNameSeparator) - if (separatorIndex <= 0 || separatorIndex === name.length - toolNameSeparator.length) { - throw new Error(`invalid qualified tool name: ${name}`) - } - - return { - serverName: name.slice(0, separatorIndex), - toolName: name.slice(separatorIndex + toolNameSeparator.length), - } -} - -function resolveFallbackToolName(toolName: string): string | undefined { - const normalizedTransportPrefix = toolName - .replace(/^\.(?:stdio|stdo)::/, '') - .replace(/^(?:stdio|stdo)::/, '') - if (normalizedTransportPrefix !== toolName) { - return normalizedTransportPrefix - } - - const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator) - if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) { - return undefined - } - - return toolName.slice(lastSeparatorIndex + toolNameSeparator.length) -} - -async function closeSession(session: McpServerSession) { - try { - await session.client.close() - } - catch { - await session.transport.close() - } -} - -export function createMcpStdioManager(): McpStdioManager { - const log = useLogg('main/mcp-stdio').useGlobalConfig() - const sessions = new Map() - const runtimeStatuses = new Map() - let updatedAt = Date.now() - - const setRuntimeStatus = (status: ElectronMcpStdioServerRuntimeStatus) => { - runtimeStatuses.set(status.name, status) - updatedAt = Date.now() - } - - const ensureConfigFile = async () => { - const path = getConfigPath() - await mkdir(app.getPath('userData'), { recursive: true }) - - try { - await readFile(path, 'utf-8') - } - catch { - await writeFile(path, `${JSON.stringify(defaultMcpConfig, null, 2)}\n`) - } - - return { path } - } - - const openConfigFile = async () => { - const { path } = await ensureConfigFile() - shell.showItemInFolder(path) - return { path } - } - - const readConfigFile = async (path: string): Promise => { - const raw = await readFile(path, 'utf-8') - return parseElectronMcpConfigText(raw) - } - - const stopAll = async () => { - const entries = [...sessions.entries()] - for (const [name, session] of entries) { - await closeSession(session) - setRuntimeStatus({ - name, - state: 'stopped', - command: session.config.command, - args: session.config.args ?? [], - pid: null, - }) - sessions.delete(name) - } - } - - const startServer = async (name: string, config: ElectronMcpStdioServerConfig) => { - const transport = new StdioClientTransport({ - command: config.command, - args: config.args ?? [], - env: config.env, - cwd: config.cwd, - stderr: 'pipe', - }) - const client = new Client({ - name: `proj-airi:stage-tamagotchi:mcp:${name}`, - version: app.getVersion(), - }) - - try { - await client.connect(transport) - transport.stderr?.on('data', (data) => { - const text = data.toString('utf-8').trim() - if (text) { - log.withFields({ serverName: name }).warn(text) - } - }) - sessions.set(name, { client, transport, config }) - setRuntimeStatus({ - name, - state: 'running', - command: config.command, - args: config.args ?? [], - pid: transport.pid, - }) - } - catch (error) { - await transport.close().catch(() => {}) - throw error - } - } - - const applyAndRestart = async (): Promise => { - const { path } = await ensureConfigFile() - const config = await readConfigFile(path) - - await stopAll() - runtimeStatuses.clear() - - const result: ElectronMcpStdioApplyResult = { - path, - started: [], - failed: [], - skipped: [], - } - - for (const [name, server] of Object.entries(config.mcpServers)) { - if (server.enabled === false) { - result.skipped.push({ name, reason: 'disabled' }) - setRuntimeStatus({ - name, - state: 'stopped', - command: server.command, - args: server.args ?? [], - pid: null, - }) - continue - } - - try { - await startServer(name, server) - result.started.push({ name }) - } - catch (error) { - const message = stringifyError(error) - result.failed.push({ name, error: message }) - setRuntimeStatus({ - name, - state: 'error', - command: server.command, - args: server.args ?? [], - pid: null, - lastError: message, - }) - } - } - - updatedAt = Date.now() - - return result - } - - const listTools = async (): Promise => { - const entries = [...sessions.entries()].sort(([left], [right]) => left.localeCompare(right)) - const listResult = await Promise.all(entries.map(async ([serverName, session]) => { - try { - const response = await session.client.listTools(undefined, { - timeout: mcpRequestTimeoutMsec, - maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, - }) - return response.tools.map(item => ({ - serverName, - name: `${serverName}${toolNameSeparator}${item.name}`, - toolName: item.name, - description: item.description, - inputSchema: item.inputSchema, - })) - } - catch (error) { - log.withFields({ serverName }).withError(error).warn('failed to list tools from mcp server') - return [] - } - })) - - return listResult.flat() - } - - const callTool = async (payload: ElectronMcpCallToolPayload): Promise => { - const { serverName, toolName } = parseQualifiedToolName(payload.name) - const session = sessions.get(serverName) - if (!session) { - throw new Error(`mcp server is not running: ${serverName}`) - } - - let result - try { - result = await session.client.callTool({ - name: toolName, - arguments: payload.arguments ?? {}, - }, undefined, { - timeout: mcpRequestTimeoutMsec, - maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, - }) - } - catch (error) { - const fallbackToolName = resolveFallbackToolName(toolName) - if (!fallbackToolName || fallbackToolName === toolName) { - throw error - } - - log.withFields({ - serverName, - requestedToolName: toolName, - fallbackToolName, - }).warn('retrying mcp tool call with normalized tool name') - - result = await session.client.callTool({ - name: fallbackToolName, - arguments: payload.arguments ?? {}, - }, undefined, { - timeout: mcpRequestTimeoutMsec, - maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, - }) - } - - const normalized: ElectronMcpCallToolResult = {} - if ('content' in result && Array.isArray(result.content)) { - normalized.content = result.content as Array> - } - if ('structuredContent' in result && result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) { - normalized.structuredContent = result.structuredContent as Record - } - if ('isError' in result && typeof result.isError === 'boolean') { - normalized.isError = result.isError - } - if ('toolResult' in result) { - normalized.toolResult = result.toolResult - } - - return normalized - } - - const getRuntimeStatus = (): ElectronMcpStdioRuntimeStatus => { - return { - path: getConfigPath(), - servers: [...runtimeStatuses.values()].sort((left, right) => left.name.localeCompare(right.name)), - updatedAt, - } - } - - const readConfigText = async (): Promise => { - const { path } = await ensureConfigFile() - const text = await readFile(path, 'utf-8') - return { path, text } - } - - const writeConfigText = async (text: string): Promise => { - const { path } = await ensureConfigFile() - const validated = parseElectronMcpConfigText(text) - const normalized = `${JSON.stringify(validated, null, 2)}\n` - await writeFile(path, normalized) - return { path, text: normalized } - } - - const testServer = async (payload: ElectronMcpStdioTestPayload): Promise => { - const startedAt = Date.now() - let transport: StdioClientTransport | null = null - let client: Client | null = null - const stderrChunks: string[] = [] - - const withDeadline = (promise: Promise, ms: number, label: string): Promise => { - let timer: NodeJS.Timeout | undefined - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms) - }) - return Promise.race([promise, timeout]).finally(() => { - if (timer) - clearTimeout(timer) - }) - } - - try { - transport = new StdioClientTransport({ - command: payload.config.command, - args: payload.config.args ?? [], - env: payload.config.env, - cwd: payload.config.cwd, - stderr: 'pipe', - }) - client = new Client({ - name: `proj-airi:stage-tamagotchi:mcp:test:${payload.name}`, - version: app.getVersion(), - }) - - transport.stderr?.on('data', (data) => { - const text = data.toString('utf-8') - if (text) - stderrChunks.push(text) - }) - - await withDeadline(client.connect(transport), mcpRequestMaxTotalTimeoutMsec, 'connect') - - const response = await client.listTools(undefined, { - timeout: mcpRequestTimeoutMsec, - maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, - }) - - if (stderrChunks.length > 0) { - log.withFields({ serverName: payload.name }).debug(stderrChunks.join('').trim()) - } - - return { - ok: true, - tools: response.tools.map(tool => tool.name), - durationMs: Date.now() - startedAt, - } - } - catch (error) { - const message = stringifyError(error) - // Keep only the tail so a noisy failed server cannot flood the settings UI. - const stderr = stderrChunks.join('').trim().slice(-mcpTestStderrMaxChars) - return { - ok: false, - error: stderr ? `${message}\n\n${stderr}` : message, - durationMs: Date.now() - startedAt, - } - } - finally { - if (client) { - await client.close().catch(() => {}) - } - if (transport) { - await transport.close().catch(() => {}) - } - } - } - - return { - ensureConfigFile, - openConfigFile, - applyAndRestart, - listTools, - callTool, - stopAll, - getRuntimeStatus, - readConfigText, - writeConfigText, - testServer, - } -} - -export async function setupMcpStdioManager() { - const log = useLogg('main/mcp-stdio').useGlobalConfig() - const manager = createMcpStdioManager() - - onAppBeforeQuit(async () => { - await manager.stopAll() - }) - - await manager.ensureConfigFile() - - try { - await manager.applyAndRestart() - } - catch (error) { - log.withError(error).warn('failed to apply mcp stdio config during startup') - } - - return manager -} - -export function createMcpServersService(params: { context: ReturnType['context'], manager: McpStdioManager }) { - defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => { - return params.manager.openConfigFile() - }) - - defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => { - return params.manager.applyAndRestart() - }) - - defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => { - return params.manager.getRuntimeStatus() - }) - - defineInvokeHandler(params.context, electronMcpListTools, async () => { - return params.manager.listTools() - }) - - defineInvokeHandler(params.context, electronMcpCallTool, async (payload) => { - return params.manager.callTool(payload) - }) - - defineInvokeHandler(params.context, electronMcpReadConfigText, async () => { - return params.manager.readConfigText() - }) - - defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => { - return params.manager.writeConfigText(payload.text) - }) - - defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => { - return params.manager.testServer(payload) - }) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts b/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts deleted file mode 100644 index e52980580..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { BrowserWindow } from 'electron' - -import type { OnboardingWindowManager } from '../../../windows/onboarding' - -import { defineInvokeHandler } from '@moeru/eventa' -import { screen } from 'electron' - -import { electronOpenOnboarding } from '../../../../shared/eventa' -import { Animator } from '../../../windows/shared/animator' -import { computeAdjacentPosition } from '../../../windows/shared/display' - -const ANIMATION_DURATION = 350 - -export function createOnboardingService(params: { - context: ReturnType['context'] - onboardingWindowManager: OnboardingWindowManager - mainWindow: BrowserWindow -}) { - const mainWindowAnimator = new Animator(params.mainWindow) - let cleanupOnClosed: (() => void) | undefined - - defineInvokeHandler(params.context, electronOpenOnboarding, async () => { - const savedBounds = params.mainWindow.getBounds() - - const onboardingWindow = await params.onboardingWindowManager.getAndToggleWindow() - const onboardingBounds = onboardingWindow.getBounds() - const display = screen.getDisplayMatching(onboardingBounds) - - const adjacent = computeAdjacentPosition( - onboardingBounds, - { width: savedBounds.width, height: savedBounds.height }, - display.workArea, - ) - - mainWindowAnimator.windowBoundsAnimateTo({ - x: adjacent.x, - y: adjacent.y, - width: adjacent.width, - height: adjacent.height, - }, { duration: ANIMATION_DURATION }) - - let userMovedManually = false - let ignoreNextMoves = true - - const moveListener = () => { - if (ignoreNextMoves) - return - userMovedManually = true - } - - params.mainWindow.on('move', moveListener) - params.mainWindow.on('resize', moveListener) - setTimeout(() => { - ignoreNextMoves = false - }, ANIMATION_DURATION + 50) - - cleanupOnClosed?.() - - cleanupOnClosed = params.onboardingWindowManager.onClosed(() => { - params.mainWindow.removeListener('move', moveListener) - params.mainWindow.removeListener('resize', moveListener) - - if (!userMovedManually && !params.mainWindow.isDestroyed()) { - mainWindowAnimator.windowBoundsAnimateTo(savedBounds, { duration: ANIMATION_DURATION }) - } - - cleanupOnClosed = undefined - }) - }) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md deleted file mode 100644 index 04b096264..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Devtools Sample Plugin - -This sample extension is for validating extension host behavior in the **Extension Host Inspector** page. - -## Files - -- `extension.airi.json`: extension manifest (`ExtensionManifestV1`) -- `devtools-sample-plugin.mjs`: extension implementation - -The manifest declares the extension entrypoint used by the host inspector sample. - -## How to use - -1. Open `/devtools/plugin-host` in Stage Tamagotchi. -2. Note the `registry.root` path from the page. -3. Copy both files into that `registry.root` directory. -4. In Extension Host Inspector: - - click `Refresh` - - find `devtools-sample-plugin` - - click `Enable` - - click `Load` (or `Load Enabled`) -5. Confirm: - - extension appears as `loaded` - - session phase becomes `ready` - - capability list is visible - -## What this extension does - -- `setup`: logs startup in renderer/main console. - -It does not mutate app state; it is safe for lifecycle verification. diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.mjs b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.mjs deleted file mode 100644 index d30adf07a..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import { defineExtension } from '@proj-airi/plugin-sdk' - -function nowIso() { - return new Date().toISOString() -} - -/** - * Example plugin for verifying plugin-host lifecycle in devtools. - * - * This module uses the public extension authoring API so it matches the - * package shape expected by the current host loader. - */ -export default defineExtension({ - id: 'devtools-sample-plugin', - setup() { - console.info('[devtools-sample-plugin] setup', { at: nowIso() }) - }, -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/extension.airi.json b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/extension.airi.json deleted file mode 100644 index 67f9a2ef7..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/extension.airi.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "apiVersion": "v1", - "kind": "manifest.extension.airi.moeru.ai", - "id": "devtools-sample-plugin", - "permissions": { - "apis": [ - { - "key": "proj-airi:plugin-sdk:apis:protocol:capabilities:wait", - "actions": ["invoke"] - }, - { - "key": "proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers", - "actions": ["invoke"] - } - ], - "resources": [ - { - "key": "proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers", - "actions": ["read"] - } - ], - "capabilities": [ - { - "key": "proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers", - "actions": ["wait"] - } - ] - }, - "entrypoints": { - "electron": "./devtools-sample-plugin.mjs" - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts deleted file mode 100644 index 871039d44..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { FSWatcher } from 'node:fs' - -import type { useLogg } from '@guiiai/logg' - -import type { ExtensionConfig, ManifestEntry } from '../../types' - -import { watch as watchFile } from 'node:fs' - -import { manifestIdOf } from '../../host/registry' - -/** - * Declares the host-owned callbacks needed by the extension auto-reload feature. - * - * Use when: - * - Installing the optional auto-reload feature into the Electron extension host - * - Keeping file-watcher ownership outside the core host bootstrap - * - * Expects: - * - `reload` unloads, refreshes, and loads the named extension - * - `resolveWatchPaths` returns stable absolute file paths for the extension - * - `getConfig`, `listEntries`, and `isLoaded` always reflect current host state - * - * Returns: - * - N/A - */ -export interface ExtensionAutoReloadFeatureOptions { - log: ReturnType - getConfig: () => ExtensionConfig - listEntries: () => ManifestEntry[] - isLoaded: (extensionId: string) => boolean - resolveWatchPaths: (extensionId: string) => string[] - reload: (extensionId: string, changedPath: string) => Promise -} - -/** - * Manages optional extension auto-reload watchers and debounce timers. - * - * Use when: - * - The Electron extension host wants manifest and entrypoint file watching as an installable feature - * - Host bootstrap should delegate watcher lifecycle and reload scheduling out of `host/index.ts` - * - * Expects: - * - Call `sync()` after registry/config/load-state changes - * - Call `clearExtension(extensionId)` before unloading or disabling an extension - * - Call `dispose()` during host shutdown - * - * Returns: - * - The installed auto-reload feature controller - */ -export function createExtensionAutoReloadFeature(options: ExtensionAutoReloadFeatureOptions) { - const autoReloadInFlight = new Set() - const autoReloadTimers = new Map>() - const autoReloadWatchers = new Map() - - const clearTimer = (extensionId: string) => { - const timer = autoReloadTimers.get(extensionId) - if (!timer) { - return - } - - clearTimeout(timer) - autoReloadTimers.delete(extensionId) - } - - const closeWatchers = (extensionId: string) => { - const watchers = autoReloadWatchers.get(extensionId) - if (!watchers) { - return - } - - for (const watcher of watchers) { - watcher.close() - } - - autoReloadWatchers.delete(extensionId) - } - - const reloadExtensionById = async (extensionId: string, changedPath: string) => { - if (autoReloadInFlight.has(extensionId)) { - return - } - - autoReloadInFlight.add(extensionId) - try { - await options.reload(extensionId, changedPath) - options.log.log('extension auto-reloaded after file change', { extensionId, path: changedPath }) - } - catch (error) { - options.log.withError(error).withFields({ extensionId, path: changedPath }).error('extension auto-reload failed') - } - finally { - autoReloadInFlight.delete(extensionId) - } - } - - const scheduleReload = (extensionId: string, changedPath: string) => { - clearTimer(extensionId) - autoReloadTimers.set(extensionId, setTimeout(() => { - autoReloadTimers.delete(extensionId) - void reloadExtensionById(extensionId, changedPath) - }, 180)) - } - - return { - sync() { - const enabledExtensionIds = new Set(options.getConfig().autoReload) - const desiredExtensionIds = new Set(options.listEntries() - .map(entry => manifestIdOf(entry.manifest)) - .filter(extensionId => enabledExtensionIds.has(extensionId) && options.isLoaded(extensionId))) - - for (const extensionId of autoReloadWatchers.keys()) { - if (!desiredExtensionIds.has(extensionId)) { - clearTimer(extensionId) - closeWatchers(extensionId) - } - } - - for (const extensionId of desiredExtensionIds) { - if (autoReloadWatchers.has(extensionId)) { - continue - } - - const watchPaths = options.resolveWatchPaths(extensionId) - if (watchPaths.length === 0) { - continue - } - - const watchers: FSWatcher[] = [] - for (const watchPath of watchPaths) { - try { - const watcher = watchFile(watchPath, { persistent: false }, () => scheduleReload(extensionId, watchPath)) - watcher.on('error', (error) => { - options.log.withError(error).withFields({ extensionId, path: watchPath }).warn('extension auto-reload watcher error') - }) - watchers.push(watcher) - } - catch (error) { - options.log.withError(error).withFields({ extensionId, path: watchPath }).warn('failed to watch extension file for auto-reload') - } - } - - if (watchers.length > 0) { - autoReloadWatchers.set(extensionId, watchers) - } - } - }, - clearExtension(extensionId: string) { - clearTimer(extensionId) - closeWatchers(extensionId) - }, - dispose() { - const managedNames = new Set([ - ...autoReloadTimers.keys(), - ...autoReloadWatchers.keys(), - ]) - - for (const extensionId of managedNames) { - clearTimer(extensionId) - closeWatchers(extensionId) - } - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts deleted file mode 100644 index 40d845cd1..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { StaticAssetService } from '../../../http-server/static-assets' -import type { StaticAssetSession } from '../../../http-server/static-assets/types' -import type { ExtensionAssetCookie, ExtensionAssetCookieAdapter } from './index' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { createExtensionAssetService } from './index' - -const mockState = vi.hoisted(() => ({ - createStaticAssetService: vi.fn(), -})) - -vi.mock('../../../http-server/static-assets', () => ({ - createStaticAssetService: mockState.createStaticAssetService, -})) - -function createSession(assetSessionId: string, extensionId = 'airi-plugin-game-chess'): StaticAssetSession { - return { - assetSessionId, - cookieName: `airi_extension_asset_session_${assetSessionId}`, - cookieValue: `cookie-value-${assetSessionId}`, - cookiePath: `/_airi/extensions/${extensionId}/sessions/${assetSessionId}/ui`, - expiresAt: 123_456, - } -} - -function createFakeServer(options: { - baseUrl?: string - createSessionResult?: StaticAssetSession - revokeByOwnerSessionIdResult?: StaticAssetSession[] - revokeByExtensionIdResult?: StaticAssetSession[] - revokeAllResult?: StaticAssetSession[] -} = {}) { - return { - key: 'static-assets', - start: vi.fn(async () => {}), - stop: vi.fn(async () => {}), - getBaseUrl: vi.fn(() => options.baseUrl), - createSession: vi.fn(() => options.createSessionResult ?? createSession('asset-session-1')), - revokeSession: vi.fn((assetSessionId: string) => createSession(assetSessionId)), - revokeByOwnerSessionId: vi.fn(() => options.revokeByOwnerSessionIdResult ?? []), - revokeByExtensionId: vi.fn(() => options.revokeByExtensionIdResult ?? []), - revokeAll: vi.fn(() => options.revokeAllResult ?? []), - } satisfies StaticAssetService -} - -function createFakeCookieAdapter() { - const setCookies: ExtensionAssetCookie[] = [] - const removedCookies: ExtensionAssetCookie[] = [] - - return { - adapter: { - setCookie: vi.fn(async (cookie) => { - setCookies.push(cookie) - }), - removeCookie: vi.fn(async (cookie) => { - removedCookies.push(cookie) - }), - } satisfies ExtensionAssetCookieAdapter, - removedCookies, - setCookies, - } -} - -describe('createExtensionAssetService', () => { - beforeEach(() => { - mockState.createStaticAssetService.mockReset() - }) - - it('creates a cookie-backed asset session before returning the mounted URL', async () => { - const server = createFakeServer({ - baseUrl: 'http://127.0.0.1:48123', - createSessionResult: createSession('asset-session-1'), - }) - const { adapter, setCookies } = createFakeCookieAdapter() - mockState.createStaticAssetService.mockReturnValue(server) - - const service = createExtensionAssetService({ - getManifestEntryByExtensionId: () => new Map(), - cookieAdapter: adapter, - }) - - const result = await service.createAssetSession({ - extensionId: 'airi-plugin-game-chess', - version: '1.0.0', - ownerSessionId: 'owner-session-1', - routeAssetPath: 'assets/app.js', - pathPrefix: 'assets/', - ttlMs: 60_000, - }) - - expect(server.createSession).toHaveBeenCalledWith({ - extensionId: 'airi-plugin-game-chess', - version: '1.0.0', - ownerSessionId: 'owner-session-1', - pathPrefix: 'assets/', - ttlMs: 60_000, - }) - expect(adapter.setCookie).toHaveBeenCalledOnce() - expect(setCookies).toEqual([ - { - name: 'airi_extension_asset_session_asset-session-1', - value: 'cookie-value-asset-session-1', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui', - path: '/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui', - expiresAt: 123_456, - }, - ]) - expect(result).toEqual({ - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/assets/app.js', - assetSessionId: 'asset-session-1', - cookie: setCookies[0], - expiresAt: 123_456, - }) - }) - - it('revokes the server session when base URL is missing before setting a cookie', async () => { - const server = createFakeServer({ - baseUrl: undefined, - createSessionResult: createSession('asset-session-2'), - }) - const { adapter, setCookies } = createFakeCookieAdapter() - mockState.createStaticAssetService.mockReturnValue(server) - - const service = createExtensionAssetService({ - getManifestEntryByExtensionId: () => new Map(), - cookieAdapter: adapter, - }) - - await expect(service.createAssetSession({ - extensionId: 'airi-plugin-game-chess', - version: '1.0.0', - ownerSessionId: 'owner-session-1', - routeAssetPath: 'assets/app.js', - pathPrefix: 'assets/', - ttlMs: 60_000, - })).rejects.toThrow('Extension asset server base URL is unavailable') - - expect(server.revokeSession).toHaveBeenCalledWith('asset-session-2') - expect(adapter.setCookie).not.toHaveBeenCalled() - expect(setCookies).toEqual([]) - }) - - it('revokes the server session when route path or cookie setup fails', async () => { - const server = createFakeServer({ - baseUrl: 'http://127.0.0.1:48123', - createSessionResult: createSession('asset-session-3'), - }) - const { adapter } = createFakeCookieAdapter() - mockState.createStaticAssetService.mockReturnValue(server) - const service = createExtensionAssetService({ - getManifestEntryByExtensionId: () => new Map(), - cookieAdapter: adapter, - }) - - await expect(service.createAssetSession({ - extensionId: 'airi-plugin-game-chess', - version: '1.0.0', - ownerSessionId: 'owner-session-1', - routeAssetPath: '../secret.txt', - pathPrefix: '', - ttlMs: 60_000, - })).rejects.toThrow('Extension asset session routeAssetPath must be a safe extension asset path') - - expect(server.revokeSession).toHaveBeenCalledWith('asset-session-3') - expect(adapter.setCookie).not.toHaveBeenCalled() - - server.createSession.mockReturnValue(createSession('asset-session-4')) - adapter.setCookie.mockRejectedValueOnce(new Error('cookie jar unavailable')) - - await expect(service.createAssetSession({ - extensionId: 'airi-plugin-game-chess', - version: '1.0.0', - ownerSessionId: 'owner-session-1', - routeAssetPath: 'assets/app.js', - pathPrefix: 'assets/', - ttlMs: 60_000, - })).rejects.toThrow('cookie jar unavailable') - - expect(server.revokeSession).toHaveBeenCalledWith('asset-session-4') - }) - - it('removes cookies returned by asset, owner, plugin, and global revocation', async () => { - const directSession = createSession('direct-asset-session') - const ownerSession = createSession('owner-asset-session') - const pluginSession = createSession('plugin-asset-session') - const allSession = createSession('all-asset-session') - const server = createFakeServer({ - baseUrl: 'http://127.0.0.1:48123', - revokeByOwnerSessionIdResult: [ownerSession], - revokeByExtensionIdResult: [pluginSession], - revokeAllResult: [allSession], - }) - server.revokeSession.mockReturnValue(directSession) - const { adapter, removedCookies } = createFakeCookieAdapter() - mockState.createStaticAssetService.mockReturnValue(server) - - const service = createExtensionAssetService({ - getManifestEntryByExtensionId: () => new Map(), - cookieAdapter: adapter, - }) - - await service.revokeSession('direct-asset-session') - await service.revokeByOwnerSessionId('owner-session-1') - await service.revokeByExtensionId('airi-plugin-game-chess') - await service.revokeAll() - - expect(server.revokeSession).toHaveBeenCalledWith('direct-asset-session') - expect(server.revokeByOwnerSessionId).toHaveBeenCalledWith('owner-session-1') - expect(server.revokeByExtensionId).toHaveBeenCalledWith('airi-plugin-game-chess') - expect(server.revokeAll).toHaveBeenCalledOnce() - expect(adapter.removeCookie).toHaveBeenCalledTimes(4) - expect(removedCookies).toEqual([ - { - name: 'airi_extension_asset_session_direct-asset-session', - value: 'cookie-value-direct-asset-session', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui', - path: '/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui', - expiresAt: 123_456, - }, - { - name: 'airi_extension_asset_session_owner-asset-session', - value: 'cookie-value-owner-asset-session', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui', - path: '/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui', - expiresAt: 123_456, - }, - { - name: 'airi_extension_asset_session_plugin-asset-session', - value: 'cookie-value-plugin-asset-session', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui', - path: '/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui', - expiresAt: 123_456, - }, - { - name: 'airi_extension_asset_session_all-asset-session', - value: 'cookie-value-all-asset-session', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui', - path: '/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui', - expiresAt: 123_456, - }, - ]) - }) - - it('revokes all sessions and removes cookies before stopping the server', async () => { - const allSession = createSession('stop-asset-session') - const server = createFakeServer({ - baseUrl: 'http://127.0.0.1:48123', - revokeAllResult: [allSession], - }) - const { adapter, removedCookies } = createFakeCookieAdapter() - mockState.createStaticAssetService.mockReturnValue(server) - - const service = createExtensionAssetService({ - getManifestEntryByExtensionId: () => new Map(), - cookieAdapter: adapter, - }) - - await service.stop() - - expect(server.revokeAll).toHaveBeenCalledOnce() - expect(adapter.removeCookie).toHaveBeenCalledOnce() - expect(server.stop).toHaveBeenCalledOnce() - expect(removedCookies).toEqual([ - { - name: 'airi_extension_asset_session_stop-asset-session', - value: 'cookie-value-stop-asset-session', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui', - path: '/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui', - expiresAt: 123_456, - }, - ]) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts deleted file mode 100644 index e56eb5aa5..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts +++ /dev/null @@ -1,267 +0,0 @@ -import type { ServerManager } from '../../../http-server/server-manager/types' -import type { StaticAssetManifestEntry } from '../../../http-server/static-assets' -import type { StaticAssetSession } from '../../../http-server/static-assets/types' - -import { createStaticAssetService } from '../../../http-server/static-assets' -import { buildMountedStaticAssetPath } from '../../../http-server/static-assets/paths' - -/** - * Describes one extension asset session creation request. - * - * Use when: - * - An extension-owned asset URL must be mounted behind the local loopback server with cookie auth - * - Snapshot builders need a transport-agnostic way to authorize one extension asset route before iframe load - * - * Expects: - * - `extensionId` matches a manifest entry registered in the asset host - * - `routeAssetPath` identifies the iframe entry asset relative to the mounted `/ui` route - * - `pathPrefix` is scoped to the mounted route prefix accepted by the session store - * - * Returns: - * - N/A - */ -export interface ExtensionAssetSessionInput { - /** Extension manifest id that owns the static asset root. */ - extensionId: string - /** Extension/plugin version expected by the server-side session validator. */ - version: string - /** Parent extension session id used for owner-scoped revocation. */ - ownerSessionId: string - /** Asset path to mount in the returned renderer-facing URL. */ - routeAssetPath: string - /** Allowed asset path prefix enforced by the server-side session store. */ - pathPrefix: string - /** Session lifetime in milliseconds from creation or refresh time. */ - ttlMs: number -} - -/** - * Describes the cookie material Electron must apply before loading an extension asset URL. - * - * Use when: - * - Main process bridges server-side asset sessions into Electron's cookie jar - * - Revocation needs enough cookie identity to remove previously issued asset cookies - * - * Expects: - * - `url` belongs to the local asset server origin - * - `path` matches the server-issued cookie path for the asset session route - * - * Returns: - * - N/A - */ -export interface ExtensionAssetCookie { - /** Cookie name generated for the asset session. */ - name: string - /** Opaque cookie value required by the static asset route. */ - value: string - /** Absolute URL on the local asset server used by Electron cookie APIs. */ - url: string - /** Route path scope generated by the asset session store. */ - path: string - /** Unix timestamp in milliseconds when the cookie-backed asset session expires. */ - expiresAt: number -} - -/** - * Applies and removes extension asset cookies from the Electron host. - * - * Use when: - * - Asset sessions must exist in Electron's cookie jar before an iframe navigates to its URL - * - Asset session revocation must remove browser-visible cookie state - * - * Expects: - * - `setCookie` resolves only after Electron can send the cookie for matching asset URLs - * - `removeCookie` is idempotent for already-removed cookies - * - * Returns: - * - N/A - */ -export interface ExtensionAssetCookieAdapter { - setCookie: (cookie: ExtensionAssetCookie) => Promise - removeCookie: (cookie: ExtensionAssetCookie) => Promise -} - -/** - * Describes the extension asset methods needed while building renderer-facing snapshots. - * - * Use when: - * - Snapshot builders must request route-scoped asset sessions without depending on HTTP server internals - * - Host bootstrap wants to layer caching or policy on top of the raw asset transport - * - * Expects: - * - `routeAssetPath` identifies the mounted asset file being exposed in the snapshot - * - Implementations set host cookies before returning renderer-facing URLs - * - * Returns: - * - A mounted asset URL and cookie-backed session metadata - */ -export interface ExtensionAssetSnapshotService { - getBaseUrl: () => string | undefined - createAssetSession: (input: Omit) => Promise -} - -/** - * Describes an extension asset session prepared for renderer iframe navigation. - * - * Use when: - * - A plugin iframe needs a mounted static asset URL and pre-applied cookie state - * - Callers need the opaque session id for later targeted revocation - * - * Expects: - * - `cookie` was set through the host adapter before the value is returned - * - * Returns: - * - Renderer-facing URL plus server and cookie metadata - */ -export interface ExtensionAssetSession { - /** Absolute mounted asset URL safe to hand to a renderer iframe after cookie setup. */ - url: string - /** Opaque server-side asset session id embedded in mounted asset routes. */ - assetSessionId: string - /** Cookie data that was applied through the host adapter. */ - cookie: ExtensionAssetCookie - /** Unix timestamp in milliseconds when the cookie-backed asset session expires. */ - expiresAt: number -} - -/** - * Defines the extension-owned asset hosting service used by the extension host. - * - * Use when: - * - Plugin snapshots need mounted asset URLs without depending on the H3 server shape - * - Host teardown must revoke extension asset access independently from widget/gamelet logic - * - * Expects: - * - Implementations own the underlying transport, cookie, and session lifecycle - * - * Returns: - * - A startable/stoppable asset-hosting service with generic extension-facing methods - */ -export interface ExtensionAssetService extends ServerManager { - getBaseUrl: () => string | undefined - createAssetSession: (input: ExtensionAssetSessionInput) => Promise - revokeSession: (assetSessionId: string) => Promise - revokeByOwnerSessionId: (ownerSessionId: string) => Promise - revokeByExtensionId: (extensionId: string) => Promise - revokeAll: () => Promise -} - -function createExtensionAssetCookie(baseUrl: string, session: StaticAssetSession): ExtensionAssetCookie { - return { - name: session.cookieName, - value: session.cookieValue, - url: new URL(session.cookiePath, baseUrl).toString(), - path: session.cookiePath, - expiresAt: session.expiresAt, - } -} - -/** - * Creates the extension asset host service backed by the extension static asset server. - * - * Use when: - * - The extension host needs to expose mounted asset URLs to renderer snapshots - * - Asset session lifecycle should stay inside the extension domain instead of the HTTP server layer - * - * Expects: - * - `getManifestEntryByExtensionId` returns the latest extension root/version map - * - `cookieAdapter` writes and removes cookies in the Electron host session used by plugin iframes - * - * Returns: - * - An extension-facing asset host service with generic extension asset methods - */ -export function createExtensionAssetService(options: { - getManifestEntryByExtensionId: () => Map - cookieAdapter: ExtensionAssetCookieAdapter -}): ExtensionAssetService { - const server = createStaticAssetService({ getManifestEntryByExtensionId: options.getManifestEntryByExtensionId }) - let lastBaseUrl: string | undefined - - const readBaseUrl = () => { - const baseUrl = server.getBaseUrl() - lastBaseUrl = baseUrl ?? lastBaseUrl - return baseUrl - } - - const revokeSessions = async (sessions: readonly StaticAssetSession[]) => { - const baseUrl = readBaseUrl() ?? lastBaseUrl - if (!baseUrl) { - return - } - - await Promise.all( - sessions.map(session => options.cookieAdapter.removeCookie(createExtensionAssetCookie(baseUrl, session))), - ) - } - - return { - key: 'extension-assets', - async start() { - await server.start() - }, - async stop() { - await revokeSessions(server.revokeAll()) - await server.stop() - }, - getBaseUrl() { - return readBaseUrl() - }, - async createAssetSession(input) { - const session = server.createSession({ - extensionId: input.extensionId, - version: input.version, - ownerSessionId: input.ownerSessionId, - pathPrefix: input.pathPrefix, - ttlMs: input.ttlMs, - }) - - try { - const baseUrl = readBaseUrl() - if (!baseUrl) { - throw new Error('Extension asset server base URL is unavailable; start the asset server before creating asset sessions') - } - - const mountedPath = buildMountedStaticAssetPath({ - extensionId: input.extensionId, - assetSessionId: session.assetSessionId, - assetPath: input.routeAssetPath, - }) - - if (!mountedPath) { - throw new RangeError('Extension asset session routeAssetPath must be a safe extension asset path') - } - - const cookie = createExtensionAssetCookie(baseUrl, session) - await options.cookieAdapter.setCookie(cookie) - - return { - url: new URL(mountedPath, baseUrl).toString(), - assetSessionId: session.assetSessionId, - cookie, - expiresAt: session.expiresAt, - } - } - catch (error) { - server.revokeSession(session.assetSessionId) - throw error - } - }, - async revokeSession(assetSessionId) { - const session = server.revokeSession(assetSessionId) - if (!session) { - return - } - - await revokeSessions([session]) - }, - async revokeByOwnerSessionId(ownerSessionId) { - await revokeSessions(server.revokeByOwnerSessionId(ownerSessionId)) - }, - async revokeByExtensionId(extensionId) { - await revokeSessions(server.revokeByExtensionId(extensionId)) - }, - async revokeAll() { - await revokeSessions(server.revokeAll()) - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts deleted file mode 100644 index 7ab7fab4b..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { ExtensionConfig } from '../types' - -import { array, object, record, string } from 'valibot' - -import { createConfig } from '../../../../libs/electron/persistence' - -const extensionConfigSchema = object({ - enabled: array(string()), - autoReload: array(string()), - known: record(string(), object({ - path: string(), - })), -}) - -function createDefaultExtensionConfig(): ExtensionConfig { - return { - enabled: [], - autoReload: [], - known: {}, - } -} - -/** - * Persists extension host enablement and discovery metadata. - * - * Use when: - * - Bootstrapping the Electron extension host - * - Reading or updating `extensions-v1.json` state - * - * Expects: - * - `setup()` runs before `get()` or `update()` - * - Consumers write complete `ExtensionConfig` snapshots - * - * Returns: - * - Accessors around the persisted extension config document - */ -export interface ExtensionHostConfigStore { - setup: () => void - get: () => ExtensionConfig - update: (config: ExtensionConfig) => void -} - -/** - * Creates the persisted config store used by the extension host bootstrap. - * - * Use when: - * - Host bootstrap modules need config persistence without inlining schema setup - * - * Expects: - * - Electron `app.getPath('userData')` is available through the persistence layer - * - * Returns: - * - A small config store that always falls back to the default extension config - */ -export function createExtensionHostConfigStore(): ExtensionHostConfigStore { - const extensionConfig = createConfig('extensions', 'v1.json', extensionConfigSchema, { - default: createDefaultExtensionConfig(), - autoHeal: true, - }) - - return { - setup() { - extensionConfig.setup() - }, - get() { - return extensionConfig.get() ?? createDefaultExtensionConfig() - }, - update(config) { - extensionConfig.update(config) - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts deleted file mode 100644 index bbb649920..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host' - -import type { - PluginHostDebugSnapshot, -} from '../../../../../shared/eventa/plugin/host' -import type { ExtensionAssetSnapshotService } from '../features/static-assets' -import type { ExtensionConfig, ManifestEntry } from '../types' - -import { rewriteWidgetModuleAssetUrl } from '../kits/widget' -import { buildPluginRegistrySnapshot } from './registry' - -/** - * Builds the debug snapshot exposed by the Electron extension host inspector. - * - * Use when: - * - Renderer devtools need sessions, kits, modules, and capability state - * - Widget iframe asset URLs must be rewritten to mounted extension asset URLs - * - * Expects: - * - `host` is the initialized extension host instance - * - `manifestEntryByExtensionId` contains entries for any extension-owned modules being inspected - * - `extensionAssetService` owns extension asset URL/session lifecycle when mounted asset URLs are needed - * - * Returns: - * - A full debug snapshot with registry, sessions, kits, modules, and capabilities - */ -export function buildPluginHostDebugSnapshot(options: { - host: ExtensionHost - extensionsRoot: string - entries: ManifestEntry[] - config: ExtensionConfig - loaded: Set - manifestEntryByExtensionId: Map - extensionAssetService?: ExtensionAssetSnapshotService -}): Promise { - const extensionAssetService = options.extensionAssetService - const modules = Promise.all(options.host - .listBindings() - .map(module => - rewriteWidgetModuleAssetUrl( - module, - options.manifestEntryByExtensionId, - { - extensionAssetBaseUrl: extensionAssetService?.getBaseUrl(), - ...(extensionAssetService - ? { - createAssetSession: ({ extensionId, version, sessionId, routeAssetPath, sessionPathPrefix }: { - extensionId: string - version: string - sessionId: string - routeAssetPath: string - sessionPathPrefix: string - }) => extensionAssetService.createAssetSession({ - extensionId, - version, - ownerSessionId: sessionId, - routeAssetPath, - pathPrefix: sessionPathPrefix, - }), - } - : {}), - }, - ), - )) - - return modules.then(resolvedModules => ({ - registry: buildPluginRegistrySnapshot({ - extensionsRoot: options.extensionsRoot, - entries: options.entries, - config: options.config, - loaded: options.loaded, - }), - sessions: options.host.listSessions().map(session => ({ - id: session.id, - extensionId: session.manifest.id, - phase: session.phase, - runtime: session.runtime ?? 'electron', - moduleId: session.extension.id, - })), - kits: options.host.listKits(), - modules: resolvedModules, - capabilities: options.host.listCapabilities(), - refreshedAt: Date.now(), - })) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts deleted file mode 100644 index 32af2f8e6..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts +++ /dev/null @@ -1,529 +0,0 @@ -import type { TamagotchiToolRegistry } from '@proj-airi/plugin-sdk-tamagotchi/tools' - -import type { - PluginHostDebugSnapshot, - PluginRegistrySnapshot, -} from '../../../../../shared/eventa/plugin/host' -import type { - ExtensionAssetCookie, - ExtensionAssetSession, - ExtensionAssetSnapshotService, -} from '../features/static-assets' -import type { ExtensionHostService, SetupExtensionHostOptions } from '../types' - -import { dirname, join } from 'node:path' - -import { useLogg } from '@guiiai/logg' -import { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host' -import { app, session as electronSession } from 'electron' - -import { createExtensionAutoReloadFeature } from '../features/auto-reload' -import { createExtensionAssetService } from '../features/static-assets' -import { createBuiltInExtensionKitRuntime } from '../kits' -import { createExtensionHostConfigStore } from './config' -import { buildPluginHostDebugSnapshot } from './debug' -import { - buildPluginRegistrySnapshot, - createExtensionHostRegistry, - createManifestForLoad, - manifestIdOf, - resolvePluginRuntimeEntrypointPath, -} from './registry' - -const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000 - -function createElectronExtensionAssetCookieAdapter() { - return { - async setCookie(cookie: ExtensionAssetCookie) { - await electronSession.defaultSession.cookies.set({ - url: cookie.url, - name: cookie.name, - value: cookie.value, - path: cookie.path, - httpOnly: true, - sameSite: 'no_restriction', - secure: true, - expirationDate: Math.floor(cookie.expiresAt / 1000), - }) - }, - async removeCookie(cookie: ExtensionAssetCookie) { - await electronSession.defaultSession.cookies.remove(cookie.url, cookie.name) - }, - } -} - -/** - * Internal extension host bootstrap service used by the public `setupExtensionHost(...)` facade. - * - * Use when: - * - `plugins/index.ts` needs a smaller orchestration layer with the same caller-facing API - * - Host wiring should stay separate from config, registry, and snapshot helpers - * - * Expects: - * - Consumers treat this as an internal bootstrap surface and keep the public facade unchanged - * - `widgetsManager` is ready before startup begins - * - * Returns: - * - The plain `ExtensionHostService` fields plus internal helpers for list/load/unload/inspect/dispose - */ -export interface ExtensionHostServiceInternal extends ExtensionHostService { - /** Tamagotchi-owned extension tool registry used by IPC tool bridges. */ - tools: TamagotchiToolRegistry - - /** - * Lists the current extension registry snapshot. - * - * Use when: - * - IPC callers need the latest discovered plugin entries and enablement state - * - Host operations need a refreshed renderer-facing registry view - * - * Expects: - * - Manifest discovery can be refreshed before the snapshot is built - * - * Returns: - * - The latest extension registry snapshot for renderer consumption - */ - list: () => Promise - - /** - * Persists whether one plugin is enabled. - * - * Use when: - * - Renderer controls toggle plugin enablement - * - Host state must remember a known manifest path for a plugin name - * - * Expects: - * - `payload.extensionId` matches a discovered or previously known extension - * - `payload.path` is only needed when the manifest is not currently discoverable - * - * Returns: - * - The updated extension registry snapshot after persistence - */ - setEnabled: (payload: { extensionId: string, enabled: boolean, path?: string }) => Promise - - /** - * Persists whether one loaded plugin should use auto-reload. - * - * Use when: - * - Renderer controls toggle plugin file watching during development - * - Host features need to resync optional watcher state after config changes - * - * Expects: - * - `payload.extensionId` matches one extension entry in config or discovery state - * - * Returns: - * - The updated extension registry snapshot after persistence - */ - setAutoReload: (payload: { extensionId: string, enabled: boolean }) => Promise - - /** - * Loads every plugin currently marked as enabled. - * - * Use when: - * - App startup wants to restore persisted enabled plugins - * - Renderer requests a bulk load after configuration changes - * - * Expects: - * - Discovery state is current before load begins - * - * Returns: - * - The extension registry snapshot after load attempts finish - */ - loadEnabled: () => Promise - - /** - * Loads one extension by manifest id. - * - * Use when: - * - Renderer explicitly requests one plugin to start - * - Host features need to restart a plugin after manifest or entrypoint changes - * - * Expects: - * - `extensionId` resolves to a manifest entry in the current registry - * - * Returns: - * - The extension registry snapshot after the load completes - */ - load: (extensionId: string) => Promise - - /** - * Stops one loaded extension by manifest id. - * - * Use when: - * - Renderer explicitly requests one plugin to stop - * - Host features need to stop a plugin before reload or disposal - * - * Expects: - * - `extensionId` identifies an extension that may or may not currently be loaded - * - * Returns: - * - The extension registry snapshot after unload bookkeeping completes - */ - unload: (extensionId: string) => Promise - - /** - * Builds the full extension host debug snapshot. - * - * Use when: - * - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs - * - Host debugging needs a fresh runtime snapshot after registry refresh - * - * Expects: - * - The host and extension asset service are both initialized - * - * Returns: - * - The full debug snapshot exposed through plugin inspection IPC - */ - inspect: () => Promise - - /** - * Returns the mounted base URL for plugin-served assets. - * - * Use when: - * - Renderer code needs to construct extension asset URLs - * - Snapshot consumers need the current loopback asset mount base - * - * Expects: - * - The extension asset service may be started before this is called - * - * Returns: - * - The current extension asset base URL, or an empty string when unavailable - */ - getAssetBaseUrl: () => string - - /** - * Disposes optional host features and asset hosting resources. - * - * Use when: - * - Electron shutdown needs to stop extension-owned background work - * - Tests need to release watchers and local asset servers deterministically - * - * Expects: - * - Disposal may be called after partial startup or after prior plugin failures - * - * Returns: - * - A promise that resolves after feature and asset cleanup finish - */ - dispose: () => Promise -} - -/** - * Builds the extracted Electron extension host bootstrap used by the public facade. - * - * Use when: - * - The public extension service wants one internal bootstrap entrypoint - * - Tests need direct access to the internal host bootstrap helper - * - * Expects: - * - Electron `app.getPath('userData')` is available - * - Extension manifests live under `/extensions/v1` - * - * Returns: - * - The internal bootstrap service that powers the public extension-host IPC facade - */ -export async function setupExtensionHostServiceInternal( - options: SetupExtensionHostOptions, -): Promise { - const log = useLogg('main/extension-host').useGlobalConfig() - const extensionsRoot = join(app.getPath('userData'), 'extensions', 'v1') - - // Config - const extensionConfig = createExtensionHostConfigStore() - extensionConfig.setup() - - // Kit API, Host - const builtInKitRuntime = createBuiltInExtensionKitRuntime(options) - const host = new ExtensionHost({ runtime: 'electron' }) - log.withFields({ extensionsRoot }).log('loading extension manifests') - builtInKitRuntime.registerHostKits(host) - - // extension registry - const extensionRegistry = createExtensionHostRegistry({ extensionsRoot, log }) - - await extensionRegistry.refresh() - log.withFields({ count: extensionRegistry.listEntries().length }).log('extension manifests loaded') - for (const entry of extensionRegistry.listEntries()) { - log.withFields({ name: manifestIdOf(entry.manifest), path: entry.path }).log('extension manifest found') - } - - // Extension feature: Static Assets serving - const extensionAssetService = createExtensionAssetService({ - getManifestEntryByExtensionId: () => extensionRegistry.getManifestEntryByExtensionId(), - cookieAdapter: createElectronExtensionAssetCookieAdapter(), - }) - await extensionAssetService.start() - - const loaded = new Set() - const loadedSessionIds = new Map() - const moduleAssetSessionCache = new Map() - - const clearModuleAssetSessionCacheByExtensionId = (extensionId: string) => { - for (const key of moduleAssetSessionCache.keys()) { - if (key.startsWith(`${extensionId}:`)) { - moduleAssetSessionCache.delete(key) - } - } - } - - const clearModuleAssetSessionCacheByOwnerSessionId = (ownerSessionId: string) => { - for (const key of moduleAssetSessionCache.keys()) { - const segments = key.split(':') - if (segments[2] === ownerSessionId) { - moduleAssetSessionCache.delete(key) - } - } - } - - const refreshManifests = async () => { - await extensionRegistry.refresh() - } - - const getConfig = () => extensionConfig.get() - - const listSnapshot = (): PluginRegistrySnapshot => { - return buildPluginRegistrySnapshot({ - extensionsRoot, - entries: extensionRegistry.listEntries(), - config: getConfig(), - loaded, - }) - } - - const createModuleAssetSession = async (input: { - extensionId: string - version: string - ownerSessionId: string - routeAssetPath: string - pathPrefix: string - }) => { - const { extensionId, version, ownerSessionId, routeAssetPath, pathPrefix } = input - const cacheKey = `${extensionId}:${version}:${ownerSessionId}:${routeAssetPath}:${pathPrefix}` - const cachedSession = moduleAssetSessionCache.get(cacheKey) - if (cachedSession) { - return cachedSession - } - - const session = await extensionAssetService.createAssetSession({ - extensionId, - version, - ownerSessionId, - routeAssetPath, - pathPrefix, - ttlMs: extensionAssetSessionTtlMs, - }) - moduleAssetSessionCache.set(cacheKey, session) - return session - } - - const extensionAssetSnapshotService: ExtensionAssetSnapshotService = { - getBaseUrl: extensionAssetService.getBaseUrl, - createAssetSession: ({ extensionId, version, ownerSessionId, routeAssetPath, pathPrefix }) => { - return createModuleAssetSession({ - extensionId, - version, - ownerSessionId, - routeAssetPath, - pathPrefix, - }) - }, - } - - const inspectSnapshot = async (): Promise => { - return await buildPluginHostDebugSnapshot({ - host, - extensionsRoot, - entries: extensionRegistry.listEntries(), - config: getConfig(), - loaded, - manifestEntryByExtensionId: extensionRegistry.getManifestEntryByExtensionId(), - extensionAssetService: extensionAssetSnapshotService, - }) - } - - const loadExtensionById = async ( - extensionId: string, - loadOptions: { cacheBustKey?: string } = {}, - ) => { - if (loaded.has(extensionId)) { - return - } - - const entry = extensionRegistry.findManifestEntry(extensionId) - if (!entry) { - throw new Error(`Extension manifest not found: ${extensionId}`) - } - - const manifestForLoad = createManifestForLoad(entry, loadOptions) - const session = await host.start(manifestForLoad, { cwd: dirname(entry.path) }) - loaded.add(extensionId) - loadedSessionIds.set(extensionId, session.id) - log.withFields({ extensionId, sessionId: session.id }).log('extension loaded') - } - - const stopLoadedExtensionById = async (extensionId: string) => { - const sessionId = loadedSessionIds.get(extensionId) - if (!sessionId) { - loaded.delete(extensionId) - return - } - - await host.stop(sessionId) - loadedSessionIds.delete(extensionId) - loaded.delete(extensionId) - - clearModuleAssetSessionCacheByOwnerSessionId(sessionId) - await extensionAssetService.revokeByOwnerSessionId(sessionId) - - log.withFields({ extensionId, sessionId }).log('extension unloaded') - } - - const resolveAutoReloadWatchPaths = (extensionId: string) => { - const entry = extensionRegistry.findManifestEntry(extensionId) - if (!entry) { - return [] - } - - const entrypointPath = resolvePluginRuntimeEntrypointPath(entry) - return [...new Set([entry.path, entrypointPath].filter((path): path is string => Boolean(path)))] - } - - // Extension feature: Auto-reload for plugins - const autoReloadFeature = createExtensionAutoReloadFeature({ - log, - getConfig, - listEntries: () => extensionRegistry.listEntries(), - isLoaded: extensionId => loaded.has(extensionId), - resolveWatchPaths: resolveAutoReloadWatchPaths, - reload: async (extensionId) => { - await stopLoadedExtensionById(extensionId) - await refreshManifests() - await loadExtensionById(extensionId, { cacheBustKey: `auto-reload-${Date.now()}` }) - }, - }) - - const unloadExtensionById = async (extensionId: string) => { - autoReloadFeature.clearExtension(extensionId) - await stopLoadedExtensionById(extensionId) - } - - const loadEnabledExtensions = async () => { - const config = getConfig() - for (const entry of extensionRegistry.listEntries()) { - const extensionId = manifestIdOf(entry.manifest) - if (!config.enabled.includes(extensionId)) { - continue - } - if (loaded.has(extensionId)) { - continue - } - - try { - await loadExtensionById(extensionId) - } - catch (error) { - log.withError(error).withFields({ extensionId }).error('extension failed to start') - } - } - - autoReloadFeature.sync() - } - - await refreshManifests() - await loadEnabledExtensions() - autoReloadFeature.sync() - - return { - host, - // REVIEW: Tool registry ownership is currently hidden inside the built-in kit runtime even though - // the host service also exposes it for IPC listing/invocation. Consider moving registry ownership - // to this host service and passing it into kit registration as a dependency. - tools: builtInKitRuntime.tools, - manifests: extensionRegistry.listManifests(), - async list() { - await refreshManifests() - autoReloadFeature.sync() - return listSnapshot() - }, - async setEnabled(payload) { - await refreshManifests() - - const config = getConfig() - const enabled = new Set(config.enabled) - if (payload.enabled) { - enabled.add(payload.extensionId) - } - else { - enabled.delete(payload.extensionId) - clearModuleAssetSessionCacheByExtensionId(payload.extensionId) - await extensionAssetService.revokeByExtensionId(payload.extensionId) - } - - const entry = extensionRegistry.findManifestEntry(payload.extensionId) - const manifestPath = entry?.path ?? payload.path ?? '' - extensionConfig.update({ - enabled: [...enabled], - autoReload: config.autoReload, - known: { - ...config.known, - [payload.extensionId]: { path: manifestPath }, - }, - }) - - autoReloadFeature.sync() - return listSnapshot() - }, - async setAutoReload(payload) { - await refreshManifests() - - const config = getConfig() - const autoReload = new Set(config.autoReload) - if (payload.enabled) { - autoReload.add(payload.extensionId) - } - else { - autoReload.delete(payload.extensionId) - } - - extensionConfig.update({ - ...config, - autoReload: [...autoReload], - }) - - autoReloadFeature.sync() - return listSnapshot() - }, - async loadEnabled() { - await refreshManifests() - await loadEnabledExtensions() - autoReloadFeature.sync() - return listSnapshot() - }, - async load(extensionId) { - await refreshManifests() - await loadExtensionById(extensionId) - autoReloadFeature.sync() - return listSnapshot() - }, - async unload(extensionId) { - await unloadExtensionById(extensionId) - autoReloadFeature.sync() - return listSnapshot() - }, - async inspect() { - await refreshManifests() - autoReloadFeature.sync() - return await inspectSnapshot() - }, - getAssetBaseUrl() { - return extensionAssetService.getBaseUrl() ?? '' - }, - async dispose() { - autoReloadFeature.dispose() - builtInKitRuntime.dispose() - - moduleAssetSessionCache.clear() - await extensionAssetService.revokeAll() - await extensionAssetService.stop() - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts deleted file mode 100644 index 2f13803b2..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts +++ /dev/null @@ -1,355 +0,0 @@ -import type { Dirent } from 'node:fs' - -import type { useLogg } from '@guiiai/logg' -import type { ExtensionManifestV1 } from '@proj-airi/plugin-sdk/plugin-host' - -import type { - PluginManifestSummary, - PluginRegistrySnapshot, -} from '../../../../../shared/eventa/plugin/host' -import type { ExtensionConfig, ManifestEntry } from '../types' - -import { mkdir, readdir, readFile, realpath, stat } from 'node:fs/promises' -import { dirname, isAbsolute, join, resolve } from 'node:path' - -import { extensionManifestV1Schema } from '@proj-airi/plugin-sdk/plugin-host' -import { safeParse } from 'valibot' - -export const extensionManifestFileName = 'extension.airi.json' - -function isExtensionManifestV1(value: unknown): value is ExtensionManifestV1 { - return safeParse(extensionManifestV1Schema, value).success -} - -export function manifestIdOf(manifest: ExtensionManifestV1) { - return manifest.id -} - -async function realPathOf(entry: Dirent, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> { - if (!entry.isSymbolicLink()) { - return { resolved: false } - } - - try { - const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name)) - const stats = await stat(resolvedPath) - if (stats.isFile() || stats.isDirectory()) { - return { resolved: true, path: resolvedPath } - } - - return { resolved: false } - } - catch (error) { - return { resolved: false, error } - } -} - -/** - * Loads extension manifests from plugin subdirectories under the configured root. - * - * Use when: - * - Refreshing the extension registry state from disk - * - Resolving symlink-backed plugin directories before manifest parsing - * - * Expects: - * - Root directory may not exist yet - * - Each plugin is nested under its own child directory - * - Each extension directory may include `extension.airi.json` and optional `package.json` - * - * Returns: - * - Array of validated manifest entries with resolved paths and version metadata - */ -export async function loadManifestsFrom( - dir: string, - log: ReturnType, -): Promise { - await mkdir(dir, { recursive: true }) - const entries = await readdir(dir, { withFileTypes: true }) - const manifests: ManifestEntry[] = [] - const manifestPaths: Array<{ path: string, rootDir: string }> = [] - - for (const entry of entries) { - if (!entry.isDirectory()) { - if (entry.isSymbolicLink()) { - const { resolved, error } = await realPathOf(entry, { cwd: dir }) - if (error) { - log.withError(error).withFields({ name: entry.name }).warn('failed to resolve extension manifest path, skipping') - continue - } - if (!resolved) { - log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping') - continue - } - } - else { - continue - } - } - - let extensionDir = join(dir, entry.name) - if (entry.isSymbolicLink()) { - const { path, resolved } = await realPathOf(entry, { cwd: dir }) - if (resolved) { - extensionDir = path - } - else { - log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping') - continue - } - } - - const extensionEntries = await readdir(extensionDir, { withFileTypes: true }) - const manifestEntry = extensionEntries.find(candidate => candidate.name === extensionManifestFileName) - if (!manifestEntry) { - continue - } - - const manifestPath = join(extensionDir, extensionManifestFileName) - if (manifestEntry.isFile()) { - manifestPaths.push({ path: manifestPath, rootDir: extensionDir }) - continue - } - if (!manifestEntry.isSymbolicLink()) { - continue - } - - try { - const resolvedPath = await realpath(manifestPath) - const stats = await stat(resolvedPath) - if (!stats.isFile()) { - continue - } - manifestPaths.push({ path: manifestPath, rootDir: extensionDir }) - } - catch (error) { - log.withError(error).withFields({ name: manifestEntry.name }).warn('failed to resolve symlink, skipping') - } - } - - for (const manifestPath of manifestPaths) { - try { - const raw = await readFile(manifestPath.path, 'utf-8') - const parsed = JSON.parse(raw) as unknown - if (!isExtensionManifestV1(parsed)) { - log.warn('invalid extension manifest schema', { path: manifestPath.path }) - continue - } - - let version = '0.0.0' - try { - const packageJsonRaw = await readFile(join(manifestPath.rootDir, 'package.json'), 'utf-8') - const packageJson = JSON.parse(packageJsonRaw) as Record - if (typeof packageJson.version === 'string' && packageJson.version.trim()) { - version = packageJson.version.trim() - } - } - catch { - // Ignore package.json read failures; extension manifests without package metadata - // still load with a deterministic fallback version. - } - - manifests.push({ - manifest: parsed, - path: manifestPath.path, - rootDir: manifestPath.rootDir, - version, - }) - } - catch (error) { - log.withError(error).withFields({ path: manifestPath.path }).error('failed to read extension manifest') - } - } - - return manifests -} - -/** - * Builds a renderer-facing extension summary from manifest, config, and runtime state. - * - * Use when: - * - Registry snapshots need one UI-friendly entry per discovered plugin - * - * Expects: - * - `entry` corresponds to a currently discovered manifest - * - `config` is the latest persisted extension config - * - `loaded` tracks currently running plugin names - * - * Returns: - * - Stable manifest summary for UI consumption - */ -export function createPluginSummary( - entry: ManifestEntry, - config: ExtensionConfig, - loaded: Set, -): PluginManifestSummary { - const extensionId = manifestIdOf(entry.manifest) - return { - extensionId, - entrypoints: entry.manifest.entrypoints, - path: entry.path, - enabled: config.enabled.includes(extensionId), - autoReload: config.autoReload.includes(extensionId), - loaded: loaded.has(extensionId), - isNew: !config.known[extensionId], - } -} - -/** - * Builds the renderer-facing extension registry snapshot. - * - * Use when: - * - IPC clients request the plugin list - * - Internal host operations need a fresh registry view after config or load changes - * - * Expects: - * - `entries`, `config`, and `loaded` come from the latest in-memory host state - * - * Returns: - * - A stable registry snapshot for renderer consumption - */ -export function buildPluginRegistrySnapshot(options: { - extensionsRoot: string - entries: ManifestEntry[] - config: ExtensionConfig - loaded: Set -}): PluginRegistrySnapshot { - return { - root: options.extensionsRoot, - plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)), - } -} - -/** - * Resolves the absolute runtime entrypoint path used by load and auto-reload flows. - * - * Use when: - * - File watching needs the runtime entrypoint path - * - Host loading needs to reason about the resolved runtime file - * - * Expects: - * - Entrypoint is either absolute or relative to the manifest directory - * - * Returns: - * - Absolute file path when entrypoint exists; otherwise `undefined` - */ -export function resolvePluginRuntimeEntrypointPath(entry: ManifestEntry): string | undefined { - const entrypoint = entry.manifest.entrypoints.electron ?? entry.manifest.entrypoints.default - if (!entrypoint) { - return undefined - } - - const manifestDir = dirname(entry.path) - return isAbsolute(entrypoint) ? entrypoint : resolve(manifestDir, entrypoint) -} - -function appendCacheBustKey(entrypoint: string, cacheBustKey: string): string { - const delimiter = entrypoint.includes('?') ? '&' : '?' - return `${entrypoint}${delimiter}cacheBust=${encodeURIComponent(cacheBustKey)}` -} - -/** - * Produces the manifest used for runtime loading, optionally with a cache-busted entrypoint. - * - * Use when: - * - Loading a plugin normally - * - Reloading a plugin after file changes to avoid stale module cache - * - * Expects: - * - `cacheBustKey` is omitted for standard loads - * - `cacheBustKey` is deterministic enough for one reload cycle when provided - * - * Returns: - * - Original manifest or cloned manifest with cache-busted runtime entrypoint - */ -export function createManifestForLoad( - entry: ManifestEntry, - options: { cacheBustKey?: string }, -): ExtensionManifestV1 { - const loadManifest = entry.manifest - if (!options.cacheBustKey) { - return loadManifest - } - - const manifest = structuredClone(loadManifest) - if (manifest.entrypoints.electron) { - manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey) - } - else if (manifest.entrypoints.default) { - manifest.entrypoints.default = appendCacheBustKey(manifest.entrypoints.default, options.cacheBustKey) - } - return manifest -} - -/** - * Tracks the manifest registry state used by the Electron extension host. - * - * Use when: - * - Refreshing extension manifests from disk - * - Looking up manifests by extension id during load or inspect operations - * - * Expects: - * - `refresh()` is called before consumers read entries or manifests - * - `extensionsRoot` points at the extension manifest root under user data - * - * Returns: - * - Read access to the current manifest entries, manifest list, and lookup map - */ -export interface ExtensionHostRegistry { - getRoot: () => string - refresh: () => Promise - listEntries: () => ManifestEntry[] - listManifests: () => ExtensionManifestV1[] - findManifestEntry: (extensionId: string) => ManifestEntry | undefined - getManifestEntryByExtensionId: () => Map -} - -/** - * Creates the manifest registry store used by the extension host bootstrap. - * - * Use when: - * - Host bootstrap needs in-memory manifest lookup and refresh operations - * - * Expects: - * - `log` is the plugin-host logger used for manifest loading diagnostics - * - * Returns: - * - A registry wrapper around the current manifest entry array and lookup map - */ -export function createExtensionHostRegistry(options: { - extensionsRoot: string - log: ReturnType -}): ExtensionHostRegistry { - let entries: ManifestEntry[] = [] - let manifests: ExtensionManifestV1[] = [] - let manifestEntryByExtensionId = new Map() - - return { - getRoot() { - return options.extensionsRoot - }, - async refresh() { - entries = await loadManifestsFrom(options.extensionsRoot, options.log) - manifestEntryByExtensionId = new Map() - for (const entry of entries) { - const id = manifestIdOf(entry.manifest) - if (!manifestEntryByExtensionId.has(id)) { - manifestEntryByExtensionId.set(id, entry) - } - } - manifests = entries.map(entry => entry.manifest) - return entries - }, - listEntries() { - return entries - }, - listManifests() { - return manifests - }, - findManifestEntry(extensionId) { - return manifestEntryByExtensionId.get(extensionId) - }, - getManifestEntryByExtensionId() { - return manifestEntryByExtensionId - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts deleted file mode 100644 index b23a2539a..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts +++ /dev/null @@ -1,1406 +0,0 @@ -import type { createContext } from '@moeru/eventa' -import type { - BindingRecord, - ExtensionManifestV1, - HostDataRecord, - ModulePermissionDeclaration, -} from '@proj-airi/plugin-sdk/plugin-host' - -import type { WidgetsAddPayload, WidgetSnapshot, WidgetsUpdatePayload } from '../../../../shared/eventa' -import type { ExtensionHostService } from './types' - -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { basename, join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' - -import { useLogg } from '@guiiai/logg' -import { defineInvoke } from '@moeru/eventa' -import { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host' -import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' - -import { electronPluginGetAssetBaseUrl } from '../../../../shared/eventa/plugin/assets' -import { electronPluginUpdateCapability } from '../../../../shared/eventa/plugin/capabilities' -import { - electronPluginInspect, - electronPluginList, - electronPluginLoad, - electronPluginLoadEnabled, - electronPluginSetAutoReload, - electronPluginSetEnabled, - electronPluginUnload, -} from '../../../../shared/eventa/plugin/host' -import { electronPluginToolsChanged } from '../../../../shared/eventa/plugin/tools' -import { setupExtensionHostServiceInternal } from './host' -import { loadManifestsFrom } from './host/registry' -import { setupExtensionHost as setupExtensionHostService } from './index' -import { gameletPluginKitDescriptor } from './kits/gamelet' -import { createGameletOrchestrationRuntime } from './kits/gamelet/orchestration' -import { widgetPluginKitDescriptor } from './kits/widget' - -const appMock = vi.hoisted(() => ({ - getPath: vi.fn(), -})) -const protocolMock = vi.hoisted(() => ({ - handle: vi.fn(), -})) -const sessionMock = vi.hoisted(() => ({ - defaultSession: { - cookies: { - remove: vi.fn(async (_url: string, _name: string) => {}), - set: vi.fn(async (_details: { name: string, value: string }) => {}), - }, - }, -})) -const contextState = vi.hoisted(() => ({ - lastContext: undefined as ReturnType> | undefined, -})) - -vi.mock('electron', () => ({ - app: appMock, - ipcMain: {}, - protocol: protocolMock, - session: sessionMock, -})) - -vi.mock('@moeru/eventa/adapters/electron/main', async () => { - const eventa = await import('@moeru/eventa') - return { - createContext: () => { - const context = eventa.createContext() - contextState.lastContext = context - return { context, dispose: () => {} } - }, - } -}) - -const testDataRoot = resolve( - import.meta.dirname, - '..', - '..', - '..', - '..', - '..', - '..', - '..', - 'packages', - 'plugin-sdk', - 'src', - 'plugin-host', - 'testdata', -) -const repoRoot = resolve( - import.meta.dirname, - '..', - '..', - '..', - '..', - '..', - '..', - '..', -) -const samplePluginRoot = resolve( - import.meta.dirname, - 'examples', - 'devtools-sample-plugin', -) -const extensionManifestFileName = 'extension.airi.json' - -async function writeManifest(params: { dir: string, name: string, entrypoint: string }) { - const manifest = { - apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id: params.name, - permissions: {}, - entrypoints: { - electron: params.entrypoint, - }, - } - - const path = join(params.dir, extensionManifestFileName) - await writeFile(path, JSON.stringify(manifest, null, 2)) - return path -} - -async function writeManifestInPluginDir(params: { rootDir: string, pluginDirName: string, pluginName: string, entrypointPath: string }) { - const pluginDir = join(params.rootDir, params.pluginDirName) - await mkdir(pluginDir, { recursive: true }) - const entrypointFile = await copyEntrypoint({ dir: pluginDir, path: params.entrypointPath }) - const manifestPath = await writeManifest({ - dir: pluginDir, - name: params.pluginName, - entrypoint: `./${entrypointFile}`, - }) - - return { pluginDir, manifestPath } -} - -async function copyEntrypoint(params: { dir: string, path: string }) { - const file = basename(params.path) - const destination = join(params.dir, file) - const contents = await readFile(params.path, 'utf-8') - await writeFile(destination, contents) - return file -} - -async function writeEntrypoint(params: { dir: string, name: string, contents: string }) { - const destination = join(params.dir, params.name) - await writeFile(destination, params.contents) - return destination -} - -async function linkWorkspacePackageForPlugin(pluginDir: string, packageName: '@proj-airi/plugin-sdk' | '@proj-airi/plugin-sdk-tamagotchi') { - const packageDirName = packageName.replace('@proj-airi/', '') - const packageDir = join(pluginDir, 'node_modules', '@proj-airi', packageDirName) - await mkdir(packageDir, { recursive: true }) - await symlink(resolve(repoRoot, 'packages', packageDirName, 'src'), join(packageDir, 'src'), 'dir') - - const exports = packageName === '@proj-airi/plugin-sdk' - ? { - '.': './src/index.ts', - './plugin-host': './src/plugin-host/index.ts', - } - : { - '.': './src/index.ts', - './widgets': './src/widgets/index.ts', - './gamelet': './src/gamelet/index.ts', - './kits/gamelet': './src/kits/gamelet/index.ts', - './kits/tool': './src/kits/tool/index.ts', - './tools': './src/tools/index.ts', - } - - await writeFile(join(packageDir, 'package.json'), JSON.stringify({ - name: packageName, - type: 'module', - exports, - })) -} - -function createEmptyExtensionEntrypoint(id: string) { - const pluginSdkUrl = pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href - return [ - `import { defineExtension } from ${JSON.stringify(pluginSdkUrl)}`, - '', - 'export default defineExtension({', - ` id: ${JSON.stringify(id)},`, - ' setup() {},', - '})', - ].join('\n') -} - -async function removeDirWithRetry(path: string, options: { attempts?: number, waitMs?: number } = {}) { - const attempts = Math.max(1, options.attempts ?? 5) - const waitMs = Math.max(1, options.waitMs ?? 20) - - for (let index = 0; index < attempts; index += 1) { - try { - await rm(path, { recursive: true, force: true }) - return - } - catch (error) { - if (index >= attempts - 1) { - throw error - } - await new Promise(resolve => setTimeout(resolve, waitMs)) - } - } -} - -function createDynamicModuleManifest(entrypoint: string, id = 'test-dynamic-module'): ExtensionManifestV1 { - const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' - const permissions: ModulePermissionDeclaration = { - apis: [ - { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, - { key: providersCapability, actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] }, - ], - resources: [ - { key: providersCapability, actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:kits', actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings', actions: ['read', 'write'] }, - ], - capabilities: [ - { key: providersCapability, actions: ['wait'] }, - ], - } - - return { - apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id, - permissions, - entrypoints: { - electron: entrypoint, - }, - } -} - -function createExtensionGameletKitManifest(entrypoint: string, id = 'test-extension-gamelet-kit'): ExtensionManifestV1 { - return { - apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id, - permissions: { - apis: [ - { key: 'kit.gamelet', actions: ['invoke'] }, - ], - resources: [ - { key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings', actions: ['write'] }, - ], - }, - entrypoints: { - electron: entrypoint, - }, - } -} - -function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = {}) { - const respondToRequests = options.respondToRequests ?? true - const widgetSnapshots = new Map() - const openWindow = vi.fn(async (_params?: { id?: string }) => {}) - const pushWidget = vi.fn(async (payload: WidgetsAddPayload) => { - const snapshot: WidgetSnapshot = { - id: payload.id ?? Math.random().toString(36).slice(2, 10), - componentName: payload.componentName, - componentProps: payload.componentProps ?? {}, - alwaysOnTop: payload.alwaysOnTop ?? false, - size: payload.size ?? 'm', - windowSize: payload.windowSize, - ttlMs: payload.ttlMs ?? 0, - } - - widgetSnapshots.set(snapshot.id, snapshot) - return snapshot.id - }) - const updateWidget = vi.fn(async (payload: WidgetsUpdatePayload) => { - const existing = widgetSnapshots.get(payload.id) - if (!existing) { - return - } - - widgetSnapshots.set(payload.id, { - ...existing, - componentProps: payload.componentProps ?? existing.componentProps, - alwaysOnTop: payload.alwaysOnTop ?? existing.alwaysOnTop, - size: payload.size ?? existing.size, - windowSize: payload.windowSize ?? existing.windowSize, - ttlMs: payload.ttlMs ?? existing.ttlMs, - }) - }) - const requestWidgetIframe = vi.fn() - requestWidgetIframe.mockImplementation(async () => { - if (!respondToRequests) { - throw new Error('Widget iframe request was not handled.') - } - - return { fen: 'fen-after-request' } - }) - const removeWidget = vi.fn(async (id: string) => { - widgetSnapshots.delete(id) - }) - const getWidgetSnapshot = vi.fn((id: string) => widgetSnapshots.get(id)) - - return { - widgetSnapshots, - widgetsManager: { - openWindow, - pushWidget, - updateWidget, - removeWidget, - getWidgetSnapshot, - requestWidgetIframe, - }, - } -} - -async function setupExtensionHostForTest() { - const widgets = createWidgetsManagerDouble() - const service = await setupExtensionHostService({ widgetsManager: widgets.widgetsManager }) - return { service, ...widgets } -} - -async function setupExtensionHostServiceInternalForTest() { - const widgets = createWidgetsManagerDouble() - const service = await setupExtensionHostServiceInternal({ widgetsManager: widgets.widgetsManager }) - return { service, ...widgets } -} - -async function setupExtensionHost() { - return (await setupExtensionHostForTest()).service -} - -describe('setupExtensionHost', () => { - let userDataDir: string - let pluginsDir: string - - it('types the setup host service as the plain ExtensionHost surface', () => { - expectTypeOf().toMatchTypeOf() - }) - - it('types getBinding as an optional lookup on the plain ExtensionHost surface', () => { - expectTypeOf>().toMatchTypeOf | undefined>() - }) - - it('loads manifests through the internal host bootstrap helper', async () => { - const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') - await writeManifestInPluginDir({ - rootDir: pluginsDir, - pluginDirName: 'test-host-helper', - pluginName: 'test-host-helper', - entrypointPath: normalEntrypoint, - }) - - const { service } = await setupExtensionHostServiceInternalForTest() - - expect(service.host).toBeInstanceOf(ExtensionHost) - expect(service.manifests).toEqual([ - expect.objectContaining({ id: 'test-host-helper' }), - ]) - }) - - beforeEach(async () => { - userDataDir = await mkdtemp(join(tmpdir(), 'airi-plugins-')) - pluginsDir = join(userDataDir, 'extensions', 'v1') - await mkdir(pluginsDir, { recursive: true }) - appMock.getPath.mockReturnValue(userDataDir) - }) - - afterEach(async () => { - await removeDirWithRetry(userDataDir) - contextState.lastContext = undefined - vi.restoreAllMocks() - vi.clearAllMocks() - }) - - it('lists manifests from plugin subdirectories', async () => { - const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') - const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts') - - const { manifestPath: normalPath } = await writeManifestInPluginDir({ - rootDir: pluginsDir, - pluginDirName: 'test-normal', - pluginName: 'test-normal', - entrypointPath: normalEntrypoint, - }) - const { manifestPath: errorPath } = await writeManifestInPluginDir({ - rootDir: pluginsDir, - pluginDirName: 'test-error', - pluginName: 'test-error', - entrypointPath: errorEntrypoint, - }) - - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeList = defineInvoke(contextState.lastContext!, electronPluginList) - const snapshot = await invokeList() - - expect(snapshot.root).toBe(pluginsDir) - expect(snapshot.plugins).toHaveLength(2) - expect(snapshot.plugins).toEqual(expect.arrayContaining([ - expect.objectContaining({ extensionId: 'test-normal', path: normalPath, enabled: false, loaded: false, isNew: true }), - expect.objectContaining({ extensionId: 'test-error', path: errorPath, enabled: false, loaded: false, isNew: true }), - ])) - }) - - it('discovers extension manifests and ignores legacy extension manifests', async () => { - const extensionDir = join(pluginsDir, 'extension-test') - const legacyDir = join(pluginsDir, 'plugin-legacy') - await mkdir(extensionDir, { recursive: true }) - await mkdir(legacyDir, { recursive: true }) - - await writeFile(join(extensionDir, extensionManifestFileName), JSON.stringify({ - apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id: 'airi-extension-test', - permissions: {}, - entrypoints: { - electron: './extension.mjs', - }, - }, null, 2)) - - await writeFile(join(legacyDir, extensionManifestFileName), JSON.stringify({ - apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'airi-plugin-legacy', - permissions: {}, - entrypoints: { - electron: './plugin.mjs', - }, - }, null, 2)) - - const entries = await loadManifestsFrom(pluginsDir, useLogg('test/plugin-registry')) - - expect(entries.map(entry => entry.path)).toEqual([ - join(extensionDir, extensionManifestFileName), - ]) - expect(entries.map(entry => 'id' in entry.manifest ? entry.manifest.id : undefined)).toEqual([ - 'airi-extension-test', - ]) - }) - - it('ignores root-level manifests and only loads manifests from subdirectories', async () => { - const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') - - const { manifestPath } = await writeManifestInPluginDir({ - rootDir: pluginsDir, - pluginDirName: 'devtools-sample-plugin', - pluginName: 'devtools-sample-plugin', - entrypointPath: normalEntrypoint, - }) - const rootEntrypointFile = await copyEntrypoint({ dir: pluginsDir, path: normalEntrypoint }) - await writeManifest({ - dir: pluginsDir, - name: 'root-level-plugin', - entrypoint: rootEntrypointFile, - }) - - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeList = defineInvoke(contextState.lastContext!, electronPluginList) - const snapshot = await invokeList() - - expect(snapshot.plugins).toEqual([ - expect.objectContaining({ - extensionId: 'devtools-sample-plugin', - path: manifestPath, - enabled: false, - loaded: false, - isNew: true, - }), - ]) - }) - - it('loads enabled plugins and keeps failed plugins unloaded', async () => { - const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts') - - const successPluginDir = join(pluginsDir, 'test-normal') - await mkdir(successPluginDir, { recursive: true }) - await writeEntrypoint({ - dir: successPluginDir, - name: 'test-normal-plugin.ts', - contents: createEmptyExtensionEntrypoint('test-normal'), - }) - await writeManifest({ - dir: successPluginDir, - name: 'test-normal', - entrypoint: './test-normal-plugin.ts', - }) - await writeManifestInPluginDir({ - rootDir: pluginsDir, - pluginDirName: 'test-error', - pluginName: 'test-error', - entrypointPath: errorEntrypoint, - }) - - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) - const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - - await invokeSetEnabled({ extensionId: 'test-normal', enabled: true }) - await invokeSetEnabled({ extensionId: 'test-error', enabled: true }) - - const snapshot = await invokeLoadEnabled() - - const normal = snapshot.plugins.find(plugin => plugin.extensionId === 'test-normal') - const error = snapshot.plugins.find(plugin => plugin.extensionId === 'test-error') - - expect(normal).toEqual(expect.objectContaining({ enabled: true, loaded: true })) - expect(error).toEqual(expect.objectContaining({ enabled: true, loaded: false })) - }) - - it('emits a plugin tools changed event after loading an extension through IPC', async () => { - const pluginDir = join(pluginsDir, 'test-tools-changed') - await mkdir(pluginDir, { recursive: true }) - await writeEntrypoint({ - dir: pluginDir, - name: 'test-tools-changed.ts', - contents: createEmptyExtensionEntrypoint('test-tools-changed'), - }) - await writeManifest({ - dir: pluginDir, - name: 'test-tools-changed', - entrypoint: './test-tools-changed.ts', - }) - - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const toolsChangedEvents: Array<{ reason: string, extensionId?: string }> = [] - contextState.lastContext!.on(electronPluginToolsChanged, (event) => { - if (!event.body) { - throw new Error('Expected plugin tools changed event body.') - } - toolsChangedEvents.push(event.body) - }) - - const invokeLoad = defineInvoke(contextState.lastContext!, electronPluginLoad) - - await invokeLoad({ extensionId: 'test-tools-changed' }) - - expect(toolsChangedEvents).toEqual([ - { - reason: 'loaded', - extensionId: 'test-tools-changed', - }, - ]) - }) - - it('loads the first matching manifest when duplicate plugin names exist', async () => { - const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts') - - const firstPluginDir = join(pluginsDir, 'duplicate-plugin-first') - await mkdir(firstPluginDir, { recursive: true }) - await writeEntrypoint({ - dir: firstPluginDir, - name: 'test-normal-plugin.ts', - contents: createEmptyExtensionEntrypoint('duplicate-plugin'), - }) - await writeManifest({ - dir: firstPluginDir, - name: 'duplicate-plugin', - entrypoint: './test-normal-plugin.ts', - }) - await writeManifestInPluginDir({ - rootDir: pluginsDir, - pluginDirName: 'duplicate-plugin-second', - pluginName: 'duplicate-plugin', - entrypointPath: errorEntrypoint, - }) - - const { service } = await setupExtensionHostForTest() - - expect(contextState.lastContext).toBeDefined() - const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) - const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - - await invokeSetEnabled({ extensionId: 'duplicate-plugin', enabled: true }) - await invokeLoadEnabled() - - const duplicateSession = service.host - .listSessions() - .find(session => session.manifest.id === 'duplicate-plugin') - - expect(duplicateSession).toBeDefined() - expect(duplicateSession?.manifest.entrypoints.electron).toBe('./test-normal-plugin.ts') - }) - - it('persists plugin auto-reload state and surfaces it in registry snapshots', async () => { - const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') - await writeManifestInPluginDir({ - rootDir: pluginsDir, - pluginDirName: 'test-auto-reload', - pluginName: 'test-auto-reload', - entrypointPath: normalEntrypoint, - }) - - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeSetAutoReload = defineInvoke(contextState.lastContext!, electronPluginSetAutoReload) - const invokeList = defineInvoke(contextState.lastContext!, electronPluginList) - - await invokeSetAutoReload({ extensionId: 'test-auto-reload', enabled: true }) - let snapshot = await invokeList() - expect(snapshot.plugins).toEqual(expect.arrayContaining([ - expect.objectContaining({ extensionId: 'test-auto-reload', autoReload: true }), - ])) - - await invokeSetAutoReload({ extensionId: 'test-auto-reload', enabled: false }) - snapshot = await invokeList() - expect(snapshot.plugins).toEqual(expect.arrayContaining([ - expect.objectContaining({ extensionId: 'test-auto-reload', autoReload: false }), - ])) - }) - - it('reloads a loaded plugin when auto-reload is enabled and entrypoint changes', async () => { - const pluginDir = join(pluginsDir, 'test-auto-reload-reload') - await mkdir(pluginDir, { recursive: true }) - const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-auto-reload-reload.ts', - contents: createEmptyExtensionEntrypoint('test-auto-reload-reload'), - }) - await writeManifest({ - dir: pluginDir, - name: 'test-auto-reload-reload', - entrypoint: './test-auto-reload-reload.ts', - }) - - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) - const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - const invokeSetAutoReload = defineInvoke(contextState.lastContext!, electronPluginSetAutoReload) - const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) - const invokeUnload = defineInvoke(contextState.lastContext!, electronPluginUnload) - - await invokeSetEnabled({ extensionId: 'test-auto-reload-reload', enabled: true }) - await invokeLoadEnabled() - await invokeSetAutoReload({ extensionId: 'test-auto-reload-reload', enabled: true }) - - const before = await invokeInspect() - const beforeSession = before.sessions.find(session => session.extensionId === 'test-auto-reload-reload') - expect(beforeSession).toBeDefined() - - const pluginSdkUrl = pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href - await writeFile(entrypointPath, [ - `import { defineExtension } from ${JSON.stringify(pluginSdkUrl)}`, - '', - 'export default defineExtension({', - ' id: \'test-auto-reload-reload\',', - ' setup() {', - ' return \'changed\'', - ' },', - '})', - ].join('\n')) - - const deadline = Date.now() + 3000 - let afterSessionId = beforeSession?.id - while (Date.now() < deadline && afterSessionId === beforeSession?.id) { - await new Promise(resolve => setTimeout(resolve, 100)) - const snapshot = await invokeInspect() - const currentSessionId = snapshot.sessions.find(session => session.extensionId === 'test-auto-reload-reload')?.id - - // ROOT CAUSE: - // - // Reload stops the old session before starting the replacement, so an inspect call can - // observe the intentional short-lived gap with no session. Treat that state as pending - // instead of ending the poll before the replacement session is available. - if (currentSessionId && currentSessionId !== beforeSession?.id) { - afterSessionId = currentSessionId - } - } - - expect(afterSessionId).toBeDefined() - expect(afterSessionId).not.toEqual(beforeSession?.id) - - await invokeSetAutoReload({ extensionId: 'test-auto-reload-reload', enabled: false }) - await invokeUnload({ extensionId: 'test-auto-reload-reload' }) - }) - - it('loads enabled plugins with absolute manifest entrypoints outside the plugin directory', async () => { - const externalDir = await mkdtemp(join(tmpdir(), 'airi-plugin-external-')) - - try { - const pluginDir = join(pluginsDir, 'test-absolute-entrypoint') - await mkdir(pluginDir, { recursive: true }) - const externalEntrypoint = await writeEntrypoint({ - dir: externalDir, - name: 'test-absolute-plugin.ts', - contents: createEmptyExtensionEntrypoint('test-absolute-entrypoint'), - }) - await writeManifest({ - dir: pluginDir, - name: 'test-absolute-entrypoint', - entrypoint: externalEntrypoint, - }) - - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) - const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - - await invokeSetEnabled({ extensionId: 'test-absolute-entrypoint', enabled: true }) - - const snapshot = await invokeLoadEnabled() - const plugin = snapshot.plugins.find(item => item.extensionId === 'test-absolute-entrypoint') - - expect(plugin).toEqual(expect.objectContaining({ enabled: true, loaded: true })) - } - finally { - await rm(externalDir, { recursive: true, force: true }) - } - }) - - it('loads the devtools sample plugin with its declared protocol permissions', async () => { - const pluginDir = join(pluginsDir, 'devtools-sample-plugin') - await mkdir(pluginDir, { recursive: true }) - await writeFile( - join(pluginDir, extensionManifestFileName), - await readFile(join(samplePluginRoot, extensionManifestFileName), 'utf-8'), - ) - await writeFile( - join(pluginDir, 'devtools-sample-plugin.mjs'), - (await readFile(join(samplePluginRoot, 'devtools-sample-plugin.mjs'), 'utf-8')) - .replace( - '\'@proj-airi/plugin-sdk\'', - JSON.stringify(pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href), - ), - ) - - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) - const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - - await invokeSetEnabled({ extensionId: 'devtools-sample-plugin', enabled: true }) - - const snapshot = await invokeLoadEnabled() - const plugin = snapshot.plugins.find(item => item.extensionId === 'devtools-sample-plugin') - - expect(plugin).toEqual(expect.objectContaining({ enabled: true, loaded: true })) - }) - - it('loads the chess-like demo plugin and exposes a gamelet module snapshot', async () => { - const pluginDir = join(pluginsDir, 'airi-plugin-game-chess') - await mkdir(pluginDir, { recursive: true }) - await writeFile( - join(pluginDir, extensionManifestFileName), - JSON.stringify({ - apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id: 'airi-plugin-game-chess', - permissions: { - apis: [ - { key: 'kit.gamelet', actions: ['invoke'] }, - ], - resources: [ - { key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings', actions: ['write'] }, - ], - }, - entrypoints: { - electron: './airi-plugin-game-chess.mjs', - }, - }, null, 2), - ) - await writeFile(join(pluginDir, 'airi-plugin-game-chess.mjs'), [ - `import { defineExtension } from ${JSON.stringify(pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href)}`, - `import { gameletKit } from ${JSON.stringify(pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk-tamagotchi/src/index.ts')).href)}`, - '', - 'export default defineExtension({', - ' id: "airi-plugin-game-chess",', - ' async setup(ctx) {', - ' const module = await ctx.modules.register({', - ' id: "chess-like-main",', - ' permissions: {', - ' apis: [{ key: "kit.gamelet", actions: ["invoke"] }],', - ' resources: [{ key: "proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings", actions: ["write"] }],', - ' },', - ' })', - ' const gamelets = await module.kits.use(gameletKit)', - ' await gamelets.mount({', - ' title: "Chess",', - ' ui: {', - ' mount: "iframe",', - ' iframe: { assetPath: "ui/index.html", sandbox: "allow-scripts allow-same-origin allow-forms allow-popups" },', - ' },', - ' init: { airiSide: "white", opening: "queen-gambit" },', - ' })', - ' },', - '})', - ].join('\n')) - await mkdir(join(pluginDir, 'ui'), { recursive: true }) - await writeFile(join(pluginDir, 'ui', 'index.html'), 'fallback') - - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) - const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) - - await invokeSetEnabled({ extensionId: 'airi-plugin-game-chess', enabled: true }) - - const registry = await invokeLoadEnabled() - const plugin = registry.plugins.find(item => item.extensionId === 'airi-plugin-game-chess') - expect(plugin).toEqual(expect.objectContaining({ enabled: true, loaded: true })) - - const snapshot = await invokeInspect() - - // Verify the host exposes the announced module snapshot after activation. - expect(snapshot.modules).toEqual(expect.arrayContaining([ - expect.objectContaining({ - moduleId: 'chess-like-main:gamelet', - ownerExtensionId: 'airi-plugin-game-chess', - kitId: 'kit.gamelet', - kitModuleType: 'gamelet', - runtime: 'electron', - state: 'announced', - config: expect.objectContaining({ - title: 'Chess', - widget: expect.objectContaining({ - mount: 'iframe', - iframe: expect.objectContaining({ - assetPath: 'ui/index.html', - src: expect.stringMatching( - /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/airi-plugin-game-chess\/sessions\/[\w-]{10,}\/ui\/index\.html$/, - ), - sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', - }), - }), - config: { - init: { - airiSide: 'white', - opening: 'queen-gambit', - }, - }, - }), - }), - ])) - }) - - it('exposes plugin asset base URL through Eventa invoke', async () => { - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeGetAssetBaseUrl = defineInvoke(contextState.lastContext!, electronPluginGetAssetBaseUrl) - - const baseUrl = await invokeGetAssetBaseUrl() - expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) - }) - - it('rewrites plugin widget iframe asset URLs in inspect snapshots', async () => { - const pluginDir = join(pluginsDir, 'test-plugin-widget-asset-url') - await mkdir(pluginDir, { recursive: true }) - await mkdir(join(pluginDir, 'ui'), { recursive: true }) - await mkdir(join(pluginDir, 'ui', 'private'), { recursive: true }) - await writeFile(join(pluginDir, 'ui', 'index.html'), 'widget') - await writeFile(join(pluginDir, 'ui', 'other.html'), 'other') - await writeFile(join(pluginDir, 'ui', 'private', 'secret.txt'), 'secret') - const entrypointFile = await writeEntrypoint({ - dir: pluginDir, - name: 'test-plugin-widget-asset-url.ts', - contents: createEmptyExtensionEntrypoint('test-plugin-widget-asset-url'), - }) - await writeFile(join(pluginDir, extensionManifestFileName), JSON.stringify({ - apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id: 'test-plugin-widget-asset-url', - permissions: { - apis: [ - { key: 'kit.widget', actions: ['invoke'] }, - ], - resources: [ - { key: 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings', actions: ['read', 'write'] }, - ], - }, - entrypoints: { - electron: `./${basename(entrypointFile)}`, - }, - }, null, 2)) - - const { service } = await setupExtensionHostForTest() - - expect(contextState.lastContext).toBeDefined() - const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) - const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) - - await invokeSetEnabled({ extensionId: 'test-plugin-widget-asset-url', enabled: true }) - await invokeLoadEnabled() - const session = service.host - .listSessions() - .find(item => item.extension.id === 'test-plugin-widget-asset-url') - if (!session) { - throw new Error('Expected widget asset URL test extension to be loaded.') - } - service.host.bindExtensionKitModule(session.id, { - moduleId: 'widget-shell-under-test', - kitId: 'kit.widget', - kitModuleType: 'window', - config: { - title: 'Widget Shell Under Test', - entrypoint: './ui/index.html', - widget: { - mount: 'iframe', - iframe: { - assetPath: './ui/index.html', - sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', - }, - windowSize: { - width: 980, - height: 840, - minWidth: 640, - minHeight: 640, - }, - }, - }, - }) - const snapshot = await invokeInspect() - - expect(snapshot.modules).toEqual(expect.arrayContaining([ - expect.objectContaining({ - moduleId: 'widget-shell-under-test', - ownerExtensionId: 'test-plugin-widget-asset-url', - kitId: 'kit.widget', - kitModuleType: 'window', - runtime: 'electron', - config: expect.objectContaining({ - title: 'Widget Shell Under Test', - widget: expect.objectContaining({ - iframe: expect.objectContaining({ - assetPath: './ui/index.html', - src: expect.stringMatching( - /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/test-plugin-widget-asset-url\/sessions\/[\w-]{10,}\/ui\/index\.html$/, - ), - sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', - }), - }), - }), - }), - ])) - - const iframeSource = (snapshot.modules.find(module => module.moduleId === 'widget-shell-under-test')?.config as Record) - ?.widget - const iframeRecord = iframeSource && typeof iframeSource === 'object' && !Array.isArray(iframeSource) - ? (iframeSource as Record).iframe - : undefined - const iframeUrlSource = iframeRecord && typeof iframeRecord === 'object' && !Array.isArray(iframeRecord) - ? (iframeRecord as Record).src - : undefined - const iframeUrlString = typeof iframeUrlSource === 'string' ? iframeUrlSource : undefined - expect(iframeUrlString).toBeTruthy() - expect(iframeUrlString).not.toContain('?t=') - expect(sessionMock.defaultSession.cookies.set).toHaveBeenCalledOnce() - - const setCookie = sessionMock.defaultSession.cookies.set.mock.calls.at(0)?.[0] as { name: string, value: string } | undefined - if (!setCookie) { - throw new Error('Expected plugin asset cookie to be set before iframe URL is returned') - } - const cookieHeader = `${setCookie.name}=${setCookie.value}` - const iframeWithoutCookieResponse = await fetch(iframeUrlString!) - expect(iframeWithoutCookieResponse.status).toBe(401) - - const iframeResponse = await fetch(iframeUrlString!, { - headers: { - cookie: cookieHeader, - }, - }) - expect(iframeResponse.status).toBe(200) - expect(await iframeResponse.text()).toContain('widget') - - const iframeUrl = new URL(iframeUrlString!) - const outsideSessionUrl = `${iframeUrl.origin}/_airi/extensions/test-plugin-widget-asset-url/ui/private/secret.txt` - const outsideSessionResponse = await fetch(outsideSessionUrl, { - headers: { - cookie: cookieHeader, - }, - }) - expect(outsideSessionResponse.status).toBe(401) - }) - - it('mirrors degraded and withdrawn capability updates into the host snapshot', async () => { - await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) - const invokeUpdateCapability = defineInvoke(contextState.lastContext!, electronPluginUpdateCapability) - - await invokeUpdateCapability({ - key: 'cap:renderer-status', - state: 'degraded', - metadata: { reason: 'renderer-restarting' }, - }) - - let snapshot = await invokeInspect() - expect(snapshot.capabilities).toEqual(expect.arrayContaining([ - expect.objectContaining({ - key: 'cap:renderer-status', - state: 'degraded', - metadata: { reason: 'renderer-restarting' }, - }), - ])) - - await invokeUpdateCapability({ - key: 'cap:renderer-status', - state: 'withdrawn', - metadata: { reason: 'renderer-unmounted' }, - }) - - snapshot = await invokeInspect() - expect(snapshot.capabilities).toEqual(expect.arrayContaining([ - expect.objectContaining({ - key: 'cap:renderer-status', - state: 'withdrawn', - metadata: { reason: 'renderer-unmounted' }, - }), - ])) - }) - - it('includes built-in kits and module snapshots in inspect responses without leaking mutable references', async () => { - const { host } = await setupExtensionHost() - - expect(contextState.lastContext).toBeDefined() - const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) - - const dynamicEntrypoint = await writeEntrypoint({ - dir: pluginsDir, - name: 'test-dynamic-module.ts', - contents: createEmptyExtensionEntrypoint('test-dynamic-module'), - }) - const session = await host.start(createDynamicModuleManifest(dynamicEntrypoint), { cwd: pluginsDir }) - host.bindExtensionKitModule(session.id, { - moduleId: 'widget-shell', - kitId: 'kit.widget', - kitModuleType: 'window', - config: { route: '/widgets/runtime' }, - }) - - const snapshot = await invokeInspect() - - expect(snapshot.kits).toEqual(expect.arrayContaining([ - expect.objectContaining({ - kitId: 'kit.widget', - runtimes: ['electron', 'web'], - capabilities: [ - { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, - ], - }), - expect.objectContaining({ - kitId: 'kit.gamelet', - runtimes: ['electron', 'web'], - capabilities: [ - { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, - ], - }), - ])) - expect(snapshot.modules).toEqual(expect.arrayContaining([ - expect.objectContaining({ - moduleId: 'widget-shell', - ownerSessionId: session.id, - ownerExtensionId: 'test-dynamic-module', - kitId: 'kit.widget', - kitModuleType: 'window', - runtime: 'electron', - state: 'announced', - config: { route: '/widgets/runtime' }, - }), - ])) - - snapshot.kits[0]!.kitId = 'kit.mutated' - snapshot.kits[0]!.capabilities[0]!.actions.push('tampered') - snapshot.modules[0]!.config = { route: '/widgets/tampered' } - - const nextSnapshot = await invokeInspect() - - expect(nextSnapshot.kits).toEqual(expect.arrayContaining([ - expect.objectContaining({ - kitId: 'kit.widget', - capabilities: [ - { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, - ], - }), - expect.objectContaining({ - kitId: 'kit.gamelet', - capabilities: [ - { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, - ], - }), - ])) - expect(nextSnapshot.modules).toEqual(expect.arrayContaining([ - expect.objectContaining({ - moduleId: 'widget-shell', - config: { route: '/widgets/runtime' }, - }), - ])) - }) - - it('sources built-in kit descriptors from installable kit modules', () => { - expect(widgetPluginKitDescriptor).toEqual({ - kitId: 'kit.widget', - version: '1.0.0', - runtimes: ['electron', 'web'], - capabilities: [ - { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, - ], - }) - - expect(gameletPluginKitDescriptor).toEqual({ - kitId: 'kit.gamelet', - version: '1.0.0', - runtimes: ['electron', 'web'], - capabilities: [ - { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, - ], - }) - }) - - /** - * @example - * expect(service.host.getBinding('kit-module:gamelet')).toEqual(expect.objectContaining({ kitId: 'kit.gamelet' })) - */ - it('injects host services into defineExtension gamelet kit clients', async () => { - const { service } = await setupExtensionHostForTest() - const pluginDir = join(pluginsDir, 'test-extension-gamelet-kit') - await mkdir(pluginDir, { recursive: true }) - const pluginSdkUrl = pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href - const tamagotchiSdkUrl = pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk-tamagotchi/src/index.ts')).href - const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-extension-gamelet-kit.ts', - contents: [ - `import { defineExtension } from '${pluginSdkUrl}'`, - `import { gameletKit } from '${tamagotchiSdkUrl}'`, - '', - 'export default defineExtension({', - ' id: \'test-extension-gamelet-kit\',', - ' async setup(ctx) {', - ' const module = await ctx.modules.register({', - ' id: \'kit-module\',', - ' permissions: {', - ' apis: [{ key: \'kit.gamelet\', actions: [\'invoke\'] }],', - ' resources: [{ key: \'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings\', actions: [\'write\'] }],', - ' },', - ' })', - ' const gamelets = await module.kits.use(gameletKit)', - ' await gamelets.mount({', - ' title: \'Kit Runtime Gamelet\',', - ' ui: gamelets.iframe({ assetPath: \'ui/index.html\' }),', - ' })', - ' },', - '})', - ].join('\n'), - }) - - const session = await service.host.start(createExtensionGameletKitManifest(entrypointPath), { cwd: pluginDir }) - const binding = service.host.getBinding('kit-module:gamelet') - - expect(binding).toEqual(expect.objectContaining({ - moduleId: 'kit-module:gamelet', - ownerExtensionId: 'test-extension-gamelet-kit', - ownerSessionId: session.id, - kitId: 'kit.gamelet', - kitModuleType: 'gamelet', - })) - expect(binding?.config).toEqual({ - title: 'Kit Runtime Gamelet', - widget: { - mount: 'iframe', - iframe: { - assetPath: 'ui/index.html', - sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', - }, - }, - config: { - init: {}, - }, - }) - }) - - /** - * @example - * expect(widgetsManager.pushWidget).toHaveBeenCalledWith(expect.objectContaining({ id: 'kit-module:board' })) - * expect(widgetsManager.updateWidget).toHaveBeenCalledWith(expect.objectContaining({ id: 'kit-module:board' })) - */ - it('injects gamelet orchestration methods backed by the widget manager', async () => { - const { service, widgetsManager } = await setupExtensionHostForTest() - const pluginDir = join(pluginsDir, 'test-extension-gamelet-orchestration') - await mkdir(pluginDir, { recursive: true }) - await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk') - await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk-tamagotchi') - const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-extension-gamelet-orchestration.ts', - contents: [ - 'import { defineExtension } from \'@proj-airi/plugin-sdk\'', - 'import { gameletKit } from \'@proj-airi/plugin-sdk-tamagotchi\'', - '', - 'export default defineExtension({', - ' id: \'test-extension-gamelet-orchestration\',', - ' async setup(ctx) {', - ' const module = await ctx.modules.register({', - ' id: \'kit-module\',', - ' permissions: {', - ' apis: [{ key: \'kit.gamelet\', actions: [\'invoke\'] }],', - ' resources: [{ key: \'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings\', actions: [\'write\'] }],', - ' },', - ' })', - ' const gamelets = await module.kits.use(gameletKit)', - ' await gamelets.mount({', - ' bindingId: \'kit-module:board\',', - ' title: \'Kit Runtime Gamelet\',', - ' ui: gamelets.iframe({ assetPath: \'ui/index.html\' }),', - ' })', - ' await gamelets.orchestration.open(\'kit-module:board\', { mode: \'new\' })', - ' await gamelets.orchestration.open(\'kit-module:board\', { mode: \'resume\' })', - ' await gamelets.orchestration.configure(\'kit-module:board\', { command: { requestId: \'ignored-by-test-double\' } })', - ' const snapshot = await gamelets.orchestration.request(\'kit-module:board\', { action: \'snapshot\' }, { timeoutMs: 1000 })', - ' if (snapshot.fen !== \'fen-after-request\') {', - ' throw new Error(\'Expected request to resolve from widget iframe request\')', - ' }', - ' if (!(await gamelets.orchestration.isOpen(\'kit-module:board\'))) {', - ' throw new Error(\'Expected gamelet to be open before close\')', - ' }', - ' await gamelets.orchestration.close(\'kit-module:board\')', - ' },', - '})', - ].join('\n'), - }) - - await service.host.start(createExtensionGameletKitManifest(entrypointPath, 'test-extension-gamelet-orchestration'), { cwd: pluginDir }) - - expect(widgetsManager.pushWidget).toHaveBeenCalledWith(expect.objectContaining({ - id: 'kit-module:board', - componentName: 'extension-ui', - componentProps: { - moduleId: 'kit-module:board', - payload: { mode: 'new' }, - }, - size: 'l', - })) - expect(widgetsManager.openWindow).toHaveBeenCalledWith({ id: 'kit-module:board' }) - expect(widgetsManager.updateWidget).toHaveBeenCalledWith({ - id: 'kit-module:board', - componentProps: { - moduleId: 'kit-module:board', - payload: { mode: 'resume' }, - }, - size: 'l', - }) - expect(widgetsManager.updateWidget).toHaveBeenCalledWith({ - id: 'kit-module:board', - componentProps: { - moduleId: 'kit-module:board', - payload: { command: { requestId: 'ignored-by-test-double' } }, - }, - }) - expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith( - 'kit-module:board', - { action: 'snapshot' }, - { timeoutMs: 1000 }, - ) - expect(widgetsManager.getWidgetSnapshot).toHaveBeenCalledWith('kit-module:board') - expect(widgetsManager.removeWidget).toHaveBeenCalledWith('kit-module:board') - }) - - /** - * @example - * expect(widgetsManager.removeWidget).toHaveBeenCalledWith('chess:board') - */ - it('closes mounted gamelets when the owning extension session stops', async () => { - const { service, widgetsManager } = await setupExtensionHostForTest() - const pluginDir = join(pluginsDir, 'test-extension-gamelet-session-cleanup') - await mkdir(pluginDir, { recursive: true }) - await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk') - await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk-tamagotchi') - const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-extension-gamelet-session-cleanup.ts', - contents: [ - 'import { createModule, defineExtension } from \'@proj-airi/plugin-sdk\'', - 'import { createGamelet } from \'@proj-airi/plugin-sdk-tamagotchi/kits/gamelet\'', - '', - 'export default defineExtension({', - ' id: \'test-extension-gamelet-session-cleanup\',', - ' async setup(ctx) {', - ' const chess = await createModule(ctx, { id: \'chess\' })', - ' const board = await createGamelet(chess, {', - ' id: \'board\',', - ' title: \'Chess\',', - ' indexPath: \'ui/index.html\',', - ' })', - ' await board.open({ mode: \'new\' })', - ' },', - '})', - ].join('\n'), - }) - - const session = await service.host.start(createExtensionGameletKitManifest(entrypointPath, 'test-extension-gamelet-session-cleanup'), { cwd: pluginDir }) - await service.host.stop(session.id) - - expect(widgetsManager.removeWidget).toHaveBeenCalledWith('chess:board') - }) - - /** - * @example - * await expect(request).rejects.toThrow('Board rejected the snapshot request.') - */ - it('propagates gamelet request rejection from the widget iframe manager', async () => { - const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) - widgetsManager.requestWidgetIframe.mockRejectedValueOnce(new Error('Board rejected the snapshot request.')) - const gamelets = createGameletOrchestrationRuntime(widgetsManager) - - await gamelets.open('kit-module:board') - - await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Board rejected the snapshot request.') - expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith( - 'kit-module:board', - { action: 'snapshot' }, - { timeoutMs: 30000 }, - ) - gamelets.dispose() - }) - - /** - * @example - * expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) - */ - it('uses the default gamelet request timeout when no timeout is provided', async () => { - const { widgetsManager } = createWidgetsManagerDouble() - const gamelets = createGameletOrchestrationRuntime(widgetsManager) - - await gamelets.open('kit-module:board') - await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).resolves.toEqual({ fen: 'fen-after-request' }) - - expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith( - 'kit-module:board', - { action: 'snapshot' }, - { timeoutMs: 30000 }, - ) - gamelets.dispose() - }) - - /** - * @example - * await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet `kit-module:board` is not open.') - */ - it('rejects gamelet requests immediately when the widget is not open', async () => { - const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) - const gamelets = createGameletOrchestrationRuntime(widgetsManager) - - await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet `kit-module:board` is not open.') - expect(widgetsManager.updateWidget).not.toHaveBeenCalled() - expect(widgetsManager.requestWidgetIframe).not.toHaveBeenCalled() - gamelets.dispose() - }) - - it('handles gamelet requests without legacy widget response event APIs', async () => { - const { widgetsManager } = createWidgetsManagerDouble() - const gamelets = createGameletOrchestrationRuntime(widgetsManager) - - await gamelets.open('kit-module:board') - await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).resolves.toEqual({ fen: 'fen-after-request' }) - - expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith( - 'kit-module:board', - { action: 'snapshot' }, - { timeoutMs: 30000 }, - ) - gamelets.dispose() - }) - - it('rejects module announce when the kit runtime does not match the host runtime', async () => { - const { host } = await setupExtensionHost() - - const dynamicEntrypoint = await writeEntrypoint({ - dir: pluginsDir, - name: 'test-dynamic-module.ts', - contents: createEmptyExtensionEntrypoint('test-dynamic-module'), - }) - const session = await host.start(createDynamicModuleManifest(dynamicEntrypoint), { cwd: pluginsDir }) - host.registerKit({ - kitId: 'kit.web-only', - version: '1.0.0', - runtimes: ['web'], - capabilities: [{ key: 'kit.web-only.module', actions: ['announce'] }], - }) - - expect(() => host.bindExtensionKitModule(session.id, { - moduleId: 'web-only-shell', - kitId: 'kit.web-only', - kitModuleType: 'window', - config: { route: '/widgets/web-only' }, - })).toThrowError(/not available for runtime `electron`/i) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts deleted file mode 100644 index 82eba897c..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { ExtensionHostService, SetupExtensionHostOptions } from './types' - -import { defineInvoke, defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { app, ipcMain } from 'electron' - -import { electronPluginGetAssetBaseUrl } from '../../../../shared/eventa/plugin/assets' -import { - electronPluginUpdateCapability, - pluginProtocolListProviders, - pluginProtocolListProvidersEventName, -} from '../../../../shared/eventa/plugin/capabilities' -import { - electronPluginInspect, - electronPluginList, - electronPluginLoad, - electronPluginLoadEnabled, - electronPluginSetAutoReload, - electronPluginSetEnabled, - electronPluginUnload, -} from '../../../../shared/eventa/plugin/host' -import { - electronPluginInvokeTool, - electronPluginListAgentTools, - electronPluginListXsaiTools, - electronPluginToolsChanged, -} from '../../../../shared/eventa/plugin/tools' -import { setupExtensionHostServiceInternal } from './host' - -/** - * Initializes the Electron extension host and wires IPC handlers. - * Call once during app startup; it loads manifests, returns the host instance, - * and registers Eventa handlers for listing, enabling, and loading plugins. - * - * Loads extension manifests from the app config directory under `extensions/v1`. - * - * - Windows: %APPDATA%\${appId}\extensions\v1 - * - Linux: $XDG_CONFIG_HOME/${appId}/extensions/v1 or ~/.config/${appId}/extensions/v1 - * - macOS: ~/Library/Application Support/${appId}/extensions/v1 - * - * Persists enablement/known state to `extensions-v1.json` alongside config data. - * - * - Windows: %APPDATA%\${appId}/extensions-v1.json - * - Linux: $XDG_CONFIG_HOME/${appId}/extensions-v1.json or ~/.config/${appId}/extensions-v1.json - * - macOS: ~/Library/Application Support/${appId}/extensions-v1.json - */ -export async function setupExtensionHost(options: SetupExtensionHostOptions): Promise { - const hostService = await setupExtensionHostServiceInternal(options) - const { context } = createContext(ipcMain) - const invokePluginProtocolListProviders = defineInvoke(context, pluginProtocolListProviders) - - defineInvokeHandler(context, electronPluginList, async () => { - return await hostService.list() - }) - - defineInvokeHandler(context, electronPluginSetEnabled, async (payload) => { - const result = await hostService.setEnabled(payload) - context.emit(electronPluginToolsChanged, { - reason: 'enabled-state-changed', - extensionId: payload.extensionId, - }) - return result - }) - - defineInvokeHandler(context, electronPluginSetAutoReload, async (payload) => { - return await hostService.setAutoReload(payload) - }) - - defineInvokeHandler(context, electronPluginLoadEnabled, async () => { - const result = await hostService.loadEnabled() - context.emit(electronPluginToolsChanged, { - reason: 'load-enabled', - }) - return result - }) - - defineInvokeHandler(context, electronPluginLoad, async (payload) => { - const result = await hostService.load(payload.extensionId) - context.emit(electronPluginToolsChanged, { - reason: 'loaded', - extensionId: payload.extensionId, - }) - return result - }) - - defineInvokeHandler(context, electronPluginUnload, async (payload) => { - const result = await hostService.unload(payload.extensionId) - context.emit(electronPluginToolsChanged, { - reason: 'unloaded', - extensionId: payload.extensionId, - }) - return result - }) - - defineInvokeHandler(context, electronPluginInspect, async () => { - return await hostService.inspect() - }) - - defineInvokeHandler(context, electronPluginGetAssetBaseUrl, async () => { - return hostService.getAssetBaseUrl() - }) - - defineInvokeHandler(context, electronPluginListAgentTools, async () => { - return await hostService.tools.listAvailableDescriptors() - }) - - defineInvokeHandler(context, electronPluginListXsaiTools, async () => { - return await hostService.tools.listSerializedXsaiTools() - }) - - defineInvokeHandler(context, electronPluginInvokeTool, async (payload) => { - return await hostService.tools.invoke(payload.ownerExtensionId, payload.name, payload.input) - }) - - defineInvokeHandler(context, electronPluginUpdateCapability, async (payload) => { - if (payload.key === pluginProtocolListProvidersEventName && payload.state === 'ready') { - hostService.host.setResourceResolver( - pluginProtocolListProvidersEventName, - async () => await invokePluginProtocolListProviders(), - ) - } - - switch (payload.state) { - case 'announced': - return hostService.host.announceCapability(payload.key, payload.metadata) - case 'ready': - return hostService.host.markCapabilityReady(payload.key, payload.metadata) - case 'degraded': - return hostService.host.markCapabilityDegraded(payload.key, payload.metadata) - case 'withdrawn': - return hostService.host.withdrawCapability(payload.key, payload.metadata) - default: { - const unexpectedState: never = payload.state - throw new Error(`Unsupported capability state: ${unexpectedState}`) - } - } - }) - - if (typeof app.once === 'function') { - app.once('before-quit', () => { - void hostService.dispose() - }) - } - - return { - host: hostService.host, - manifests: hostService.manifests, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts deleted file mode 100644 index 7cd6f2a15..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ExtensionHost, KitDescriptor } from '@proj-airi/plugin-sdk/plugin-host' - -/** - * Declares the built-in gamelet kit exposed by `stage-tamagotchi`. - * - * Use when: - * - Bootstrapping the Electron extension host with gamelet support - * - Reading the stable built-in gamelet kit descriptor in tests or snapshots - * - * Expects: - * - The host registers this descriptor during startup - * - * Returns: - * - The gamelet kit descriptor used for `kit.gamelet` - */ -export const gameletPluginKitDescriptor = { - kitId: 'kit.gamelet', - version: '1.0.0', - runtimes: ['electron', 'web'], - capabilities: [ - { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, - ], -} satisfies KitDescriptor - -/** - * Registers the built-in gamelet kit on one host instance. - * - * Use when: - * - Bootstrapping the Electron extension host with gamelet kit support - * - Keeping gamelet descriptor registration inside the gamelet kit module - * - * Expects: - * - `host` is the initialized extension host instance - * - * Returns: - * - The registered gamelet kit descriptor - */ -export function registerGameletPluginKit(host: ExtensionHost): KitDescriptor { - return host.registerKit(gameletPluginKitDescriptor) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts deleted file mode 100644 index 2d1cb4481..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { GameletKitRuntime } from '@proj-airi/plugin-sdk-tamagotchi/gamelet' -import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' - -import type { ExtensionHostGameletWidgetsManager } from '../../types' - -const DEFAULT_REQUEST_TIMEOUT_MS = 30000 - -export interface GameletOrchestrationRuntime extends NonNullable { - dispose: () => void -} - -/** - * Creates the Electron host implementation for gamelet lifecycle and request calls. - * - * Use when: - * - Built-in `kit.gamelet` clients need to open iframe-backed extension UI widgets - * - Extension-side gamelet handles need request/response orchestration through widget iframe requests - * - * Expects: - * - Widget ids are the same values as gamelet binding ids - * - The widget manager owns iframe request correlation, timeout, and cleanup - * - * Returns: - * - A gamelet orchestration runtime backed by the stage widget manager - */ -export function createGameletOrchestrationRuntime( - widgetsManager: ExtensionHostGameletWidgetsManager, -): GameletOrchestrationRuntime { - return { - async open(bindingId, payload) { - const componentProps = createComponentProps(bindingId, payload ?? {}) - - if (widgetsManager.getWidgetSnapshot(bindingId)) { - await widgetsManager.updateWidget({ - id: bindingId, - componentProps, - size: 'l', - }) - } - else { - await widgetsManager.pushWidget({ - id: bindingId, - componentName: 'extension-ui', - componentProps, - size: 'l', - }) - } - - await widgetsManager.openWindow({ id: bindingId }) - }, - async configure(bindingId, payload) { - await widgetsManager.updateWidget({ - id: bindingId, - componentProps: createComponentProps(bindingId, payload), - }) - }, - async request(bindingId: string, payload: HostDataRecord, options?: { timeoutMs?: number }): Promise { - if (!widgetsManager.getWidgetSnapshot(bindingId)) { - throw new Error(`Gamelet \`${bindingId}\` is not open.`) - } - - return await widgetsManager.requestWidgetIframe>( - bindingId, - payload, - { - timeoutMs: options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, - }, - ) as TResponse - }, - async close(bindingId) { - await widgetsManager.removeWidget(bindingId) - }, - async isOpen(bindingId) { - return Boolean(widgetsManager.getWidgetSnapshot(bindingId)) - }, - dispose() {}, - } -} - -function createComponentProps(bindingId: string, payload: HostDataRecord): HostDataRecord { - return { - moduleId: bindingId, - payload, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts deleted file mode 100644 index 15732cb79..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { KitRef } from '@proj-airi/plugin-sdk' -import type { ToolKitRuntime } from '@proj-airi/plugin-sdk-tamagotchi/tools' -import type { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host' - -import type { SetupExtensionHostOptions } from '../types' -import type { GameletOrchestrationRuntime } from './gamelet/orchestration' - -import { gameletKit, toolKit } from '@proj-airi/plugin-sdk-tamagotchi' -import { TamagotchiToolRegistry } from '@proj-airi/plugin-sdk-tamagotchi/tools' - -import { registerGameletPluginKit } from './gamelet' -import { createGameletOrchestrationRuntime } from './gamelet/orchestration' -import { registerWidgetPluginKit } from './widget' - -type GameletKitClient = ReturnType -type ToolKitClient = ReturnType - -function createHostGameletKit(options: { host: ExtensionHost, gamelets: GameletOrchestrationRuntime }): KitRef { - return { - ...gameletKit, - createClient(runtime) { - const hostRuntime = { - ...runtime, - bindings: { - bind: (input: Parameters[1]) => options.host.bindExtensionKitModule(runtime.sessionId, input, runtime.moduleId), - }, - gamelets: options.gamelets, - } - - return gameletKit.createClient(hostRuntime) - }, - } -} - -function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef { - return { - ...toolKit, - createClient(runtime) { - let cleanupRegistered = false - const ensureCleanup = () => { - if (cleanupRegistered) { - return - } - - cleanupRegistered = true - runtime.subscriptions.add({ - dispose: () => { - options.tools.unregisterOwnerScope(runtime.sessionId, runtime.moduleId) - }, - }) - } - - const hostRuntime: ToolKitRuntime = { - ...runtime, - tools: { - register: (input) => { - ensureCleanup() - options.tools.register({ - ownerSessionId: runtime.sessionId, - ownerExtensionId: runtime.extensionId, - ownerModuleId: runtime.moduleId, - ...input, - }) - }, - registerToolsetPrompt: (input) => { - ensureCleanup() - options.tools.registerToolsetPrompt({ - ownerSessionId: runtime.sessionId, - ownerExtensionId: runtime.extensionId, - ownerModuleId: runtime.moduleId, - toolset: input, - }) - }, - }, - } - - return toolKit.createClient(hostRuntime) - }, - } -} - -/** - * Creates the built-in kit runtime installed by the Electron extension host. - * - * Use when: - * - Host bootstrap should depend on a kit-layer API instead of wiring widget/gamelet details inline - * - Built-in kit registration should remain outside the host layer - * - * Expects: - * - `widgetsManager` is initialized before host construction - * - * Returns: - * - Helpers to register built-in kits on the host - */ -export function createBuiltInExtensionKitRuntime(options: SetupExtensionHostOptions): { - registerHostKits: (host: ExtensionHost) => void - tools: TamagotchiToolRegistry - dispose: () => void -} { - const gamelets = createGameletOrchestrationRuntime(options.widgetsManager) - const tools = new TamagotchiToolRegistry() - - return { - registerHostKits(host) { - registerWidgetPluginKit(host) - registerGameletPluginKit(host) - host.registerKitApi(createHostGameletKit({ host, gamelets })) - host.registerKitApi(createHostToolKit({ tools })) - }, - tools, - dispose() { - gamelets.dispose() - tools.clear() - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.test.ts deleted file mode 100644 index 97fb59ce2..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { resolveWidgetAssetRoute } from './asset-url' - -describe('resolveWidgetAssetRoute', () => { - it('derives widget route asset path and session prefix with /ui semantics', () => { - expect(resolveWidgetAssetRoute('./ui/index.html')).toEqual({ - routeAssetPath: 'index.html', - sessionPathPrefix: '', - }) - - expect(resolveWidgetAssetRoute('ui/index.html')).toEqual({ - routeAssetPath: 'index.html', - sessionPathPrefix: '', - }) - - expect(resolveWidgetAssetRoute('ui/assets/index.html')).toEqual({ - routeAssetPath: 'assets/index.html', - sessionPathPrefix: 'assets/', - }) - - expect(resolveWidgetAssetRoute('assets/index.html')).toEqual({ - routeAssetPath: 'assets/index.html', - sessionPathPrefix: 'assets/', - }) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts deleted file mode 100644 index 191b48cae..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { PluginHostModuleSummary } from '../../../../../../shared/eventa/plugin/host' -import type { ManifestEntry } from '../../types' - -import { isPlainObject } from 'es-toolkit' - -import { buildMountedStaticAssetPath, normalizeStaticAssetPath } from '../../../http-server/static-assets/paths' - -/** - * Describes one widget iframe asset as seen from the mounted `/ui` route. - * - * Use when: - * - Converting extension config asset paths into mounted extension asset URLs - * - Creating sessions that must validate against route-relative asset paths - * - * Expects: - * - `routeAssetPath` is relative to `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/` - * - `sessionPathPrefix` is a directory prefix under that same route, or empty for root - * - * Returns: - * - N/A - */ -export interface WidgetAssetRoute { - routeAssetPath: string - sessionPathPrefix: string -} - -function normalizeWidgetAssetPath(assetPath: string): string | undefined { - const trimmed = assetPath.trim().replaceAll('\\', '/') - if (!trimmed) { - return undefined - } - - const withoutRelativePrefix = trimmed.startsWith('./') - ? trimmed.slice(2) - : trimmed - - return normalizeStaticAssetPath(withoutRelativePrefix) -} - -/** - * Normalizes a widget iframe asset path into `/ui` route semantics. - * - * Use when: - * - Building mounted widget iframe URLs - * - Creating asset sessions that must validate against the `/ui` static asset route - * - Keeping widget route semantics owned by the widget kit module - * - * Expects: - * - `assetPath` points to a file-like path under plugin static assets - * - * Returns: - * - The route-relative asset path and the allowed session prefix for that route - */ -export function resolveWidgetAssetRoute(assetPath: string): WidgetAssetRoute | undefined { - const normalized = normalizeWidgetAssetPath(assetPath) - if (!normalized) { - return undefined - } - - const routeAssetPath = normalized.startsWith('ui/') - ? normalized.slice(3) - : normalized - if (!routeAssetPath) { - return undefined - } - - const segments = routeAssetPath.split('/').filter(Boolean) - if (segments.length <= 1) { - return { - routeAssetPath, - sessionPathPrefix: normalized.startsWith('ui/') ? '' : routeAssetPath, - } - } - - return { - routeAssetPath, - sessionPathPrefix: `${segments.slice(0, -1).join('/')}/`, - } -} - -/** - * Rewrites widget iframe config to use mounted plugin asset URLs. - * - * Use when: - * - Building plugin inspect snapshots with renderer-consumable widget iframe URLs - * - Creating temporary asset sessions for widget-owned iframe assets - * - * Expects: - * - Module config may contain widget iframe `src` or `assetPath` fields - * - Mapping includes a manifest entry for `module.ownerExtensionId` - * - * Returns: - * - Original module when rewrite is not applicable - * - Cloned module with injected iframe `src` when asset path mount succeeds - */ -export function rewriteWidgetModuleAssetUrl( - module: PluginHostModuleSummary, - manifestEntryByExtensionId: Map, - options?: { - extensionAssetBaseUrl?: string - createAssetSession?: (input: { - extensionId: string - version: string - sessionId: string - routeAssetPath: string - sessionPathPrefix: string - }) => Promise<{ assetSessionId: string, url?: string }> - }, -): Promise | PluginHostModuleSummary { - const entry = manifestEntryByExtensionId.get(module.ownerExtensionId) - if (!entry) { - return module - } - - const config = isPlainObject(module.config) ? module.config as Record : {} - const widgetConfig = isPlainObject(config.widget) ? config.widget as Record : {} - const iframeConfig = isPlainObject(widgetConfig.iframe) ? widgetConfig.iframe as Record : {} - const iframeSrc = typeof iframeConfig.src === 'string' ? iframeConfig.src.trim() : '' - if (iframeSrc) { - return module - } - - const assetPath = normalizeWidgetAssetPath( - typeof iframeConfig.assetPath === 'string' - ? iframeConfig.assetPath - : typeof widgetConfig.iframeAssetPath === 'string' - ? widgetConfig.iframeAssetPath - : typeof config.iframeAssetPath === 'string' - ? config.iframeAssetPath - : '', - ) - if (!assetPath) { - return module - } - - const widgetAssetRoute = resolveWidgetAssetRoute(assetPath) - if (!widgetAssetRoute) { - return module - } - - if (!options?.extensionAssetBaseUrl || !options.createAssetSession) { - return module - } - - return options.createAssetSession({ - extensionId: module.ownerExtensionId, - version: entry.version, - sessionId: module.ownerSessionId, - routeAssetPath: widgetAssetRoute.routeAssetPath, - sessionPathPrefix: widgetAssetRoute.sessionPathPrefix, - }).then((session) => { - const mountedPath = buildMountedStaticAssetPath({ - extensionId: module.ownerExtensionId, - assetSessionId: session.assetSessionId, - assetPath: widgetAssetRoute.routeAssetPath, - }) - const iframeUrl = session.url ?? (mountedPath ? new URL(mountedPath, options.extensionAssetBaseUrl).toString() : '') - if (!iframeUrl) { - return module - } - - return { - ...module, - config: { - ...config, - widget: { - ...widgetConfig, - iframe: { - ...iframeConfig, - src: iframeUrl, - }, - }, - }, - } - }) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts deleted file mode 100644 index 5f2218f57..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ExtensionHost, KitDescriptor } from '@proj-airi/plugin-sdk/plugin-host' - -export { resolveWidgetAssetRoute, rewriteWidgetModuleAssetUrl } from './asset-url' - -/** - * Declares the built-in widget kit exposed by `stage-tamagotchi`. - * - * Use when: - * - Bootstrapping the Electron extension host with widget support - * - Reading the stable built-in widget kit descriptor in tests or snapshots - * - * Expects: - * - The host registers this descriptor during startup - * - * Returns: - * - The widget kit descriptor used for `kit.widget` - */ -export const widgetPluginKitDescriptor = { - kitId: 'kit.widget', - version: '1.0.0', - runtimes: ['electron', 'web'], - capabilities: [ - { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, - ], -} satisfies KitDescriptor - -/** - * Registers the built-in widget kit on one host instance. - * - * Use when: - * - Bootstrapping the Electron extension host with widget kit support - * - Keeping widget descriptor registration inside the widget kit module - * - * Expects: - * - `host` is the initialized extension host instance - * - * Returns: - * - The registered widget kit descriptor - */ -export function registerWidgetPluginKit(host: ExtensionHost): KitDescriptor { - return host.registerKit(widgetPluginKitDescriptor) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts deleted file mode 100644 index c8eb9b427..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { ExtensionHost, ExtensionManifestV1 } from '@proj-airi/plugin-sdk/plugin-host' - -import type { - WidgetsAddPayload, - WidgetSnapshot, - WidgetsUpdatePayload, -} from '../../../../shared/eventa' - -/** - * Stable manifest id used as the runtime identity for one extension. - */ -export type ExtensionId = string - -/** - * Runtime-facing extension host service bundle returned by setup. - * - * Use when: - * - Bootstrapping extension infrastructure during Electron startup - * - Accessing loaded manifests after host initialization - * - * Expects: - * - `host` is an initialized Electron runtime extension host - * - `manifests` reflect the latest loaded manifest snapshot at setup time - * - * Returns: - * - A stable object containing host instance and manifest list - */ -export interface ExtensionHostService { - host: ExtensionHost - manifests: ExtensionManifestV1[] -} - -/** - * Describes the widget manager surface required by extension-driven gamelet APIs. - * - * Use when: - * - `setupExtensionHost(...)` needs to open, update, or close extension-ui widgets - * - * Expects: - * - Widget ids remain stable and may be reused for the same module id - * - * Returns: - * - The minimal widget-manager contract consumed by the extension host service - */ -export interface ExtensionHostGameletWidgetsManager { - openWindow: (params?: { id?: string }) => Promise - pushWidget: (payload: WidgetsAddPayload) => Promise - updateWidget: (payload: WidgetsUpdatePayload) => Promise - removeWidget: (id: string) => Promise - getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined - requestWidgetIframe: = Record>( - id: string, - payload: Record, - options?: { timeoutMs?: number }, - ) => Promise -} - -/** - * Configures the runtime dependencies required by `setupExtensionHost(...)`. - * - * Use when: - * - Wiring the extension host during Electron startup - * - Providing test doubles for extension-driven gamelet orchestration - * - * Expects: - * - `widgetsManager` is already initialized and ready to manage overlay widgets - * - * Returns: - * - N/A - */ -export interface SetupExtensionHostOptions { - widgetsManager: ExtensionHostGameletWidgetsManager -} - -/** - * Binding announcement payload used by extension-side runtime registration. - * - * Use when: - * - Announcing a new module for a registered kit - * - Reusing existing module ownership with the same module identifier - * - * Expects: - * - `moduleId` is unique per owner session/plugin pair - * - `kitId` and `kitModuleType` map to a registered kit descriptor - * - `config` is a JSON-compatible record - * - * Returns: - * - N/A - */ -export interface ExtensionHostBindingAnnounceInput { - moduleId: string - kitId: string - kitModuleType: string - config: Record -} - -/** - * Optional filters for listing announced bindings. - * - * Use when: - * - Querying only modules from one session - * - Querying modules belonging to one kit - * - * Expects: - * - Any provided key is treated as a strict equality filter - * - * Returns: - * - N/A - */ -export interface ExtensionHostBindingListOptions { - ownerSessionId?: string - kitId?: string -} - -/** - * Persisted extension configuration snapshot. - * - * Use when: - * - Reading/writing enabled and auto-reload extension state - * - Keeping known extension manifest path metadata - * - * Expects: - * - Arrays contain extension manifest ids - * - `known` maps extension manifest ids to canonical manifest paths - * - * Returns: - * - N/A - */ -export interface ExtensionConfig { - enabled: ExtensionId[] - autoReload: ExtensionId[] - known: Record -} - -/** - * Internal manifest record with resolved location and package version. - * - * Use when: - * - Loading extension manifests from disk - * - Resolving runtime entrypoints and extension asset metadata - * - * Expects: - * - `manifest` is schema-validated - * - `path` points to `extension.airi.json` - * - `rootDir` is the extension root directory - * - `version` is discovered from package metadata or fallback - * - * Returns: - * - N/A - */ -export interface ManifestEntry { - manifest: ExtensionManifestV1 - path: string - rootDir: string - version: string -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/artistry-bridge.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/artistry-bridge.ts deleted file mode 100644 index e19a2e292..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/artistry-bridge.ts +++ /dev/null @@ -1,552 +0,0 @@ -import type { createContext as createMainEventaContext } from '@moeru/eventa/adapters/electron/main' -import type { ProvidedBy } from 'injeca' - -import type { artistryConfigSchema } from '../../../configs/artistry' -import type { Config } from '../../../libs/electron/persistence' -import type { WidgetsWindowManager } from '../../../windows/widgets' -import type { ArtistryProvider, ArtistryRequest } from './providers/base' - -import { Buffer } from 'node:buffer' -import { createHash } from 'node:crypto' - -import { useLogg } from '@guiiai/logg' -import { defineInvokeHandler } from '@moeru/eventa' -import { errorMessageFrom } from '@moeru/std' -import { artistryGenerateHeadless, artistrySyncConfig, artistryTestComfyUIConnection, errorMessageFromValue } from '@proj-airi/stage-shared' -import { injeca } from 'injeca' - -import { ComfyUIProvider } from './providers/comfyui' -import { NanoBananaProvider } from './providers/nanobanana' -import { ReplicateProvider } from './providers/replicate' - -const log = useLogg('artistry-bridge').useGlobalConfig() -const DEFAULT_REMIX_ID = '48250602' -const DEFAULT_ARTISTRY_PROVIDER = 'none' - -interface ArtistrySyncSnapshot { - provider?: string - model?: string - promptPrefix?: string - options?: Record - globals?: Record -} - -interface TriggerConfig { - provider?: string - model?: string - promptPrefix?: string - options?: Record - globals?: Record -} - -function robustParse(input: unknown, context?: string): Record { - if (typeof input === 'object' && input !== null) - return input as Record - if (typeof input === 'string' && input.trim()) { - try { - const parsed = JSON.parse(input) - if (typeof parsed === 'object' && parsed !== null) - return parsed as Record - log.warn(`[Artistry Bridge] robustParse(${context || 'unknown'}): Parsed JSON is not an object: ${typeof parsed}`) - return {} - } - catch (e) { - log.warn(`[Artistry Bridge] robustParse(${context || 'unknown'}): JSON parse failed: ${errorMessageFrom(e)} | Input: ${input.slice(0, 100)}`) - return {} - } - } - return {} -} - -const lastTriggerMap = new Map() -const activeRunMap = new Map() - -/** - * Volatile storage for active character card artistry defaults. - * Synced from the renderer App.vue whenever the character or settings change. - */ -const cardDefaults: ArtistrySyncSnapshot = { - provider: undefined as string | undefined, - model: undefined as string | undefined, - promptPrefix: undefined as string | undefined, - options: undefined as Record | undefined, - globals: undefined as Record | undefined, -} - -function createRunId(widgetId: string) { - return `${widgetId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}` -} - -async function downloadImageAsBase64(url: string): Promise { - try { - log.log(`[Artistry Bridge] Downloading image from: ${url}`) - const response = await fetch(url) - if (!response.ok) - throw new Error(`Failed to fetch image: ${response.statusText}`) - const buffer = await response.arrayBuffer() - const base64 = Buffer.from(buffer).toString('base64') - // NOTICE: Downstream renderer paths consume this via fetch(), which requires a data URL. - return `data:image/png;base64,${base64}` - } - catch (error: unknown) { - log.error(`[Artistry Bridge] Failed to download image: ${errorMessageFrom(error)}`) - throw error - } -} - -function supportsJobCallback(provider: ArtistryProvider): provider is ArtistryProvider & Required> { - return typeof provider.setJobCallback === 'function' -} - -// Maintaining a registry of providers -export const artistryProviders = new Map() -artistryProviders.set('comfyui', new ComfyUIProvider()) -artistryProviders.set('replicate', new ReplicateProvider()) -artistryProviders.set('nanobanana', new NanoBananaProvider()) - -// Deduplication map for headless requests -const pendingHeadlessRequests = new Map>() - -export async function generateHeadless(params: { - prompt: string - model?: string - provider?: string - options?: Record - globals?: Record -}): Promise<{ imageUrl?: string, base64?: string, error?: string }> { - // Resolve config and effective globals early to secure the deduplication fingerprint - const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy> }) - const activeGlobals = (params.globals || artistryConfig.get()?.artistryGlobals || {}) as Record - - // Create a fingerprint for deduplication - const sourceImage = activeGlobals?.image - const imageHash = typeof sourceImage === 'string' - ? createHash('sha256').update(sourceImage).digest('hex') - : 'NONE' - - // We hash the globals (excluding the heavy image already covered by imageHash) - // to ensure that changing a workflow or provider setting triggers a unique execution. - const { image: _image, ...globalsForFingerprint } = activeGlobals - const globalsHash = createHash('sha256').update(JSON.stringify(globalsForFingerprint)).digest('hex') - - const fingerprint = JSON.stringify({ - p: params.prompt, - m: params.model, - pr: params.provider, - o: params.options, - ih: imageHash, - gh: globalsHash, // Include globals hash (Issue #39) - }) - - if (pendingHeadlessRequests.has(fingerprint)) { - log.log(`[Headless] Deduplicating identical request: ${params.prompt.slice(0, 30)}...`) - return pendingHeadlessRequests.get(fingerprint)! - } - - const executionPromise = (async () => { - const requestedProvider = (params.provider || artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER).trim().toLowerCase() - if (requestedProvider === 'none') { - log.log('[Headless] Provider is \'none\'. Bypassing generation.') - throw new Error('Artistry provider is disabled.') - } - - const provider = artistryProviders.get(requestedProvider) - if (!provider) { - log.error(`[Headless] Provider '${requestedProvider}' not found in registry.`) - throw new Error(`Provider '${requestedProvider}' not found.`) - } - - // Initialize the provider - if (provider.initialize && activeGlobals) { - log.log(`[Headless] Initializing provider ${requestedProvider} with globals...`) - await provider.initialize(activeGlobals) - } - - log.log(`[Headless] Globals keys: ${Object.keys(activeGlobals || {}).join(', ')}`) - if (activeGlobals?.image) - log.log(`[Headless] Source image length: ${activeGlobals.image.length}`) - - const request: ArtistryRequest = { - prompt: params.prompt, - negativePrompt: params.options?.negativePrompt, - width: typeof params.options?.width === 'number' ? params.options.width : undefined, - height: typeof params.options?.height === 'number' ? params.options.height : undefined, - model: params.model, - extra: { - ...params.options, - image: activeGlobals?.image, - internalJobId: createRunId('headless'), - }, - } - - log.log(`[Headless] Starting generation with provider: ${requestedProvider}, model: ${params.model || 'default'}`) - const job = await provider.generate(request) - log.log(`[Headless] Job created: ${job.jobId}`) - - // Polling/Wait for result - if (!supportsJobCallback(provider)) { - let isDone = false - let lastStatus = await provider.getStatus(job.jobId) - const start = Date.now() - const timeout = 1000 * 60 * 5 // 5 minutes timeout - - while (!isDone) { - if (Date.now() - start > timeout) { - log.error(`[Headless] Job ${job.jobId} timed out after 5 minutes.`) - throw new Error('Image generation timed out after 5 minutes.') - } - - log.log(`[Headless] Polling status for job: ${job.jobId}...`) - lastStatus = await provider.getStatus(job.jobId) - log.log(`[Headless] Status for job ${job.jobId}: ${lastStatus.status}`) - - if (lastStatus.status === 'succeeded' || lastStatus.status === 'failed') { - isDone = true - } - if (!isDone) { - await new Promise(resolve => setTimeout(resolve, 2000)) - } - } - - if (lastStatus.status === 'failed') { - log.error(`[Headless] Job ${job.jobId} failed: ${lastStatus.error || 'Unknown error'}`) - throw new Error(lastStatus.error || 'Generation failed') - } - - log.log(`[Headless] Job ${job.jobId} succeeded. Image URL: ${lastStatus.imageUrl}`) - const base64 = lastStatus.imageUrl ? await downloadImageAsBase64(lastStatus.imageUrl) : undefined - return { imageUrl: lastStatus.imageUrl, base64 } - } - else { - // For providers with callbacks (like ComfyUI), we wait for the result via the callback - log.log(`[Headless] Using callback-based wait logic for provider: ${requestedProvider}`) - return new Promise<{ imageUrl?: string, base64?: string }>((resolve, reject) => { - const timeout = 1000 * 60 * 5 // 5 minutes timeout - const timer = setTimeout(() => { - reject(new Error('Image generation timed out after 5 minutes.')) - }, timeout) - - provider.setJobCallback(request.extra?.internalJobId as string, async (status) => { - if (status.status === 'succeeded') { - clearTimeout(timer) - try { - const base64 = status.imageUrl ? await downloadImageAsBase64(status.imageUrl) : undefined - resolve({ imageUrl: status.imageUrl, base64 }) - } - catch (e) { - reject(e) - } - } - else if (status.status === 'failed') { - clearTimeout(timer) - reject(new Error(status.error || 'Generation failed')) - } - }) - }) - } - })() - - pendingHeadlessRequests.set(fingerprint, executionPromise) - - try { - return await executionPromise - } - catch (err) { - return { error: errorMessageFromValue(err) } - } - finally { - // Remove from map after completion so it can be re-triggered later - pendingHeadlessRequests.delete(fingerprint) - } -} - -async function handleArtistryTrigger(params: { - id: string - componentName?: string - componentProps?: unknown - widgetsManager: WidgetsWindowManager -}) { - if (params.componentName !== 'comfy' && params.componentName !== 'artistry') - return - - log.log(`🔍 Intercepted widget update [${params.id}] for component: ${params.componentName}`) - - const props = robustParse(params.componentProps, 'componentProps') - const payload = robustParse(props.payload, 'payload') - const artistryConfigOverrides = robustParse(props._artistryConfig, '_artistryConfig') - const status = props.status - const prompt = (payload.prompt || props.prompt) as string | undefined - - // Build configuration with fallbacks: - // 1. Explicitly provided in component props (_artistryConfig) - // 2. Character-level defaults synced from renderer (cardDefaults) - const config: TriggerConfig = { - provider: artistryConfigOverrides.provider as string | undefined, - model: (artistryConfigOverrides.model as string | undefined) || cardDefaults.model, - promptPrefix: (artistryConfigOverrides.promptPrefix as string | undefined) || cardDefaults.promptPrefix, - options: { - ...cardDefaults.options, - ...robustParse(artistryConfigOverrides.options, 'artistryOptions'), - }, - // NOTICE: Keep legacy `Globals` fallback while standardizing on `globals`. - // Older widget payloads can still send `Globals`, and dropping it now would break them. - globals: robustParse(artistryConfigOverrides.globals || artistryConfigOverrides.Globals || cardDefaults.globals, 'artistryGlobals'), - } - const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy> }) - const providerId = config.provider || cardDefaults.provider || artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER - - // [BY DESIGN]: Short-circuit if artistry is explicitly disabled (provider: 'none'). - // This prevents noisy "Provider not found" errors when the feature is intentionally bypassed. - if (providerId === 'none') { - log.log(`[Artistry Bridge] Provider is 'none'. Bypassing generation for widget: ${params.id}`) - return - } - - // Extract options and remix ID fallback - const options = config.options || {} - // TODO: move remix defaults into per-card/provider config to remove this fallback heuristic. - const remixId = (payload.remixId || props.remixId || options.remixId) as string | undefined - || (props.status === 'generating' && !prompt ? DEFAULT_REMIX_ID : undefined) - - const mode = props.mode || (remixId ? 'remix' : 'generate') - const triggerFingerprint = `${mode}:${remixId || ''}:${prompt || ''}` - - // [BY DESIGN]: We only trigger a new generation if the fingerprint (mode + remixId + prompt) - // has actually changed for this specific widget instance. This denotes our stance on the matter: - // it serves as a critical safety guard against redundant, billable API calls triggered - // by reactive UI loops or state synchronization "storms". While this prevents retrying - // the exact same prompt on the same widget instance without a manual modification, - // it protects users from unexpected credit consumption in a high-frequency reactive - // bridge environment. (Refer to Catalog Issue #31). - if (status === 'generating' && lastTriggerMap.get(params.id) !== triggerFingerprint && (prompt || remixId)) { - log.log(`🎯 TRIGGER DETECTED [${params.id}]: ${triggerFingerprint} | Mode: ${mode} | Provider: ${providerId}`) - lastTriggerMap.set(params.id, triggerFingerprint) - const runId = createRunId(params.id) - activeRunMap.set(params.id, runId) - - const provider = artistryProviders.get(providerId) - if (!provider) { - log.error(`🔴 Provider '${providerId}' not found.`) - params.widgetsManager.updateWidget({ - id: params.id, - componentProps: { status: 'error', actionLabel: `Provider '${providerId}' not available` }, - }) - return - } - - // Initialize the provider with global config fallback - const activeGlobals = config.globals || artistryConfig.get()?.artistryGlobals - if (provider.initialize && activeGlobals) { - log.log(`[Artistry Bridge] Initializing provider ${providerId} with ${config.globals ? 'provided' : 'fallback'} globals...`) - await provider.initialize(activeGlobals) - } - - try { - // Build the abstract request - const request: ArtistryRequest = { - prompt: config.promptPrefix ? `${config.promptPrefix} ${prompt}` : (prompt || ''), - model: config.model, - extra: { - ...options, - ...props, // Include root componentProps overrides (template, node overrides) - ...payload, // Payload takes precedence - internalJobId: runId, // Track each generation independently, even on the same widget. - remixId, - }, - } - - const updateIfActive = (statusUpdate: Record) => { - // NOTICE: the same widget can kick off another generation before the previous one fully - // settles. Only the most recent run is allowed to keep updating the widget state. - if (activeRunMap.get(params.id) !== runId) - return - - // [BY DESIGN]: Merging status updates into existing props preserves fields like imageUrl - // that would otherwise be lost when the final 'done' status is sent. - const existing = params.widgetsManager.getWidgetSnapshot(params.id) - params.widgetsManager.updateWidget({ - id: params.id, - componentProps: { - ...(existing?.componentProps as any), - ...statusUpdate, - }, - }) - } - - // If the provider accepts callbacks (like ComfyUI streaming stdout) - if (supportsJobCallback(provider)) { - provider.setJobCallback(runId, (statusUpdate) => { - updateIfActive(statusUpdate as Record) - if (statusUpdate.status === 'succeeded') { - log.log(`🎉 Job complete (via callback) for ${params.id}. Sending final status: done`) - updateIfActive({ status: 'done', progress: 100, actionLabel: undefined }) - } - else if (statusUpdate.status === 'failed') { - log.log(`🔴 Job failed (via callback) for ${params.id}. Preserving error status.`) - // [BY DESIGN]: Don't send status: 'done' here to avoid clearing the error message (Issue #56) - } - }) - } - - const job = await provider.generate(request) - - // Polling loop for providers that don't do callbacks (like Replicate) - if (!supportsJobCallback(provider)) { - let isDone = false - const startTime = Date.now() - const timeoutLength = 1000 * 60 * 5 // 5 minutes timeout (Issue #56) - - while (!isDone) { - // Check for timeout - if (Date.now() - startTime > timeoutLength) { - log.error(`[Artistry Bridge] Job ${job.jobId} timed out after 5 minutes.`) - updateIfActive({ status: 'error', actionLabel: 'Generation timed out' }) - break - } - - // Check if this run is still the active one for this widget. - // If a user started a new generation, we must kill the old polling loop. - if (activeRunMap.get(params.id) !== runId) { - log.log(`[Artistry Bridge] Stale polling loop detected for ${params.id}. Aborting background task.`) - break - } - - const status = await provider.getStatus(job.jobId) - if (status.status === 'succeeded' || status.status === 'failed') { - isDone = true - } - - updateIfActive(status as Record) - - if (!isDone) { - await new Promise(resolve => setTimeout(resolve, 2000)) - } - } - - if (isDone) { - const finalStatus = await provider.getStatus(job.jobId) - if (finalStatus.status === 'succeeded') { - log.log(`🎉 Job complete (via polling) for ${params.id}. Sending final status: done`) - updateIfActive({ status: 'done', progress: 100, actionLabel: undefined }) - } - else { - log.log(`🔴 Job failed (via polling) for ${params.id}. Preserving error status.`) - } - } - } - } - catch (error: unknown) { - const message = errorMessageFrom(error) ?? 'Unknown generation error' - log.error(`🔴 Generation failed: ${message}`) - if (activeRunMap.get(params.id) === runId) { - lastTriggerMap.delete(params.id) // [BY DESIGN]: Clear fingerprint on failure to allow retry (Issue #44) - params.widgetsManager.updateWidget({ - id: params.id, - componentProps: { status: 'error', actionLabel: message }, - }) - } - } - } -} - -export async function setupArtistryBridge(params: { - widgetsManager: WidgetsWindowManager - context?: ReturnType['context'] - artistryConfig: Config -}) { - log.log('🚀 Initializing Artistry bridge (Spawn + Update Interceptor + Headless Handler)...') - - if (params.context) { - defineInvokeHandler(params.context, artistryGenerateHeadless, async (payload) => { - log.log(`[Artistry Bridge] [Headless] Received invoke for prompt: ${payload.prompt.slice(0, 50)}...`) - return await generateHeadless(payload) - }) - - defineInvokeHandler(params.context, artistrySyncConfig, (payload) => { - log.log(`🔄 Syncing artistry config to main. Provider: ${payload.provider}`) - params.artistryConfig.update({ - artistryProvider: payload.provider || params.artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER, - artistryGlobals: payload.globals || params.artistryConfig.get()?.artistryGlobals || { - comfyuiServerUrl: 'http://localhost:8188', - comfyuiSavedWorkflows: [], - comfyuiActiveWorkflow: '', - replicateApiKey: '', - replicateDefaultModel: 'black-forest-labs/flux-schnell', - replicateAspectRatio: '16:9', - replicateInferenceSteps: 4, - nanobananaApiKey: '', - nanobananaModel: 'gemini-3.1-flash-image-preview', - nanobananaResolution: '1K', - }, - }) - - // Update character-level defaults (volatile only) - cardDefaults.provider = payload.provider - cardDefaults.model = payload.model - cardDefaults.promptPrefix = payload.promptPrefix - cardDefaults.options = payload.options - cardDefaults.globals = payload.globals - }) - - defineInvokeHandler(params.context, artistryTestComfyUIConnection, async (payload) => { - log.log(`🔌 Testing ComfyUI connection at: ${payload.url}`) - try { - const url = payload.url.replace(/\/+$/, '') - const controller = new AbortController() - const id = setTimeout(() => controller.abort(), 10000) - const resp = await fetch(`${url}/system_stats`, { signal: controller.signal }) - clearTimeout(id) - - if (!resp.ok) - throw new Error(`HTTP ${resp.status}`) - const data = await resp.json() as { devices?: Array<{ name?: string, vram_total?: number }> } - const gpus = data.devices?.map(d => d.name).join(', ') || 'Unknown GPU' - const vram = data.devices?.[0]?.vram_total - const vramStr = vram ? `${(vram / 1024 / 1024 / 1024).toFixed(1)} GB` : '' - return { - ok: true, - info: `Connected — ${gpus}${vramStr ? ` (${vramStr} VRAM)` : ''}`, - } - } - catch (e: unknown) { - const message = errorMessageFrom(e) ?? 'Unknown connection error' - log.error(`🔌 ComfyUI connection test failed: ${message}`) - return { - ok: false, - info: `Failed: ${message}`, - } - } - }) - } - - const originalUpdateWidget = params.widgetsManager.updateWidget - params.widgetsManager.updateWidget = async (payload) => { - const snapshot = params.widgetsManager.getWidgetSnapshot(payload.id) - await originalUpdateWidget.call(params.widgetsManager, payload) - await handleArtistryTrigger({ - id: payload.id, - componentName: snapshot?.componentName, - componentProps: payload.componentProps, - widgetsManager: params.widgetsManager, - }) - } - - const originalPushWidget = params.widgetsManager.pushWidget - params.widgetsManager.pushWidget = async (payload) => { - if (payload.componentName === 'comfy' || payload.componentName === 'artistry') { - log.log(`🖼️ Enabling 'Living Wall' mode for ${payload.id}. Forcing infinite TTL. (Component: ${payload.componentName})`) - payload.ttlMs = 0 - } - - const resultId = await originalPushWidget.call(params.widgetsManager, payload) - - await handleArtistryTrigger({ - id: resultId, - componentName: payload.componentName, - componentProps: payload.componentProps, - widgetsManager: params.widgetsManager, - }) - - return resultId - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts deleted file mode 100644 index 34e1d26e0..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import { createContext } from '@moeru/eventa' -import { describe, expect, it, vi } from 'vitest' - -import { widgetsIframeRequestResultEvent } from '../../../../shared/eventa' -import { createWidgetsService } from './index' - -function createWindow(id: number): BrowserWindow { - return { - webContents: { - id, - }, - } as BrowserWindow -} - -function createWidgetsManager() { - return { - clearWidgets: vi.fn(), - fetchWidget: vi.fn(), - getWindow: vi.fn(), - getWidgetSnapshot: vi.fn(), - hideWindow: vi.fn(), - onWidgetEvent: vi.fn(), - openWindow: vi.fn(), - prepareWidgetWindow: vi.fn(), - publishWidgetEvent: vi.fn(), - publishWidgetIframeRequestResult: vi.fn(), - pushWidget: vi.fn(), - removeWidget: vi.fn(), - requestWidgetIframe: vi.fn(), - updateWidget: vi.fn(), - } -} - -describe('createWidgetsService', () => { - it('routes iframe request results from the widgets window to the manager', () => { - const context = createContext() - const widgetsManager = createWidgetsManager() - const window = createWindow(1) - createWidgetsService({ - context: context as never, - widgetsManager, - window, - }) - - context.emit(widgetsIframeRequestResultEvent, { - id: 'kit-module:board', - requestId: 'req-1', - ok: true, - result: { fen: 'fen-after-request' }, - }, { - raw: { - ipcMainEvent: { - sender: { id: 1 }, - }, - }, - } as never) - - expect(widgetsManager.publishWidgetIframeRequestResult).toHaveBeenCalledWith({ - id: 'kit-module:board', - requestId: 'req-1', - ok: true, - result: { fen: 'fen-after-request' }, - }) - }) - - it('ignores iframe request results from other windows', () => { - const context = createContext() - const widgetsManager = createWidgetsManager() - const window = createWindow(1) - createWidgetsService({ - context: context as never, - widgetsManager, - window, - }) - - context.emit(widgetsIframeRequestResultEvent, { - id: 'kit-module:board', - requestId: 'req-1', - ok: true, - result: { fen: 'fen-after-request' }, - }, { - raw: { - ipcMainEvent: { - sender: { id: 2 }, - }, - }, - } as never) - - expect(widgetsManager.publishWidgetIframeRequestResult).not.toHaveBeenCalled() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts deleted file mode 100644 index 4a3280cb8..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { BrowserWindow, IpcMainEvent } from 'electron' - -import type { WidgetsWindowManager } from '../../../windows/widgets' - -import { defineInvokeHandlers } from '@moeru/eventa' - -import { - widgetsAdd, - widgetsClear, - widgetsFetch, - widgetsHideWindow, - widgetsIframePublish, - widgetsIframeRequestResultEvent, - widgetsOpenWindow, - widgetsPrepareWindow, - widgetsRemove, - widgetsUpdate, -} from '../../../../shared/eventa' -import { - normalizeOptionalWidgetId, - normalizeRequiredWidgetId, - validateWidgetIframeEvent, - validateWidgetIframeRequestResult, - validateWidgetsAddPayload, - validateWidgetsUpdatePayload, -} from './validation' - -interface InvokeOptions { - raw?: { ipcMainEvent?: IpcMainEvent } -} - -function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow) { - const sender = options?.raw?.ipcMainEvent?.sender - if (!sender) - return false - return sender.id === window.webContents.id -} - -/** - * Registers widget-related Electron invoke handlers for one window context. - * - * Use when: - * - A main-process window should expose widget management invokes to its renderer - * - Widget requests must be validated before reaching {@link WidgetsWindowManager} - * - * Expects: - * - `context` is an Eventa context bound to the target Electron window - * - `window` is the only renderer allowed to use the registered invokes - * - * Returns: - * - Registers handlers on the provided context and does not return a value - * - * Call stack: - * - * createWidgetsService (./index) - * -> {@link defineInvokeHandlers} - * -> {@link validateWidgetsAddPayload} - * -> {@link WidgetsWindowManager.pushWidget} - */ -export function createWidgetsService(params: { context: ReturnType['context'], widgetsManager: WidgetsWindowManager, window: BrowserWindow }) { - params.context.on(widgetsIframeRequestResultEvent, (event, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return - - params.widgetsManager.publishWidgetIframeRequestResult( - validateWidgetIframeRequestResult(event.body), - ) - }) - - defineInvokeHandlers( - params.context, - { - widgetsPrepareWindow, - widgetsOpenWindow, - widgetsHideWindow, - widgetsAdd, - widgetsUpdate, - widgetsRemove, - widgetsClear, - widgetsFetch, - widgetsIframePublish, - }, - { - widgetsPrepareWindow: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - const id = normalizeOptionalWidgetId(payload?.id) - return params.widgetsManager.prepareWidgetWindow(id ? { id } : undefined) - }, - widgetsOpenWindow: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - const id = normalizeOptionalWidgetId(payload?.id) - return params.widgetsManager.openWindow(id ? { id } : undefined) - }, - widgetsHideWindow: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - return params.widgetsManager!.hideWindow(payload ?? undefined) - }, - widgetsAdd: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - return params.widgetsManager.pushWidget(validateWidgetsAddPayload(payload)) - }, - widgetsUpdate: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - return params.widgetsManager.updateWidget(validateWidgetsUpdatePayload(payload)) - }, - widgetsRemove: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - return params.widgetsManager.removeWidget( - normalizeRequiredWidgetId(payload?.id, 'id is required to remove a widget.'), - ) - }, - widgetsClear: async (_payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - return params.widgetsManager.clearWidgets() - }, - widgetsFetch: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - return params.widgetsManager.getWidgetSnapshot( - normalizeRequiredWidgetId(payload?.id, 'id is required to fetch a widget snapshot.'), - ) - }, - widgetsIframePublish: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - const id = normalizeRequiredWidgetId(payload?.id, 'id is required to publish a widget iframe event.') - params.widgetsManager.publishWidgetEvent(id, validateWidgetIframeEvent(payload?.event)) - }, - }, - ) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/base.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/base.ts deleted file mode 100644 index 0905cb61c..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/base.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Abstract Artistry Provider Interface - * - * All image generation providers (ComfyUI, Replicate, etc.) must implement - * this interface. The bridge dispatches to the active provider based on - * the current AIRI card's artistry settings. - */ - -export interface ArtistryRequest { - /** The text prompt describing the desired image */ - prompt: string - /** Negative prompt — things to avoid (provider support varies) */ - negativePrompt?: string - /** Image width in pixels */ - width?: number - /** Image height in pixels */ - height?: number - /** Provider-specific model identifier */ - model?: string - /** Provider-specific extras (e.g. remixId, checkpoint, seed, aspect_ratio) */ - extra?: Record -} - -export interface ArtistryJob { - /** Internal job ID for tracking */ - jobId: string - /** Provider's native job/prediction ID */ - providerJobId: string -} - -export type ArtistryJobStatusType = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' - -export interface ArtistryJobStatus { - status: ArtistryJobStatusType - /** Generation progress 0-100 (not all providers support this) */ - progress?: number - /** Final output image URL */ - imageUrl?: string - /** Error message if failed */ - error?: string - /** Human-readable label of current stage (e.g. "Sampling", "VAE Decode") */ - actionLabel?: string -} - -export interface ArtistryProviderConfig { - /** Unique provider ID (e.g. "comfyui", "replicate") */ - id: string - /** Human-readable display name */ - name: string - /** Provider-specific configuration (API keys, paths, etc.) */ - settings: Record -} - -export interface ArtistryProvider { - /** Unique provider ID */ - readonly id: string - /** Human-readable display name */ - readonly name: string - - /** - * Start an image generation job. - * Returns a job handle for tracking. - */ - generate: (request: ArtistryRequest) => Promise - - /** - * Poll the current status of a running job. - * Returns status, progress, and final image URL when done. - */ - getStatus: (jobId: string) => Promise - - /** - * Cancel a running job (optional — not all providers support this). - */ - cancel?: (jobId: string) => Promise - - /** - * Called when the provider is first initialized with its config. - */ - initialize?: (config: Record) => Promise - - /** - * Optional push callback for providers that stream or callback status updates. - */ - setJobCallback?: (jobId: string, callback: (status: ArtistryJobStatus) => void) => void - - /** - * Clean up resources when the provider is being switched out. - */ - dispose?: () => void -} - -/** - * Per-card artistry settings stored in AiriExtension.modules.artistry - */ -export interface ArtistryModuleSettings { - /** Active provider ID (e.g. "comfyui", "replicate") */ - provider?: string - /** Provider-specific model identifier */ - model?: string - /** String prepended to every LLM-generated prompt for style consistency */ - defaultPromptPrefix?: string - /** - * Free-form provider-specific options as a JSON object. - * For Replicate: { go_fast: true, megapixels: "1", aspect_ratio: "16:9", ... } - * For ComfyUI: { remixId: 48250602, checkpoint: "bunnyMint.safetensors" } - */ - providerOptions?: Record -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/comfyui.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/comfyui.ts deleted file mode 100644 index d4aec9463..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/comfyui.ts +++ /dev/null @@ -1,394 +0,0 @@ -import type { ArtistryJob, ArtistryJobStatus, ArtistryProvider, ArtistryRequest } from './base' - -import { Buffer } from 'node:buffer' - -import { useLogg } from '@guiiai/logg' - -const log = useLogg('providers-comfyui').useGlobalConfig() - -const POLL_INTERVAL_MS = 5000 -const POLL_TIMEOUT_MS = 1000 * 60 * 5 // 5 minutes - -export class ComfyUIProvider implements ArtistryProvider { - readonly id = 'comfyui' - readonly name = 'ComfyUI (Local)' - - private serverUrl = 'http://localhost:8188' - private savedWorkflows: any[] = [] - private activeWorkflowId = '' - - private jobResults = new Map() - private callbacks = new Map void>() - - private async fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 30000) { - const controller = new AbortController() - const id = setTimeout(() => controller.abort(), timeoutMs) - try { - const response = await fetch(url, { - ...options, - signal: controller.signal, - }) - clearTimeout(id) - return response - } - catch (error) { - clearTimeout(id) - throw error - } - } - - setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) { - this.callbacks.set(jobId, callback) - // If we already have a result, fire it immediately - const result = this.jobResults.get(jobId) - if (result) - callback(result) - } - - private updateStatus(jobId: string, status: ArtistryJobStatus) { - this.jobResults.set(jobId, status) - const callback = this.callbacks.get(jobId) - if (callback) - callback(status) - } - - async initialize(config: any): Promise { - if (config?.comfyuiServerUrl) - this.serverUrl = config.comfyuiServerUrl.replace(/\/+$/, '') // strip trailing slashes - if (config?.comfyuiSavedWorkflows) - this.savedWorkflows = config.comfyuiSavedWorkflows - if (config?.comfyuiActiveWorkflow) - this.activeWorkflowId = config.comfyuiActiveWorkflow - } - - async generate(request: ArtistryRequest): Promise { - const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2) - - // Resolve which workflow template to use --- per-request template override takes precedence over card model default - const templateId = request.extra?.template || request.model || this.activeWorkflowId - const template = this.savedWorkflows.find((w: any) => w.id === templateId) - - if (!template) { - this.updateStatus(jobId, { - status: 'failed', - error: 'No workflow template configured. Upload a workflow in Settings > Providers > ComfyUI.', - actionLabel: 'Error: No workflow configured', - }) - return { jobId, providerJobId: jobId } - } - - // Start async generation - this.pollForResult(jobId, template, request) - - return { jobId, providerJobId: jobId } - } - - private async pollForResult( - jobId: string, - template: { workflow: Record, exposedFields: Record }, - request: ArtistryRequest, - ) { - this.updateStatus(jobId, { status: 'running', actionLabel: 'Preparing workflow...' }) - - try { - // 0. Handle potential image and prompt upload bidirectional flow - const extraStr = JSON.stringify(request.extra || {}) - const workflowStr = JSON.stringify(template.workflow || {}) - const hasImagePlaceholder = extraStr.includes('{{IMAGE}}') || workflowStr.includes('{{IMAGE}}') - const hasPromptPlaceholder = extraStr.includes('{{PROMPT}}') || workflowStr.includes('{{PROMPT}}') - - let uploadedImageName = '' - if (hasImagePlaceholder && request.extra?.image) { - log.log(`[ComfyUI] Bidirectional flow detected. Uploading texture for job ${jobId}...`) - this.updateStatus(jobId, { status: 'running', actionLabel: 'Uploading texture to ComfyUI...' }) - try { - uploadedImageName = await this.uploadImage(request.extra.image) - log.log(`[ComfyUI] Texture uploaded as: ${uploadedImageName}`) - } - catch (e: any) { - log.error(`[ComfyUI] Texture upload failed: ${e.message}`) - } - } - - // 1. Apply overrides to the workflow template (standard injection) - let resolvedPrompt = this.applyOverrides(template, request) - - // 2. Perform final placeholder resolution across the ENTIRE resolved prompt - if (hasImagePlaceholder || hasPromptPlaceholder) { - log.log(`[ComfyUI] Performing final placeholder resolution for ${jobId}...`) - const replacements: Record = { - '{{PROMPT}}': request.prompt || '', - } - if (uploadedImageName) { - replacements['{{IMAGE}}'] = uploadedImageName - } - - resolvedPrompt = this.replacePlaceholders(resolvedPrompt, replacements) - } - - log.log(`[ComfyUI] Resolved prompt for ${jobId}:`, JSON.stringify(resolvedPrompt, null, 2)) - - // 2. POST /prompt to queue the workflow - this.updateStatus(jobId, { status: 'running', actionLabel: 'Queuing in ComfyUI...' }) - - let queueResp: Response - try { - queueResp = await this.fetchWithTimeout(`${this.serverUrl}/prompt`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: resolvedPrompt }), - }, 15000) - } - catch (e: any) { - throw new Error(`Cannot connect to ComfyUI at ${this.serverUrl}: ${e.message}`) - } - - if (!queueResp.ok) { - const errorBody = await queueResp.text() - throw new Error(`Workflow error: ${errorBody.slice(0, 200)}`) - } - - const queueData = await queueResp.json() - const promptId = queueData.prompt_id - if (!promptId) { - throw new Error('ComfyUI returned no prompt_id') - } - - log.log(`[ComfyUI] Queued prompt ${promptId} for job ${jobId}`) - this.updateStatus(jobId, { status: 'running', actionLabel: 'Generating...' }) - - // 3. Poll /history/{prompt_id} until completion - let historyDone = false - let attempt = 0 - const startTime = Date.now() - - while (!historyDone) { - await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)) - attempt++ - - if (Date.now() - startTime > POLL_TIMEOUT_MS) { - throw new Error('Generation timed out after 5 minutes') - } - - if (attempt % 3 === 0) { - log.log(`[ComfyUI] Polling history for ${promptId}... attempt ${attempt}`) - } - - let histResp: Response - try { - histResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000) - } - catch (e: any) { - throw new Error(`ComfyUI disconnected during polling: ${e.message}`) - } - - if (histResp.ok) { - const histData = await histResp.json() - if (histData[promptId]) { - let outputs = histData[promptId].outputs - const stats = histData[promptId].status - - // 3.1. Race condition protection: If outputs are missing, wait a beat and retry once - if ((!outputs || Object.keys(outputs).length === 0) && !historyDone) { - log.warn(`[ComfyUI] Job ${jobId} finished but outputs are empty. Retrying history in 1s...`) - await new Promise(r => setTimeout(r, 1000)) - const retryResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000) - if (retryResp.ok) { - const retryData = await retryResp.json() - if (retryData[promptId] && retryData[promptId].outputs) { - log.log(`[ComfyUI] Retry successful for ${jobId}. Managed to find outputs!`) - outputs = retryData[promptId].outputs - } - } - } - - // Log raw history if no images found or if there are status messages - if (stats?.messages && stats.messages.length > 0) { - log.warn(`[ComfyUI] History messages for ${promptId}:`, stats.messages) - } - - // Find first image in any node's output - for (const nodeId in outputs) { - const nodeOutput = outputs[nodeId] - if (nodeOutput.images && nodeOutput.images.length > 0) { - const img = nodeOutput.images[0] - const imageUrl = `${this.serverUrl}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}` - log.log(`[ComfyUI] Generation complete for job ${jobId}. Image: ${imageUrl}`) - this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl }) - historyDone = true - break - } - } - - // Job finished but no images - if (!historyDone) { - log.error(`[ComfyUI] Job finished for ${jobId} (Prompt ${promptId}) but no output images found. Raw History:`, JSON.stringify(histData[promptId], null, 2)) - this.updateStatus(jobId, { - status: 'failed', - error: 'Job completed but no images were generated', - actionLabel: 'Error: No images generated', - }) - historyDone = true - } - } - } - } - } - catch (error: any) { - const errorMessage = error.message || String(error) - log.error(`[ComfyUI] Generation failed for job ${jobId}: ${errorMessage}`) - this.updateStatus(jobId, { - status: 'failed', - error: errorMessage, - actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`, - }) - } - finally { - // Clean up callback and job result after completion to prevent memory leaks - setTimeout(() => { - this.callbacks.delete(jobId) - this.jobResults.delete(jobId) - }, 10000) - } - } - - /** - * Apply request overrides to a workflow template. - * Matches nodes by _meta.title and overwrites exposed input fields. - * Mirrors the logic from CUIPP's getComfyTemplate.js. - */ - private applyOverrides( - template: { workflow: Record, exposedFields: Record }, - request: ArtistryRequest, - ): Record { - // Deep clone the workflow so we don't mutate the stored template - const prompt = JSON.parse(JSON.stringify(template.workflow)) - - // Build overrides from the request - const overrides: Record> = {} - - // The main prompt text goes into the first exposed "text" field we find - // COMPAT: If the user ALREADY used a {{PROMPT}} placeholder in the extra params, we skip this auto-injection - const hasPromptPlaceholder = JSON.stringify(request.extra).includes('{{PROMPT}}') - if (request.prompt && !hasPromptPlaceholder) { - for (const [nodeTitle, fields] of Object.entries(template.exposedFields)) { - if (fields.includes('text')) { - if (!overrides[nodeTitle]) - overrides[nodeTitle] = {} - overrides[nodeTitle].text = request.prompt - break // Only inject into the first text field - } - } - } - - // Merge in any explicit per-node overrides from request.extra - // We skip known reserved keys and look for keys that might be node titles - const reservedKeys = ['template', 'internalJobId', 'remixId', 'options'] - if (request.extra) { - for (const [key, value] of Object.entries(request.extra)) { - if (reservedKeys.includes(key)) - continue - - // If it's an object, treat it as a potential node override - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - if (!overrides[key]) - overrides[key] = {} - Object.assign(overrides[key], value) - } - } - } - - // Still support legacy .options nesting just in case - if (request.extra?.options) { - for (const [nodeTitle, fields] of Object.entries(request.extra.options as Record>)) { - if (!overrides[nodeTitle]) - overrides[nodeTitle] = {} - Object.assign(overrides[nodeTitle], fields) - } - } - - // Apply overrides to matching nodes - for (const nodeId in prompt) { - const node = prompt[nodeId] - const title = node._meta?.title - if (title && overrides[title]) { - const nodeOverrides = overrides[title] - for (const [field, value] of Object.entries(nodeOverrides)) { - // Only override exposed fields (security boundary) - if (template.exposedFields[title]?.includes(field)) { - node.inputs[field] = value - } - } - } - } - - // Auto-randomize seed if it's exposed and not explicitly set - for (const [nodeTitle, fields] of Object.entries(template.exposedFields)) { - if (fields.includes('seed') && (overrides[nodeTitle]?.seed === undefined || overrides[nodeTitle]?.seed === null)) { - for (const nodeId in prompt) { - const node = prompt[nodeId] - if (node._meta?.title === nodeTitle) { - node.inputs.seed = Math.floor(Math.random() * 1e15) - break - } - } - } - } - - return prompt - } - - async getStatus(jobId: string): Promise { - return this.jobResults.get(jobId) || { status: 'queued' } - } - - private async uploadImage(base64Data: string): Promise { - // 1. Clean data URL prefix if present - const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '') - const buffer = Buffer.from(base64, 'base64') - - // 2. Prepare multipart form data - const formData = new FormData() - const fileName = `vhack_${Date.now()}.png` - - // Electron/Node 18+ fetch handles Blobs in FormData - const blob = new Blob([buffer], { type: 'image/png' }) - formData.append('image', blob, fileName) - formData.append('overwrite', 'true') - - const response = await this.fetchWithTimeout(`${this.serverUrl}/upload/image`, { - method: 'POST', - body: formData, - }, 60000) // 1 minute timeout for uploads - - if (!response.ok) { - const error = await response.text() - throw new Error(`ComfyUI upload failed: ${error}`) - } - - const data = await response.json() - return data.name // Returns the filename in ComfyUI's input folder - } - - private replacePlaceholders(obj: any, replacements: Record): any { - if (typeof obj === 'string') { - let result = obj - for (const [placeholder, value] of Object.entries(replacements)) { - result = result.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'), 'g'), value) - } - return result - } - - if (Array.isArray(obj)) - return obj.map(item => this.replacePlaceholders(item, replacements)) - - if (obj !== null && typeof obj === 'object') { - const newObj: any = {} - for (const [key, value] of Object.entries(obj)) { - newObj[key] = this.replacePlaceholders(value, replacements) - } - return newObj - } - return obj - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/nanobanana.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/nanobanana.ts deleted file mode 100644 index a93ea8243..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/nanobanana.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { ArtistryJob, ArtistryJobStatus, ArtistryProvider, ArtistryRequest } from './base' - -import { useLogg } from '@guiiai/logg' - -const log = useLogg('providers-nanobanana').useGlobalConfig() - -export class NanoBananaProvider implements ArtistryProvider { - readonly id = 'nanobanana' - readonly name = 'Nano Banana (Google AI Studio)' - private apiKey = '' - private defaultModel = 'gemini-1.5-flash' - private defaultResolution = '1K' - - private jobResults = new Map() - private callbacks = new Map void>() - - setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) { - this.callbacks.set(jobId, callback) - const result = this.jobResults.get(jobId) - if (result) - callback(result) - } - - private updateStatus(jobId: string, status: ArtistryJobStatus) { - this.jobResults.set(jobId, status) - const callback = this.callbacks.get(jobId) - if (callback) - callback(status) - } - - async initialize(config: any) { - this.apiKey = config.nanobananaApiKey || config.apiKey || '' - if (config.nanobananaModel) - this.defaultModel = config.nanobananaModel - if (config.nanobananaResolution) - this.defaultResolution = config.nanobananaResolution - log.log(`[Nano Banana] Initialized. API Key present: ${!!this.apiKey}`) - } - - async generate(request: ArtistryRequest): Promise { - if (!this.apiKey) { - throw new Error('Nano Banana API Key not configured') - } - - const jobId = request.extra?.internalJobId || `nanobanana-${Date.now()}` - const model = request.model || this.defaultModel - const resolution = request.extra?.resolution || this.defaultResolution - - // Robust image extraction & cleansing - let base64Image = request.extra?.image || request.extra?.providerOptions?.image || '' - if (base64Image.includes('base64,')) - base64Image = base64Image.split('base64,')[1] - - this.runGeneration(jobId, model, resolution, request.prompt, base64Image) - - return { - jobId, - providerJobId: jobId, - } - } - - private async runGeneration(jobId: string, model: string, resolution: string, prompt: string, base64Image: string) { - this.updateStatus(jobId, { status: 'running', actionLabel: 'Inscribing with Nano Banana...' }) - - try { - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${this.apiKey}` - const generationParts: any[] = [{ text: prompt }] - if (base64Image) { - generationParts.push({ inline_data: { mime_type: 'image/jpeg', data: base64Image } }) - } - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contents: [{ parts: generationParts }], - generationConfig: { imageConfig: { aspectRatio: '1:1', imageSize: resolution } }, - }), - }) - - const json = await response.json() - if (json.error) { - throw new Error(json.error.message || 'Nano Banana API Error') - } - - // Search all parts for the first image - const responseParts = json.candidates?.[0]?.content?.parts || [] - const imagePart = responseParts.find((p: any) => p.inlineData?.data) - const inlineData = imagePart?.inlineData - - if (inlineData?.data) { - const dataUrl = `data:${inlineData.mimeType};base64,${inlineData.data}` - this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl: dataUrl }) - } - else { - throw new Error('No image data returned from Nano Banana') - } - } - catch (e: any) { - log.error(`[Nano Banana] Generation failed: ${e.message}`) - this.updateStatus(jobId, { status: 'failed', error: e.message }) - } - finally { - // Clean up callback and job result after completion to prevent memory leaks - setTimeout(() => { - this.callbacks.delete(jobId) - this.jobResults.delete(jobId) - }, 10000) - } - } - - async getStatus(jobId: string): Promise { - return this.jobResults.get(jobId) || { status: 'queued' } - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/replicate.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/replicate.ts deleted file mode 100644 index f80c01428..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/replicate.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { ArtistryJob, ArtistryJobStatus, ArtistryProvider, ArtistryRequest } from './base' - -import Replicate from 'replicate' - -import { useLogg } from '@guiiai/logg' - -const log = useLogg('providers-replicate').useGlobalConfig() - -export class ReplicateProvider implements ArtistryProvider { - readonly id = 'replicate' - readonly name = 'Replicate.ai (Cloud)' - - private apiKey = '' - private defaultModel = 'black-forest-labs/flux-schnell' - private aspectRatio = '16:9' - private inferenceSteps = 4 - private replicate: Replicate | null = null - - private jobResults = new Map() - private callbacks = new Map void>() - - setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) { - this.callbacks.set(jobId, callback) - const result = this.jobResults.get(jobId) - if (result) - callback(result) - } - - private updateStatus(jobId: string, status: ArtistryJobStatus) { - this.jobResults.set(jobId, status) - const callback = this.callbacks.get(jobId) - if (callback) - callback(status) - } - - async initialize(config: any): Promise { - if (config?.replicateApiKey) { - this.apiKey = config.replicateApiKey - this.replicate = new Replicate({ auth: this.apiKey }) - } - else { - this.apiKey = '' - this.replicate = null - } - if (config?.replicateDefaultModel) - this.defaultModel = config.replicateDefaultModel - if (config?.replicateAspectRatio) - this.aspectRatio = config.replicateAspectRatio - if (config?.replicateInferenceSteps) - this.inferenceSteps = config.replicateInferenceSteps - } - - async generate(request: ArtistryRequest): Promise { - if (!this.replicate) { - throw new Error('Replicate provider is not configured. Missing API Key.') - } - - const model = (request.model || request.extra?.model || this.defaultModel) as `${string}/${string}` - const base64Image = request.extra?.image || '' - - // 1. Start with defaults - const hasPromptPlaceholder = JSON.stringify(request.extra).includes('{{PROMPT}}') - let inputOptions: Record = { - go_fast: request.extra?.go_fast ?? true, - aspect_ratio: request.extra?.aspect_ratio ?? this.aspectRatio, - output_format: request.extra?.output_format ?? 'png', - output_quality: request.extra?.output_quality ?? 80, - num_inference_steps: request.extra?.num_inference_steps ?? this.inferenceSteps, - } - - // Default prompt injection if NO placeholder is used in overrides - if (request.prompt && !hasPromptPlaceholder) { - inputOptions.prompt = request.prompt - } - - // 2. Merge overrides from the "JSON Parameters" textarea if present - if (request.extra) { - const { image: _image, internalJobId: _internalJobId, remixId: _remixId, ...rest } = request.extra - // [BY DESIGN]: Strip 'prompt' from rest to avoid overwriting the prefixed version from the bridge. - const { prompt: _overriddenPrompt, ...safeRest } = rest as any - inputOptions = { ...inputOptions, ...safeRest } - } - - // 3. Recursive placeholder replacement for {{IMAGE}} and {{PROMPT}} - const replacePlaceholders = (obj: any): any => { - if (typeof obj === 'string') { - let result = obj - // Handle image replacement - if (result.includes('{{IMAGE}}')) { - const dataUrl = base64Image.startsWith('data:') ? base64Image : `data:image/jpeg;base64,${base64Image}` - result = result.replace(/\{\{IMAGE\}\}/g, dataUrl) - } - // Handle prompt replacement - if (result.includes('{{PROMPT}}')) { - const truncatedPrompt = this.truncatePrompt(request.prompt || '') - result = result.replace(/\{\{PROMPT\}\}/g, truncatedPrompt) - } - return result - } - if (Array.isArray(obj)) - return obj.map(replacePlaceholders) - if (typeof obj === 'object' && obj !== null) { - const newObj: any = {} - for (const key in obj) - newObj[key] = replacePlaceholders(obj[key]) - return newObj - } - return obj - } - - inputOptions = replacePlaceholders(inputOptions) - - // Ensure main prompt is also truncated if not using a placeholder - if (inputOptions.prompt && !hasPromptPlaceholder) { - inputOptions.prompt = this.truncatePrompt(inputOptions.prompt) - } - - log.log(`[Replicate] Generating with model ${model}. Input keys: ${Object.keys(inputOptions).join(', ')}`) - - // We don't await the result here because the interface expects us to return an ArtistryJob immediately. - // However, replicate.run() blocks until completion. We'll run it in the background and store the result. - const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2) - - // Start generation asynchronously - this.runGeneration(jobId, model, inputOptions) - - return { jobId, providerJobId: jobId } - } - - private async runGeneration(jobId: string, model: `${string}/${string}`, input: object) { - this.updateStatus(jobId, { status: 'running', actionLabel: 'Requesting cloud generation...' }) - - try { - const output = await this.replicate!.run(model, { input }) - - if (!output) { - throw new Error('No output received from Replicate.') - } - - log.log(`[Replicate] Raw output type: ${typeof output}, isArray: ${Array.isArray(output)}`) - - // Replicate's run() can return a single string, an array of strings, or an array of FileUpload objects - const items = Array.isArray(output) ? output : [output] - if (items.length > 0) { - const first = items[0] - let imageUrl: string | undefined - - // Case 1: FileUpload object with .url() method (common in recent SDK versions) - if (typeof first === 'object' && first !== null && 'url' in first && typeof (first as any).url === 'function') { - imageUrl = (first as any).url().href - } - // Case 2: Object with url property as a string - else if (typeof first === 'object' && first !== null && 'url' in first && typeof (first as any).url === 'string') { - imageUrl = (first as any).url - } - // Case 3: Simple string (the URL itself) - else if (typeof first === 'string') { - imageUrl = first - } - - if (imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('data:'))) { - log.log(`[Replicate] EXTRACTED IMAGE: ${imageUrl.startsWith('data:') ? 'DATA_URL' : imageUrl}`) - this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl }) - } - else { - log.error(`[Replicate] Failed to extract URL from output: ${JSON.stringify(first)}`) - throw new Error('Output does not contain a recognizable image URL.') - } - } - else { - throw new Error('Replicate returned an empty output array.') - } - } - catch (error: any) { - const errorMessage = error.message || (typeof error === 'object' ? JSON.stringify(error) : String(error)) - log.error(`[Replicate] Generation Failed for ${jobId}: ${errorMessage}`) - this.updateStatus(jobId, { - status: 'failed', - error: errorMessage, - actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`, - }) - } - finally { - // Clean up callback and job result after completion to prevent memory leaks - setTimeout(() => { - this.callbacks.delete(jobId) - this.jobResults.delete(jobId) - }, 10000) - } - } - - async getStatus(jobId: string): Promise { - return this.jobResults.get(jobId) || { status: 'queued' } - } - - private truncatePrompt(prompt: string, maxChars: number = 380): string { - if (prompt.length <= maxChars) - return prompt - log.log(`[Replicate] Truncating prompt from ${prompt.length} to ${maxChars} chars.`) - return `${prompt.slice(0, maxChars)}...` - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts deleted file mode 100644 index d9bbb2a8b..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - normalizeOptionalWidgetId, - normalizeRequiredWidgetId, - validateWidgetIframeRequestResult, - validateWidgetsAddPayload, - validateWidgetsUpdatePayload, -} from './validation' - -describe('widget invoke validation', () => { - describe('validateWidgetsAddPayload', () => { - it('normalizes add payloads for the widgets manager', () => { - expect(validateWidgetsAddPayload({ - id: ' widget-1 ', - componentName: ' weather ', - componentProps: { city: 'Tokyo' }, - alwaysOnTop: true, - ttlMs: 2500.9, - windowSize: { - width: 620.8, - height: 480.2, - minWidth: 320.9, - }, - })).toEqual({ - id: 'widget-1', - componentName: 'weather', - componentProps: { city: 'Tokyo' }, - alwaysOnTop: true, - ttlMs: 2500, - windowSize: { - width: 620, - height: 480, - minWidth: 320, - }, - }) - }) - - it('rejects empty component names and invalid payload fields', () => { - expect(() => validateWidgetsAddPayload({ - componentName: ' ', - } as any)).toThrow('componentName is required to spawn a widget.') - - expect(() => validateWidgetsAddPayload({ - componentName: 'weather', - componentProps: [] as any, - })).toThrow('componentProps must be a plain object.') - - expect(() => validateWidgetsAddPayload({ - componentName: 'weather', - ttlMs: -1, - })).toThrow('ttlMs must be a non-negative finite number.') - - expect(() => validateWidgetsAddPayload({ - componentName: 'weather', - windowSize: { width: 0, height: 320 }, - } as any)).toThrow('windowSize must contain a positive finite width and height.') - - expect(() => validateWidgetsAddPayload({ - componentName: 'weather', - alwaysOnTop: 'yes' as any, - })).toThrow('alwaysOnTop must be a boolean when provided.') - }) - }) - - describe('validateWidgetsUpdatePayload', () => { - it('normalizes widget updates and keeps optional fields optional', () => { - expect(validateWidgetsUpdatePayload({ - id: ' widget-1 ', - componentProps: { city: 'Taipei' }, - alwaysOnTop: false, - ttlMs: 1500.4, - })).toEqual({ - id: 'widget-1', - componentProps: { city: 'Taipei' }, - alwaysOnTop: false, - ttlMs: 1500, - windowSize: undefined, - }) - }) - - it('rejects missing ids and malformed update fields', () => { - expect(() => validateWidgetsUpdatePayload({ - id: ' ', - } as any)).toThrow('id is required to update a widget.') - - expect(() => validateWidgetsUpdatePayload({ - id: 'widget-1', - componentProps: [] as any, - })).toThrow('componentProps must be a plain object.') - - expect(() => validateWidgetsUpdatePayload({ - id: 'widget-1', - windowSize: { width: Number.NaN, height: 400 }, - } as any)).toThrow('windowSize must contain a positive finite width and height.') - - expect(() => validateWidgetsUpdatePayload({ - id: 'widget-1', - alwaysOnTop: 'yes' as any, - })).toThrow('alwaysOnTop must be a boolean when provided.') - }) - }) - - describe('widget id normalization helpers', () => { - it('normalizes optional ids for open/prepare flows', () => { - expect(normalizeOptionalWidgetId(' widget-1 ')).toBe('widget-1') - expect(normalizeOptionalWidgetId(' ')).toBeUndefined() - }) - - it('enforces required ids for destructive flows', () => { - expect(normalizeRequiredWidgetId(' widget-1 ', 'id required')).toBe('widget-1') - expect(() => normalizeRequiredWidgetId(' ', 'id required')).toThrow('id required') - }) - }) - - describe('validateWidgetIframeRequestResult', () => { - it('normalizes successful iframe request results', () => { - expect(validateWidgetIframeRequestResult({ - id: ' kit-module:board ', - requestId: ' req-1 ', - ok: true, - result: { fen: 'fen-after-request' }, - })).toEqual({ - id: 'kit-module:board', - requestId: 'req-1', - ok: true, - result: { fen: 'fen-after-request' }, - }) - }) - - it('normalizes failed iframe request results', () => { - expect(validateWidgetIframeRequestResult({ - id: 'kit-module:board', - requestId: 'req-1', - ok: false, - error: 'Board rejected request.', - })).toEqual({ - id: 'kit-module:board', - requestId: 'req-1', - ok: false, - error: 'Board rejected request.', - }) - }) - - it('rejects malformed iframe request results', () => { - expect(() => validateWidgetIframeRequestResult(null)).toThrow('iframe request result must be a plain object.') - expect(() => validateWidgetIframeRequestResult({ - id: 'kit-module:board', - requestId: 'req-1', - ok: true, - })).toThrow('iframe request result payload must be a plain object.') - expect(() => validateWidgetIframeRequestResult({ - id: 'kit-module:board', - requestId: 'req-1', - ok: false, - })).toThrow('iframe request result error is required.') - }) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts deleted file mode 100644 index 7d8343d46..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { - WidgetsAddPayload, - WidgetsIframeRequestResultPayload, - WidgetsUpdatePayload, -} from '../../../../shared/eventa' - -import { isPlainObject } from 'es-toolkit' - -import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size' - -function normalizeWidgetId(value?: string): string | undefined { - if (!value) - return undefined - - const normalized = value.trim() - return normalized || undefined -} - -function normalizeTtlMs(ttlMs?: number): number { - if (ttlMs === undefined) - return 0 - - if (!Number.isFinite(ttlMs) || ttlMs < 0) - throw new Error('ttlMs must be a non-negative finite number.') - - return Math.floor(ttlMs) -} - -function normalizeComponentProps(componentProps?: Record): Record { - if (componentProps === undefined) - return {} - - if (!isPlainObject(componentProps)) - throw new Error('componentProps must be a plain object.') - - return componentProps -} - -function normalizeOptionalBoolean(value: boolean | undefined, fieldName: string): boolean | undefined { - if (value === undefined) - return undefined - - if (typeof value !== 'boolean') - throw new Error(`${fieldName} must be a boolean when provided.`) - - return value -} - -/** - * Validates and normalizes widget spawn payloads at the Electron invoke boundary. - * - * Use when: - * - `defineInvokeHandler(...)` receives a widgets add request from a renderer - * - * Expects: - * - `componentName` is a non-empty string - * - `componentProps`, when provided, is a plain object - * - `alwaysOnTop`, when provided, is a boolean - * - `ttlMs`, when provided, is a non-negative finite number - * - * Returns: - * - A normalized payload safe to pass into the widgets manager - */ -export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsAddPayload { - if (!payload) - throw new Error('widgets.add requires a payload.') - - const componentName = payload.componentName?.trim() - if (!componentName) - throw new Error('componentName is required to spawn a widget.') - - const normalizedWindowSize = payload.windowSize === undefined - ? undefined - : normalizeWidgetWindowSize(payload.windowSize) - - if (payload.windowSize !== undefined && !normalizedWindowSize) - throw new Error('windowSize must contain a positive finite width and height.') - - return { - ...payload, - id: normalizeWidgetId(payload.id), - componentName, - componentProps: normalizeComponentProps(payload.componentProps), - alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'), - ttlMs: normalizeTtlMs(payload.ttlMs), - windowSize: normalizedWindowSize, - } -} - -/** - * Validates and normalizes widget update payloads at the Electron invoke boundary. - * - * Use when: - * - `defineInvokeHandler(...)` receives a widgets update request from a renderer - * - * Expects: - * - `id` is a non-empty string after trimming - * - `componentProps`, when provided, is a plain object - * - `alwaysOnTop`, when provided, is a boolean - * - * Returns: - * - A normalized payload safe to pass into the widgets manager - */ -export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): WidgetsUpdatePayload { - if (!payload) - throw new Error('widgets.update requires a payload.') - - const id = normalizeWidgetId(payload.id) - if (!id) - throw new Error('id is required to update a widget.') - - const normalizedWindowSize = payload.windowSize === undefined - ? undefined - : normalizeWidgetWindowSize(payload.windowSize) - - if (payload.windowSize !== undefined && !normalizedWindowSize) - throw new Error('windowSize must contain a positive finite width and height.') - - return { - ...payload, - id, - componentProps: payload.componentProps === undefined - ? undefined - : normalizeComponentProps(payload.componentProps), - alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'), - ttlMs: payload.ttlMs === undefined - ? undefined - : normalizeTtlMs(payload.ttlMs), - windowSize: normalizedWindowSize, - } -} - -/** - * Validates widget ids for remove/fetch/open operations at the Electron boundary. - * - * Use when: - * - A widget operation requires an existing widget id - * - * Expects: - * - `id` is a string or `undefined` - * - * Returns: - * - The trimmed id, or `undefined` for empty input - */ -export function normalizeRequiredWidgetId(id?: string, reason = 'id is required.'): string { - const normalized = normalizeWidgetId(id) - if (!normalized) - throw new Error(reason) - - return normalized -} - -/** - * Normalizes optional widget ids for open/prepare operations. - * - * Before: - * - `" widget-1 "` - * - `""` - * - * After: - * - `"widget-1"` - * - `undefined` - */ -export function normalizeOptionalWidgetId(id?: string): string | undefined { - return normalizeWidgetId(id) -} - -/** - * Validates iframe-published widget events at the Electron invoke boundary. - * - * Use when: - * - A renderer extension iframe publishes a structured event through its host widget shell - * - * Expects: - * - `event` is a plain JSON-like object - * - * Returns: - * - The event record safe to route through the widget manager - */ -export function validateWidgetIframeEvent(event: unknown): Record { - if (!isPlainObject(event)) { - throw new Error('iframe event must be a plain object.') - } - - return event as Record -} - -/** - * Validates renderer-to-main iframe request results before they settle pending gamelet requests. - * - * Use when: - * - The widgets renderer reports a response from a mounted extension iframe - * - * Expects: - * - `id` and `requestId` are non-empty strings - * - Successful results contain a plain response record - * - Failed results contain an error message - * - * Returns: - * - A discriminated request result safe to pass into the widgets manager - */ -export function validateWidgetIframeRequestResult(result: unknown): WidgetsIframeRequestResultPayload { - if (!isPlainObject(result)) { - throw new Error('iframe request result must be a plain object.') - } - - const id = normalizeWidgetId(typeof result.id === 'string' ? result.id : undefined) - if (!id) { - throw new Error('iframe request result id is required.') - } - - const requestId = normalizeWidgetId(typeof result.requestId === 'string' ? result.requestId : undefined) - if (!requestId) { - throw new Error('iframe request result requestId is required.') - } - - if (result.ok === true) { - if (!isPlainObject(result.result)) { - throw new Error('iframe request result payload must be a plain object.') - } - - return { - id, - requestId, - ok: true, - result: result.result, - } - } - - if (result.ok === false) { - if (typeof result.error !== 'string' || !result.error.trim()) { - throw new Error('iframe request result error is required.') - } - - return { - id, - requestId, - ok: false, - error: result.error, - } - } - - throw new Error('iframe request result ok must be a boolean.') -} diff --git a/apps/stage-tamagotchi/src/main/services/electron/app.test.ts b/apps/stage-tamagotchi/src/main/services/electron/app.test.ts deleted file mode 100644 index e22178914..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/app.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createContext, defineInvoke } from '@moeru/eventa' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { electronAppOpenUserDataFolder } from '../../../shared/eventa' -import { createAppService } from './app' - -const appMock = vi.hoisted(() => ({ - getPath: vi.fn(), - quit: vi.fn(), -})) - -const shellMock = vi.hoisted(() => ({ - openPath: vi.fn(), -})) - -vi.mock('electron', () => ({ - app: appMock, - shell: shellMock, -})) - -vi.mock('std-env', () => ({ - isLinux: false, - isMacOS: false, - isWindows: true, -})) - -describe('createAppService', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('opens the Electron userData folder and returns its path', async () => { - const context = createContext() - appMock.getPath.mockReturnValue('/tmp/airi-user-data') - shellMock.openPath.mockResolvedValue('') - - createAppService({ context: context as never, window: {} as never }) - - const openUserDataFolder = defineInvoke(context, electronAppOpenUserDataFolder) - - await expect(openUserDataFolder()).resolves.toEqual({ path: '/tmp/airi-user-data' }) - expect(appMock.getPath).toHaveBeenCalledWith('userData') - expect(shellMock.openPath).toHaveBeenCalledWith('/tmp/airi-user-data') - }) - - it('throws when Electron fails to open the userData folder', async () => { - const context = createContext() - appMock.getPath.mockReturnValue('/tmp/airi-user-data') - shellMock.openPath.mockResolvedValue('Failed to open path') - - createAppService({ context: context as never, window: {} as never }) - - const openUserDataFolder = defineInvoke(context, electronAppOpenUserDataFolder) - - await expect(openUserDataFolder()).rejects.toThrow('Failed to open path') - expect(appMock.getPath).toHaveBeenCalledWith('userData') - expect(shellMock.openPath).toHaveBeenCalledWith('/tmp/airi-user-data') - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/electron/app.ts b/apps/stage-tamagotchi/src/main/services/electron/app.ts deleted file mode 100644 index 94e445ddc..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/app.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { BrowserWindow } from 'electron' - -import { defineInvokeHandler } from '@moeru/eventa' -import { app, shell } from 'electron' -import { isLinux, isMacOS, isWindows } from 'std-env' - -import { electron, electronAppOpenUserDataFolder, electronAppQuit } from '../../../shared/eventa' - -export function createAppService(params: { context: ReturnType['context'], window: BrowserWindow }) { - defineInvokeHandler(params.context, electron.app.isMacOS, () => isMacOS) - defineInvokeHandler(params.context, electron.app.isWindows, () => isWindows) - defineInvokeHandler(params.context, electron.app.isLinux, () => isLinux) - defineInvokeHandler(params.context, electronAppOpenUserDataFolder, async () => { - const path = app.getPath('userData') - const openResult = await shell.openPath(path) - if (openResult) { - throw new Error(openResult) - } - return { path } - }) - defineInvokeHandler(params.context, electronAppQuit, () => app.quit()) -} diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts deleted file mode 100644 index 03e14815f..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const appMock = vi.hoisted(() => ({ - getVersion: vi.fn(() => '0.9.0-beta.4'), - getPath: vi.fn((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`), - quit: vi.fn(), - isPackaged: false, -})) - -const isDevState = vi.hoisted(() => ({ - value: false, -})) - -const stdEnvState = vi.hoisted(() => ({ - isWindows: false, -})) - -const updaterState = vi.hoisted(() => ({ - instance: createUpdaterMock(), -})) - -function createUpdaterMock() { - return { - on: vi.fn(), - autoDownload: true, - allowPrerelease: false, - channel: undefined as string | undefined, - logger: undefined as any, - forceDevUpdateConfig: false, - setFeedURL: vi.fn(), - checkForUpdates: vi.fn().mockResolvedValue(undefined), - downloadUpdate: vi.fn().mockResolvedValue(undefined), - quitAndInstall: vi.fn(), - } -} - -vi.mock('electron', () => ({ - app: appMock, -})) - -vi.mock('@electron-toolkit/utils', () => ({ - is: { - get dev() { - return isDevState.value - }, - }, -})) - -vi.mock('std-env', () => ({ - get isWindows() { - return stdEnvState.isWindows - }, -})) - -vi.mock('@guiiai/logg', () => ({ - useLogg: () => ({ - useGlobalConfig: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - withError: () => ({ - error: vi.fn(), - }), - }), - }), -})) - -vi.mock('electron-updater', () => ({ - default: { - get autoUpdater() { - return updaterState.instance - }, - }, -})) - -vi.mock('~build/git', () => ({ - committerDate: '2026-04-01T00:00:00.000Z', -})) - -describe('setupAutoUpdater', () => { - const expectedChannelByArch = process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64' - - const laneReleaseTagMap = { - latest: 'v0.9.12-nightly.7', - stable: 'v0.9.9', - beta: 'v0.9.10-beta.3', - alpha: 'v0.9.11-alpha.4', - nightly: 'v0.9.12-nightly.7', - } as const - const bundleVersions = ['0.9.0', '0.9.0-beta.4', '0.9.0-alpha.2'] as const - const laneMatrix = ['latest', 'stable', 'beta', 'alpha', 'nightly'] as const - - const defaultReleases = [ - { tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true }, - ] - const matrixReleases = [ - { tag_name: 'v0.9.7', draft: false, prerelease: false }, - { tag_name: 'v0.9.9', draft: false, prerelease: false }, - { tag_name: 'v0.9.9-beta.1', draft: false, prerelease: true }, - { tag_name: 'v0.9.10-beta.3', draft: false, prerelease: true }, - { tag_name: 'v0.9.10-alpha.5', draft: false, prerelease: true }, - { tag_name: 'v0.9.11-alpha.4', draft: false, prerelease: true }, - { tag_name: 'v0.9.11-nightly.1', draft: false, prerelease: true }, - { tag_name: 'v0.9.12-nightly.7', draft: false, prerelease: true }, - ] - - function mockGitHubReleasesFetch(releases = defaultReleases) { - const fetchSpy = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => releases, - }) - vi.stubGlobal('fetch', fetchSpy) - return fetchSpy - } - - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - updaterState.instance = createUpdaterMock() - appMock.getVersion.mockReturnValue('0.9.0-beta.4') - appMock.getPath.mockImplementation((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`) - isDevState.value = false - stdEnvState.isWindows = false - delete process.env.UPDATE_SERVER_URL - delete process.env.AIRI_UPDATE_CHANNEL - mockGitHubReleasesFetch() - }) - - it('resolves release tag from GitHub API and configures generic provider for checks', async () => { - const fetchSpy = mockGitHubReleasesFetch([ - { tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true }, - { tag_name: 'v0.9.0-beta.5', draft: false, prerelease: true }, - ]) - const { setupAutoUpdater } = await import('./auto-updater') - const service = setupAutoUpdater() - - await Promise.resolve() - await service.checkForUpdates() - - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({ - provider: 'generic', - url: 'https://github.com/moeru-ai/airi/releases/download/v0.9.0-beta.6', - }) - expect(updaterState.instance.channel).toBe(expectedChannelByArch) - }) - - it('ignores UPDATE_SERVER_URL in non-dev runtime', async () => { - process.env.UPDATE_SERVER_URL = 'http://localhost:8787/stable' - - const { setupAutoUpdater } = await import('./auto-updater') - const service = setupAutoUpdater() - await service.checkForUpdates() - - expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({ - provider: 'generic', - url: 'https://github.com/moeru-ai/airi/releases/download/v0.9.0-beta.6', - }) - }) - - it('uses UPDATE_SERVER_URL only in dev mode for update-test harness', async () => { - isDevState.value = true - process.env.UPDATE_SERVER_URL = 'http://localhost:8787/stable' - - const fetchSpy = mockGitHubReleasesFetch() - const { setupAutoUpdater } = await import('./auto-updater') - setupAutoUpdater() - - expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({ - provider: 'generic', - url: 'http://localhost:8787/stable', - }) - expect(updaterState.instance.forceDevUpdateConfig).toBe(true) - expect(fetchSpy).not.toHaveBeenCalled() - }) - - // https://github.com/moeru-ai/airi/pull/1827 - it('keeps storefront-managed distributions outside the GitHub updater flow (PR #1827)', async () => { - const fetchSpy = mockGitHubReleasesFetch() - const { setupAutoUpdater } = await import('./auto-updater') - const service = setupAutoUpdater({ enabled: false }) - - await service.checkForUpdates() - await service.downloadUpdate() - await service.quitAndInstall() - - expect(service.state.status).toBe('disabled') - expect(fetchSpy).not.toHaveBeenCalled() - expect(updaterState.instance.on).not.toHaveBeenCalled() - expect(updaterState.instance.checkForUpdates).not.toHaveBeenCalled() - expect(updaterState.instance.downloadUpdate).not.toHaveBeenCalled() - expect(updaterState.instance.quitAndInstall).not.toHaveBeenCalled() - }) - - it('supports explicit stable lane selection for future dynamic channel switching', async () => { - process.env.AIRI_UPDATE_CHANNEL = 'stable' - mockGitHubReleasesFetch([ - { tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true }, - { tag_name: 'v0.8.9', draft: false, prerelease: false }, - { tag_name: 'v0.8.8', draft: false, prerelease: false }, - ]) - - const { setupAutoUpdater } = await import('./auto-updater') - const service = setupAutoUpdater() - await service.checkForUpdates() - - expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({ - provider: 'generic', - url: 'https://github.com/moeru-ai/airi/releases/download/v0.8.9', - }) - }) - - it.each(laneMatrix)('supports AIRI_UPDATE_CHANNEL override for lane=%s', async (lane) => { - appMock.getVersion.mockReturnValue('0.9.0-alpha.2') - process.env.AIRI_UPDATE_CHANNEL = lane - mockGitHubReleasesFetch(matrixReleases) - - const { setupAutoUpdater } = await import('./auto-updater') - const service = setupAutoUpdater() - await service.checkForUpdates() - - expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({ - provider: 'generic', - url: `https://github.com/moeru-ai/airi/releases/download/${laneReleaseTagMap[lane]}`, - }) - }) - - it.each(bundleVersions)('uses bundled version lane when no AIRI_UPDATE_CHANNEL (bundle=%s)', async (bundleVersion) => { - appMock.getVersion.mockReturnValue(bundleVersion) - mockGitHubReleasesFetch(matrixReleases) - - const { setupAutoUpdater } = await import('./auto-updater') - const service = setupAutoUpdater() - await service.checkForUpdates() - - const expectedLane = bundleVersion.includes('-beta') - ? 'beta' - : bundleVersion.includes('-alpha') - ? 'alpha' - : 'stable' - - expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({ - provider: 'generic', - url: `https://github.com/moeru-ai/airi/releases/download/${laneReleaseTagMap[expectedLane]}`, - }) - }) - - it.each(bundleVersions.flatMap(bundleVersion => laneMatrix.map(lane => ({ bundleVersion, lane }))))( - 'matrix lane/feed/bundle works with UPDATE_SERVER_URL override (%o)', - async ({ bundleVersion, lane }) => { - appMock.getVersion.mockReturnValue(bundleVersion) - isDevState.value = true - process.env.AIRI_UPDATE_CHANNEL = lane - process.env.UPDATE_SERVER_URL = `http://127.0.0.1:8787/${lane}` - - const fetchSpy = mockGitHubReleasesFetch(matrixReleases) - const { setupAutoUpdater } = await import('./auto-updater') - const service = setupAutoUpdater() - await service.checkForUpdates() - - expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({ - provider: 'generic', - url: `http://127.0.0.1:8787/${lane}`, - }) - expect(fetchSpy).not.toHaveBeenCalled() - }, - ) - - it('reports only authoritative diagnostics fields', async () => { - const { setupAutoUpdater } = await import('./auto-updater') - const service = setupAutoUpdater() - - expect(service.state.diagnostics).toEqual(expect.objectContaining({ - platform: process.platform, - arch: process.arch, - channel: expectedChannelByArch, - executablePath: expect.any(String), - logFilePath: expect.stringMatching(/stage-tamagotchi-updater[\\/]updater-log\.txt$/), - isOverrideActive: false, - })) - expect(service.state.diagnostics).not.toHaveProperty('updaterCacheDir') - expect(service.state.diagnostics).not.toHaveProperty('pendingDir') - expect(service.state.diagnostics).not.toHaveProperty('uninstallPath') - expect(service.state.diagnostics).not.toHaveProperty('uninstallExists') - }) - - it('does not treat build metadata as prerelease', async () => { - appMock.getVersion.mockReturnValue('1.2.3+build-1') - - const { setupAutoUpdater } = await import('./auto-updater') - setupAutoUpdater() - - expect(updaterState.instance.allowPrerelease).toBe(false) - }) - - it('uses silent relaunch install on Windows only', async () => { - stdEnvState.isWindows = true - const { setupAutoUpdater } = await import('./auto-updater') - const service = setupAutoUpdater() - - await service.quitAndInstall() - expect(updaterState.instance.quitAndInstall).toHaveBeenCalledWith(true, true) - - stdEnvState.isWindows = false - updaterState.instance.quitAndInstall.mockClear() - await service.quitAndInstall() - expect(updaterState.instance.quitAndInstall).toHaveBeenCalledWith() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts deleted file mode 100644 index 513a6f298..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts +++ /dev/null @@ -1,607 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { AutoUpdaterState } from '@proj-airi/electron-eventa/electron-updater' -import type { BrowserWindow } from 'electron' -import type { UpdateInfo } from 'electron-updater' - -import type { ElectronUpdaterChannel } from '../../../shared/eventa' - -import process from 'node:process' - -import { appendFile, mkdir, rm } from 'node:fs/promises' -import { dirname, join, normalize } from 'node:path' - -import electronUpdater from 'electron-updater' -import semver from 'semver' - -import { is } from '@electron-toolkit/utils' -import { useLogg } from '@guiiai/logg' -import { defineInvokeHandler } from '@moeru/eventa' -import { tryCatch } from '@moeru/std' -import { errorMessageFromValue } from '@proj-airi/stage-shared' -import { committerDate } from '~build/git' -import { app } from 'electron' -import { Semaphore } from 'es-toolkit' -import { isWindows } from 'std-env' - -import { - autoUpdater as autoUpdaterEventa, - electronAutoUpdaterStateChanged, - electronGetUpdaterPreferences, - electronSetUpdaterPreferences, - -} from '../../../shared/eventa' -import { MockAutoUpdater } from './mock-auto-updater' - -function getReleaseChannelName() { - return process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64' -} - -const GITHUB_RELEASES_API_URL = 'https://api.github.com/repos/moeru-ai/airi/releases?per_page=100' -const GITHUB_RELEASES_ATOM_URL = 'https://github.com/moeru-ai/airi/releases.atom' -const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/moeru-ai/airi/releases/download' -const UPDATE_CHANNEL_ENV_KEY = 'AIRI_UPDATE_CHANNEL' - -function getCacheRoot() { - // NOTICE: Electron resolves the cache directory per platform/app, but the - // shipped type definitions here do not expose `cache`, so we cast the key. - return app.getPath('cache' as Parameters[0]) -} - -function getLegacyCacheRoot() { - switch (process.platform) { - case 'win32': - return process.env.LOCALAPPDATA || join(process.env.USERPROFILE || '', 'AppData', 'Local') - case 'darwin': - return join(process.env.HOME || '', 'Library', 'Caches') - default: - return process.env.XDG_CACHE_HOME || join(process.env.HOME || '', '.cache') - } -} - -const UPDATER_DEBUG_CACHE_DIR = join(getCacheRoot(), 'stage-tamagotchi-updater') -const UPDATER_LOG_FILE = join(UPDATER_DEBUG_CACHE_DIR, 'updater-log.txt') -const OFFICIAL_UPDATER_CACHE_DIR = join(getCacheRoot(), 'ai.moeru.airi-updater') -const LEGACY_OFFICIAL_UPDATER_CACHE_DIR = join(getLegacyCacheRoot(), 'ai.moeru.airi-updater') -const OFFICIAL_UPDATER_CACHE_DIRS = Array.from(new Set([ - OFFICIAL_UPDATER_CACHE_DIR, - LEGACY_OFFICIAL_UPDATER_CACHE_DIR, -])) - -async function logToFile(level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG', message: string) { - await mkdir(UPDATER_DEBUG_CACHE_DIR, { recursive: true }).catch(() => {}) - await appendFile(UPDATER_LOG_FILE, `${new Date().toISOString()} [${level}] ${message}\n`).catch(() => {}) -} - -async function cleanupStaleUpdateFiles() { - // Remove both current and legacy updater cache roots so stale installers do not linger. - await Promise.allSettled(OFFICIAL_UPDATER_CACHE_DIRS.map(cacheDir => rm(cacheDir, { recursive: true, force: true }))) - await logToFile('INFO', `Updater cache cleanup attempted: ${OFFICIAL_UPDATER_CACHE_DIRS.join(', ')}`) -} - -export type UpdateLane = ElectronUpdaterChannel -interface GitHubReleaseRecord { - tag_name?: string - draft?: boolean - prerelease?: boolean -} - -function getUpdateServerOverride() { - // NOTICE: UPDATE_SERVER_URL is intentionally development-only for local update-test harness. - // Production update routing must not depend on this variable. - if (!is.dev) - return undefined - - const value = process.env.UPDATE_SERVER_URL?.trim() - return value || undefined -} - -function normalizeLane(value: string | undefined): UpdateLane | undefined { - if (!value) - return undefined - - switch (value.toLowerCase()) { - case 'stable': - case 'latest': - case 'alpha': - case 'beta': - case 'nightly': - case 'canary': - return value.toLowerCase() as UpdateLane - default: - return undefined - } -} - -function laneFromVersion(version: string): UpdateLane { - const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase() - return normalizeLane(prerelease) ?? 'stable' -} - -function getPreferredUpdateLane(params: { version: string, storedLane?: UpdateLane }): UpdateLane { - return normalizeLane(process.env[UPDATE_CHANNEL_ENV_KEY]?.trim()) ?? params.storedLane ?? laneFromVersion(params.version) -} - -function getSemverFromTag(tag: string) { - return semver.valid(tag) ?? semver.valid(tag.startsWith('v') ? tag.slice(1) : tag) -} - -function isTagInLane(tag: string, lane: UpdateLane) { - const version = getSemverFromTag(tag) - if (!version) - return false - - if (lane === 'latest') - return true - - const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase() - if (lane === 'stable') - return !prerelease - - return prerelease === lane -} - -function isPathInside(parentPath: string, targetPath: string) { - const normalizedParent = normalize(parentPath) - const normalizedTarget = normalize(targetPath) - const parentWithSeparator = normalizedParent.endsWith('\\') ? normalizedParent : `${normalizedParent}\\` - return normalizedTarget === normalizedParent || normalizedTarget.startsWith(parentWithSeparator) -} - -function getWindowsProtectedInstallRoots() { - return [ - process.env.ProgramFiles, - process.env['ProgramFiles(x86)'], - process.env.ProgramW6432, - process.env.SystemRoot, - process.env.windir, - ] - .filter((value): value is string => Boolean(value)) - .map(value => normalize(value)) -} - -function requiresAdminForInstallPath(executablePath: string) { - if (!isWindows) - return false - - const installDirectory = dirname(executablePath) - return getWindowsProtectedInstallRoots().some(root => isPathInside(root, installDirectory)) -} - -function selectLatestTagForLane(releases: GitHubReleaseRecord[], lane: UpdateLane) { - const candidates = releases - .filter(release => !release.draft && typeof release.tag_name === 'string' && isTagInLane(release.tag_name, lane)) - .map((release) => { - const tag = release.tag_name as string - const version = getSemverFromTag(tag) - return version ? { tag, version } : null - }) - .filter(Boolean) as Array<{ tag: string, version: string }> - - candidates.sort((a, b) => semver.rcompare(a.version, b.version)) - return candidates[0]?.tag -} - -/** - * Extract release tags from GitHub releases Atom feed without adding XML-parser dependencies. - * - * The current feed contains entries like: - * `` - * and - * `tag:github.com,2008:Repository/963495975/v0.9.0-alpha.36` - * - * We intentionally scan for `/moeru-ai/airi/releases/tag/` so we only consume actual release tag links. - */ -function extractReleaseTagsFromAtom(atom: string) { - const tags: string[] = [] - const marker = '/moeru-ai/airi/releases/tag/' - let offset = 0 - - while (offset < atom.length) { - const markerIndex = atom.indexOf(marker, offset) - if (markerIndex === -1) - break - - const start = markerIndex + marker.length - let end = start - while (end < atom.length) { - const char = atom[end] - if (char === '"' || char === '<' || char === '?' || char === '&') - break - end += 1 - } - - // Slice the raw path segment after the marker, e.g. `v0.9.0-beta.6`. - const rawTag = atom.slice(start, end).trim() - // Atom encodes URLs, so decode in case future tags contain escaped characters. - const decodedTag = decodeURIComponent(rawTag) - // Feed entries can repeat across updates; keep a unique ordered tag list. - if (decodedTag && !tags.includes(decodedTag)) - tags.push(decodedTag) - - offset = end + 1 - } - - return tags -} - -export interface AppUpdaterLike { - on: (event: string, listener: (...args: any[]) => void) => any - checkForUpdates: () => Promise - downloadUpdate: () => Promise - quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => Promise | void - setFeedURL?: (options: { provider: 'generic', url: string }) => void - logger?: any - allowPrerelease?: boolean - autoDownload?: boolean - channel?: string - forceDevUpdateConfig?: boolean -} - -// NOTICE: this part of code is copied from https://www.electron.build/auto-update -// Or https://github.com/electron-userland/electron-builder/blob/b866e99ccd3ea9f85bc1e840f0f6a6a162fca388/pages/auto-update.md?plain=1#L57-L66 -export function fromImported(): AppUpdaterLike { - if (is.dev && !getUpdateServerOverride()) - return new MockAutoUpdater() - - const { autoUpdater } = electronUpdater - return autoUpdater as unknown as AppUpdaterLike -} - -type MainContext = ReturnType['context'] - -export interface AutoUpdater { - state: AutoUpdaterState - checkForUpdates: () => Promise - downloadUpdate: () => Promise - quitAndInstall: () => Promise - getPreferredUpdateLane: () => UpdateLane | undefined - setPreferredUpdateLane: (lane: UpdateLane | undefined) => Promise - subscribe: (callback: (state: AutoUpdaterState) => void) => () => void -} - -export interface AutoUpdaterOptions { - /** - * Whether AIRI owns application updates for this distribution. - * - * @default true - */ - enabled?: boolean - /** Reads the release channel persisted by the application configuration. */ - getStoredUpdateLane?: () => UpdateLane | undefined - /** Persists a release-channel change requested through updater IPC. */ - setStoredUpdateLane?: (lane: UpdateLane | undefined) => void -} - -function isPrereleaseVersion(version: string) { - return (semver.prerelease(version)?.length ?? 0) > 0 -} - -/** - * Preserves the updater IPC contract when the storefront owns application updates. - * - * No method reaches Electron Updater or a release feed, while preference reads and - * subscriptions remain available to existing renderer consumers. - */ -function createDisabledAutoUpdater(options: AutoUpdaterOptions): AutoUpdater { - const state: AutoUpdaterState = { status: 'disabled' } - let storedPreferredLane = options.getStoredUpdateLane?.() - - return { - state, - async checkForUpdates() {}, - async downloadUpdate() {}, - async quitAndInstall() {}, - getPreferredUpdateLane() { - return storedPreferredLane - }, - async setPreferredUpdateLane(lane) { - storedPreferredLane = lane - options.setStoredUpdateLane?.(lane) - }, - subscribe(callback) { - callback(state) - return () => {} - }, - } -} - -export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater { - if (options.enabled === false) - return createDisabledAutoUpdater(options) - - const semaphore = new Semaphore(1) - const appVersion = app.getVersion() - const isPrereleaseBuild = isPrereleaseVersion(appVersion) - const log = useLogg('auto-updater').useGlobalConfig() - const autoUpdater = fromImported() - const feedUrlOverride = getUpdateServerOverride() - let storedPreferredLane = options.getStoredUpdateLane?.() - const releaseChannelName = getReleaseChannelName() - let activeFeedUrlOverride = feedUrlOverride - let resolvedReleaseTag: string | undefined - let prepareFeedPromise: Promise | undefined - - autoUpdater.allowPrerelease = isPrereleaseBuild - autoUpdater.autoDownload = false - void cleanupStaleUpdateFiles() - if (activeFeedUrlOverride) - autoUpdater.channel = releaseChannelName - autoUpdater.forceDevUpdateConfig = !!feedUrlOverride && !app.isPackaged - autoUpdater.logger = { - info: (message: string) => { - log.log(message) - void logToFile('INFO', message) - }, - warn: (message: string) => { - log.warn(message) - void logToFile('WARN', message) - }, - error: (message: string) => { - log.error(message) - void logToFile('ERROR', message) - }, - debug: (message: string) => { - log.debug(message) - void logToFile('DEBUG', message) - }, - } - - if (activeFeedUrlOverride) - autoUpdater.setFeedURL?.({ provider: 'generic', url: activeFeedUrlOverride }) - - const withDiagnostics = (next: AutoUpdaterState): AutoUpdaterState => ({ - ...next, - diagnostics: { - platform: process.platform, - arch: process.arch, - channel: autoUpdater.channel || releaseChannelName, - logFilePath: UPDATER_LOG_FILE, - executablePath: process.execPath, - installDirectory: dirname(process.execPath), - requiresAdminForInstallPath: requiresAdminForInstallPath(process.execPath), - isOverrideActive: !!activeFeedUrlOverride, - ...(activeFeedUrlOverride ? { feedUrl: activeFeedUrlOverride } : {}), - }, - }) - - let state: AutoUpdaterState = withDiagnostics({ status: 'idle' }) - const hooks = new Set<(state: AutoUpdaterState) => void>() - - function broadcast(next: AutoUpdaterState) { - state = withDiagnostics(next) - - for (const listener of hooks) { - try { - listener(state) - } - catch (error) { - log.withError(error).error('Failed to notify listener') - } - } - } - - function broadcastUpdaterError(error: unknown, reason: string) { - broadcast({ - status: 'error', - error: { message: errorMessageFromValue(error) }, - }) - log.withError(error).error(reason) - } - - function applyGenericFeedOverride(url: string, reason: string) { - activeFeedUrlOverride = url - autoUpdater.channel = releaseChannelName - autoUpdater.setFeedURL?.({ provider: 'generic', url }) - log.warn(`[auto-updater] applied generic feed override (${reason}): ${url}`) - } - - function resetPreparedFeedForLaneChange() { - if (feedUrlOverride) - return - - activeFeedUrlOverride = undefined - resolvedReleaseTag = undefined - prepareFeedPromise = undefined - autoUpdater.channel = undefined - } - - async function resolveGitHubReleaseTagForLane(lane: UpdateLane) { - try { - const response = await fetch(GITHUB_RELEASES_API_URL, { - headers: { - accept: 'application/vnd.github+json', - }, - }) - - if (!response.ok) - throw new Error(`Failed to fetch GitHub releases (${response.status} ${response.statusText})`) - - const payload = await response.json() - if (!Array.isArray(payload)) - throw new Error('Unexpected GitHub releases payload shape') - - const tag = selectLatestTagForLane(payload as GitHubReleaseRecord[], lane) - if (tag) - return tag - } - catch (error) { - log.withError(error).warn('GitHub releases API lookup failed, trying releases.atom fallback') - } - - const atomResponse = await fetch(GITHUB_RELEASES_ATOM_URL) - if (!atomResponse.ok) - throw new Error(`Failed to fetch GitHub releases atom (${atomResponse.status} ${atomResponse.statusText})`) - - const atom = await atomResponse.text() - const releasesFromAtom = extractReleaseTagsFromAtom(atom).map(tag => ({ tag_name: tag })) - const tag = selectLatestTagForLane(releasesFromAtom, lane) - if (!tag) - throw new Error(`No GitHub release found for update lane "${lane}"`) - - return tag - } - - async function prepareGitHubGenericFeed() { - if (activeFeedUrlOverride) - return - if (resolvedReleaseTag) - return - if (prepareFeedPromise) { - await prepareFeedPromise - return - } - - prepareFeedPromise = (async () => { - const preferredLane = getPreferredUpdateLane({ version: appVersion, storedLane: storedPreferredLane }) - const tag = await resolveGitHubReleaseTagForLane(preferredLane) - resolvedReleaseTag = tag - applyGenericFeedOverride(`${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${tag}`, `github-release-lane:${preferredLane}`) - })() - - try { - await prepareFeedPromise - } - finally { - prepareFeedPromise = undefined - } - } - - async function checkForUpdatesWithPreparedFeed() { - await prepareGitHubGenericFeed() - await autoUpdater.checkForUpdates() - } - - autoUpdater.on('error', error => broadcastUpdaterError(error, 'autoUpdater error')) - autoUpdater.on('checking-for-update', () => broadcast({ status: 'checking' })) - autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ status: 'available', info })) - autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ status: 'downloaded', info })) - autoUpdater.on('update-not-available', () => broadcast({ - status: 'not-available', - info: { - version: app.getVersion(), - files: [], - releaseDate: committerDate, - }, - })) - autoUpdater.on('download-progress', progress => broadcast({ - ...state, - status: 'downloading', - progress: { - percent: progress.percent, - bytesPerSecond: progress.bytesPerSecond, - transferred: progress.transferred, - total: progress.total, - }, - })) - - void checkForUpdatesWithPreparedFeed() - .catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed')) - - return { - get state() { - return state - }, - async checkForUpdates() { - broadcast({ status: 'checking' }) - await checkForUpdatesWithPreparedFeed().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed')) - }, - async downloadUpdate() { - if (state.status === 'downloading' || state.status === 'downloaded') - return - - await semaphore.acquire() - - try { - await autoUpdater.downloadUpdate() - } - finally { - semaphore.release() - } - }, - async quitAndInstall() { - await semaphore.acquire() - - try { - if (isWindows) - autoUpdater.quitAndInstall(true, true) - else - autoUpdater.quitAndInstall() - } - finally { - semaphore.release() - } - }, - getPreferredUpdateLane() { - return storedPreferredLane - }, - async setPreferredUpdateLane(lane) { - if (storedPreferredLane === lane) - return - - storedPreferredLane = lane - options.setStoredUpdateLane?.(lane) - resetPreparedFeedForLaneChange() - // Keep UI state consistent with the newly selected lane. - // A fresh check runs right after channel update from renderer. - broadcast({ status: 'idle' }) - }, - subscribe(callback) { - hooks.add(callback) - - try { - callback(state) - } - catch {} - - return () => { - hooks.delete(callback) - } - }, - } -} - -export function createAutoUpdaterService(params: { context: MainContext, window: BrowserWindow, service: AutoUpdater }) { - const { context, window, service } = params - - const log = useLogg('auto-updater-service').useGlobalConfig() - - const unsubscribe = service.subscribe((state) => { - if (window.isDestroyed()) - return - - tryCatch(() => context.emit(electronAutoUpdaterStateChanged, state)) - }) - - const cleanups: Array<() => void> = [ - unsubscribe, - defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state), - defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => { - await service.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed')) - return service.state - }), - defineInvokeHandler(context, autoUpdaterEventa.downloadUpdate, async () => { - await service.downloadUpdate() - return service.state - }), - defineInvokeHandler(context, electronGetUpdaterPreferences, async () => ({ - channel: service.getPreferredUpdateLane(), - })), - defineInvokeHandler(context, electronSetUpdaterPreferences, async (payload) => { - await service.setPreferredUpdateLane(payload?.channel) - return { - channel: service.getPreferredUpdateLane(), - } - }), - defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, async () => { - await service.quitAndInstall() - }), - ] - - const cleanup = () => { - for (const fn of cleanups) - fn() - } - - window.on('closed', cleanup) - return cleanup -} diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.test.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.test.ts deleted file mode 100644 index 2b55906b8..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import type { ShortcutBinding } from '@proj-airi/stage-shared/global-shortcut' - -import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -/** - * Builds a binding for the uiohook driver. - * - * Defaults to `receiveKeyUps: true` because that flag is the dispatch - * signal in the orchestrator; the driver itself does not inspect it, - * but tests stay closer to how callers will use the driver this way. - * - * @example - * exampleBinding('ptt') - * // => { id: 'ptt', accelerator: { modifiers: ['shift'], key: 'KeyK' }, - * // scope: 'global', receiveKeyUps: true } - */ -function exampleBinding(id: string, modifiers: ShortcutBinding['accelerator']['modifiers'] = ['shift'], key = 'KeyK'): ShortcutBinding { - return { - id, - accelerator: { modifiers, key }, - scope: 'global', - receiveKeyUps: true, - } -} - -interface KeyboardEvent { - keycode: number - altKey: boolean - ctrlKey: boolean - metaKey: boolean - shiftKey: boolean -} - -function event(partial: Partial & Pick): KeyboardEvent { - return { - altKey: false, - ctrlKey: false, - metaKey: false, - shiftKey: false, - ...partial, - } -} - -/** - * Wires mocks for `uiohook-napi` (singleton + listeners) and the - * `electron` `systemPreferences` surface, then imports the driver - * factory under test. - * - * @example - * const m = await setupMocks() - * const driver = m.createUiohookDriver({ ... }) - * m.fire('keydown', event({ keycode: 37 })) - */ -async function setupMocks() { - const onMock = vi.fn() - const removeListenerMock = vi.fn() - const startMock = vi.fn() - const stopMock = vi.fn() - const isTrustedAccessibilityClientMock = vi.fn(() => true) - - const listeners = new Map void>>() - - onMock.mockImplementation((event: string, listener: (e: KeyboardEvent) => void) => { - const arr = listeners.get(event) ?? [] - arr.push(listener) - listeners.set(event, arr) - }) - - removeListenerMock.mockImplementation((event: string, listener: (e: KeyboardEvent) => void) => { - const arr = listeners.get(event) - if (!arr) - return - listeners.set(event, arr.filter(l => l !== listener)) - }) - - // Mirrors the literal subset of `UiohookKey` that exercises the - // mapper. KeyK = 37, KeyA = 30 (matches real upstream constants so - // tests assert real keycodes, not arbitrary numbers). - const UiohookKey = { - K: 37, - A: 30, - Q: 16, - } as const - - vi.doMock('uiohook-napi', () => ({ - uIOhook: { - on: onMock, - removeListener: removeListenerMock, - start: startMock, - stop: stopMock, - }, - UiohookKey, - })) - - vi.doMock('electron', () => ({ - systemPreferences: { - isTrustedAccessibilityClient: isTrustedAccessibilityClientMock, - }, - })) - - const { createUiohookDriver } = await import('./global-shortcut-uiohook') - - function fire(name: 'keydown' | 'keyup', e: KeyboardEvent): void { - for (const listener of listeners.get(name) ?? []) - listener(e) - } - - function createDriver(overrides: { platform?: NodeJS.Platform, sessionType?: string } = {}) { - const broadcastTriggered = vi.fn<(id: string, phase: 'down' | 'up') => void>() - const logger = { - warn: vi.fn(), - withError: vi.fn(() => ({ warn: vi.fn() })), - } - const driver = createUiohookDriver({ - broadcastTriggered, - logger: logger as unknown as Parameters[0]['logger'], - platform: overrides.platform ?? 'darwin', - sessionType: overrides.sessionType, - }) - return { driver, broadcastTriggered, logger } - } - - return { - onMock, - removeListenerMock, - startMock, - stopMock, - isTrustedAccessibilityClientMock, - fire, - createDriver, - } -} - -describe('createUiohookDriver', () => { - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - vi.restoreAllMocks() - }) - - it('starts the OS hook lazily and installs keydown/keyup listeners on first registration', async () => { - const m = await setupMocks() - const { driver } = m.createDriver() - - expect(m.startMock).not.toHaveBeenCalled() - expect(m.onMock).not.toHaveBeenCalled() - - const result = driver.tryRegister(exampleBinding('ptt')) - - expect(result).toEqual({ id: 'ptt', ok: true }) - expect(m.startMock).toHaveBeenCalledTimes(1) - expect(m.onMock).toHaveBeenCalledWith('keydown', expect.any(Function)) - expect(m.onMock).toHaveBeenCalledWith('keyup', expect.any(Function)) - }) - - it('stops the OS hook only after the last binding is unregistered', async () => { - const m = await setupMocks() - const { driver } = m.createDriver() - - driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA')) - driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ')) - expect(m.stopMock).not.toHaveBeenCalled() - - driver.unregisterById('a') - expect(m.stopMock).not.toHaveBeenCalled() - - driver.unregisterById('b') - expect(m.stopMock).toHaveBeenCalledTimes(1) - }) - - it('broadcasts a "down" event when a matching keydown arrives', async () => { - const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() - driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) - - m.fire('keydown', event({ keycode: 37, shiftKey: true })) - - expect(broadcastTriggered).toHaveBeenCalledTimes(1) - expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down') - }) - - it('suppresses OS auto-repeat — repeated keydowns between matching down/up collapse to one broadcast', async () => { - // ROOT CAUSE: - // - // libuiohook reports the OS-level keydown stream verbatim, which - // includes auto-repeat events while the key remains physically - // held. Without per-binding `pressed` tracking, a held PTT key - // would emit hundreds of `down` broadcasts per second and the mic - // would start/stop frantically. - const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() - driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) - - m.fire('keydown', event({ keycode: 37, shiftKey: true })) - m.fire('keydown', event({ keycode: 37, shiftKey: true })) - m.fire('keydown', event({ keycode: 37, shiftKey: true })) - - const downCalls = broadcastTriggered.mock.calls.filter(c => c[1] === 'down') - expect(downCalls).toHaveLength(1) - }) - - it('broadcasts "up" on matching keyup and re-arms the binding for the next press', async () => { - const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() - driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) - - m.fire('keydown', event({ keycode: 37, shiftKey: true })) - m.fire('keyup', event({ keycode: 37, shiftKey: false })) - m.fire('keydown', event({ keycode: 37, shiftKey: true })) - - expect(broadcastTriggered).toHaveBeenNthCalledWith(1, 'ptt', 'down') - expect(broadcastTriggered).toHaveBeenNthCalledWith(2, 'ptt', 'up') - expect(broadcastTriggered).toHaveBeenNthCalledWith(3, 'ptt', 'down') - }) - - it('matches keyup by keycode even when modifiers were released before the main key', async () => { - // NOTICE: - // Users routinely release the modifier first (e.g. let Cmd go - // before letting K go). The keyup event for K therefore carries - // `metaKey: false`, which would fail strict modifier matching. - // The driver keys the "up" broadcast off the prior `pressed` - // state rather than the modifier predicate. - const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() - driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK')) - - m.fire('keydown', event({ keycode: 37, metaKey: true })) - m.fire('keyup', event({ keycode: 37, metaKey: false })) - - expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down') - expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'up') - }) - - it('ignores keyup when no matching keydown was tracked', async () => { - const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() - driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) - - m.fire('keyup', event({ keycode: 37, shiftKey: true })) - - expect(broadcastTriggered).not.toHaveBeenCalled() - }) - - it('does not match a keydown that carries an extra modifier', async () => { - // Strict matching mirrors Electron's accelerator semantics: a - // `Shift+K` binding must not fire on `Cmd+Shift+K`. - const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() - driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) - - m.fire('keydown', event({ keycode: 37, shiftKey: true, metaKey: true })) - - expect(broadcastTriggered).not.toHaveBeenCalled() - }) - - it('maps cmd-or-ctrl to metaKey on darwin', async () => { - const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver({ platform: 'darwin' }) - driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK')) - - m.fire('keydown', event({ keycode: 37, metaKey: true })) - m.fire('keydown', event({ keycode: 37, ctrlKey: true })) - - expect(broadcastTriggered).toHaveBeenCalledTimes(1) - expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down') - }) - - it('maps cmd-or-ctrl to ctrlKey on non-darwin platforms', async () => { - const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver({ platform: 'win32' }) - driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK')) - - m.fire('keydown', event({ keycode: 37, ctrlKey: true })) - m.fire('keydown', event({ keycode: 37, metaKey: true })) - - // First (ctrl) matches; second (meta) does not — the pressed - // state stays cleared and produces no extra broadcast. - expect(broadcastTriggered).toHaveBeenCalledTimes(1) - expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down') - }) - - it('rejects duplicate ids with reason "duplicate-id" and does not double-start the hook', async () => { - const m = await setupMocks() - const { driver } = m.createDriver() - - expect(driver.tryRegister(exampleBinding('ptt'))).toEqual({ id: 'ptt', ok: true }) - const second = driver.tryRegister(exampleBinding('ptt')) - expect(second).toEqual({ id: 'ptt', ok: false, reason: ShortcutFailureReasons.DuplicateId }) - expect(m.startMock).toHaveBeenCalledTimes(1) - }) - - it('returns Unsupported under a native Wayland session', async () => { - const m = await setupMocks() - const { driver } = m.createDriver({ platform: 'linux', sessionType: 'wayland' }) - - const result = driver.tryRegister(exampleBinding('ptt')) - expect(result).toEqual({ id: 'ptt', ok: false, reason: ShortcutFailureReasons.Unsupported }) - expect(m.startMock).not.toHaveBeenCalled() - }) - - it('permits registration on Linux under X11 / XWayland', async () => { - const m = await setupMocks() - const { driver } = m.createDriver({ platform: 'linux', sessionType: 'x11' }) - - expect(driver.tryRegister(exampleBinding('ptt'))).toEqual({ id: 'ptt', ok: true }) - expect(m.startMock).toHaveBeenCalledTimes(1) - }) - - it('returns Denied when macOS Accessibility permission is not granted', async () => { - const m = await setupMocks() - m.isTrustedAccessibilityClientMock.mockReturnValue(false) - const { driver } = m.createDriver({ platform: 'darwin' }) - - const result = driver.tryRegister(exampleBinding('ptt')) - expect(result).toEqual({ id: 'ptt', ok: false, reason: ShortcutFailureReasons.Denied }) - expect(m.isTrustedAccessibilityClientMock).toHaveBeenCalledWith(true) - expect(m.startMock).not.toHaveBeenCalled() - }) - - it('skips the Accessibility check entirely on non-darwin', async () => { - const m = await setupMocks() - const { driver } = m.createDriver({ platform: 'win32' }) - driver.tryRegister(exampleBinding('ptt')) - expect(m.isTrustedAccessibilityClientMock).not.toHaveBeenCalled() - }) - - it('unregisterAll clears every binding and stops the hook in one shot', async () => { - const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() - - driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA')) - driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ')) - driver.unregisterAll() - - m.fire('keydown', event({ keycode: 30, shiftKey: true })) - m.fire('keydown', event({ keycode: 16, shiftKey: true })) - - expect(broadcastTriggered).not.toHaveBeenCalled() - expect(m.stopMock).toHaveBeenCalledTimes(1) - }) - - it('dispose removes the keydown/keyup listeners', async () => { - const m = await setupMocks() - const { driver } = m.createDriver() - driver.tryRegister(exampleBinding('ptt')) - - driver.dispose() - - expect(m.removeListenerMock).toHaveBeenCalledWith('keydown', expect.any(Function)) - expect(m.removeListenerMock).toHaveBeenCalledWith('keyup', expect.any(Function)) - }) - - it('keeps per-binding pressed state independent across multiple bindings', async () => { - const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() - driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA')) - driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ')) - - m.fire('keydown', event({ keycode: 30, shiftKey: true })) - m.fire('keydown', event({ keycode: 16, shiftKey: true })) - m.fire('keyup', event({ keycode: 30 })) - - expect(broadcastTriggered).toHaveBeenNthCalledWith(1, 'a', 'down') - expect(broadcastTriggered).toHaveBeenNthCalledWith(2, 'b', 'down') - expect(broadcastTriggered).toHaveBeenNthCalledWith(3, 'a', 'up') - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.ts deleted file mode 100644 index eb0486e96..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.ts +++ /dev/null @@ -1,317 +0,0 @@ -import type { useLogg } from '@guiiai/logg' -import type { - ShortcutAccelerator, - ShortcutBinding, - ShortcutKey, - ShortcutModifier, - ShortcutRegistrationResult, -} from '@proj-airi/stage-shared/global-shortcut' -import type { UiohookKeyboardEvent } from 'uiohook-napi' - -import process from 'node:process' - -import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut' -import { systemPreferences } from 'electron' -import { uIOhook, UiohookKey } from 'uiohook-napi' - -type Logger = ReturnType['useGlobalConfig']> - -interface ModifierMask { - ctrl: boolean - shift: boolean - alt: boolean - meta: boolean -} - -interface UiohookEntry { - binding: ShortcutBinding - predicate: (event: UiohookKeyboardEvent) => boolean - expectedKeycode: number - pressed: boolean -} - -const W3C_TO_UIOHOOK: Readonly> = buildKeycodeMap() - -function buildKeycodeMap(): Record { - const map: Record = {} - - for (let i = 0; i < 26; i++) { - const letter = String.fromCharCode(65 + i) - map[`Key${letter}`] = (UiohookKey as unknown as Record)[letter] - } - - for (let i = 0; i <= 9; i++) { - map[`Digit${i}`] = (UiohookKey as unknown as Record)[String(i)] - } - - for (let i = 1; i <= 24; i++) { - map[`F${i}`] = (UiohookKey as unknown as Record)[`F${i}`] - } - - const named: Record = { - Space: 'Space', - Tab: 'Tab', - Enter: 'Enter', - Escape: 'Escape', - Backspace: 'Backspace', - Delete: 'Delete', - Insert: 'Insert', - ArrowUp: 'ArrowUp', - ArrowDown: 'ArrowDown', - ArrowLeft: 'ArrowLeft', - ArrowRight: 'ArrowRight', - Home: 'Home', - End: 'End', - PageUp: 'PageUp', - PageDown: 'PageDown', - Backquote: 'Backquote', - Minus: 'Minus', - Equal: 'Equal', - BracketLeft: 'BracketLeft', - BracketRight: 'BracketRight', - Backslash: 'Backslash', - Semicolon: 'Semicolon', - Quote: 'Quote', - Comma: 'Comma', - Period: 'Period', - Slash: 'Slash', - } - for (const [w3c, uioName] of Object.entries(named)) - map[w3c] = (UiohookKey as unknown as Record)[uioName as string] - - return map -} - -function resolveModifierMask(modifiers: readonly ShortcutModifier[], platform: NodeJS.Platform): ModifierMask { - const mask: ModifierMask = { ctrl: false, shift: false, alt: false, meta: false } - for (const m of modifiers) { - switch (m) { - case 'cmd-or-ctrl': - if (platform === 'darwin') - mask.meta = true - else - mask.ctrl = true - break - case 'cmd': - case 'super': - // libuiohook surfaces macOS Cmd, Windows key, and X11 Super - // through the same `metaKey` flag. - mask.meta = true - break - case 'ctrl': - mask.ctrl = true - break - case 'alt': - mask.alt = true - break - case 'shift': - mask.shift = true - break - } - } - return mask -} - -function buildPredicate(acc: ShortcutAccelerator, platform: NodeJS.Platform): { predicate: UiohookEntry['predicate'], expectedKeycode: number } | undefined { - const expectedKeycode = W3C_TO_UIOHOOK[acc.key] - if (expectedKeycode === undefined) - return undefined - const required = resolveModifierMask(acc.modifiers, platform) - const predicate: UiohookEntry['predicate'] = e => - e.keycode === expectedKeycode - && e.ctrlKey === required.ctrl - && e.shiftKey === required.shift - && e.altKey === required.alt - && e.metaKey === required.meta - return { predicate, expectedKeycode } -} - -function isNativeWayland(platform: NodeJS.Platform, sessionType: string | undefined): boolean { - return platform === 'linux' && sessionType === 'wayland' -} - -function isMacAccessibilityTrusted(platform: NodeJS.Platform, prompt: boolean): boolean { - if (platform !== 'darwin') - return true - try { - return systemPreferences.isTrustedAccessibilityClient(prompt) - } - catch { - return true - } -} - -export interface UiohookDriverOptions { - broadcastTriggered: (id: string, phase: 'down' | 'up') => void - logger: Logger - /** - * Host platform; injected so tests can exercise cross-platform - * modifier mapping without stubbing `process`. - * - * @default process.platform - */ - platform?: NodeJS.Platform - /** - * `XDG_SESSION_TYPE` value used for the Wayland refusal check; - * injected for the same reason as `platform`. - * - * @default process.env.XDG_SESSION_TYPE - */ - sessionType?: string -} - -export interface UiohookDriver { - tryRegister: (binding: ShortcutBinding) => ShortcutRegistrationResult - unregisterById: (id: string) => void - unregisterAll: () => void - dispose: () => void -} - -/** - * Driver that captures global key-down and key-up events via - * libuiohook (through `uiohook-napi`). - * - * Use when: - * - A binding asks for `receiveKeyUps: true` (push-to-talk and similar - * hold-driven flows) - * - * Lifecycle: - * - The OS hook starts lazily on the first successful registration and - * stops once the last binding is unregistered, so apps that never - * bind a PTT shortcut never pay the permission/perf cost. - * - * Constraints: - * - macOS: requires the Accessibility permission. First registration - * triggers the system prompt; subsequent failures return `Denied`. - * - Linux: requires X11 or XWayland. Native Wayland sessions return - * `Unsupported` because XRecord cannot observe Wayland clients. - */ -export function createUiohookDriver(options: UiohookDriverOptions): UiohookDriver { - const { - broadcastTriggered, - logger, - platform = process.platform, - sessionType = process.env.XDG_SESSION_TYPE, - } = options - const entries = new Map() - let started = false - let listenersInstalled = false - - function ensureListeners(): void { - if (listenersInstalled) - return - listenersInstalled = true - uIOhook.on('keydown', onKeydown) - uIOhook.on('keyup', onKeyup) - } - - function startIfNeeded(): void { - if (started || entries.size === 0) - return - try { - uIOhook.start() - started = true - } - catch (error) { - logger.withError(error).warn('Failed to start uIOhook') - } - } - - function stopIfIdle(): void { - if (!started || entries.size > 0) - return - try { - uIOhook.stop() - } - catch (error) { - logger.withError(error).warn('Failed to stop uIOhook') - } - started = false - } - - function onKeydown(event: UiohookKeyboardEvent): void { - for (const entry of entries.values()) { - if (!entry.predicate(event)) - continue - // Auto-repeat suppression: OS may deliver repeated keydown - // while the key stays physically held. Emit one `down` per - // physical press until the matching keyup clears the flag. - if (entry.pressed) - continue - entry.pressed = true - broadcastTriggered(entry.binding.id, 'down') - } - } - - function onKeyup(event: UiohookKeyboardEvent): void { - // NOTICE: - // Match keyup by keycode alone; modifier flags may be released - // before the main key (e.g. Cmd released before K), in which case - // the strict predicate would not match. Pairing keyup to the - // binding via the prior `pressed` state ensures every `down` - // emits a matching `up`. - for (const entry of entries.values()) { - if (!entry.pressed) - continue - if (event.keycode !== entry.expectedKeycode) - continue - entry.pressed = false - broadcastTriggered(entry.binding.id, 'up') - } - } - - function tryRegister(binding: ShortcutBinding): ShortcutRegistrationResult { - if (entries.has(binding.id)) - return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId } - - if (isNativeWayland(platform, sessionType)) { - // libuiohook hooks install but never receive events under - // native Wayland. Refuse rather than register a binding that - // would silently no-op. - return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Unsupported } - } - - if (!isMacAccessibilityTrusted(platform, true)) - return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Denied } - - const built = buildPredicate(binding.accelerator, platform) - if (built === undefined) { - logger.warn(`uiohook driver: no keycode mapping for "${binding.accelerator.key}"`) - return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Unsupported } - } - - entries.set(binding.id, { - binding, - predicate: built.predicate, - expectedKeycode: built.expectedKeycode, - pressed: false, - }) - ensureListeners() - startIfNeeded() - return { id: binding.id, ok: true } - } - - function unregisterById(id: string): void { - if (!entries.delete(id)) - return - stopIfIdle() - } - - function unregisterAll(): void { - if (entries.size === 0) - return - entries.clear() - stopIfIdle() - } - - function dispose(): void { - unregisterAll() - if (listenersInstalled) { - uIOhook.removeListener('keydown', onKeydown) - uIOhook.removeListener('keyup', onKeyup) - listenersInstalled = false - } - } - - return { tryRegister, unregisterById, unregisterAll, dispose } -} diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts deleted file mode 100644 index ba2f1d773..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts +++ /dev/null @@ -1,439 +0,0 @@ -import type { ShortcutBinding } from '@proj-airi/stage-shared/global-shortcut' -import type { BrowserWindow } from 'electron' - -import type { EventaContext } from './global-shortcut' - -import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -function exampleBinding(id: string, key = 'KeyK'): ShortcutBinding { - return { - id, - accelerator: { modifiers: ['cmd-or-ctrl', 'shift'], key }, - scope: 'global', - } -} - -interface MockContext { - emit: ReturnType - invokeHandlers: Map unknown> -} - -interface MockWindow { - on: ReturnType - /** Manually trigger the registered `closed` handler. */ - close: () => void -} - -function createMockContext(): MockContext { - return { - emit: vi.fn(), - invokeHandlers: new Map(), - } -} - -// NOTICE: -// MockWindow only models what the driver touches: subscribing to a -// `'closed'` event. The mock exposes a manual `close()` so tests can -// assert the auto-cleanup path. -function createMockWindow(): MockWindow { - let closedHandler: (() => void) | undefined - return { - on: vi.fn((event: string, handler: () => void) => { - if (event === 'closed') - closedHandler = handler - }), - close() { - closedHandler?.() - }, - } -} - -// NOTICE: -// MockContext / MockWindow are intentionally minimal — only what the -// driver touches. Casting through `unknown` lets us pass them to -// `service.registerWindow` whose typed signature wants the full -// `EventaContext` and `BrowserWindow` types. -function asEventaContext(ctx: MockContext): EventaContext { - return ctx as unknown as EventaContext -} - -function asBrowserWindow(window: MockWindow): BrowserWindow { - return window as unknown as BrowserWindow -} - -function registerMockWindow(service: { registerWindow: (params: { context: EventaContext, window: BrowserWindow }) => void }, ctx: MockContext): MockWindow { - const window = createMockWindow() - service.registerWindow({ - context: asEventaContext(ctx), - window: asBrowserWindow(window), - }) - return window -} - -/** - * Mocks the heavy collaborators (`electron`, eventa, bootkit, logger) - * so the driver can be exercised through its public interface in a - * single test file. - */ -async function setupMocks() { - const registerMock = vi.fn<(accelerator: string, callback: () => void) => boolean>(() => true) - const unregisterMock = vi.fn<(accelerator: string) => void>() - const unregisterAllMock = vi.fn<() => void>() - const triggerCallbacks = new Map void>() - - registerMock.mockImplementation((accelerator, callback) => { - triggerCallbacks.set(accelerator, callback) - return true - }) - unregisterMock.mockImplementation((accelerator) => { - triggerCallbacks.delete(accelerator) - }) - unregisterAllMock.mockImplementation(() => { - triggerCallbacks.clear() - }) - - const onAppBeforeQuitMock = vi.fn<(fn: () => void | Promise) => void>() - - vi.doMock('electron', () => ({ - globalShortcut: { - register: registerMock, - unregister: unregisterMock, - unregisterAll: unregisterAllMock, - }, - systemPreferences: { - isTrustedAccessibilityClient: vi.fn(() => true), - }, - })) - - vi.doMock('./global-shortcut-uiohook', () => ({ - createUiohookDriver: () => ({ - tryRegister: vi.fn(async (binding: ShortcutBinding) => ({ id: binding.id, ok: true })), - unregisterById: vi.fn(), - unregisterAll: vi.fn(), - dispose: vi.fn(), - }), - })) - - vi.doMock('@moeru/eventa', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - defineInvokeHandler: (context: MockContext, eventa: { sendEvent: { id: string } }, handler: (payload: unknown) => unknown) => { - // `defineInvokeEventa('foo')` returns `{ sendEvent: { id: 'foo-send' }, ... }`; - // strip the `-send` suffix so test lookups match the contract name. - const id = eventa.sendEvent.id.replace(/-send$/, '') - context.invokeHandlers.set(id, handler) - }, - } - }) - - vi.doMock('../../libs/bootkit/lifecycle', () => ({ - onAppBeforeQuit: onAppBeforeQuitMock, - })) - - vi.doMock('@guiiai/logg', () => ({ - useLogg: () => ({ - useGlobalConfig: () => ({ - warn: vi.fn(), - withError: vi.fn(() => ({ warn: vi.fn() })), - }), - }), - })) - - const { setupGlobalShortcutService } = await import('./global-shortcut') - - return { - setupGlobalShortcutService, - registerMock, - unregisterMock, - unregisterAllMock, - triggerCallbacks, - onAppBeforeQuitMock, - } -} - -describe('setupGlobalShortcutService', () => { - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - vi.restoreAllMocks() - }) - - it('registers a binding via the invoke handler', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const ctx = createMockContext() - registerMockWindow(service, ctx) - - const handler = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register') - expect(handler).toBeDefined() - - const result = handler!(exampleBinding('toggle')) as { id: string, ok: boolean } - expect(result).toEqual({ id: 'toggle', ok: true }) - expect(m.registerMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+K', expect.any(Function)) - }) - - it('routes receiveKeyUps:true to the uiohook driver and bypasses electron.globalShortcut', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const ctx = createMockContext() - registerMockWindow(service, ctx) - - const handler = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - const result = await handler({ ...exampleBinding('ptt'), receiveKeyUps: true }) as { id: string, ok: boolean } - expect(result).toEqual({ id: 'ptt', ok: true }) - expect(m.registerMock).not.toHaveBeenCalled() - }) - - it('reports conflict when globalShortcut.register returns false', async () => { - const m = await setupMocks() - m.registerMock.mockImplementationOnce(() => false) - const service = m.setupGlobalShortcutService() - const ctx = createMockContext() - registerMockWindow(service, ctx) - - const handler = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - const result = handler(exampleBinding('toggle')) as { id: string, ok: boolean, reason?: string } - expect(result).toEqual({ id: 'toggle', ok: false, reason: ShortcutFailureReasons.Conflict }) - }) - - it('rejects duplicate id with reason "duplicate-id" without touching globalShortcut', async () => { - // Strict registration: the second register call under the same id - // must fail explicitly so silent overrides between unrelated - // registration sites cannot happen. Callers rebind by calling - // `unregister` first. - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const ctx = createMockContext() - registerMockWindow(service, ctx) - - const handler = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - const first = handler(exampleBinding('toggle', 'KeyK')) as { ok: boolean } - const second = handler(exampleBinding('toggle', 'KeyZ')) as { id: string, ok: boolean, reason?: string } - - expect(first.ok).toBe(true) - expect(second).toEqual({ id: 'toggle', ok: false, reason: ShortcutFailureReasons.DuplicateId }) - expect(m.registerMock).toHaveBeenCalledTimes(1) - expect(m.unregisterMock).not.toHaveBeenCalled() - }) - - it('rebinds main-owned shortcuts transactionally', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - service.registerMainShortcut({ - binding: exampleBinding('spotlight', 'KeyA'), - onTriggered: vi.fn(), - }) - const secondTriggered = vi.fn() - const success = service.registerMainShortcut({ - binding: exampleBinding('spotlight', 'KeyB'), - onTriggered: secondTriggered, - }) - - expect(success).toEqual({ id: 'spotlight', ok: true }) - expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+A') - m.triggerCallbacks.get('CmdOrCtrl+Shift+B')?.() - expect(secondTriggered).toHaveBeenCalledTimes(1) - - const oldTriggered = vi.fn() - service.registerMainShortcut({ binding: exampleBinding('spotlight', 'KeyA'), onTriggered: oldTriggered }) - m.unregisterMock.mockClear() - m.registerMock.mockImplementationOnce(() => false) - const result = service.registerMainShortcut({ - binding: exampleBinding('spotlight', 'KeyC'), - onTriggered: vi.fn(), - }) - - expect(result).toEqual({ id: 'spotlight', ok: false, reason: ShortcutFailureReasons.Conflict }) - expect(m.unregisterMock).not.toHaveBeenCalled() - m.triggerCallbacks.get('CmdOrCtrl+Shift+A')?.() - expect(oldTriggered).toHaveBeenCalledTimes(1) - }) - - it('replaces the callback when rebinding a main-owned shortcut to the same accelerator', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const oldTriggered = vi.fn() - const nextTriggered = vi.fn() - - service.registerMainShortcut({ - binding: exampleBinding('spotlight', 'KeyA'), - onTriggered: oldTriggered, - }) - const result = service.registerMainShortcut({ - binding: exampleBinding('spotlight', 'KeyA'), - onTriggered: nextTriggered, - }) - - expect(result).toEqual({ id: 'spotlight', ok: true }) - expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+A') - m.triggerCallbacks.get('CmdOrCtrl+Shift+A')?.() - expect(oldTriggered).not.toHaveBeenCalled() - expect(nextTriggered).toHaveBeenCalledTimes(1) - }) - - it('allows re-register after explicit unregister', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const ctx = createMockContext() - registerMockWindow(service, ctx) - - const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - const unreg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:unregister')! - - reg(exampleBinding('toggle', 'KeyK')) - unreg({ id: 'toggle' }) - const result = reg(exampleBinding('toggle', 'KeyZ')) as { ok: boolean } - - expect(result.ok).toBe(true) - expect(m.registerMock).toHaveBeenLastCalledWith('CmdOrCtrl+Shift+Z', expect.any(Function)) - }) - - it('broadcasts a "down" trigger to every registered context', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const ctxA = createMockContext() - const ctxB = createMockContext() - registerMockWindow(service, ctxA) - registerMockWindow(service, ctxB) - - const handler = ctxA.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - handler(exampleBinding('toggle')) - - const callback = m.triggerCallbacks.get('CmdOrCtrl+Shift+K') - expect(callback).toBeDefined() - callback!() - - expect(ctxA.emit).toHaveBeenCalledWith( - expect.objectContaining({ id: 'eventa:event:electron:shortcut:triggered' }), - { id: 'toggle', phase: 'down' }, - ) - expect(ctxB.emit).toHaveBeenCalledWith( - expect.objectContaining({ id: 'eventa:event:electron:shortcut:triggered' }), - { id: 'toggle', phase: 'down' }, - ) - }) - - it('unregister removes the active binding', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const ctx = createMockContext() - registerMockWindow(service, ctx) - - const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - reg(exampleBinding('toggle')) - const unreg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:unregister')! - unreg({ id: 'toggle' }) - - expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+K') - }) - - it('list returns currently active bindings', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const ctx = createMockContext() - registerMockWindow(service, ctx) - - const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - reg(exampleBinding('a', 'KeyA')) - reg(exampleBinding('b', 'KeyB')) - - const list = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:list')! - const result = list(undefined) as ShortcutBinding[] - expect(result.map(b => b.id).sort()).toEqual(['a', 'b']) - }) - - it('unregisterAll only unregisters bindings owned by this service', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const ctx = createMockContext() - registerMockWindow(service, ctx) - - const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - reg(exampleBinding('a', 'KeyA')) - reg(exampleBinding('b', 'KeyB')) - const unregAll = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:unregister-all')! - unregAll(undefined) - - expect(m.unregisterAllMock).not.toHaveBeenCalled() - expect(m.unregisterMock).toHaveBeenCalledTimes(2) - expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+A') - expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+B') - - const list = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:list')! - expect(list(undefined)).toEqual([]) - }) - - it('removes a context from broadcast set when its window closes', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - - const ctxA = createMockContext() - const ctxB = createMockContext() - const winA = registerMockWindow(service, ctxA) - registerMockWindow(service, ctxB) - - const handler = ctxA.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - handler(exampleBinding('toggle')) - - // ctxA's window closes; subsequent triggers should only reach ctxB - winA.close() - const callback = m.triggerCallbacks.get('CmdOrCtrl+Shift+K')! - callback() - - expect(ctxA.emit).not.toHaveBeenCalled() - expect(ctxB.emit).toHaveBeenCalledWith( - expect.objectContaining({ id: 'eventa:event:electron:shortcut:triggered' }), - { id: 'toggle', phase: 'down' }, - ) - }) - - it('hooks dispose into onAppBeforeQuit and clears state on call', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - expect(m.onAppBeforeQuitMock).toHaveBeenCalledTimes(1) - - const ctx = createMockContext() - registerMockWindow(service, ctx) - const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - reg(exampleBinding('a')) - - service.dispose() - expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+K') - - // After dispose, a fresh trigger callback should not reach contexts - const callback = m.triggerCallbacks.get('CmdOrCtrl+Shift+K') - callback?.() - expect(ctx.emit).not.toHaveBeenCalled() - }) - - it('rejects malformed register payloads at the IPC boundary', async () => { - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const ctx = createMockContext() - registerMockWindow(service, ctx) - - const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! - expect(() => reg({})).toThrow(TypeError) - expect(() => reg({ id: 'no-accel' })).toThrow(TypeError) - expect(() => reg({ accelerator: { modifiers: [], key: 'KeyK' } })).toThrow(TypeError) - expect(m.registerMock).not.toHaveBeenCalled() - }) - - it('ignores unregister payloads with missing id and skips unknown ids', async () => { - // The Eventa contract types `payload` as `{ id: string }`, so a - // `null`/`undefined` payload is a programmer error and surfaces as - // a thrown TypeError. A well-shaped payload with an empty or - // unknown id is a no-op. - const m = await setupMocks() - const service = m.setupGlobalShortcutService() - const ctx = createMockContext() - registerMockWindow(service, ctx) - - const unreg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:unregister')! - expect(() => unreg({ id: '' })).not.toThrow() - expect(() => unreg({ id: 'never-registered' })).not.toThrow() - expect(m.unregisterMock).not.toHaveBeenCalled() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts deleted file mode 100644 index 226ed1ebd..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { ShortcutBinding, ShortcutRegistrationResult } from '@proj-airi/stage-shared/global-shortcut' -import type { BrowserWindow } from 'electron' - -import type { UiohookDriver } from './global-shortcut-uiohook' - -import { useLogg } from '@guiiai/logg' -import { defineInvokeHandler } from '@moeru/eventa' -import { formatElectronAccelerator, ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut' -import { globalShortcut } from 'electron' - -import { - electronShortcutList, - electronShortcutRegister, - electronShortcutTriggered, - electronShortcutUnregister, - electronShortcutUnregisterAll, -} from '../../../shared/eventa' -import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle' - -export type EventaContext = ReturnType['context'] - -export interface RegisterWindowParams { - context: EventaContext - window: BrowserWindow -} - -export interface RegisterMainShortcutParams { - binding: ShortcutBinding - onTriggered: () => void -} - -export interface GlobalShortcutService { - registerWindow: (params: RegisterWindowParams) => void - registerMainShortcut: (params: RegisterMainShortcutParams) => ShortcutRegistrationResult - dispose: () => void -} - -type ActiveBinding - = | { binding: ShortcutBinding, owner: 'renderer', driver: 'electron', electronAccelerator: string } - | { binding: ShortcutBinding, owner: 'main', driver: 'electron', electronAccelerator: string, onTriggered: () => void } - | { binding: ShortcutBinding, owner: 'renderer', driver: 'uiohook' } - -export function setupGlobalShortcutService(): GlobalShortcutService { - const log = useLogg('global-shortcut').useGlobalConfig() - - const contexts = new Set() - const active = new Map() - - function broadcastTriggered(id: string, phase: 'down' | 'up') { - for (const context of contexts) { - try { - context.emit(electronShortcutTriggered, { id, phase }) - } - catch (error) { - log.withError(error).warn(`Failed to emit shortcut trigger for "${id}"`) - } - } - } - - let uiohookDriver: UiohookDriver | undefined - - function tryRegisterElectron(binding: ShortcutBinding): ShortcutRegistrationResult { - const electronAccelerator = formatElectronAccelerator(binding.accelerator) - const ok = globalShortcut.register(electronAccelerator, () => broadcastTriggered(binding.id, 'down')) - - if (!ok) { - // `globalShortcut.register` returns false for several distinct - // causes (held by another app, or denied by the OS for media - // keys / Accessibility-gated combos on macOS). Electron does not - // expose which case applied, so this driver reports `Conflict` - // for both. The uiohook driver path can emit `Denied` directly. - return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict } - } - - active.set(binding.id, { binding, owner: 'renderer', driver: 'electron', electronAccelerator }) - return { id: binding.id, ok: true } - } - - async function tryRegisterUiohook(binding: ShortcutBinding): Promise { - if (!uiohookDriver) { - // NOTICE: - // uiohook-napi@1.5.5 ships an x86-64 binary in its Linux ARM64 prebuild. - // Loading the existing driver only when needed keeps AIRI startup working. - // Source: https://app.unpkg.com/uiohook-napi@1.5.5/files/prebuilds/linux-arm64 - // Remove this workaround when that prebuild contains an ARM64 binary. - try { - const { createUiohookDriver } = await import('./global-shortcut-uiohook') - uiohookDriver = createUiohookDriver({ broadcastTriggered, logger: log }) - } - catch (error) { - log.withError(error).warn('uiohook driver is unavailable on this system') - return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Unsupported } - } - } - - const result = uiohookDriver.tryRegister(binding) - if (result.ok) - active.set(binding.id, { binding, owner: 'renderer', driver: 'uiohook' }) - return result - } - - // Main-owned shortcuts stay live without a renderer context. - function registerMainShortcut({ binding, onTriggered }: RegisterMainShortcutParams): ShortcutRegistrationResult { - const existing = active.get(binding.id) - if (existing && existing.owner !== 'main') - return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId } - - const electronAccelerator = formatElectronAccelerator(binding.accelerator) - const nextEntry: ActiveBinding = { binding, owner: 'main', driver: 'electron', electronAccelerator, onTriggered } - if (existing?.electronAccelerator === electronAccelerator) { - releaseEntry(binding.id, existing) - if (globalShortcut.register(electronAccelerator, onTriggered)) { - active.set(binding.id, nextEntry) - return { id: binding.id, ok: true } - } - - if (globalShortcut.register(existing.electronAccelerator, existing.onTriggered)) - active.set(binding.id, existing) - else - log.warn(`Failed to restore main-owned shortcut "${binding.id}" after rebinding failure`) - return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict } - } - - if (!globalShortcut.register(electronAccelerator, onTriggered)) - return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict } - - if (existing) - releaseEntry(binding.id, existing) - active.set(binding.id, nextEntry) - return { id: binding.id, ok: true } - } - - function releaseEntry(id: string, entry: ActiveBinding): void { - if (entry.driver === 'electron') { - try { - globalShortcut.unregister(entry.electronAccelerator) - } - catch (error) { - log.withError(error).warn(`Failed to unregister accelerator for "${id}"`) - } - } - else { - uiohookDriver?.unregisterById(id) - } - active.delete(id) - } - - function tryRegister(binding: ShortcutBinding): ShortcutRegistrationResult | Promise { - if (active.has(binding.id)) { - return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId } - } - - return binding.receiveKeyUps - ? tryRegisterUiohook(binding) - : tryRegisterElectron(binding) - } - - function unregisterById(id: string): void { - const entry = active.get(id) - if (!entry || entry.owner === 'main') - return - - releaseEntry(id, entry) - } - - // Renderer resets must not drop main-owned shortcuts such as Spotlight. - function unregisterAll(includeMainOwned = false): void { - for (const [id, entry] of active) { - if (!includeMainOwned && entry.owner === 'main') - continue - releaseEntry(id, entry) - } - } - - const registerWindow: GlobalShortcutService['registerWindow'] = ({ context, window }) => { - contexts.add(context) - window.on('closed', () => { - contexts.delete(context) - }) - - defineInvokeHandler(context, electronShortcutRegister, (binding) => { - if (!binding.id) { - throw new TypeError('electronShortcutRegister called with invalid binding payload') - } - return tryRegister(binding) - }) - - defineInvokeHandler(context, electronShortcutUnregister, (payload) => { - if (!payload.id) - return - unregisterById(payload.id) - }) - - defineInvokeHandler(context, electronShortcutUnregisterAll, () => { - unregisterAll() - }) - - defineInvokeHandler(context, electronShortcutList, () => { - return Array.from(active.values(), entry => entry.binding) - }) - } - - const dispose: GlobalShortcutService['dispose'] = () => { - unregisterAll(true) - uiohookDriver?.dispose() - contexts.clear() - } - - onAppBeforeQuit(() => dispose()) - - return { registerWindow, registerMainShortcut, dispose } -} diff --git a/apps/stage-tamagotchi/src/main/services/electron/index.ts b/apps/stage-tamagotchi/src/main/services/electron/index.ts deleted file mode 100644 index d62c659be..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './app' -export * from './auto-updater' -export * from './global-shortcut' -export * from './powerMonitor' -export * from './screen' -export * from './system-preferences' -export * from './window' diff --git a/apps/stage-tamagotchi/src/main/services/electron/media-permissions.test.ts b/apps/stage-tamagotchi/src/main/services/electron/media-permissions.test.ts deleted file mode 100644 index 3ce4423df..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/media-permissions.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import type { DevicePermissionHandlerHandlerDetails, HIDDevice, MediaAccessPermissionRequest, PermissionCheckHandlerHandlerDetails, Session, WebContents } from 'electron' - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { setupPermissionHandlers, shouldGrantAudioCapturePermission, shouldGrantElectronPermission } from './media-permissions' - -const localWebContents = { - getURL: () => 'file:///app/index.html', -} satisfies Pick - -/** - * Creates official Electron request details for media permission tests. - */ -function createMediaRequestDetails(overrides: Partial = {}): MediaAccessPermissionRequest { - return { - isMainFrame: true, - requestingUrl: 'file:///app/index.html', - ...overrides, - } -} - -/** - * Creates official Electron check details for media permission tests. - */ -function createPermissionCheckDetails(overrides: Partial = {}): PermissionCheckHandlerHandlerDetails { - return { - isMainFrame: true, - ...overrides, - } -} - -function createHIDPermissionDetails(overrides: Partial = {}): DevicePermissionHandlerHandlerDetails { - const device: HIDDevice = { - collections: [{ - children: [], - featureReports: [], - inputReports: [], - outputReports: [], - type: 1, - usage: 0x05, - usagePage: 0x01, - }], - deviceId: 'dualsense-1', - name: 'DualSense Wireless Controller', - productId: 0x0CE6, - vendorId: 0x054C, - } - - return { - device, - deviceType: 'hid', - origin: 'file://', - ...overrides, - } -} - -/** - * @example - * shouldGrantElectronPermission(localWebContents, 'media', origin, details) - */ -describe('media permissions', () => { - beforeEach(() => { - vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173') - }) - - afterEach(() => { - vi.unstubAllEnvs() - }) - - /** @example Local packaged pages may request audio-only media. */ - it('grants local audio media permission requests', () => { - expect(shouldGrantAudioCapturePermission( - localWebContents, - 'media', - undefined, - createMediaRequestDetails({ mediaTypes: ['audio'] }), - )).toBe(true) - }) - - /** @example Camera-only requests remain denied. */ - it('rejects video-only media permission requests', () => { - expect(shouldGrantAudioCapturePermission( - localWebContents, - 'media', - undefined, - createMediaRequestDetails({ mediaTypes: ['video'] }), - )).toBe(false) - }) - - /** @example Combined microphone and camera requests remain denied. */ - it('rejects media permission requests that include video', () => { - expect(shouldGrantAudioCapturePermission( - localWebContents, - 'media', - undefined, - createMediaRequestDetails({ mediaTypes: ['audio', 'video'] }), - )).toBe(false) - }) - - /** @example A generic media request without a declared audio type is not inferred as safe. */ - it('does not treat missing media details as audio', () => { - expect(shouldGrantAudioCapturePermission( - localWebContents, - 'media', - undefined, - createMediaRequestDetails(), - )).toBe(false) - }) - - /** @example Electron permission checks report audio through mediaType. */ - it('grants local audio permission checks', () => { - expect(shouldGrantAudioCapturePermission( - null, - 'media', - 'file:///app/index.html', - createPermissionCheckDetails({ mediaType: 'audio' }), - )).toBe(true) - }) - - /** @example A remote top-level origin cannot request the microphone. */ - it('rejects audio requests from non-local origins', () => { - expect(shouldGrantAudioCapturePermission( - null, - 'media', - 'https://example.com', - createPermissionCheckDetails({ mediaType: 'audio' }), - )).toBe(false) - }) - - /** @example A remote requesting frame is rejected even inside a local BrowserWindow. */ - it('rejects remote frame requests even when the host window is local', () => { - expect(shouldGrantAudioCapturePermission( - localWebContents, - 'media', - undefined, - createMediaRequestDetails({ mediaTypes: ['audio'], requestingUrl: 'https://example.com/frame.html' }), - )).toBe(false) - }) - - /** @example A local child frame embedded by a remote page is not AIRI-owned. */ - it('rejects local frames embedded by a remote origin', () => { - expect(shouldGrantAudioCapturePermission( - null, - 'media', - 'http://localhost:5173', - createPermissionCheckDetails({ - embeddingOrigin: 'https://example.com', - mediaType: 'audio', - securityOrigin: 'http://localhost:5173', - }), - )).toBe(false) - }) - - /** @example All explicit requester identities are accepted when they remain local. */ - it('grants audio requests with explicit local requester URLs', () => { - expect(shouldGrantAudioCapturePermission( - null, - 'media', - 'http://localhost:5173', - createPermissionCheckDetails({ - mediaType: 'audio', - requestingUrl: 'http://localhost:5173', - securityOrigin: 'http://localhost:5173', - }), - )).toBe(true) - }) - - /** @example Extension assets served from AIRI's loopback server remain untrusted. */ - it('rejects plugin asset frames served from a loopback origin', () => { - // ROOT CAUSE: - // - // Treating every loopback HTTP origin as AIRI-owned also trusts extension UI frames. - // Those frames use the same loopback transport but do not share the renderer origin. - // We fixed this by matching HTTP origins against ELECTRON_RENDERER_URL exactly. - expect(shouldGrantAudioCapturePermission( - null, - 'media', - 'http://127.0.0.1:48123', - createPermissionCheckDetails({ - mediaType: 'audio', - requestingUrl: 'http://127.0.0.1:48123/_airi/extensions/example/sessions/session/ui/index.html', - securityOrigin: 'http://127.0.0.1:48123', - }), - )).toBe(false) - }) - - /** @example A plugin development server cannot inherit AIRI renderer permissions. */ - it('rejects plugin frames served from another localhost port', () => { - expect(shouldGrantAudioCapturePermission( - null, - 'media', - 'http://localhost:4173', - createPermissionCheckDetails({ - mediaType: 'audio', - requestingUrl: 'http://localhost:4173/index.html', - securityOrigin: 'http://localhost:4173', - }), - )).toBe(false) - }) - - /** @example Chromium's opaque origin does not override an explicit packaged file URL. */ - it('ignores opaque file origins when packaged local pages request audio', () => { - expect(shouldGrantAudioCapturePermission( - localWebContents, - 'media', - 'null', - createMediaRequestDetails({ mediaTypes: ['audio'] }), - )).toBe(true) - }) - - /** @example Local AIRI pages retain screen-capture access. */ - it('grants display capture requests from local app pages', () => { - expect(shouldGrantElectronPermission( - localWebContents, - 'display-capture', - undefined, - createMediaRequestDetails(), - )).toBe(true) - }) - - /** @example Remote frames cannot invoke screen capture through the global session handler. */ - it('rejects display capture requests from remote pages', () => { - expect(shouldGrantElectronPermission( - localWebContents, - 'display-capture', - undefined, - createMediaRequestDetails({ requestingUrl: 'https://example.com/capture.html' }), - )).toBe(false) - }) - - // https://github.com/moeru-ai/airi/issues/2177 - it('grants screen capture requests reported as media from local app pages (Issue #2177)', () => { - // ROOT CAUSE: - // - // `navigator.mediaDevices.getDisplayMedia()` reaches `setPermissionRequestHandler` as the `media` - // permission, and Electron only appends `audio` or `video` to `mediaTypes` for device capture, so a - // desktop capture request arrives with an empty `mediaTypes` list. - // - // `shouldGrantElectronPermission` returned early for every `media` operation and demanded audio-only - // details, so screen capture was denied before the allowlisted `display-capture` entry was reached: - // - // if (permission === 'media') - // return shouldGrantAudioCapturePermission(webContents, permission, requestingOrigin, details) - // - // We fixed this by resolving a `media` operation without device media types to `display-capture`, so - // the existing allowlist and local-frame checks decide the outcome. - expect(shouldGrantElectronPermission( - localWebContents, - 'media', - undefined, - createMediaRequestDetails({ mediaTypes: [], securityOrigin: 'file:///app/index.html' }), - () => true, - )).toBe(true) - }) - - it('rejects screen capture requests reported as media from remote pages', () => { - expect(shouldGrantElectronPermission( - localWebContents, - 'media', - undefined, - createMediaRequestDetails({ - mediaTypes: [], - requestingUrl: 'https://example.com/capture.html', - securityOrigin: 'https://example.com', - }), - () => true, - )).toBe(false) - }) - - // https://github.com/moeru-ai/airi/pull/2178#discussion_r3681573150 - it('rejects desktop capture that no renderer asked for', () => { - // ROOT CAUSE: - // - // Electron reports the legacy `chromeMediaSource: 'desktop'` constraint with the same empty - // `mediaTypes` list as `getDisplayMedia()`, but serves it from `HandleUserMediaRequest` instead of - // `setDisplayMediaRequestHandler`. Granting on empty `mediaTypes` alone therefore also handed a local - // page the full desktop through `getUserMedia()`, skipping AIRI's own source selection: - // - // const allowlistPermission = isDisplayCaptureMediaPermission(permission, details) ? 'display-capture' : permission - // - // We fixed this by additionally requiring an authorized capture source, which only AIRI's selected - // source flow installs. - expect(shouldGrantElectronPermission( - localWebContents, - 'media', - undefined, - createMediaRequestDetails({ mediaTypes: [], securityOrigin: 'file:///app/index.html' }), - () => false, - )).toBe(false) - }) - - it('denies desktop capture when no authorization callback is supplied', () => { - expect(shouldGrantElectronPermission( - localWebContents, - 'media', - undefined, - createMediaRequestDetails({ mediaTypes: [], securityOrigin: 'file:///app/index.html' }), - )).toBe(false) - }) - - it('keeps camera requests denied now that screen capture shares the media permission', () => { - expect(shouldGrantElectronPermission( - localWebContents, - 'media', - undefined, - createMediaRequestDetails({ mediaTypes: ['video'], securityOrigin: 'file:///app/index.html' }), - () => true, - )).toBe(false) - }) - - /** @example Local AIRI pages retain sanitized clipboard writes used by chat copy actions. */ - it('grants sanitized clipboard writes from local app pages', () => { - expect(shouldGrantElectronPermission( - localWebContents, - 'clipboard-sanitized-write', - 'file:///app/index.html', - createPermissionCheckDetails(), - )).toBe(true) - }) - - /** @example Unreviewed permission categories are denied by default. */ - it('rejects unrelated permissions instead of granting all local requests', () => { - expect(shouldGrantElectronPermission( - localWebContents, - 'notifications', - 'file:///app/index.html', - createPermissionCheckDetails(), - )).toBe(false) - }) - - it('grants local AIRI pages access to HID devices through the device permission handler', () => { - const targetSession = { - setDevicePermissionHandler: vi.fn(), - setPermissionCheckHandler: vi.fn(), - setPermissionRequestHandler: vi.fn(), - } - - setupPermissionHandlers(targetSession, () => false) - - expect(targetSession.setDevicePermissionHandler).toHaveBeenCalledOnce() - - const handler = targetSession.setDevicePermissionHandler.mock.calls[0]?.[0] - expect(handler).not.toBeNull() - expect(handler?.(createHIDPermissionDetails())).toBe(true) - expect(handler?.(createHIDPermissionDetails({ origin: 'https://example.com' }))).toBe(false) - expect(handler?.(createHIDPermissionDetails({ deviceType: 'usb' }))).toBe(false) - }) - - it('allows HID permission checks only for local AIRI pages', () => { - expect(shouldGrantElectronPermission( - localWebContents, - 'hid', - 'file:///app/index.html', - createPermissionCheckDetails(), - )).toBe(true) - expect(shouldGrantElectronPermission( - localWebContents, - 'hid', - 'https://example.com', - createPermissionCheckDetails(), - )).toBe(false) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts b/apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts deleted file mode 100644 index ba7997c0d..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { DevicePermissionHandlerHandlerDetails, HIDDevice, Session, WebContents } from 'electron' - -import { isLocalAppURL } from '../../libs/electron/url' - -type PermissionCheckHandler = Exclude[0], null> -type PermissionRequestHandler = Exclude[0], null> -type ElectronPermission = Parameters[1] | Parameters[1] -type ElectronPermissionDetails = Parameters[3] | Parameters[3] -type LocalAppWebContents = Pick - -const LOCAL_APP_PERMISSION_NAMES = new Set([ - 'display-capture', - 'clipboard-sanitized-write', - 'hid', -]) - -const GENERIC_DESKTOP_USAGE_PAGE = 0x01 -const GAME_CONTROLLER_USAGES = new Set([ - 0x04, // Joystick - 0x05, // Game Pad - 0x08, // Multi-axis Controller -]) - -/** - * Filters out Chromium's opaque origin marker before evaluating explicit frame URLs. - */ -function isUsableRequesterURL(rawURL: string | undefined): rawURL is string { - return !!rawURL && rawURL !== 'null' -} - -/** - * Checks whether Electron described an audio-only media permission operation. - */ -function isAudioMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean { - if (permission !== 'media' || !details) - return false - - if ('mediaTypes' in details && details.mediaTypes?.length) { - return details.mediaTypes.includes('audio') && !details.mediaTypes.includes('video') - } - - return 'mediaType' in details && details.mediaType === 'audio' -} - -/** - * Checks whether Electron described a desktop capture operation of any kind. - * - * Electron routes desktop capture through the `media` permission and only appends `audio` or `video` to - * `mediaTypes` for device capture, so desktop capture is the media operation that declares no media type - * at all. Both `getDisplayMedia()` and the legacy `chromeMediaSource: 'desktop'` constraint look like - * this, so the permission details alone cannot tell them apart. - * See {@link https://github.com/electron/electron/blob/v41.2.1/shell/browser/web_contents_permission_helper.cc#L249-L274}. - */ -function isDesktopCaptureMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean { - if (permission !== 'media' || !details) - return false - - return 'mediaTypes' in details && details.mediaTypes?.length === 0 -} - -/** - * Checks whether every requester identity supplied by Electron is local to AIRI. - */ -function shouldGrantLocalAppPermission( - webContents: LocalAppWebContents | null, - requestingOrigin?: string, - details?: ElectronPermissionDetails, -): boolean { - const requesterURLs = [ - requestingOrigin, - details?.requestingUrl, - details && 'securityOrigin' in details ? details.securityOrigin : undefined, - details && 'embeddingOrigin' in details ? details.embeddingOrigin : undefined, - ].filter(isUsableRequesterURL) - - if (requesterURLs.length) - return requesterURLs.every(isLocalAppURL) - - return isLocalAppURL(webContents?.getURL()) -} - -function isGameController(device: HIDDevice): boolean { - return device.collections.some(collection => - collection.usagePage === GENERIC_DESKTOP_USAGE_PAGE - && GAME_CONTROLLER_USAGES.has(collection.usage), - ) -} - -/** - * Grants device access only to game controllers requested by an AIRI-owned page. - * - * Triggering workflow: - * - * {@link Navigator.hid} - * -> {@link Session.setDevicePermissionHandler} - * -> `hid` - * -> {@link shouldGrantDevicePermission} - * - * Upstream: - * - {@link setupPermissionHandlers} - * - * Downstream: - * - {@link isLocalAppURL} - */ -function shouldGrantDevicePermission(details: DevicePermissionHandlerHandlerDetails): boolean { - if (details.deviceType !== 'hid' || !('collections' in details.device) || !isLocalAppURL(details.origin)) - return false - - return isGameController(details.device) -} - -/** - * Decides whether an Electron media operation is an AIRI-owned audio-only request. - * - * Use when: - * - Chromium asks the default session to check or request microphone access - * - A caller needs the same local-frame policy outside the session callbacks - * - * Expects: - * - Permission details come from Electron's official request or check handler contracts - * - Packaged pages use file URLs and development pages use loopback HTTP URLs - * - * Returns: - * - Whether the operation is audio-only and every supplied requester identity is local - */ -export function shouldGrantAudioCapturePermission( - webContents: LocalAppWebContents | null, - permission: ElectronPermission, - requestingOrigin?: string, - details?: ElectronPermissionDetails, -): boolean { - return isAudioMediaPermission(permission, details) - && shouldGrantLocalAppPermission(webContents, requestingOrigin, details) -} - -/** - * Applies AIRI's allowlist to an Electron session permission operation. - * - * Use when: - * - Wiring both Electron permission check and request handlers - * - Preserving reviewed local display-capture and clipboard behavior - * - * Expects: - * - Unknown or unreviewed permission categories must remain denied - * - All explicit frame, security, and embedding origins must identify local AIRI pages - * - Electron reports desktop capture through the `media` permission instead of `display-capture` - * - Desktop capture is only ever requested by AIRI's own selected-source flow - * - * Returns: - * - Whether the requested permission is allowlisted, locally owned, and authorized for desktop capture - */ -export function shouldGrantElectronPermission( - webContents: LocalAppWebContents | null, - permission: ElectronPermission, - requestingOrigin?: string, - details?: ElectronPermissionDetails, - isDesktopCaptureAuthorized: () => boolean = () => false, -): boolean { - if (shouldGrantAudioCapturePermission(webContents, permission, requestingOrigin, details)) - return true - - // Desktop capture arrives as a `media` operation, so it has to be resolved back to the reviewed - // `display-capture` entry before the allowlist is consulted. Camera and microphone operations keep - // reporting their device media type and therefore never reach the allowlist through this path. - const isDesktopCapture = isDesktopCaptureMediaPermission(permission, details) - - // Electron cannot distinguish `getDisplayMedia()` from the legacy `chromeMediaSource: 'desktop'` - // constraint here, and only the former is routed through `setDisplayMediaRequestHandler`. Requiring an - // authorized source keeps the legacy path from capturing the full desktop behind AIRI's picker, and - // costs the supported path nothing: without that handler Electron answers `NOT_SUPPORTED` regardless. - if (isDesktopCapture && !isDesktopCaptureAuthorized()) - return false - - const allowlistPermission = isDesktopCapture ? 'display-capture' : permission - - return LOCAL_APP_PERMISSION_NAMES.has(allowlistPermission) - && shouldGrantLocalAppPermission(webContents, requestingOrigin, details) -} - -/** - * Registers the paired Electron session handlers required for complete permission policy. - * - * Use when: - * - Initializing Electron's default session after app readiness - * - * Expects: - * - The session is the one used by AIRI renderer windows - * - macOS systemPreferences remains responsible for OS-level consent prompts and status - * - `isDesktopCaptureAuthorized` reports whether a renderer already selected a capture source - * - * Returns: - * - Nothing; permission request, permission check, and device permission handlers are installed - */ -export function setupPermissionHandlers( - targetSession: Pick, - isDesktopCaptureAuthorized: () => boolean, -): void { - targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => { - callback(shouldGrantElectronPermission(webContents, permission, undefined, details, isDesktopCaptureAuthorized)) - }) - - targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { - return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details, isDesktopCaptureAuthorized) - }) - - targetSession.setDevicePermissionHandler(shouldGrantDevicePermission) -} diff --git a/apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts b/apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts deleted file mode 100644 index b47ea92da..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { EventEmitter } from 'node:events' - -import { app } from 'electron' - -export class MockAutoUpdater extends EventEmitter { - autoDownload = false - - async checkForUpdates() { - this.emit('checking-for-update') - - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 1500)) - - // Simulate update available - // We can toggle this based on some logic if needed, but for now let's assume update is always available in mock - const updateInfo = { - version: '9.9.9-mock', - files: [], - path: 'mock-path', - sha512: 'mock-sha', - releaseDate: new Date().toISOString(), - releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B', - } - - this.emit('update-available', updateInfo) - - // In real updater, if autoDownload is true, it starts downloading. - // We'll respect that if we were fully mocking, but typically we trigger download manually in this app. - return { updateInfo } - } - - async downloadUpdate() { - // Simulate download progress - const total = 100 * 1024 * 1024 // 100MB - let transferred = 0 - const speed = 5 * 1024 * 1024 // 5MB/s simulation - - const interval = setInterval(() => { - transferred += speed / 10 // Update every 100ms - if (transferred > total) - transferred = total - - const progress = { - total, - transferred, - percent: (transferred / total) * 100, - bytesPerSecond: speed, - } - - this.emit('download-progress', progress) - - if (transferred >= total) { - clearInterval(interval) - this.emit('update-downloaded', { - version: '9.9.9-mock', - files: [], - path: 'mock-path', - sha512: 'mock-sha', - releaseDate: new Date().toISOString(), - releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B', - }) - } - }, 100) - } - - async quitAndInstall() { - // eslint-disable-next-line no-console - console.log('[MockAutoUpdater] quitAndInstall called. Quitting app...') - app.quit() - } -} diff --git a/apps/stage-tamagotchi/src/main/services/electron/powerMonitor.ts b/apps/stage-tamagotchi/src/main/services/electron/powerMonitor.ts deleted file mode 100644 index 46e36c6bb..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/powerMonitor.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type EventEmitter from 'node:events' - -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { BrowserWindow } from 'electron' - -import { electronEvents } from '@proj-airi/electron-eventa' -import { powerMonitor } from 'electron' - -import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle' - -export function createPowerMonitorService(params: { context: ReturnType['context'], window: BrowserWindow }) { - function onOff(eventEmitter: EM, event: E, listener: Parameters[1]) { - eventEmitter.on(event, listener) - onAppBeforeQuit(() => { - eventEmitter.off(event, listener) - }) - } - - onOff(powerMonitor, 'suspend', () => params.context.emit(electronEvents.powerMonitor.suspended, undefined)) - onOff(powerMonitor, 'resume', () => params.context.emit(electronEvents.powerMonitor.resumed, undefined)) - onOff(powerMonitor, 'lock-screen', () => params.context.emit(electronEvents.powerMonitor.lockScreen, undefined)) - onOff(powerMonitor, 'unlock-screen', () => params.context.emit(electronEvents.powerMonitor.unlockScreen, undefined)) -} diff --git a/apps/stage-tamagotchi/src/main/services/electron/screen.ts b/apps/stage-tamagotchi/src/main/services/electron/screen.ts deleted file mode 100644 index ccf8e8b4c..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/screen.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { BrowserWindow } from 'electron' - -import { defineInvokeHandler } from '@moeru/eventa' -import { cursorScreenPoint, startLoopGetCursorScreenPoint } from '@proj-airi/electron-eventa' -import { createRendererLoop } from '@proj-airi/electron-vueuse/main' -import { screen } from 'electron' - -import { electron } from '../../../shared/eventa' -import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle' - -export function createScreenService(params: { context: ReturnType['context'], window: BrowserWindow }) { - const { start, stop } = createRendererLoop({ - window: params.window, - run: () => { - const dipPos = screen.getCursorScreenPoint() - params.context.emit(cursorScreenPoint, dipPos) - }, - }) - - onAppWindowAllClosed(() => stop()) - onAppBeforeQuit(() => stop()) - defineInvokeHandler(params.context, startLoopGetCursorScreenPoint, () => start()) - - defineInvokeHandler(params.context, electron.screen.getAllDisplays, () => screen.getAllDisplays()) - defineInvokeHandler(params.context, electron.screen.getPrimaryDisplay, () => screen.getPrimaryDisplay()) - defineInvokeHandler(params.context, electron.screen.dipToScreenPoint, point => point ? screen.dipToScreenPoint(point) : screen.getCursorScreenPoint()) - defineInvokeHandler(params.context, electron.screen.dipToScreenRect, rect => rect ? screen.dipToScreenRect(params.window, rect) : params.window.getBounds()) - defineInvokeHandler(params.context, electron.screen.screenToDipPoint, point => point ? screen.screenToDipPoint(point) : screen.getCursorScreenPoint()) - defineInvokeHandler(params.context, electron.screen.screenToDipRect, rect => rect ? screen.screenToDipRect(params.window, rect) : params.window.getBounds()) - defineInvokeHandler(params.context, electron.screen.getCursorScreenPoint, () => screen.getCursorScreenPoint()) -} diff --git a/apps/stage-tamagotchi/src/main/services/electron/system-preferences.ts b/apps/stage-tamagotchi/src/main/services/electron/system-preferences.ts deleted file mode 100644 index 011488793..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/system-preferences.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { BrowserWindow } from 'electron' - -import { defineInvokeHandler } from '@moeru/eventa' -import { systemPreferences } from 'electron' -import { isLinux } from 'std-env' - -import { electron } from '../../../shared/eventa' - -export function createSystemPreferencesService(params: { context: ReturnType['context'], window: BrowserWindow }) { - defineInvokeHandler(params.context, electron.systemPreferences.getMediaAccessStatus, (type) => { - if (isLinux || !type) { - return 'not-determined' - } - - return systemPreferences.getMediaAccessStatus(type[0]) - }) - defineInvokeHandler(params.context, electron.systemPreferences.askForMediaAccess, (type) => { - if (isLinux || !type) { - return Promise.resolve(false) - } - - return systemPreferences.askForMediaAccess(type[0]) - }) -} diff --git a/apps/stage-tamagotchi/src/main/services/electron/window.ts b/apps/stage-tamagotchi/src/main/services/electron/window.ts deleted file mode 100644 index 001d6138d..000000000 --- a/apps/stage-tamagotchi/src/main/services/electron/window.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { BrowserWindow } from 'electron' - -import type { ElectronWindowLifecycleState } from '../../../shared/eventa' - -import { defineInvokeHandler } from '@moeru/eventa' -import { bounds, startLoopGetBounds } from '@proj-airi/electron-eventa' -import { createRendererLoop, safeClose } from '@proj-airi/electron-vueuse/main' -import { isWindows } from 'std-env' - -import { - electron, - electronGetWindowLifecycleState, - electronWindowClose, - electronWindowLifecycleChanged, - electronWindowSetAlwaysOnTop, -} from '../../../shared/eventa' -import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle' -import { resizeWindowByDelta, setWindowAlwaysOnTop } from '../../windows/shared/window' - -export function createWindowService(params: { context: ReturnType['context'], window: BrowserWindow }) { - function getWindowLifecycleState(reason: ElectronWindowLifecycleState['reason']): ElectronWindowLifecycleState { - return { - focused: params.window.isFocused(), - minimized: params.window.isMinimized(), - reason, - updatedAt: Date.now(), - visible: params.window.isVisible(), - } - } - - function emitWindowLifecycle(reason: ElectronWindowLifecycleState['reason']) { - params.context.emit(electronWindowLifecycleChanged, getWindowLifecycleState(reason)) - } - - const { start, stop } = createRendererLoop({ - window: params.window, - run: () => { - params.context.emit(bounds, params.window.getBounds()) - }, - }) - - onAppWindowAllClosed(() => stop()) - onAppBeforeQuit(() => stop()) - defineInvokeHandler(params.context, startLoopGetBounds, () => start()) - defineInvokeHandler(params.context, electronGetWindowLifecycleState, (_, options) => { - if (params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) - return getWindowLifecycleState('snapshot') - }) - - params.window.on('show', () => emitWindowLifecycle('show')) - params.window.on('hide', () => emitWindowLifecycle('hide')) - params.window.on('minimize', () => emitWindowLifecycle('minimize')) - params.window.on('restore', () => emitWindowLifecycle('restore')) - params.window.on('focus', () => emitWindowLifecycle('focus')) - params.window.on('blur', () => emitWindowLifecycle('blur')) - - defineInvokeHandler(params.context, electron.window.getBounds, (_, options) => { - if (params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) { - return params.window.getBounds() - } - - return { - x: 0, - y: 0, - width: 0, - height: 0, - } - }) - - defineInvokeHandler(params.context, electron.window.setBounds, (newBounds, options) => { - if (newBounds && params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) { - params.window.setBounds(newBounds[0]) - } - }) - - defineInvokeHandler(params.context, electron.window.setIgnoreMouseEvents, (opts, options) => { - if (opts && params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) { - params.window.setIgnoreMouseEvents(...opts) - } - }) - - defineInvokeHandler(params.context, electronWindowSetAlwaysOnTop, (flag, options) => { - if (params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) { - setWindowAlwaysOnTop(params.window, Boolean(flag)) - } - }) - - defineInvokeHandler(params.context, electron.window.setVibrancy, (vibrancy, options) => { - if (vibrancy && params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) { - params.window.setVibrancy(vibrancy[0]) - } - }) - - defineInvokeHandler(params.context, electron.window.setBackgroundMaterial, (backgroundMaterial, options) => { - if (isWindows && backgroundMaterial && params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) { - params.window.setBackgroundMaterial(backgroundMaterial[0]) - } - }) - - defineInvokeHandler(params.context, electron.window.resize, (payload, options) => { - if (!payload || params.window.webContents.id !== options?.raw.ipcMainEvent.sender.id) { - return - } - - resizeWindowByDelta({ - window: params.window, - deltaX: payload.deltaX, - deltaY: payload.deltaY, - direction: payload.direction, - }) - }) - - defineInvokeHandler(params.context, electronWindowClose, (_, options) => { - if (params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) { - safeClose(params.window) - } - }) -} diff --git a/apps/stage-tamagotchi/src/main/tray/index.ts b/apps/stage-tamagotchi/src/main/tray/index.ts deleted file mode 100644 index e8ada28d5..000000000 --- a/apps/stage-tamagotchi/src/main/tray/index.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { LocaleDetector } from '@intlify/core' -import type { BrowserWindow, Rectangle } from 'electron' - -import type { I18n } from '../libs/i18n' -import type { ServerChannel } from '../services/airi/channel-server' -import type { setupBeatSync } from '../windows/beat-sync' -import type { setupCaptionWindowManager } from '../windows/caption' -import type { SettingsWindowManager } from '../windows/settings' -import type { WidgetsWindowManager } from '../windows/widgets' - -import { env } from 'node:process' - -import { is } from '@electron-toolkit/utils' -import { isRendererUnavailable } from '@proj-airi/electron-vueuse/main' -import { effect } from 'alien-signals' -import { app, Menu, nativeImage, screen, Tray } from 'electron' -import { debounce, once } from 'es-toolkit' -import { isMacOS } from 'std-env' - -import icon from '../../../resources/icon.png?asset' -import macOSTrayIcon from '../../../resources/tray-icon-macos.png?asset' - -import { findDominantDisplayArea } from '../../shared/utils/electron/display' -import { onAppBeforeQuit } from '../libs/bootkit/lifecycle' -import { setupInlayWindow } from '../windows/inlay' -import { Animator } from '../windows/shared/animator' -import { computeResizedBoundsAnchoredToDominantDisplay } from '../windows/shared/display' -import { toggleWindowShow } from '../windows/shared/window' - -const RECOMMENDED_WIDTH = 450 -const RECOMMENDED_HEIGHT = 600 -const ASPECT_RATIO = RECOMMENDED_WIDTH / RECOMMENDED_HEIGHT - -function applyWindowSize(window: BrowserWindow, width: number, height: number, x?: number, y?: number): void { - if (isRendererUnavailable(window)) { - return - } - - window.setResizable(true) - - const bounds = x !== undefined && y !== undefined - ? { - x: Math.round(x), - y: Math.round(y), - width: Math.round(width), - height: Math.round(height), - } - : computeResizedBoundsAnchoredToDominantDisplay({ - currentBounds: window.getBounds(), - targetSize: { width, height }, - displays: screen.getAllDisplays(), - }) - - window.setBounds(bounds) - window.show() -} - -function resolveAlignedWindowBounds( - window: BrowserWindow, - workArea: Rectangle, - position: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right', -): Rectangle { - const { width: windowWidth, height: windowHeight } = window.getBounds() - const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = workArea - - let x = areaX - let y = areaY - - switch (position) { - case 'center': - x = areaX + Math.floor((areaWidth - windowWidth) / 2) - y = areaY + Math.floor((areaHeight - windowHeight) / 2) - break - case 'top-left': - break - case 'top-right': - x = areaX + areaWidth - windowWidth - break - case 'bottom-left': - y = areaY + areaHeight - windowHeight - break - case 'bottom-right': - x = areaX + areaWidth - windowWidth - y = areaY + areaHeight - windowHeight - break - } - - return { x, y, width: windowWidth, height: windowHeight } -} - -function isSizeMatch(window: BrowserWindow, targetWidth: number, targetHeight: number): boolean { - const { width, height } = window.getBounds() - return Math.abs(width - Math.round(targetWidth)) <= 2 && Math.abs(height - Math.round(targetHeight)) <= 2 -} - -function isPositionMatch(window: BrowserWindow, targetX: number, targetY: number): boolean { - const { x, y } = window.getBounds() - return Math.abs(x - targetX) <= 5 && Math.abs(y - targetY) <= 5 -} - -export function setupTray(params: { - mainWindow: BrowserWindow - settingsWindow: SettingsWindowManager - captionWindow: ReturnType - widgetsWindow: WidgetsWindowManager - beatSyncBgWindow: Awaited> - aboutWindow: () => Promise - serverChannel: ServerChannel - i18n: I18n -}): void { - once(() => { - const mainWindowAnimator = new Animator(params.mainWindow) - - function animateMainWindowTo(workArea: Rectangle, position: Parameters[2]) { - const bounds = resolveAlignedWindowBounds(params.mainWindow, workArea, position) - mainWindowAnimator.windowBoundsAnimateTo(bounds) - params.mainWindow.show() - } - - function applyMainWindowSize(width: number, height: number, x?: number, y?: number) { - mainWindowAnimator.stop() - applyWindowSize(params.mainWindow, width, height, x, y) - } - - const trayImage = nativeImage.createFromPath(isMacOS ? macOSTrayIcon : icon).resize({ width: 16 }) - trayImage.setTemplateImage(isMacOS) - - const appTray = new Tray(trayImage) - - const rebuildContextMenu = debounce((): void => { - if (isRendererUnavailable(params.mainWindow)) { - return - } - - const mainWindowBounds = params.mainWindow.getBounds() - const currentDisplay = findDominantDisplayArea(mainWindowBounds, screen.getAllDisplays()) ?? screen.getDisplayMatching(mainWindowBounds) - const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = currentDisplay.workArea - const { width: windowWidth, height: windowHeight } = mainWindowBounds - - const fullHeightTarget = areaHeight - const fullWidthTarget = Math.floor(areaHeight * ASPECT_RATIO) - const halfHeightTarget = Math.floor(areaHeight / 2) - const halfWidthTarget = Math.floor(halfHeightTarget * ASPECT_RATIO) - - const contextMenu = Menu.buildFromTemplate([ - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.show'), click: () => toggleWindowShow(params.mainWindow) }, - { type: 'separator' }, - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.adjust_sizes'), - submenu: [ - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.recommended_size'), - type: 'checkbox', - checked: isSizeMatch(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT), - click: () => applyMainWindowSize(RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT), - }, - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_height'), - type: 'checkbox', - checked: isSizeMatch(params.mainWindow, fullWidthTarget, fullHeightTarget), - click: () => applyMainWindowSize(fullWidthTarget, fullHeightTarget), - }, - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.half_height'), - type: 'checkbox', - checked: isSizeMatch(params.mainWindow, halfWidthTarget, halfHeightTarget), - click: () => applyMainWindowSize(halfWidthTarget, halfHeightTarget), - }, - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_screen'), - type: 'checkbox', - checked: isSizeMatch(params.mainWindow, areaWidth, areaHeight), - click: () => applyMainWindowSize(areaWidth, areaHeight, areaX, areaY), - }, - ], - }, - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.align_to'), - submenu: [ - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.center'), - type: 'checkbox', - checked: isPositionMatch(params.mainWindow, areaX + Math.floor((areaWidth - windowWidth) / 2), areaY + Math.floor((areaHeight - windowHeight) / 2)), - click: () => animateMainWindowTo(currentDisplay.workArea, 'center'), - }, - { type: 'separator' }, - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'), - type: 'checkbox', - checked: isPositionMatch(params.mainWindow, areaX, areaY), - click: () => animateMainWindowTo(currentDisplay.workArea, 'top-left'), - }, - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_right'), - type: 'checkbox', - checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY), - click: () => animateMainWindowTo(currentDisplay.workArea, 'top-right'), - }, - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_left'), - type: 'checkbox', - checked: isPositionMatch(params.mainWindow, areaX, areaY + areaHeight - windowHeight), - click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-left'), - }, - { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_right'), - type: 'checkbox', - checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight), - click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-right'), - }, - ], - }, - { type: 'separator' }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.settings'), click: () => void params.settingsWindow.openWindow('/settings') }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.about'), click: () => params.aboutWindow().then(window => toggleWindowShow(window)) }, - { type: 'separator' }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_inlay'), click: () => setupInlayWindow({ i18n: params.i18n, serverChannel: params.serverChannel }) }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_widgets'), click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)) }, - { - label: params.i18n.t(params.captionWindow.isVisible() - ? 'tamagotchi.electron.tray.menu.labels.label.close_caption' - : 'tamagotchi.electron.tray.menu.labels.label.open_caption'), - click: () => { - void params.captionWindow.toggleVisibility().then(() => rebuildContextMenu()) - }, - }, - { - type: 'submenu', - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.caption_overlay'), - submenu: Menu.buildFromTemplate([ - { type: 'checkbox', label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.follow_window'), checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)) }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.reset_position'), click: async () => await params.captionWindow.resetToSide() }, - ]), - }, - { type: 'separator' }, - ...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG - ? [ - { type: 'header', label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.devtools') }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.troubleshoot_beatsync'), click: () => params.beatSyncBgWindow.webContents.openDevTools({ mode: 'detach' }) }, - { type: 'separator' }, - ] as const - : [], - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.quit'), click: () => app.quit() }, - ]) - - appTray.setContextMenu(contextMenu) - }, 50) - - params.mainWindow.on('resize', rebuildContextMenu) - params.mainWindow.on('move', rebuildContextMenu) - const visibilityChangeUnListener = params.captionWindow.onVisibilityChanged(rebuildContextMenu) - - rebuildContextMenu() - - const stopLocaleEffect = effect(() => { - const locale = params.i18n.locale as (() => string | LocaleDetector | undefined) - locale() - rebuildContextMenu() - }) - - onAppBeforeQuit(() => { - // Stop every menu rebuild source before canceling its pending trailing call. - // The tray must remain alive until no callback can reach it. - params.mainWindow.off('resize', rebuildContextMenu) - params.mainWindow.off('move', rebuildContextMenu) - - visibilityChangeUnListener() - stopLocaleEffect() - - rebuildContextMenu.cancel() - mainWindowAnimator.stop() - - appTray.destroy() - }) - - appTray.setToolTip('Project AIRI') - appTray.addListener('click', () => toggleWindowShow(params.mainWindow)) - - // On macOS, there's a special double-click event - if (isMacOS) { - appTray.addListener('double-click', () => toggleWindowShow(params.mainWindow)) - } - })() -} diff --git a/apps/stage-tamagotchi/src/main/windows/about/index.ts b/apps/stage-tamagotchi/src/main/windows/about/index.ts deleted file mode 100644 index 52fe4abe5..000000000 --- a/apps/stage-tamagotchi/src/main/windows/about/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' -import type { AutoUpdater } from '../../services/electron/auto-updater' - -import { join, resolve } from 'node:path' - -import { BrowserWindow } from 'electron' - -import icon from '../../../../resources/icon.png?asset' - -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createReusableWindow } from '../../libs/electron/window-manager' -import { protectPrivilegedWindowNavigation } from '../shared' -import { setupAboutWindowElectronInvokes } from './rpc/index.electron' - -export function setupAboutWindowReusable(params: { - autoUpdater: AutoUpdater - i18n: I18n - serverChannel: ServerChannel -}) { - return createReusableWindow(async () => { - const window = new BrowserWindow({ - title: 'About AIRI', - width: 670, - height: 880, - show: false, - resizable: true, - maximizable: false, - minimizable: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - }) - - window.on('ready-to-show', () => window.show()) - protectPrivilegedWindowNavigation(window) - - await setupAboutWindowElectronInvokes({ - window, - autoUpdater: params.autoUpdater, - i18n: params.i18n, - serverChannel: params.serverChannel, - }) - - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about', { - query: { 'synced-leader': 'false' }, - })) - - return window - }).getWindow -} diff --git a/apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts deleted file mode 100644 index 42e47e2f8..000000000 --- a/apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../../libs/i18n' -import type { ServerChannel } from '../../../services/airi/channel-server' -import type { AutoUpdater } from '../../../services/electron/auto-updater' - -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { ipcMain } from 'electron' - -import { createAutoUpdaterService } from '../../../services/electron' -import { setupBaseWindowElectronInvokes } from '../../shared/window' - -export async function setupAboutWindowElectronInvokes(params: { - window: BrowserWindow - autoUpdater: AutoUpdater - i18n: I18n - serverChannel: ServerChannel -}) { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, params.window) - - await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel }) - - createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) -} diff --git a/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts b/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts deleted file mode 100644 index 57e6ab790..000000000 --- a/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' - -import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main' -import { BrowserWindow } from 'electron' - -import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location' -import { protectPrivilegedWindowNavigation } from '../shared/window' - -/** Creates the hidden renderer that owns the Beat Sync capture and detector. */ -export async function setupBeatSync() { - const window = new BrowserWindow({ - show: false, - webPreferences: { - preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/beat-sync.mjs'), - sandbox: false, - }, - }) - - protectPrivilegedWindowNavigation(window) - - await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'), 'beat-sync.html')) - initScreenCaptureForWindow(window) - - return window -} diff --git a/apps/stage-tamagotchi/src/main/windows/caption/index.ts b/apps/stage-tamagotchi/src/main/windows/caption/index.ts deleted file mode 100644 index c5ca6e718..000000000 --- a/apps/stage-tamagotchi/src/main/windows/caption/index.ts +++ /dev/null @@ -1,484 +0,0 @@ -import type { BrowserWindow, BrowserWindowConstructorOptions, Rectangle } from 'electron' -import type { InferOutput } from 'valibot' - -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' - -import { createHash } from 'node:crypto' -import { join, resolve } from 'node:path' - -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { animate, utils } from 'animejs' -import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen } from 'electron' -import { debounce, throttle } from 'es-toolkit' -import { isMacOS } from 'std-env' -import { boolean, number, object, optional, record, string } from 'valibot' - -import icon from '../../../../resources/icon.png?asset' - -import { captionGetIsFollowingWindow, captionIsFollowingWindowChanged } from '../../../shared/eventa' -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createConfig } from '../../libs/electron/persistence' -import { createReusableWindow } from '../../libs/electron/window-manager' -import { mapForBreakpoints, resolutionBreakpoints, widthFrom } from '../shared/display' -import { protectPrivilegedWindowNavigation, setupBaseWindowElectronInvokes, setWindowAlwaysOnTop, transparentWindowConfig } from '../shared/window' - -const captionConfigSchema = object({ - isFollowing: boolean(), - matrices: record(string(), object({ - bounds: object({ - x: number(), - y: number(), - width: number(), - height: number(), - }), - relativeToMain: optional(object({ - dx: number(), - dy: number(), - })), - })), -}) -type CaptionConfig = InferOutput - -function computeDisplayMatrixHash(): string { - const displays = screen.getAllDisplays() - const signature = displays - .slice() - .sort((a, b) => (a.bounds.x - b.bounds.x) || (a.bounds.y - b.bounds.y)) - .map(d => [d.bounds.x, d.bounds.y, d.bounds.width, d.bounds.height, d.scaleFactor ?? 1].join(',')) - .join('|') - - return createHash('sha256').update(signature).digest('hex').slice(0, 16) -} - -function clampBoundsWithinRect(bounds: Rectangle, rect: Rectangle): Rectangle { - const x = Math.min(Math.max(bounds.x, rect.x), rect.x + rect.width - bounds.width) - const y = Math.min(Math.max(bounds.y, rect.y), rect.y + rect.height - bounds.height) - return { x, y, width: bounds.width, height: bounds.height } -} - -function computeInitialCaptionBounds(params: { mainWindow: BrowserWindow, captionOptions?: Partial }): Rectangle { - const mainBounds = params.mainWindow.getBounds() - const displayWorkArea = screen.getDisplayMatching(mainBounds).workArea - - // Base sizing from display width with sensible caps - const width = mapForBreakpoints( - displayWorkArea.width, - { - '720p': widthFrom(displayWorkArea, { percentage: 0.9, max: { actual: 560 }, min: { actual: 280 } }), - '1080p': widthFrom(displayWorkArea, { percentage: 0.5, max: { actual: 640 }, min: { actual: 320 } }), - '2k': widthFrom(displayWorkArea, { percentage: 0.4, max: { actual: 720 }, min: { actual: 360 } }), - '4k': widthFrom(displayWorkArea, { percentage: 0.33, max: { actual: 768 }, min: { actual: 420 } }), - }, - { breakpoints: resolutionBreakpoints }, - ) - const height = Math.max(Math.floor(width / 3.2), 120) - - const margin = 16 - // Prefer to the right of main window, else to the left, else bottom centered - let x = mainBounds.x + mainBounds.width + margin - let y = mainBounds.y + mainBounds.height - height - - const rightEdge = x + width - const displayRight = displayWorkArea.x + displayWorkArea.width - - if (rightEdge > displayRight) { - // Place to the left - x = mainBounds.x - width - margin - } - - // If still out of bounds horizontally, fallback to bottom center - if (x < displayWorkArea.x || (x + width) > displayRight) { - x = displayWorkArea.x + Math.floor((displayWorkArea.width - width) / 2) - } - - // Clamp vertically - if (y < displayWorkArea.y) { - y = displayWorkArea.y + margin - } - - const initial = clampBoundsWithinRect({ x, y, width, height }, displayWorkArea) - - return { ...initial, ...params.captionOptions } -} - -function createCaptionWindow(options?: BrowserWindowConstructorOptions) { - const window = new ElectronBrowserWindow({ - title: 'Caption', - width: 480, - height: 180, - show: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - // Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation. - // - // https://github.com/electron/electron/issues/10078#issuecomment-3410164802 - // https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac - type: isMacOS ? 'panel' : undefined, - ...transparentWindowConfig(), - ...options, - }) - - // Click-through is controlled by caller via setIgnoreMouseEvents - // Avoid window buttons on macOS frameless windows - // Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation. - // - // https://github.com/electron/electron/issues/10078#issuecomment-3410164802 - // https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac - window.setVisibleOnAllWorkspaces(true) - if (isMacOS) { - window.setFullScreenable(false) - window.setWindowButtonVisibility(false) - } - setWindowAlwaysOnTop(window, true, 2) - - window.on('ready-to-show', () => window.show()) - protectPrivilegedWindowNavigation(window) - - return window -} - -export function setupCaptionWindowManager(params: { - mainWindow: BrowserWindow - serverChannel: ServerChannel - i18n: I18n -}) { - const matrixHash = computeDisplayMatrixHash() - - const { - setup: setupConfig, - get: getConfigRaw, - update: updateConfig, - } = createConfig('windows-caption', 'config.json', captionConfigSchema, { - default: { isFollowing: true, matrices: {} }, - autoHeal: true, - }) - const getConfig = (): CaptionConfig => getConfigRaw() ?? { isFollowing: true, matrices: {} } - - setupConfig() - - let isFollowing = getConfig().isFollowing ?? true - let lastProgrammaticMoveAt = 0 - - // Keep references to listeners so we can detach when toggling - let detachMainMoveListener: (() => void) | undefined - - // Note: when following window, we compute and persist the current relative offset - // and start following without docking, so no immediate reposition is needed here. - - function computeRelativeOffset(win: BrowserWindow): { dx: number, dy: number } { - const caption = win.getBounds() - const main = params.mainWindow.getBounds() - return { dx: caption.x - main.x, dy: caption.y - main.y } - } - - function followMainWindow(win: BrowserWindow) { - const cfg = getConfig() ?? { isFollowing, matrices: {} } - const initialOffset = cfg?.matrices?.[matrixHash]?.relativeToMain ?? computeRelativeOffset(win) - - // Store relative offset for this matrix - const cfgToSave = getConfig() ?? { isFollowing, matrices: {} } - cfgToSave.matrices[matrixHash] = { ...cfgToSave.matrices[matrixHash], relativeToMain: initialOffset } - updateConfig(cfgToSave) - - let animation: ReturnType | null = null - const state = { x: 0, y: 0 } - - const settleTo = (toX: number, toY: number) => { - if (win.isDestroyed()) - return - if (!Number.isFinite(toX) || !Number.isFinite(toY)) - return - - const b = win.getBounds() - state.x = Number.isFinite(b.x) ? b.x : 0 - state.y = Number.isFinite(b.y) ? b.y : 0 - animation?.pause() - animation = animate(state, { - x: toX, - y: toY, - duration: 160, - ease: 'outCubic', - modifier: utils.round(0), - onRender: () => { - if (win.isDestroyed()) - return - if (!Number.isFinite(state.x) || !Number.isFinite(state.y)) - return - - const toX = Math.round(state.x) - const toY = Math.round(state.y) - lastProgrammaticMoveAt = Date.now() - win.setPosition(toX, toY) - }, - }) - } - - let lastTx = 0 - let lastTy = 0 - let lastAppliedTx = Number.NaN - let lastAppliedTy = Number.NaN - - const moveThrottled = throttle(() => { - if (win.isDestroyed()) - return - - const stored = getConfig()?.matrices[matrixHash]?.relativeToMain ?? initialOffset - const main = params.mainWindow.getBounds() - const b = win.getBounds() - let tx = main.x + stored.dx - let ty = main.y + stored.dy - const target = { x: tx, y: ty, width: b.width, height: b.height } - const workArea = screen.getDisplayMatching(target).workArea - const clamped = clampBoundsWithinRect(target, workArea) - tx = clamped.x - ty = clamped.y - lastTx = tx - lastTy = ty - if (Math.abs(lastAppliedTx - tx) <= 0 && Math.abs(lastAppliedTy - ty) <= 0) - return - lastAppliedTx = tx - lastAppliedTy = ty - // Animate towards target at throttled cadence for visible easing - settleTo(tx, ty) - }, 1000 / 60) - - const settleDebounced = debounce(() => { - settleTo(lastTx, lastTy) - }, 200) - - const onMainChange = () => { - moveThrottled() - settleDebounced() - } - onMainChange() - params.mainWindow.on('move', onMainChange) - params.mainWindow.on('resize', onMainChange) - detachMainMoveListener = () => { - params.mainWindow.removeListener('move', onMainChange) - params.mainWindow.removeListener('resize', onMainChange) - animation?.pause() - animation = null - } - } - - function detachFromMain() { - detachMainMoveListener?.() - detachMainMoveListener = undefined - } - - let eventaContext: ReturnType['context'] | undefined - let currentWindow: BrowserWindow | undefined - const visibilityListeners = new Set<() => void>() - - const emitVisibilityChanged = () => { - for (const listener of visibilityListeners) { - try { - listener() - } - catch { - } - } - } - - function applyIgnoreMouseEvents(win: BrowserWindow, ignore: boolean) { - try { - if (ignore) - win.setIgnoreMouseEvents(true, { forward: true }) - else - win.setIgnoreMouseEvents(false) - } - catch { - // ignore failures during early window lifecycle - } - } - - const reusable = createReusableWindow(async () => { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const window = createCaptionWindow() - currentWindow = window - const { context } = createContext(ipcMain, window) - eventaContext = context - - await setupBaseWindowElectronInvokes({ context, window, serverChannel: params.serverChannel, i18n: params.i18n }) - - applyIgnoreMouseEvents(window, isFollowing) - - const cfg = getConfig() - const saved = cfg?.matrices?.[matrixHash]?.bounds - - if (saved) { - const workArea = screen.getDisplayMatching(saved).workArea - const clamped = clampBoundsWithinRect(saved, workArea) - window.setBounds(clamped) - } - else { - const initialBounds = computeInitialCaptionBounds({ mainWindow: params.mainWindow }) - window.setBounds(initialBounds) - } - - const persistBounds = () => { - const config = getConfig() ?? { isFollowing, matrices: {} } - const b = window.getBounds() - config.matrices[matrixHash] = { ...config.matrices[matrixHash], bounds: b } - config.isFollowing = isFollowing - if (isFollowing && Date.now() - lastProgrammaticMoveAt > 100) { - const rel = computeRelativeOffset(window) - config.matrices[matrixHash] = { ...config.matrices[matrixHash], bounds: b, relativeToMain: rel } - } - updateConfig(config) - } - - window.on('resize', persistBounds) - window.on('move', persistBounds) - window.on('show', emitVisibilityChanged) - window.on('hide', emitVisibilityChanged) - - const cleanupGetAttached = defineInvokeHandler(context, captionGetIsFollowingWindow, async () => isFollowing) - - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/caption', { - query: { 'synced-leader': 'false' }, - })) - - try { - context.emit(captionIsFollowingWindowChanged, isFollowing) - } - catch { - - } - - if (isFollowing) { - followMainWindow(window) - } - - window.on('closed', () => { - detachFromMain() - try { - cleanupGetAttached() - } - catch { - } - - if (currentWindow === window) { - currentWindow = undefined - } - eventaContext = undefined - emitVisibilityChanged() - }) - - return window - }) - - async function getWindow(): Promise { - return reusable.getWindow() - } - - async function setFollowWindow(isFollowingWindow: boolean) { - isFollowing = isFollowingWindow - const window = await reusable.getWindow() - - applyIgnoreMouseEvents(window, isFollowing) - - if (isFollowing) { - const rel = computeRelativeOffset(window) - const config = getConfig() ?? { isFollowing, matrices: {} } - config.isFollowing = isFollowing - config.matrices[matrixHash] = { ...config.matrices[matrixHash], relativeToMain: rel } - updateConfig(config) - - // Start following main without re-docking; keep current position - followMainWindow(window) - } - else { - detachFromMain() - - const config = getConfig() ?? { isFollowing, matrices: {} } - config.isFollowing = isFollowing - updateConfig(config) - } - - // Keep window visible after toggle - window.show() - - // Notify renderer for UI state (handle visibility) - try { - eventaContext?.emit(captionIsFollowingWindowChanged, isFollowing) - } - catch { - - } - } - - async function toggleFollowWindow() { - await setFollowWindow(!isFollowing) - } - - function getIsFollowingWindow(): boolean { - return isFollowing - } - - async function resetToSide() { - const window = await reusable.getWindow() - - applyIgnoreMouseEvents(window, isFollowing) - - // Prevent user-move persistence from overwriting our programmatic move - lastProgrammaticMoveAt = Date.now() - const initialBounds = computeInitialCaptionBounds({ mainWindow: params.mainWindow }) - window.setBounds(initialBounds) - - // Persist new bounds and a clean relative offset so follow uses it - const config = getConfig() ?? { isFollowing, matrices: {} } - const b = window.getBounds() - - const rel = computeRelativeOffset(window) - config.matrices[matrixHash] = { ...config.matrices[matrixHash], bounds: b, relativeToMain: rel } - config.isFollowing = isFollowing - - updateConfig(config) - } - - function isVisible(): boolean { - return Boolean(currentWindow && !currentWindow.isDestroyed() && currentWindow.isVisible()) - } - - async function toggleVisibility() { - if (isVisible()) { - currentWindow?.hide() - return - } - - const window = await reusable.getWindow() - if (window.isMinimized()) { - window.restore() - } - window.show() - window.focus() - } - - function onVisibilityChanged(listener: () => void): () => void { - visibilityListeners.add(listener) - return () => { - visibilityListeners.delete(listener) - } - } - - return { - getWindow, - setFollowWindow, - toggleFollowWindow, - getIsFollowingWindow, - resetToSide, - isVisible, - toggleVisibility, - onVisibilityChanged, - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/chat/index.ts b/apps/stage-tamagotchi/src/main/windows/chat/index.ts deleted file mode 100644 index 52aede3ca..000000000 --- a/apps/stage-tamagotchi/src/main/windows/chat/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' -import type { McpStdioManager } from '../../services/airi/mcp-servers' -import type { WidgetsWindowManager } from '../widgets' - -import { join, resolve } from 'node:path' - -import { BrowserWindow } from 'electron' - -import icon from '../../../../resources/icon.png?asset' - -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createReusableWindow } from '../../libs/electron/window-manager' -import { protectPrivilegedWindowNavigation } from '../shared' -import { setupChatWindowElectronInvokes } from './rpc/index.electron' - -export function setupChatWindowReusableFunc(params: { - widgetsManager: WidgetsWindowManager - serverChannel: ServerChannel - mcpStdioManager: McpStdioManager - i18n: I18n -}) { - return createReusableWindow(async () => { - const window = new BrowserWindow({ - title: 'Chat', - width: 600.0, - height: 800.0, - show: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - }) - - window.on('ready-to-show', () => window.show()) - protectPrivilegedWindowNavigation(window) - - await setupChatWindowElectronInvokes({ - window, - widgetsManager: params.widgetsManager, - serverChannel: params.serverChannel, - mcpStdioManager: params.mcpStdioManager, - i18n: params.i18n, - }) - - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/chat', { - query: { - 'stage-runtime': 'minimal', - 'synced-leader': 'false', - }, - })) - - return window - }).getWindow -} diff --git a/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts deleted file mode 100644 index 25f228e9f..000000000 --- a/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../../libs/i18n' -import type { ServerChannel } from '../../../services/airi/channel-server' -import type { McpStdioManager } from '../../../services/airi/mcp-servers' -import type { WidgetsWindowManager } from '../../widgets' - -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { ipcMain } from 'electron' - -import { electronOpenMainDevtools } from '../../../../shared/eventa' -import { createMcpServersService } from '../../../services/airi/mcp-servers' -import { createWidgetsService } from '../../../services/airi/widgets' -import { setupBaseWindowElectronInvokes } from '../../shared/window' - -export async function setupChatWindowElectronInvokes(params: { - window: BrowserWindow - widgetsManager: WidgetsWindowManager - serverChannel: ServerChannel - mcpStdioManager: McpStdioManager - i18n: I18n -}) { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, params.window) - - await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel }) - - createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) - createMcpServersService({ context, manager: params.mcpStdioManager }) - - defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) -} diff --git a/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts b/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts deleted file mode 100644 index c52a9498e..000000000 --- a/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { Rectangle } from 'electron' -import type { InferOutput } from 'valibot' - -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' -import type { NoticeWindowManager } from '../notice' -import type { SettingsWindowManager } from '../settings' - -import { dirname, join, resolve } from 'node:path' -import { env } from 'node:process' -import { fileURLToPath } from 'node:url' - -import { is } from '@electron-toolkit/utils' -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main' -import { defu } from 'defu' -import { BrowserWindow, ipcMain } from 'electron' -import { isLinux } from 'std-env' -import { array, number, object, optional, string } from 'valibot' - -import icon from '../../../../resources/icon.png?asset' - -import { electronStartDraggingWindow } from '../../../shared/eventa' -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createConfig } from '../../libs/electron/persistence' -import { protectPrivilegedWindowNavigation } from '../shared' -import { setupDashboardWindowElectronInvokes } from './rpc/index.electron' - -const appConfigSchema = object({ - windows: optional(array(object({ - title: optional(string()), - tag: string(), - x: optional(number()), - y: optional(number()), - width: optional(number()), - height: optional(number()), - }))), -}) - -type AppConfig = InferOutput - -export async function setupDashboardWindow(params: { - settingsWindow: SettingsWindowManager - chatWindow: () => Promise - noticeWindow: NoticeWindowManager - onWindowCreated?: (window: BrowserWindow) => void - serverChannel: ServerChannel - i18n: I18n -}) { - const { - setup: setupConfig, - get: getConfigRaw, - update: updateConfig, - } = createConfig('app', 'config.json', appConfigSchema, { - default: { windows: [] }, - autoHeal: true, - }) - const getConfig = (): AppConfig => getConfigRaw() ?? { windows: [] } - - setupConfig() - - const windowConfig = getConfig().windows?.find(w => w.title === 'AIRI Dashboard' && w.tag === 'dashboard') - - const window = new BrowserWindow({ - title: 'AIRI Dashboard', - width: windowConfig?.width ?? 1200.0, - height: windowConfig?.height ?? 600.0, - x: windowConfig?.x, - y: windowConfig?.y, - show: false, - icon, - webPreferences: { - preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'), - sandbox: false, - }, - }) - - if (params.onWindowCreated) { - params.onWindowCreated(window) - } - - // NOTICE: in development mode, open devtools by default - if (is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG) { - try { - window.webContents.openDevTools({ mode: 'detach' }) - } - catch (err) { - console.error('failed to open devtools:', err) - } - } - - function handleNewBounds(newBounds: Rectangle) { - const config = getConfig() - if (!config.windows || !Array.isArray(config.windows)) { - config.windows = [] - } - - const existingConfigIndex = config.windows.findIndex(w => w.title === 'AIRI Dashboard' && w.tag === 'dashboard') - - if (existingConfigIndex === -1) { - config.windows.push({ - title: 'AIRI Dashboard', - tag: 'dashboard', - x: newBounds.x, - y: newBounds.y, - width: newBounds.width, - height: newBounds.height, - }) - } - else { - const windowConfig = defu(config.windows[existingConfigIndex], { title: 'AIRI Dashboard', tag: 'dashboard' }) - - windowConfig.x = newBounds.x - windowConfig.y = newBounds.y - windowConfig.width = newBounds.width - windowConfig.height = newBounds.height - - config.windows[existingConfigIndex] = windowConfig - } - - updateConfig(config) - } - - window.on('resize', () => handleNewBounds(window.getBounds())) - window.on('move', () => handleNewBounds(window.getBounds())) - - window.on('ready-to-show', () => window!.show()) - protectPrivilegedWindowNavigation(window) - - await setupDashboardWindowElectronInvokes({ - window, - settingsWindow: params.settingsWindow, - chatWindow: params.chatWindow, - noticeWindow: params.noticeWindow, - i18n: params.i18n, - serverChannel: params.serverChannel, - }) - - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/dashboard', { - query: { 'synced-leader': 'false' }, - })) - - /** - * This is a know issue (or expected behavior maybe) to Electron. - * We don't use this approach on Linux because it's not working. - * - * Discussion: https://github.com/electron/electron/issues/37789 - * Workaround: https://github.com/noobfromph/electron-click-drag-plugin - */ - if (!isLinux) { - const { default: clickDragPlugin } = await import('electron-click-drag-plugin') - - function handleStartDraggingWindow() { - try { - const windowId = window.getNativeWindowHandle() - clickDragPlugin.startDrag(windowId) - } - catch (error) { - console.error(error) - } - } - - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, window) - const cleanUpWindowDraggingInvokeHandler = defineInvokeHandler(context, electronStartDraggingWindow, handleStartDraggingWindow) - - window.on('closed', () => { - cleanUpWindowDraggingInvokeHandler() - }) - } - - initScreenCaptureForWindow(window) - - return window -} diff --git a/apps/stage-tamagotchi/src/main/windows/dashboard/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/dashboard/rpc/index.electron.ts deleted file mode 100644 index 44628227e..000000000 --- a/apps/stage-tamagotchi/src/main/windows/dashboard/rpc/index.electron.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../../libs/i18n' -import type { ServerChannel } from '../../../services/airi/channel-server' -import type { NoticeWindowManager } from '../../notice' -import type { SettingsWindowManager } from '../../settings' - -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { ipcMain } from 'electron' - -import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa' -import { toggleWindowShow } from '../../shared' -import { setupBaseWindowElectronInvokes } from '../../shared/window' - -export async function setupDashboardWindowElectronInvokes(params: { - window: BrowserWindow - settingsWindow: SettingsWindowManager - chatWindow: () => Promise - noticeWindow: NoticeWindowManager - i18n: I18n - serverChannel: ServerChannel -}) { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, params.window) - - await setupBaseWindowElectronInvokes({ context, window: params.window, serverChannel: params.serverChannel, i18n: params.i18n }) - - defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) - defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route)) - defineInvokeHandler(context, electronOpenChat, async () => toggleWindowShow(await params.chatWindow())) - defineInvokeHandler(context, noticeWindowEventa.openWindow, payload => params.noticeWindow.open(payload)) -} diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts deleted file mode 100644 index eef5f658f..000000000 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Desktop Grounding Overlay — transparent always-on-top window - * - * Renders: - * - Ghost pointer dot at the snap-resolved click position - * - Bounding box around the matched target candidate - * - Source label + confidence badge - * - Stale flags - * - * Gated by AIRI_DESKTOP_OVERLAY=1 environment variable. - * When disabled, this module is a no-op. - * - * Data flow (v1): - * - The overlay renderer polls `computer_use::desktop_get_state` via the MCP bridge - * - No IPC push from main process to renderer - * - No Eventa channels or server push - * - * The overlay is click-through (setIgnoreMouseEvents) so it never - * intercepts real user or OS-level click events. - */ - -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' -import type { McpStdioManager } from '../../services/airi/mcp-servers' - -import { join, resolve } from 'node:path' -import { env } from 'node:process' - -import { BrowserWindow, screen } from 'electron' - -import { desktopOverlayPollHeartbeatMarker, desktopOverlayPollHeartbeatQueryParam } from '../../../shared/desktop-overlay-heartbeat' -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { protectPrivilegedWindowNavigation } from '../shared/window' -import { setupDesktopOverlayElectronInvokes } from './rpc/index.electron' -import { - applyDesktopOverlayInputIsolation, - createDesktopOverlayWindowOptions, - showDesktopOverlayWithoutFocus, -} from './window-contract' - -/** Whether the desktop overlay feature is enabled */ -export function isDesktopOverlayEnabled(): boolean { - return env.AIRI_DESKTOP_OVERLAY === '1' -} - -/** - * Smoke-only overlay heartbeat mode. - * The recut desktop smoke uses this to surface renderer console lines and - * mount the in-page smoke bridge. - */ -export function isDesktopOverlayPollHeartbeatEnabled(): boolean { - return env.AIRI_DESKTOP_OVERLAY_POLL_HEARTBEAT === '1' -} - -let overlayWindow: BrowserWindow | null = null - -/** - * Create the transparent overlay window covering the full primary display. - * The window is: - * - Always on top (screen level) - * - Click-through (ignoreMouseEvents) - * - Transparent and frameless - * - Not shown in taskbar / dock - * - * Returns null if AIRI_DESKTOP_OVERLAY is not set. - */ -export async function setupDesktopOverlayWindow(params: { - mcpStdioManager: McpStdioManager - serverChannel: ServerChannel - i18n: I18n -}): Promise { - if (!isDesktopOverlayEnabled()) { - return null - } - - // Use primary display bounds (not just size) — the origin may be non-zero - // when multiple displays are arranged in macOS Display Preferences. - const primaryDisplay = screen.getPrimaryDisplay() - const preloadPath = join(getElectronMainDirname(), '../preload/index.mjs') - - overlayWindow = new BrowserWindow(createDesktopOverlayWindowOptions({ - bounds: primaryDisplay.bounds, - preloadPath, - })) - protectPrivilegedWindowNavigation(overlayWindow) - applyDesktopOverlayInputIsolation(overlayWindow) - - overlayWindow.on('ready-to-show', () => { - if (overlayWindow) - showDesktopOverlayWithoutFocus(overlayWindow) - }) - - overlayWindow.on('closed', () => { - overlayWindow = null - }) - - if (isDesktopOverlayPollHeartbeatEnabled()) { - overlayWindow.webContents.on('console-message', (_event, _level, message) => { - if (message.includes(desktopOverlayPollHeartbeatMarker)) { - console.info(message) - } - }) - } - - // NOTICE: Wire eventa RPC BEFORE loading the renderer page. - // The overlay's onMounted fires during load() and immediately starts - // polling via callTool. If the handlers aren't registered yet, the - // first eventa invoke hangs forever (no response dispatched back to - // this window), and all subsequent poll cycles never fire because - // the poll loop awaits each call sequentially. - await setupDesktopOverlayElectronInvokes({ - window: overlayWindow, - mcpStdioManager: params.mcpStdioManager, - serverChannel: params.serverChannel, - i18n: params.i18n, - }) - - // Load the overlay renderer page - await load( - overlayWindow, - withHashRoute( - baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), - isDesktopOverlayPollHeartbeatEnabled() - ? `/desktop-overlay?${desktopOverlayPollHeartbeatQueryParam}=1` - : '/desktop-overlay', - { query: { 'synced-leader': 'false' } }, - ), - ) - - return overlayWindow -} - -/** - * Get the current overlay window instance (if active). - */ -export function getDesktopOverlayWindow(): BrowserWindow | null { - return overlayWindow -} - -/** - * Tear down the overlay window. - */ -export function destroyDesktopOverlay(): void { - if (overlayWindow && !overlayWindow.isDestroyed()) { - overlayWindow.close() - overlayWindow = null - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/contracts.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/contracts.ts deleted file mode 100644 index ef9aedbf2..000000000 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/contracts.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { DesktopOverlayReadiness } from '../../../../shared/eventa' -export { getDesktopOverlayReadinessContract } from '../../../../shared/eventa' diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.test.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.test.ts deleted file mode 100644 index 349fb199d..000000000 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../../libs/i18n' -import type { ServerChannel } from '../../../services/airi/channel-server' -import type { McpStdioManager } from '../../../services/airi/mcp-servers' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { setupDesktopOverlayElectronInvokes } from './index.electron' - -const defineInvokeHandlerMock = vi.hoisted(() => vi.fn()) -const createContextMock = vi.hoisted(() => vi.fn(() => ({ context: { id: 'desktop-overlay-test' } }))) -const setupBaseWindowElectronInvokesMock = vi.hoisted(() => vi.fn()) -const createMcpServersServiceMock = vi.hoisted(() => vi.fn()) -const ipcMainMock = vi.hoisted(() => ({ setMaxListeners: vi.fn() })) - -vi.mock('@moeru/eventa', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - defineInvokeHandler: defineInvokeHandlerMock, - } -}) - -vi.mock('@moeru/eventa/adapters/electron/main', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createContext: createContextMock, - } -}) - -vi.mock('electron', () => ({ - ipcMain: ipcMainMock, -})) - -vi.mock('../../shared/window', () => ({ - setupBaseWindowElectronInvokes: setupBaseWindowElectronInvokesMock, -})) - -vi.mock('../../../services/airi/mcp-servers', () => ({ - createMcpServersService: createMcpServersServiceMock, -})) - -describe('setupDesktopOverlayElectronInvokes', () => { - const window = {} as BrowserWindow - const mcpStdioManager = {} as McpStdioManager - const serverChannel = {} as ServerChannel - const i18n = {} as I18n - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('publishes ready after the base window invokes and MCP services are wired', async () => { - let readinessHandler: (() => Promise<{ state: 'booting' | 'ready' | 'degraded', error?: string }>) | undefined - - defineInvokeHandlerMock.mockImplementation((_context, _contract, handler) => { - readinessHandler = handler - }) - setupBaseWindowElectronInvokesMock.mockResolvedValue(undefined) - createMcpServersServiceMock.mockReturnValue(undefined) - - await setupDesktopOverlayElectronInvokes({ - window, - mcpStdioManager, - serverChannel, - i18n, - }) - - expect(ipcMainMock.setMaxListeners).toHaveBeenCalledWith(0) - expect(createContextMock).toHaveBeenCalledTimes(1) - expect(setupBaseWindowElectronInvokesMock).toHaveBeenCalledTimes(1) - expect(createMcpServersServiceMock).toHaveBeenCalledTimes(1) - expect(readinessHandler).toBeDefined() - await expect(readinessHandler!()).resolves.toEqual({ state: 'ready' }) - }) - - it('publishes degraded when the base window invokes fail', async () => { - let readinessHandler: (() => Promise<{ state: 'booting' | 'ready' | 'degraded', error?: string }>) | undefined - - defineInvokeHandlerMock.mockImplementation((_context, _contract, handler) => { - readinessHandler = handler - }) - setupBaseWindowElectronInvokesMock.mockRejectedValueOnce(new Error('boom')) - - await setupDesktopOverlayElectronInvokes({ - window, - mcpStdioManager, - serverChannel, - i18n, - }) - - expect(createMcpServersServiceMock).not.toHaveBeenCalled() - expect(readinessHandler).toBeDefined() - await expect(readinessHandler!()).resolves.toEqual({ state: 'degraded', error: 'boom' }) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.ts deleted file mode 100644 index 357afc1e3..000000000 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Desktop Overlay Window — Electron RPC bootstrap - * - * Minimal eventa context setup for the overlay BrowserWindow. - * Only registers base window services and MCP tool services — - * the overlay only needs callTool/listTools for polling - * `computer_use::desktop_get_state`. - * - * Follows the same pattern as main/chat/settings window RPC setups. - */ - -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../../libs/i18n' -import type { ServerChannel } from '../../../services/airi/channel-server' -import type { McpStdioManager } from '../../../services/airi/mcp-servers' -import type { DesktopOverlayReadiness } from './contracts' - -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { errorMessageFromValue } from '@proj-airi/stage-shared' -import { ipcMain } from 'electron' - -import { getDesktopOverlayReadinessContract } from '../../../../shared/eventa' -import { createMcpServersService } from '../../../services/airi/mcp-servers' -import { setupBaseWindowElectronInvokes } from '../../shared/window' - -export async function setupDesktopOverlayElectronInvokes(params: { - window: BrowserWindow - mcpStdioManager: McpStdioManager - serverChannel: ServerChannel - i18n: I18n -}) { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, params.window) - - let readiness: DesktopOverlayReadiness = { state: 'booting' } - - defineInvokeHandler(context, getDesktopOverlayReadinessContract, async () => { - return readiness - }) - - try { - await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel }) - createMcpServersService({ context, manager: params.mcpStdioManager }) - readiness = { state: 'ready' } - } - catch (error) { - readiness = { - state: 'degraded', - error: errorMessageFromValue(error), - } - // We intentionally don't throw here so the window still opens and - // the renderer gracefully detects the degraded state via polling. - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.test.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.test.ts deleted file mode 100644 index fa6d19f37..000000000 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - applyDesktopOverlayInputIsolation, - createDesktopOverlayWindowOptions, - showDesktopOverlayWithoutFocus, -} from './window-contract' - -describe('createDesktopOverlayWindowOptions', () => { - it('creates non-focusable transparent overlay window options for display bounds', () => { - const options = createDesktopOverlayWindowOptions({ - bounds: { x: -222, y: -1080, width: 1920, height: 1080 }, - preloadPath: '/tmp/airi-overlay-preload.js', - }) - - expect(options.title).toBe('AIRI Desktop Overlay') - expect(options.x).toBe(-222) - expect(options.y).toBe(-1080) - expect(options.width).toBe(1920) - expect(options.height).toBe(1080) - expect(options.show).toBe(false) - expect(options.frame).toBe(false) - expect(options.transparent).toBe(true) - expect(options.alwaysOnTop).toBe(true) - expect(options.skipTaskbar).toBe(true) - expect(options.hasShadow).toBe(false) - expect(options.roundedCorners).toBe(false) - expect(options.focusable).toBe(false) - expect(options.webPreferences?.preload).toBe('/tmp/airi-overlay-preload.js') - expect(options.webPreferences?.sandbox).toBe(false) - expect(options.webPreferences?.backgroundThrottling).toBe(false) - }) -}) - -describe('applyDesktopOverlayInputIsolation', () => { - it('applies click-through and non-interactive overlay window flags', () => { - const window = { - setAlwaysOnTop: vi.fn(), - setContentProtection: vi.fn(), - setIgnoreMouseEvents: vi.fn(), - setVisibleOnAllWorkspaces: vi.fn(), - } - - applyDesktopOverlayInputIsolation(window) - - expect(window.setIgnoreMouseEvents).toHaveBeenCalledWith(true, { forward: true }) - expect(window.setAlwaysOnTop).toHaveBeenCalledWith(true, 'screen-saver') - expect(window.setContentProtection).toHaveBeenCalledWith(true) - expect(window.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, { visibleOnFullScreen: true }) - }) -}) - -describe('showDesktopOverlayWithoutFocus', () => { - it('uses showInactive and never calls active show or focus paths', () => { - const window = { - focus: vi.fn(), - show: vi.fn(), - showInactive: vi.fn(), - } - - showDesktopOverlayWithoutFocus(window) - - expect(window.showInactive).toHaveBeenCalledTimes(1) - expect(window.show).not.toHaveBeenCalled() - expect(window.focus).not.toHaveBeenCalled() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.ts deleted file mode 100644 index 17532fcca..000000000 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { BrowserWindow, BrowserWindowConstructorOptions, Rectangle } from 'electron' - -/** - * Build BrowserWindow options for the desktop grounding overlay. - * - * Use when: - * - Creating the transparent desktop overlay BrowserWindow - * - Testing overlay input-isolation without starting Electron - * - * Expects: - * - `bounds` are Electron screen logical coordinates for the display being covered - * - `preloadPath` is an absolute path to the renderer preload script - * - * Returns: - * - BrowserWindow options that keep the overlay visual-only and non-focusable - */ -export function createDesktopOverlayWindowOptions(params: { - bounds: Rectangle - preloadPath: string -}): BrowserWindowConstructorOptions { - return { - title: 'AIRI Desktop Overlay', - width: params.bounds.width, - height: params.bounds.height, - x: params.bounds.x, - y: params.bounds.y, - show: false, - frame: false, - transparent: true, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, - roundedCorners: false, - focusable: false, - webPreferences: { - preload: params.preloadPath, - sandbox: false, - backgroundThrottling: false, - }, - } -} - -/** - * Apply input-isolation flags to the desktop grounding overlay. - * - * Use when: - * - The overlay window has been created and must become click-through - * - The overlay should render above apps without stealing mouse or focus - * - * Expects: - * - The window is the dedicated desktop overlay window - * - * Returns: - * - Nothing; mutates Electron window flags in place - */ -export function applyDesktopOverlayInputIsolation( - window: Pick, -): void { - window.setIgnoreMouseEvents(true, { forward: true }) - window.setAlwaysOnTop(true, 'screen-saver') - window.setContentProtection(true) - window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) -} - -/** - * Show the overlay without activating or focusing it. - * - * Use when: - * - The overlay renderer is ready and should become visible - * - User focus must remain on the controlled application - * - * Expects: - * - The BrowserWindow supports Electron's `showInactive()` - * - * Returns: - * - Nothing; shows the window without stealing focus - */ -export function showDesktopOverlayWithoutFocus( - window: Pick, -): void { - window.showInactive() -} diff --git a/apps/stage-tamagotchi/src/main/windows/devtools/index.ts b/apps/stage-tamagotchi/src/main/windows/devtools/index.ts deleted file mode 100644 index 2388c049e..000000000 --- a/apps/stage-tamagotchi/src/main/windows/devtools/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { join, resolve } from 'node:path' - -import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main' -import { BrowserWindow } from 'electron' - -import icon from '../../../../resources/icon.png?asset' - -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createReusableWindow } from '../../libs/electron/window-manager' -import { protectPrivilegedWindowNavigation } from '../shared' - -export interface OpenDevtoolsWindowParams extends Partial { - key: string - route?: string -} - -export interface DevtoolsWindowManager { - openWindow: (params: OpenDevtoolsWindowParams) => Promise -} - -export function setupDevtoolsWindow(): DevtoolsWindowManager { - const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) - const defaultRoute = '/devtools' - const reusableWindows = new Map>() - - function getReusableForKey(key: string, route: string) { - const existing = reusableWindows.get(key) - if (existing) - return existing - - const reusable = createReusableWindow(async () => { - const window = new BrowserWindow({ - title: 'Devtools', - width: 1020, - height: 720, - minWidth: 640, - minHeight: 480, - show: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - // Preload exposes Electron APIs and needs Node access. - sandbox: false, - }, - }) - - window.on('ready-to-show', () => window.show()) - window.on('closed', () => { - if (reusableWindows.get(key) === reusable) - reusableWindows.delete(key) - }) - protectPrivilegedWindowNavigation(window) - initScreenCaptureForWindow(window) - - await load(window, withHashRoute(rendererBase, route, { - query: { 'synced-leader': 'false' }, - })) - return window - }) - - reusableWindows.set(key, reusable) - return reusable - } - - async function openWindow(params: OpenDevtoolsWindowParams) { - const targetRoute = params.route ?? defaultRoute - const window = await getReusableForKey(params.key, targetRoute).getWindow() - - if (params && (params.width !== undefined || params.height !== undefined || params.x !== undefined || params.y !== undefined)) { - const bounds: Partial = {} - if (params.width !== undefined) - bounds.width = params.width - if (params.height !== undefined) - bounds.height = params.height - if (params.x !== undefined) - bounds.x = params.x - if (params.y !== undefined) - bounds.y = params.y - window.setBounds(bounds) - } - - return window - } - - return { - openWindow, - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/editor/index.ts b/apps/stage-tamagotchi/src/main/windows/editor/index.ts deleted file mode 100644 index b724e6779..000000000 --- a/apps/stage-tamagotchi/src/main/windows/editor/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' - -import { join, resolve } from 'node:path' - -import { BrowserWindow as ElectronBrowserWindow } from 'electron' - -import icon from '../../../../resources/icon.png?asset' - -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createReusableWindow } from '../../libs/electron/window-manager' -import { protectPrivilegedWindowNavigation, toggleWindowShow } from '../shared' -import { setupEditorWindowInvokes } from './rpc/index.electron' - -export interface EditorWindowManager { - /** Returns the live editor window, creating it when necessary. */ - getWindow: () => Promise - /** Opens and focuses the reusable editor window. */ - openWindow: () => Promise -} - -/** - * Creates the reusable window boundary for the Tamagotchi editor. - * - * The renderer starts at an empty Tamagotchi-owned route so the editor can - * evolve independently from the current settings window and shared pages. - */ -export function setupEditorWindowManager(params: { - i18n: I18n - serverChannel: ServerChannel -}): EditorWindowManager { - const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) - const reusable = createReusableWindow(async () => { - const window = new ElectronBrowserWindow({ - title: 'AIRI Editor', - width: 1200, - height: 800, - minWidth: 800, - minHeight: 600, - show: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - }) - - window.on('ready-to-show', () => window.show()) - protectPrivilegedWindowNavigation(window) - - await setupEditorWindowInvokes({ - window, - i18n: params.i18n, - serverChannel: params.serverChannel, - }) - await load(window, withHashRoute(rendererBase, '/editor', { - query: { - 'stage-runtime': 'minimal', - 'synced-leader': 'false', - }, - })) - - return window - }) - - async function openWindow() { - toggleWindowShow(await reusable.getWindow()) - } - - return { - getWindow: reusable.getWindow, - openWindow, - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/editor/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/editor/rpc/index.electron.ts deleted file mode 100644 index 7dc1ac88c..000000000 --- a/apps/stage-tamagotchi/src/main/windows/editor/rpc/index.electron.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../../libs/i18n' -import type { ServerChannel } from '../../../services/airi/channel-server' - -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { ipcMain } from 'electron' - -import { setupBaseWindowElectronInvokes } from '../../shared/window' - -/** - * Registers only the Electron services required by the empty editor shell. - * Feature-specific RPC handlers should be added here as the editor gains capabilities. - */ -export async function setupEditorWindowInvokes(params: { - window: BrowserWindow - i18n: I18n - serverChannel: ServerChannel -}) { - // TODO: Remove this once Eventa supports window-namespaced Electron contexts. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, params.window) - - await setupBaseWindowElectronInvokes({ - context, - window: params.window, - i18n: params.i18n, - serverChannel: params.serverChannel, - }) - - return context -} diff --git a/apps/stage-tamagotchi/src/main/windows/inlay/index.ts b/apps/stage-tamagotchi/src/main/windows/inlay/index.ts deleted file mode 100644 index 154d809ae..000000000 --- a/apps/stage-tamagotchi/src/main/windows/inlay/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' - -import { join, resolve } from 'node:path' - -import { BrowserWindow } from 'electron' -import { isMacOS } from 'std-env' - -import icon from '../../../../resources/icon.png?asset' - -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { currentDisplayBounds, mapForBreakpoints, resolutionBreakpoints, widthFrom } from '../shared/display' -import { protectPrivilegedWindowNavigation, spotlightLikeWindowConfig } from '../shared/window' -import { setupInlayWindowInvokes } from './rpc/index.electron' - -export async function setupInlayWindow(params: { - serverChannel: ServerChannel - i18n: I18n -}) { - const window = new BrowserWindow({ - title: 'Inlay', - width: 450, - height: 150, - show: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - ...spotlightLikeWindowConfig(), - }) - - if (isMacOS) { - window.setWindowButtonVisibility(false) - } - - const displayBounds = currentDisplayBounds(window) - const width = mapForBreakpoints( - displayBounds.width, - { - '720p': widthFrom(displayBounds, { percentage: 1, max: { percentage: 0.5 } }), - '1080p': widthFrom(displayBounds, { percentage: 1, max: { percentage: 0.33 } }), - '2k': widthFrom(displayBounds, { percentage: 0.25, max: { actual: 710 } }), - '4k': widthFrom(displayBounds, { percentage: 0.2, max: { actual: 768 } }), - }, - { breakpoints: resolutionBreakpoints }, - ) - const height = width / 4 - - window.setBounds({ - width, - height: width / 4, - x: displayBounds.x + (displayBounds.width - width) / 2, // Center horizontally - y: mapForBreakpoints( - displayBounds.height, - { - sm: displayBounds.height / 4 * 3 - height, // Bottom quarter, minus window height - md: displayBounds.height / 5 * 4 - height, // Center vertically - lg: displayBounds.height / 6 * 5 - height, // Top quarter, minus half window height - }, - ), - }) - - window.on('ready-to-show', () => window.show()) - protectPrivilegedWindowNavigation(window) - - await setupInlayWindowInvokes({ inlayWindow: window, serverChannel: params.serverChannel, i18n: params.i18n }) - - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/inlay', { - query: { 'synced-leader': 'false' }, - })) - - return window -} diff --git a/apps/stage-tamagotchi/src/main/windows/inlay/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/inlay/rpc/index.electron.ts deleted file mode 100644 index e672e142e..000000000 --- a/apps/stage-tamagotchi/src/main/windows/inlay/rpc/index.electron.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../../libs/i18n' -import type { ServerChannel } from '../../../services/airi/channel-server' - -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { ipcMain } from 'electron' - -import { setupBaseWindowElectronInvokes } from '../../shared/window' - -export async function setupInlayWindowInvokes(params: { - inlayWindow: BrowserWindow - serverChannel: ServerChannel - i18n: I18n -}) { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, params.inlayWindow) - - await setupBaseWindowElectronInvokes({ - context, - window: params.inlayWindow, - serverChannel: params.serverChannel, - i18n: params.i18n, - }) -} diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts deleted file mode 100644 index 391455dc6..000000000 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { Rectangle } from 'electron' -import type { InferOutput } from 'valibot' - -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' -import type { GodotStageManager } from '../../services/airi/godot-stage' -import type { McpStdioManager } from '../../services/airi/mcp-servers' -import type { AutoUpdater } from '../../services/electron/auto-updater' -import type { EditorWindowManager } from '../editor' -import type { NoticeWindowManager } from '../notice' -import type { OnboardingWindowManager } from '../onboarding' -import type { SettingsWindowManager } from '../settings' -import type { WidgetsWindowManager } from '../widgets' - -import { dirname, join, resolve } from 'node:path' -import { env } from 'node:process' -import { fileURLToPath } from 'node:url' - -import { is } from '@electron-toolkit/utils' -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main' -import { defu } from 'defu' -import { BrowserWindow, ipcMain } from 'electron' -import { isLinux, isMacOS } from 'std-env' -import { array, number, object, optional, string } from 'valibot' - -import icon from '../../../../resources/icon.png?asset' - -import { electronStartDraggingWindow } from '../../../shared/eventa' -import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle' -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createConfig } from '../../libs/electron/persistence' -import { protectPrivilegedWindowNavigation, setWindowAlwaysOnTop, transparentWindowConfig } from '../shared' -import { setupMainWindowElectronInvokes } from './rpc/index.electron' - -const appConfigSchema = object({ - windows: optional(array(object({ - title: optional(string()), - tag: string(), - x: optional(number()), - y: optional(number()), - width: optional(number()), - height: optional(number()), - }))), -}) - -type AppConfig = InferOutput - -export async function setupMainWindow(params: { - editorWindow: EditorWindowManager - settingsWindow: SettingsWindowManager - chatWindow: () => Promise - widgetsManager: WidgetsWindowManager - noticeWindow: NoticeWindowManager - autoUpdater: AutoUpdater - onWindowCreated?: (window: BrowserWindow) => void - serverChannel: ServerChannel - godotStageManager: GodotStageManager - mcpStdioManager: McpStdioManager - i18n: I18n - onboardingWindowManager: OnboardingWindowManager -}) { - const { - setup: setupConfig, - get: getConfigRaw, - update: updateConfig, - } = createConfig('app', 'config.json', appConfigSchema, { - default: { windows: [] }, - autoHeal: true, - }) - const getConfig = (): AppConfig => getConfigRaw() ?? { windows: [] } - - setupConfig() - - const mainWindowConfig = getConfig().windows?.find(w => w.title === 'AIRI' && w.tag === 'main') - - const window = new BrowserWindow({ - title: 'AIRI', - width: mainWindowConfig?.width ?? 450.0, - height: mainWindowConfig?.height ?? 600.0, - x: mainWindowConfig?.x, - y: mainWindowConfig?.y, - show: false, - icon, - webPreferences: { - preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'), - sandbox: false, - }, - // Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation. - // - // https://github.com/electron/electron/issues/10078#issuecomment-3410164802 - // https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac - type: isMacOS ? 'panel' : undefined, - ...transparentWindowConfig(), - }) - - if (params.onWindowCreated) { - params.onWindowCreated(window) - } - - let allowClose = false - onAppBeforeQuit(() => { - allowClose = true - }) - - // NOTICE: in development mode, open devtools by default - if (is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG) { - try { - window.webContents.openDevTools({ mode: 'detach' }) - } - catch (err) { - console.error('failed to open devtools:', err) - } - } - - function handleNewBounds(newBounds: Rectangle) { - const config = getConfig() - if (!config.windows || !Array.isArray(config.windows)) { - config.windows = [] - } - - const existingConfigIndex = config.windows.findIndex(w => w.title === 'AIRI' && w.tag === 'main') - - if (existingConfigIndex === -1) { - config.windows.push({ - title: 'AIRI', - tag: 'main', - x: newBounds.x, - y: newBounds.y, - width: newBounds.width, - height: newBounds.height, - }) - } - else { - const mainWindowConfig = defu(config.windows[existingConfigIndex], { title: 'AIRI', tag: 'main' }) - - mainWindowConfig.x = newBounds.x - mainWindowConfig.y = newBounds.y - mainWindowConfig.width = newBounds.width - mainWindowConfig.height = newBounds.height - - config.windows[existingConfigIndex] = mainWindowConfig - } - - updateConfig(config) - } - - window.on('resize', () => handleNewBounds(window.getBounds())) - window.on('move', () => handleNewBounds(window.getBounds())) - window.on('close', (event) => { - if (allowClose) { - return - } - - event.preventDefault() - window.hide() - }) - - // Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation. - // - // https://github.com/electron/electron/issues/10078#issuecomment-3410164802 - // https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac - window.setVisibleOnAllWorkspaces(true) - if (isMacOS) { - window.setFullScreenable(false) - window.setWindowButtonVisibility(false) - } - setWindowAlwaysOnTop(window, true) - - window.on('ready-to-show', () => window!.show()) - protectPrivilegedWindowNavigation(window) - - await setupMainWindowElectronInvokes({ - window, - editorWindow: params.editorWindow, - settingsWindow: params.settingsWindow, - chatWindow: params.chatWindow, - widgetsManager: params.widgetsManager, - noticeWindow: params.noticeWindow, - autoUpdater: params.autoUpdater, - serverChannel: params.serverChannel, - godotStageManager: params.godotStageManager, - mcpStdioManager: params.mcpStdioManager, - i18n: params.i18n, - onboardingWindowManager: params.onboardingWindowManager, - }) - - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/', { - query: { 'synced-leader': 'true' }, - })) - - /** - * This is a know issue (or expected behavior maybe) to Electron. - * We don't use this approach on Linux because it's not working. - * - * Discussion: https://github.com/electron/electron/issues/37789 - * Workaround: https://github.com/noobfromph/electron-click-drag-plugin - */ - if (!isLinux) { - const { default: clickDragPlugin } = await import('electron-click-drag-plugin') - - function handleStartDraggingWindow() { - try { - const windowId = window.getNativeWindowHandle() - clickDragPlugin.startDrag(windowId) - } - catch (error) { - console.error(error) - } - } - - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, window) - const cleanUpWindowDraggingInvokeHandler = defineInvokeHandler(context, electronStartDraggingWindow, handleStartDraggingWindow) - - window.on('closed', () => { - cleanUpWindowDraggingInvokeHandler() - }) - } - - initScreenCaptureForWindow(window) - - return window -} diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts deleted file mode 100644 index f38190978..000000000 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../../libs/i18n' -import type { ServerChannel } from '../../../services/airi/channel-server' -import type { GodotStageManager } from '../../../services/airi/godot-stage' -import type { McpStdioManager } from '../../../services/airi/mcp-servers' -import type { AutoUpdater } from '../../../services/electron/auto-updater' -import type { EditorWindowManager } from '../../editor' -import type { NoticeWindowManager } from '../../notice' -import type { OnboardingWindowManager } from '../../onboarding' -import type { SettingsWindowManager } from '../../settings' -import type { WidgetsWindowManager } from '../../widgets' - -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { ipcMain } from 'electron' - -import { electronCenterMainWindow, electronOpenChat, electronOpenEditor, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa' -import { createAuthService } from '../../../services/airi/auth' -import { createGodotStageService } from '../../../services/airi/godot-stage' -import { createMcpServersService } from '../../../services/airi/mcp-servers' -import { createOnboardingService } from '../../../services/airi/onboarding' -import { createWidgetsService } from '../../../services/airi/widgets' -import { createAutoUpdaterService } from '../../../services/electron' -import { toggleWindowShow } from '../../shared' -import { centerWindowOnDisplay } from '../../shared/display' -import { setupBaseWindowElectronInvokes } from '../../shared/window' - -export async function setupMainWindowElectronInvokes(params: { - window: BrowserWindow - editorWindow: EditorWindowManager - settingsWindow: SettingsWindowManager - chatWindow: () => Promise - widgetsManager: WidgetsWindowManager - noticeWindow: NoticeWindowManager - autoUpdater: AutoUpdater - serverChannel: ServerChannel - godotStageManager: GodotStageManager - mcpStdioManager: McpStdioManager - i18n: I18n - onboardingWindowManager: OnboardingWindowManager -}) { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, params.window) - - await setupBaseWindowElectronInvokes({ context, window: params.window, serverChannel: params.serverChannel, i18n: params.i18n }) - createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) - createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) - createMcpServersService({ context, manager: params.mcpStdioManager }) - createGodotStageService({ context, manager: params.godotStageManager, window: params.window }) - createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window }) - createAuthService({ context, window: params.window }) - - defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.window)) - defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) - defineInvokeHandler(context, electronOpenEditor, () => params.editorWindow.openWindow()) - defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route)) - defineInvokeHandler(context, electronOpenChat, async () => toggleWindowShow(await params.chatWindow())) - defineInvokeHandler(context, noticeWindowEventa.openWindow, payload => params.noticeWindow.open(payload)) -} diff --git a/apps/stage-tamagotchi/src/main/windows/notice/index.ts b/apps/stage-tamagotchi/src/main/windows/notice/index.ts deleted file mode 100644 index f1acb1e26..000000000 --- a/apps/stage-tamagotchi/src/main/windows/notice/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { RequestWindowPayload } from '../../../shared/eventa' -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' - -import { join, resolve } from 'node:path' - -import { defineInvokeHandler } from '@moeru/eventa' -import { safeClose } from '@proj-airi/electron-vueuse/main' -import { BrowserWindow as ElectronBrowserWindow } from 'electron' - -import icon from '../../../../resources/icon.png?asset' - -import { noticeWindowEventa } from '../../../shared/eventa' -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createReferencedWindowManager } from '../shared/referenced-window' -import { protectPrivilegedWindowNavigation } from '../shared/window' - -export interface NoticeWindowManager { - open: (payload: RequestWindowPayload) => Promise -} - -export function setupNoticeWindowManager(params: { - i18n: I18n - serverChannel: ServerChannel -}): NoticeWindowManager { - const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) - - function createWindow(_id: string): BrowserWindow { - const window = new ElectronBrowserWindow({ - title: 'Notice', - width: 1020, - height: 600, - show: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - }) - - protectPrivilegedWindowNavigation(window) - - return window - } - - async function loadNoticeRoute(window: BrowserWindow, payload: RequestWindowPayload & { id: string }) { - const routeWithId = `${payload.route}?id=${payload.id}` - await load(window, withHashRoute(rendererBase, routeWithId, { - query: { 'synced-leader': 'false' }, - })) - } - - const manager = createReferencedWindowManager({ - eventa: noticeWindowEventa, - i18n: params.i18n, - serverChannel: params.serverChannel, - createWindow, - loadRoute: loadNoticeRoute, - }) - - return { - open: async (payload: RequestWindowPayload) => { - const handle = await manager.open(payload) - return await new Promise((resolve) => { - defineInvokeHandler(handle.context, noticeWindowEventa.windowAction, (action) => { - if (!action?.id || action.id !== handle.id) - return - resolve(action.action === 'confirm') - safeClose(handle.window) - }) - }) - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts deleted file mode 100644 index 722f8981f..000000000 --- a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' - -import { join, resolve } from 'node:path' - -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { safeClose } from '@proj-airi/electron-vueuse/main' -import { BrowserWindow, ipcMain } from 'electron' -import { isMacOS } from 'std-env' - -import icon from '../../../../resources/icon.png?asset' - -import { electronOnboardingClose } from '../../../shared/eventa' -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createReusableWindow } from '../../libs/electron/window-manager' -import { createAuthService } from '../../services/airi/auth' -import { protectPrivilegedWindowNavigation, toggleWindowShow } from '../shared' -import { setupBaseWindowElectronInvokes } from '../shared/window' - -export interface OnboardingWindowManager { - getWindow: () => Promise - getAndToggleWindow: () => Promise - onClosed: (callback: () => void) => () => void -} - -export function setupOnboardingWindowManager(params: { - serverChannel: ServerChannel - i18n: I18n -}): OnboardingWindowManager { - const closeCallbacks = new Set<() => void>() - - async function getOnboardingWindow(getWindow: () => Promise) { - const window = await getWindow() - await toggleWindowShow(window) - - return window - } - - const reusableWindow = createReusableWindow(async () => { - const newWindow = new BrowserWindow({ - title: 'Welcome to AIRI', - width: 1000, - height: 650, - minWidth: 400, - minHeight: 500, - show: false, - icon, - resizable: true, - frame: !isMacOS, - titleBarStyle: isMacOS ? 'hidden' : undefined, - transparent: false, - backgroundColor: '#0f0f0f', - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - }) - - newWindow.on('ready-to-show', () => newWindow.show()) - protectPrivilegedWindowNavigation(newWindow) - - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, newWindow) - - defineInvokeHandler(context, electronOnboardingClose, async () => { - safeClose(newWindow) - }) - - await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel }) - createAuthService({ context, window: newWindow }) - - await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding', { - query: { 'synced-leader': 'false' }, - })) - - newWindow.on('closed', () => { - for (const cb of closeCallbacks) { - try { - cb() - } - catch { /* noop */ } - } - }) - - return newWindow - }) - - return { - getWindow: async () => reusableWindow.getWindow(), - getAndToggleWindow: async () => await getOnboardingWindow(reusableWindow.getWindow), - onClosed: (callback: () => void) => { - closeCallbacks.add(callback) - return () => { - closeCallbacks.delete(callback) - } - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts deleted file mode 100644 index fe449364e..000000000 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' -import type { GodotStageManager } from '../../services/airi/godot-stage' -import type { McpStdioManager } from '../../services/airi/mcp-servers' -import type { AutoUpdater } from '../../services/electron/auto-updater' -import type { GlobalShortcutService } from '../../services/electron/global-shortcut' -import type { DevtoolsWindowManager } from '../devtools' -import type { SpotlightWindowManager } from '../spotlight' -import type { WidgetsWindowManager } from '../widgets' - -import { join, resolve } from 'node:path' - -import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main' -import { BrowserWindow } from 'electron' - -import icon from '../../../../resources/icon.png?asset' - -import { electronSettingsNavigate } from '../../../shared/eventa' -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createReusableWindow } from '../../libs/electron/window-manager' -import { protectPrivilegedWindowNavigation, toggleWindowShow } from '../shared' -import { setupSettingsWindowInvokes } from './rpc/index.electron' - -export interface SettingsWindowManager { - getWindow: () => Promise - openWindow: (route?: string) => Promise -} - -export function setupSettingsWindowReusableFunc(params: { - widgetsManager: WidgetsWindowManager - autoUpdater: AutoUpdater - devtoolsWindow: DevtoolsWindowManager - getMainWindow?: () => BrowserWindow | undefined - onWindowCreated?: (window: BrowserWindow) => void - serverChannel: ServerChannel - godotStageManager: GodotStageManager - mcpStdioManager: McpStdioManager - i18n: I18n - globalShortcut: GlobalShortcutService - spotlightWindow: SpotlightWindowManager -}): SettingsWindowManager { - const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) - const defaultRoute = '/settings' - let currentRoute = defaultRoute - let settingsContext: Awaited> | undefined - - const reusable = createReusableWindow(async () => { - const window = new BrowserWindow({ - title: 'Settings', - width: 600.0, - height: 800.0, - show: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - }) - - if (params.onWindowCreated) { - params.onWindowCreated(window) - } - - window.on('ready-to-show', () => window.show()) - protectPrivilegedWindowNavigation(window) - - settingsContext = await setupSettingsWindowInvokes({ - settingsWindow: window, - widgetsManager: params.widgetsManager, - autoUpdater: params.autoUpdater, - devtoolsWindow: params.devtoolsWindow, - getMainWindow: params.getMainWindow, - serverChannel: params.serverChannel, - godotStageManager: params.godotStageManager, - mcpStdioManager: params.mcpStdioManager, - i18n: params.i18n, - globalShortcut: params.globalShortcut, - spotlightWindow: params.spotlightWindow, - }) - - await load(window, withHashRoute(rendererBase, currentRoute, { - query: { 'synced-leader': 'false' }, - })) - - window.on('closed', () => { - if (settingsContext) - settingsContext = undefined - }) - - initScreenCaptureForWindow(window) - - return window - }) - - async function openWindow(route?: string) { - if (route) { - currentRoute = route - } - - const window = await reusable.getWindow() - - if (route && settingsContext) { - settingsContext.emit(electronSettingsNavigate, { route }) - } - - toggleWindowShow(window) - } - - return { - getWindow: reusable.getWindow, - openWindow, - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts deleted file mode 100644 index 3459d1184..000000000 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../../libs/i18n' -import type { ServerChannel } from '../../../services/airi/channel-server' -import type { GodotStageManager } from '../../../services/airi/godot-stage' -import type { McpStdioManager } from '../../../services/airi/mcp-servers' -import type { AutoUpdater } from '../../../services/electron/auto-updater' -import type { GlobalShortcutService } from '../../../services/electron/global-shortcut' -import type { DevtoolsWindowManager } from '../../devtools' -import type { SpotlightWindowManager } from '../../spotlight' -import type { WidgetsWindowManager } from '../../widgets' - -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { ipcMain } from 'electron' - -import { - electronCenterMainWindow, - electronOpenDevtoolsWindow, - electronOpenSettingsDevtools, - electronSpotlightShortcutGet, - electronSpotlightShortcutSet, -} from '../../../../shared/eventa' -import { createAuthService } from '../../../services/airi/auth' -import { createGodotStageService } from '../../../services/airi/godot-stage' -import { createMcpServersService } from '../../../services/airi/mcp-servers' -import { createWidgetsService } from '../../../services/airi/widgets' -import { createAutoUpdaterService } from '../../../services/electron' -import { centerWindowOnDisplay } from '../../shared/display' -import { setupBaseWindowElectronInvokes } from '../../shared/window' - -export async function setupSettingsWindowInvokes(params: { - settingsWindow: BrowserWindow - widgetsManager: WidgetsWindowManager - autoUpdater: AutoUpdater - devtoolsWindow: DevtoolsWindowManager - getMainWindow?: () => BrowserWindow | undefined - serverChannel: ServerChannel - godotStageManager: GodotStageManager - mcpStdioManager: McpStdioManager - i18n: I18n - globalShortcut: GlobalShortcutService - spotlightWindow: SpotlightWindowManager -}) { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context } = createContext(ipcMain, params.settingsWindow) - - await setupBaseWindowElectronInvokes({ context, window: params.settingsWindow, i18n: params.i18n, serverChannel: params.serverChannel }) - - createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow }) - createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater }) - createMcpServersService({ context, manager: params.mcpStdioManager }) - createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow }) - createAuthService({ context, window: params.settingsWindow }) - - // Register the global shortcut service for the settings window. - params.globalShortcut.registerWindow({ context, window: params.settingsWindow }) - - defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.getMainWindow?.())) - defineInvokeHandler(context, electronSpotlightShortcutGet, () => params.spotlightWindow.getShortcutAccelerator()) - defineInvokeHandler(context, electronSpotlightShortcutSet, (payload) => { - if (payload?.accelerator === undefined) - throw new TypeError('electronSpotlightShortcutSet called with invalid payload') - - return params.spotlightWindow.updateShortcutAccelerator(payload.accelerator) - }) - - defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' })) - defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => { - await params.devtoolsWindow.openWindow(payload) - }) - - return context -} diff --git a/apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts b/apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts deleted file mode 100644 index df8347096..000000000 --- a/apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { BrowserWindow, Rectangle } from 'electron' - -import { describe, expect, it, vi } from 'vitest' - -import { Animator } from './animator' - -function createWindow(initialBounds: Rectangle) { - const bounds = { ...initialBounds } - - return { - getBounds: vi.fn(() => ({ ...bounds })), - isDestroyed: vi.fn(() => false), - setPosition: vi.fn((x: number, y: number) => { - bounds.x = x - bounds.y = y - }), - setSize: vi.fn((width: number, height: number) => { - bounds.width = width - bounds.height = height - }), - } satisfies Pick -} - -function waitForAnimation(): Promise { - return new Promise(resolve => setTimeout(resolve, 30)) -} - -describe('window bounds animator', () => { - it('animates position after it applies the target size', async () => { - const window = createWindow({ x: 10, y: 20, width: 300, height: 400 }) - const animator = new Animator(window) - - animator.windowBoundsAnimateTo( - { x: 100, y: 200, width: 450, height: 600 }, - { duration: 1 }, - ) - await waitForAnimation() - - expect(window.setSize).toHaveBeenCalledWith(450, 600) - expect(window.setPosition).toHaveBeenLastCalledWith(100, 200) - }) - - it('stops the previous animation before it starts a new animation', async () => { - const window = createWindow({ x: 10, y: 20, width: 300, height: 400 }) - const animator = new Animator(window) - - animator.windowBoundsAnimateTo( - { x: 100, y: 100, width: 300, height: 400 }, - { duration: 100 }, - ) - animator.windowBoundsAnimateTo( - { x: 200, y: 200, width: 300, height: 400 }, - { duration: 1 }, - ) - await waitForAnimation() - - expect(window.setPosition).toHaveBeenLastCalledWith(200, 200) - }) - - it('does not start an animation for a destroyed window', () => { - const window = createWindow({ x: 10, y: 20, width: 300, height: 400 }) - window.isDestroyed.mockReturnValue(true) - const animator = new Animator(window) - - animator.windowBoundsAnimateTo({ x: 100, y: 200, width: 300, height: 400 }) - - expect(window.setPosition).not.toHaveBeenCalled() - expect(window.setSize).not.toHaveBeenCalled() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/animator.ts b/apps/stage-tamagotchi/src/main/windows/shared/animator.ts deleted file mode 100644 index 082a35bf5..000000000 --- a/apps/stage-tamagotchi/src/main/windows/shared/animator.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { BrowserWindow, Rectangle } from 'electron' - -import { animate, utils } from 'animejs' - -type AnimatableWindow = Pick - -/** Options for one window bounds animation. */ -export interface WindowBoundsAnimationOptions { - /** Animation duration in milliseconds. @default 350 */ - duration?: number -} - -/** - * Owns the active position animation for one Electron window. - * - * A new animation stops the previous animation. The class applies the target - * size before movement. It does not animate window resizing. - */ -export class Animator { - private animation?: ReturnType - - constructor(private readonly window: AnimatableWindow) {} - - /** Animates the window position to the target bounds. */ - windowBoundsAnimateTo(target: Rectangle, options: WindowBoundsAnimationOptions = {}): void { - this.stop() - - if (this.window.isDestroyed()) - return - - const current = this.window.getBounds() - - if (current.width !== target.width || current.height !== target.height) - this.window.setSize(target.width, target.height) - - const state = { x: current.x, y: current.y } - this.animation = animate(state, { - x: target.x, - y: target.y, - duration: options.duration ?? 350, - ease: 'outCubic', - modifier: utils.round(0), - onRender: () => { - if (!this.window.isDestroyed()) - this.window.setPosition(Math.round(state.x), Math.round(state.y)) - }, - }) - } - - /** Stops the active animation. */ - stop(): void { - this.animation?.pause() - this.animation = undefined - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts b/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts deleted file mode 100644 index 8e0cad4aa..000000000 --- a/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { Rectangle } from 'electron' - -import { screen } from 'electron' -import { describe, expect, it, vi } from 'vitest' - -import { - centerWindowOnDisplay, - computeCenteredWindowBounds, - computeResizedBoundsAnchoredToDominantDisplay, - heightFrom, - mapForBreakpoints, - widthFrom, -} from './display' - -// NOTICE: -// Mocking 'electron' is needed to prevent Vitest from attempting to resolve/load the real Electron binary during tests. -// The real 'electron' module depends on local binary installations which fail in headless CI environments. -// apps/stage-tamagotchi/src/main/windows/shared/display.test.ts -// Can be safely deleted if unit tests are executed inside an Electron-based test runner. -vi.mock('electron', () => ({ - screen: { - getDisplayMatching: vi.fn(), - }, -})) - -describe('mapForBreakpoints', () => { - it('should return the correct size based on breakpoints', () => { - const val = mapForBreakpoints(800, { sm: 100, md: 200, lg: 300 }) - expect(val).toBe(200) - }) - - it('it should fallback to nearest smaller breakpoint', () => { - const val = mapForBreakpoints(1024, { sm: 100, md: 200 }) // expected to be lg - expect(val).toBe(200) - }) - - it('it should return the largest supplied size if bounds exceed all breakpoints', () => { - const val1 = mapForBreakpoints(2000, { sm: 100, md: 200 }) // expected to be lg - expect(val1).toBe(200) - - const val2 = mapForBreakpoints(2000, { 'sm': 100, 'md': 200, '2xl': 500 }) // expected to be lg - expect(val2).toBe(500) - }) - - it('should fall back to the breakpoint with the smallest minimum width when below all breakpoints', () => { - // ROOT CAUSE: - // - // When `basedOn` is below every supplied breakpoint (e.g. display height 500 - // with sm/md/lg sizes), no breakpoint matches and the previous fallback was - // `Object.values(sizes)[0]`, which depends on object key insertion order. - // Sorting the sizes map (e.g. sm/md/lg -> lg/md/sm by a lint rule) changed - // the fallback from the sm formula to the lg formula and moved the inlay on - // small displays. - // - // We fixed this by selecting the breakpoint with the smallest minimum width - // explicitly, so the result is stable regardless of key order. - expect(mapForBreakpoints(500, { sm: 100, md: 200, lg: 300 })).toBe(100) - expect(mapForBreakpoints(500, { lg: 300, md: 200, sm: 100 })).toBe(100) - }) -}) - -describe('widthFrom', () => { - it('should return width based on percentage', () => { - expect(widthFrom({ width: 1000 } as Rectangle, { percentage: 0.5 })).toBe(500) - }) - - it('should return width based on fixed value', () => { - expect(widthFrom({ width: 1000 } as Rectangle, 300)).toBe(300) - }) - - it('should respect min constraint', () => { - expect(widthFrom({ width: 1000 } as Rectangle, { percentage: 0.1, min: 200 })).toBe(200) - expect(widthFrom({ width: 1000 } as Rectangle, { actual: 150, min: 200 })).toBe(200) - expect(widthFrom({ width: 1000 } as Rectangle, { actual: 250, min: 200 })).toBe(250) - }) - - it('should respect max constraint', () => { - expect(widthFrom({ width: 1000 } as Rectangle, { percentage: 0.5, max: 400 })).toBe(400) - expect(widthFrom({ width: 1000 } as Rectangle, { actual: 450, max: 400 })).toBe(400) - expect(widthFrom({ width: 1000 } as Rectangle, { actual: 350, max: 400 })).toBe(350) - }) -}) - -describe('heightFrom', () => { - it('should return height based on percentage', () => { - expect(heightFrom({ height: 1000 } as Rectangle, { percentage: 0.5 })).toBe(500) - }) - - it('should return height based on fixed value', () => { - expect(heightFrom({ height: 1000 } as Rectangle, 300)).toBe(300) - }) - - it('should respect min constraint', () => { - expect(heightFrom({ height: 1000 } as Rectangle, { percentage: 0.1, min: 200 })).toBe(200) - expect(heightFrom({ height: 1000 } as Rectangle, { actual: 150, min: 200 })).toBe(200) - expect(heightFrom({ height: 1000 } as Rectangle, { actual: 250, min: 200 })).toBe(250) - }) - - it('should respect max constraint', () => { - expect(heightFrom({ height: 1000 } as Rectangle, { percentage: 0.5, max: 400 })).toBe(400) - expect(heightFrom({ height: 1000 } as Rectangle, { actual: 450, max: 400 })).toBe(400) - expect(heightFrom({ height: 1000 } as Rectangle, { actual: 350, max: 400 })).toBe(350) - }) -}) - -describe('computeResizedBoundsAnchoredToDominantDisplay', () => { - const primaryDisplay = { - bounds: { x: 0, y: 0, width: 1920, height: 1080 }, - workArea: { x: 0, y: 25, width: 1920, height: 1055 }, - } - const secondaryDisplay = { - bounds: { x: 1920, y: 0, width: 1920, height: 1080 }, - workArea: { x: 1920, y: 0, width: 1920, height: 1040 }, - } - const topDisplay = { - bounds: { x: 0, y: -900, width: 1600, height: 900 }, - workArea: { x: 0, y: -900, width: 1600, height: 860 }, - } - - it('uses the display with the largest overlap when resizing a window across two displays', () => { - const bounds = computeResizedBoundsAnchoredToDominantDisplay({ - currentBounds: { x: 1700, y: 220, width: 500, height: 600 }, - targetSize: { width: 450, height: 600 }, - displays: [primaryDisplay, secondaryDisplay], - }) - - expect(bounds.x).toBe(1920) - expect(bounds.y).toBe(220) - expect(bounds.width).toBe(450) - expect(bounds.height).toBe(600) - }) - - it('uses the display with the largest overlap across three displays', () => { - const bounds = computeResizedBoundsAnchoredToDominantDisplay({ - currentBounds: { x: 1100, y: -700, width: 380, height: 620 }, - targetSize: { width: 450, height: 600 }, - displays: [primaryDisplay, secondaryDisplay, topDisplay], - }) - - expect(bounds.x).toBe(1030) - expect(bounds.y).toBe(-680) - expect(bounds.width).toBe(450) - expect(bounds.height).toBe(600) - }) - - it('keeps the matching display bottom-right corner anchored when resizing in the bottom-right quadrant', () => { - const bounds = computeResizedBoundsAnchoredToDominantDisplay({ - currentBounds: { x: 3420, y: 740, width: 300, height: 250 }, - targetSize: { width: 450, height: 600 }, - displays: [primaryDisplay, secondaryDisplay], - }) - - expect(bounds.x).toBe(3270) - expect(bounds.y).toBe(390) - expect(bounds.width).toBe(450) - expect(bounds.height).toBe(600) - }) -}) - -/** - * @example - * computeCenteredWindowBounds({ displayWorkArea, windowBounds }) - */ -describe('computeCenteredWindowBounds', () => { - /** - * @example - * A 450x600 window is centered without changing its size. - */ - it('preserves the window size and centers it inside the display work area', () => { - const result = computeCenteredWindowBounds({ - displayWorkArea: { x: 0, y: 25, width: 1440, height: 875 }, - windowBounds: { x: 1200, y: 700, width: 450, height: 600 }, - }) - - expect(result).toEqual({ x: 495, y: 162, width: 450, height: 600 }) - }) - - /** - * @example - * A display above and left of the primary screen keeps negative coordinates. - */ - it('supports display work areas with negative origins', () => { - const result = computeCenteredWindowBounds({ - displayWorkArea: { x: -1920, y: -1080, width: 1920, height: 1055 }, - windowBounds: { x: -2300, y: -1300, width: 500, height: 620 }, - }) - - expect(result).toEqual({ x: -1210, y: -863, width: 500, height: 620 }) - }) - - /** - * @example - * An oversized window starts at the work-area origin instead of moving farther off-screen. - */ - it('keeps oversized windows anchored inside the display work area origin', () => { - const result = computeCenteredWindowBounds({ - displayWorkArea: { x: 120, y: 45, width: 800, height: 500 }, - windowBounds: { x: -2000, y: -900, width: 1000, height: 640 }, - }) - - expect(result).toEqual({ x: 120, y: 45, width: 1000, height: 640 }) - }) -}) - -/** - * @example - * centerWindowOnDisplay(window) - */ -describe('centerWindowOnDisplay', () => { - /** - * @example - * The recovered window receives centered bounds and becomes visible. - */ - it('sets centered bounds and shows the window', () => { - const windowBounds = { x: 1200, y: 700, width: 450, height: 600 } - const displayWorkArea = { x: 0, y: 25, width: 1440, height: 875 } - const setBounds = vi.fn() - const show = vi.fn() - vi.mocked(screen.getDisplayMatching).mockReturnValue({ workArea: displayWorkArea } as Electron.Display) - - const result = centerWindowOnDisplay({ - getBounds: () => windowBounds, - isDestroyed: () => false, - setBounds, - show, - }) - - expect(result).toEqual({ x: 495, y: 162, width: 450, height: 600 }) - expect(screen.getDisplayMatching).toHaveBeenCalledWith(windowBounds) - expect(setBounds).toHaveBeenCalledWith({ x: 495, y: 162, width: 450, height: 600 }) - expect(show).toHaveBeenCalledTimes(1) - }) - - /** - * @example - * A missing main window reports a stable domain error to the renderer. - */ - it('rejects recovery when the target window is unavailable', () => { - expect(() => centerWindowOnDisplay(undefined)).toThrowError('Main AIRI window is not available.') - }) - - /** - * @example - * A destroyed window is rejected before Electron bounds methods are called. - */ - it('rejects recovery when the target window was destroyed', () => { - const getBounds = vi.fn() - - expect(() => centerWindowOnDisplay({ - getBounds, - isDestroyed: () => true, - setBounds: vi.fn(), - show: vi.fn(), - })).toThrowError('Main AIRI window is not available.') - expect(getBounds).not.toHaveBeenCalled() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/display.ts b/apps/stage-tamagotchi/src/main/windows/shared/display.ts deleted file mode 100644 index 09f6232c4..000000000 --- a/apps/stage-tamagotchi/src/main/windows/shared/display.ts +++ /dev/null @@ -1,377 +0,0 @@ -import type { BrowserWindow, Rectangle } from 'electron' - -import type { DisplayArea } from '../../../shared/utils/electron/display' - -import { screen } from 'electron' - -import { findDominantDisplayArea } from '../../../shared/utils/electron/display' - -export function currentDisplayBounds(window: BrowserWindow) { - const bounds = window.getBounds() - const nearbyDisplay = screen.getDisplayMatching(bounds) - - return nearbyDisplay.bounds -} - -/** - * Computes bounds that center a window inside an Electron display work area. - * - * Use when: - * - Recovering a desktop window that was moved outside the visible work area - * - Preserving the current window size while changing only its position - * - * Expects: - * - Both rectangles use Electron logical display coordinates - * - The display work area excludes menu bars, docks, and taskbars - * - * Returns: - * - Centered bounds that preserve the window width and height - */ -export function computeCenteredWindowBounds(options: { - displayWorkArea: Rectangle - windowBounds: Rectangle -}): Rectangle { - const centeredOffsetX = Math.floor((options.displayWorkArea.width - options.windowBounds.width) / 2) - const centeredOffsetY = Math.floor((options.displayWorkArea.height - options.windowBounds.height) / 2) - - return { - x: options.displayWorkArea.x + Math.max(0, centeredOffsetX), - y: options.displayWorkArea.y + Math.max(0, centeredOffsetY), - width: options.windowBounds.width, - height: options.windowBounds.height, - } -} - -/** - * Centers and reveals an Electron window on the display matching its current bounds. - * - * Use when: - * - A renderer requests recovery of an off-screen AIRI window - * - A hidden window must become visible after its position is restored - * - * Expects: - * - The window is alive and supports Electron's bounds APIs - * - * Returns: - * - The centered bounds applied to the window - */ -export function centerWindowOnDisplay(window: Pick | undefined): Rectangle { - if (!window || window.isDestroyed()) - throw new Error('Main AIRI window is not available.') - - const windowBounds = window.getBounds() - const displayWorkArea = screen.getDisplayMatching(windowBounds).workArea - const centeredBounds = computeCenteredWindowBounds({ displayWorkArea, windowBounds }) - - window.setBounds(centeredBounds) - window.show() - - return centeredBounds -} - -export interface DominantDisplayResizeOptions { - /** Current window bounds in Electron display coordinates. */ - currentBounds: Rectangle - /** Desired size before display work-area clamping. */ - targetSize: Pick - /** Displays from Electron screen APIs. */ - displays: readonly DisplayArea[] -} - -/** - * Computes resize bounds from the display that owns most of the current window. - */ -export function computeResizedBoundsAnchoredToDominantDisplay(options: DominantDisplayResizeOptions): Rectangle { - const targetWidth = Math.round(options.targetSize.width) - const targetHeight = Math.round(options.targetSize.height) - const display = findDominantDisplayArea(options.currentBounds, options.displays) - - if (!display) { - return { - ...options.currentBounds, - width: targetWidth, - height: targetHeight, - } - } - - const workArea = display.workArea - - // Target sizes may come from a larger display preset. Clamp them before - // deriving anchors so the right/bottom edge math never asks for coordinates - // outside the selected display's usable area. - const width = Math.min(targetWidth, workArea.width) - const height = Math.min(targetHeight, workArea.height) - const workAreaRight = workArea.x + workArea.width - const workAreaBottom = workArea.y + workArea.height - const currentRight = options.currentBounds.x + options.currentBounds.width - const currentBottom = options.currentBounds.y + options.currentBounds.height - - // The quadrant is based on the current window center, not the top-left - // corner, so a window crossing displays behaves according to where most of - // the visible window lives inside the selected work area. - const currentCenterX = options.currentBounds.x + options.currentBounds.width / 2 - const currentCenterY = options.currentBounds.y + options.currentBounds.height / 2 - const workAreaCenterX = workArea.x + workArea.width / 2 - const workAreaCenterY = workArea.y + workArea.height / 2 - - // Left/top quadrants keep the original x/y. Right/bottom quadrants keep the - // opposite edge visually fixed by subtracting the new size from the current - // right/bottom edge. - const x = currentCenterX > workAreaCenterX - ? currentRight - width - : options.currentBounds.x - const y = currentCenterY > workAreaCenterY - ? currentBottom - height - : options.currentBounds.y - - // The anchor can still land just outside the work area when the previous - // window crossed a screen boundary. Clamp after anchoring so resize intent - // wins first, then display safety. - return { - x: Math.round(clamp(x, workArea.x, workAreaRight - width)), - y: Math.round(clamp(y, workArea.y, workAreaBottom - height)), - width, - height, - } -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max) -} - -interface SizeActual { actual: number } -interface SizePercentage { percentage: number } -type Size = SizeActual | SizePercentage | number - -function evaluateSize(basedOn: number, size: Size) { - if (typeof size === 'number') { - return size - } - if ('actual' in size) { - return size.actual - } - - return Math.floor(basedOn * size.percentage) -} - -/** - * Breakpoint prefix Minimum width CSS - * sm 40rem (640px) @media (width >= 40rem) { ... } - * md 48rem (768px) @media (width >= 48rem) { ... } - * lg 64rem (1024px) @media (width >= 64rem) { ... } - * xl 80rem (1280px) @media (width >= 80rem) { ... } - * 2xl 96rem (1536px) @media (width >= 96rem) { ... } - * - * Additional to tailwindcss defaults: - * 3xl 112rem (1792px) @media (width >= 112rem) { ... } - * 4xl 128rem (2048px) @media (width >= 128rem) { ... } - * 5xl 144rem (2304px) @media (width >= 144rem) { ... } - * 6xl 160rem (2560px) @media (width >= 160rem) { ... } - * 7xl 176rem (2816px) @media (width >= 176rem) { ... } - * 8xl 192rem (3072px) @media (width >= 192rem) { ... } - * 9xl 208rem (3328px) @media (width >= 208rem) { ... } - * 10xl 224rem (3584px) @media (width >= 224rem) { ... } - */ -export const tailwindBreakpoints = { - 'sm': { min: 640, max: 767 }, - 'md': { min: 768, max: 1023 }, - 'lg': { min: 1024, max: 1279 }, - 'xl': { min: 1280, max: 1535 }, - '2xl': { min: 1536, max: 1791 }, - '3xl': { min: 1792, max: 2047 }, - '4xl': { min: 2048, max: 2303 }, - '5xl': { min: 2304, max: 2559 }, - '6xl': { min: 2560, max: 2815 }, - '7xl': { min: 2816, max: 3071 }, - '8xl': { min: 3072, max: 3327 }, - '9xl': { min: 3328, max: 3583 }, - '10xl': { min: 3584, max: Infinity }, -} - -/** - * Common screen resolution breakpoints. - * Mainly for reference or if you want to target specific screen resolutions. - * - * - 720p HD 1280×720 - * - 1080p FHD 1920×1080 - * - 2K QHD 2560×1440 - * - 4K UHD 3840×2160 - * - 5K 5120×2880 - * - 8K UHD 7680×4320 - * - * @see {@link https://en.wikipedia.org/wiki/Display_resolution#Common_display_resolutions} - */ -export const resolutionBreakpoints = { - '720p': { min: 0, max: 1280 }, - '1080p': { min: 1281, max: 1920 }, - '2k': { min: 1921, max: 2560 }, - '4k': { min: 2561, max: 3840 }, - '5k': { min: 3841, max: 7680 }, - '8k': { min: 7681, max: Infinity }, -} - -/** - * Achieve responsive sizes based on screen width breakpoints. - * @see {@link https://tailwindcss.com/docs/responsive-design#overview} - */ -export function mapForBreakpoints< - B extends Record = typeof tailwindBreakpoints, ->( - basedOn: number, - sizes: { [key in keyof B]?: number } | number, - options?: { breakpoints: B }, -): number { - if (typeof sizes === 'number') { - return sizes - } - - const breakpoints = options?.breakpoints ?? tailwindBreakpoints - - const matched = Object.entries(breakpoints).find(([, b]) => { - return basedOn >= b.min && basedOn <= b.max - }) - - if (matched) { - const size = sizes[matched[0]] - if (size) { - return size - } - } - - // Fallback: find nearest-least smallest breakpoint - const sortedSizes = Object.entries(sizes) - .map(([key, value]) => ({ key, value, min: breakpoints[key as keyof typeof breakpoints]?.min ?? 0 })) - .sort((a, b) => b.min - a.min) // Sort descending by min width - - const fallback = sortedSizes.find(s => s.min <= basedOn) - if (fallback?.value != null) { - return fallback.value - } - - // `basedOn` is below every supplied breakpoint (e.g. height < 640 with sm/md/lg - // sizes): use the breakpoint with the smallest minimum width. Selecting by min - // instead of `Object.values(sizes)[0]` keeps the result stable when the sizes - // object keys are reordered (e.g. by lint sorting rules). - const smallest = sortedSizes[sortedSizes.length - 1] - return smallest?.value ?? 0 -} - -/** - * Calculate width based on options similar to how Web CSS does it. - * - * @param bounds - * @param sizeOptions - * @returns width in pixels - */ -export function widthFrom(bounds: Rectangle, sizeOptions: Size & { min?: Size, max?: Size }) { - const val = evaluateSize(bounds.width, sizeOptions) - const min = sizeOptions.min ? evaluateSize(bounds.width, sizeOptions.min) : undefined - const max = sizeOptions.max ? evaluateSize(bounds.width, sizeOptions.max) : undefined - - if (min && val < min) { - return min - } - - if (max && val > max) { - return max - } - - return val -} - -export interface AdjacentPositionResult { - x: number - y: number - width: number - height: number - scale: number -} - -/** - * Compute a position for `target` adjacent to `anchor`, staying within `workArea`. - * - * Compares available space on right, left, and bottom of the anchor and picks the - * side with the most room. Tie-breaking preference: right > left > bottom. - * - * If the target doesn't fit at full size on the best side, it is scaled down - * (preserving aspect ratio) to fit, respecting `minScale`. - */ -export function computeAdjacentPosition( - anchorBounds: Rectangle, - targetSize: { width: number, height: number }, - workArea: Rectangle, - options?: { margin?: number, minScale?: number }, -): AdjacentPositionResult { - const margin = options?.margin ?? 16 - const minScale = options?.minScale ?? 0.5 - - const waRight = workArea.x + workArea.width - const waBottom = workArea.y + workArea.height - - const rightSpace = { w: waRight - (anchorBounds.x + anchorBounds.width + margin), h: workArea.height } - const leftSpace = { w: anchorBounds.x - workArea.x - margin, h: workArea.height } - const bottomSpace = { w: workArea.width, h: waBottom - (anchorBounds.y + anchorBounds.height + margin) } - - function maxScale(space: { w: number, h: number }): number { - if (space.w <= 0 || space.h <= 0) - return 0 - return Math.min(space.w / targetSize.width, space.h / targetSize.height, 1) - } - - const candidates: { side: 'right' | 'left' | 'bottom', scale: number }[] = [ - { side: 'right', scale: maxScale(rightSpace) }, - { side: 'left', scale: maxScale(leftSpace) }, - { side: 'bottom', scale: maxScale(bottomSpace) }, - ] - - candidates.sort((a, b) => b.scale - a.scale) - const best = candidates[0]! - - const scale = Math.max(best.scale, minScale) - const w = Math.round(targetSize.width * scale) - const h = Math.round(targetSize.height * scale) - - const clampX = (x: number) => Math.min(Math.max(x, workArea.x), waRight - w) - const clampY = (y: number) => Math.min(Math.max(y, workArea.y), waBottom - h) - - const centerY = anchorBounds.y + Math.floor((anchorBounds.height - h) / 2) - - switch (best.side) { - case 'right': { - const x = anchorBounds.x + anchorBounds.width + margin - return { x: clampX(x), y: clampY(centerY), width: w, height: h, scale } - } - case 'left': { - const x = anchorBounds.x - w - margin - return { x: clampX(x), y: clampY(centerY), width: w, height: h, scale } - } - case 'bottom': { - const y = anchorBounds.y + anchorBounds.height + margin - const x = anchorBounds.x + Math.floor((anchorBounds.width - w) / 2) - return { x: clampX(x), y: clampY(y), width: w, height: h, scale } - } - } -} - -/** - * Calculate height based on options similar to how Web CSS does it. - * - * @param bounds - * @param sizeOptions - * @returns height in pixels - */ -export function heightFrom(bounds: Rectangle, sizeOptions: Size & { min?: Size, max?: Size }) { - const val = evaluateSize(bounds.height, sizeOptions) - const min = sizeOptions.min ? evaluateSize(bounds.height, sizeOptions.min) : undefined - const max = sizeOptions.max ? evaluateSize(bounds.height, sizeOptions.max) : undefined - - if (min && val < min) { - return min - } - - if (max && val > max) { - return max - } - - return val -} diff --git a/apps/stage-tamagotchi/src/main/windows/shared/index.ts b/apps/stage-tamagotchi/src/main/windows/shared/index.ts deleted file mode 100644 index e4d0a0cd8..000000000 --- a/apps/stage-tamagotchi/src/main/windows/shared/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { protectPrivilegedWindowNavigation, setWindowAlwaysOnTop, toggleWindowShow, transparentWindowConfig } from './window' diff --git a/apps/stage-tamagotchi/src/main/windows/shared/persistence.ts b/apps/stage-tamagotchi/src/main/windows/shared/persistence.ts deleted file mode 100644 index e60bba1dd..000000000 --- a/apps/stage-tamagotchi/src/main/windows/shared/persistence.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createConfig } from '../../libs/electron/persistence' -export type { ConfigDiagnostics, CreateConfigOptions } from '../../libs/electron/persistence' diff --git a/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts b/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts deleted file mode 100644 index 4eca9739f..000000000 --- a/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { createRequestWindowEventa, RequestWindowPayload } from '../../../shared/eventa' -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' - -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { safeClose } from '@proj-airi/electron-vueuse/main' -import { ipcMain } from 'electron' - -import { setupBaseWindowElectronInvokes } from './window' - -export interface ReferencedWindowHandle { - id: string - window: BrowserWindow - context: ReturnType['context'] - eventa: ReturnType -} - -export interface ReferencedWindowManager { - open: (payload: Payload & { id?: string }) => Promise - close: (id: string) => void -} - -/** - * Minimal per-id window manager used by notice/widgets-like windows. - * It opens (or reuses) a window, loads the route with the id in query, and returns the window/context so - * callers can register their own action handlers. - */ -export function createReferencedWindowManager(params: { - eventa: ReturnType - i18n: I18n - serverChannel: ServerChannel - createWindow: (id: string) => BrowserWindow - loadRoute: (window: BrowserWindow, payload: Payload & { id: string }) => Promise -}): ReferencedWindowManager { - const windows = new Map['context'] }>() - - async function bindContext(id: string, payload: Payload, win: BrowserWindow) { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - const { context } = createContext(ipcMain, win) - - defineInvokeHandler(context, params.eventa.pageMounted, (req) => { - if (req?.id && req.id !== id) - return undefined - return { id, type: payload.type, payload: payload.payload } - }) - - defineInvokeHandler(context, params.eventa.pageUnmounted, (req) => { - if (req?.id && req.id !== id) - return - windows.delete(id) - }) - - await setupBaseWindowElectronInvokes({ context, window: win, i18n: params.i18n, serverChannel: params.serverChannel }) - - win.on('closed', () => windows.delete(id)) - - return { window: win, context } - } - - async function open(payload: Payload & { id?: string }): Promise { - const id = payload.id ?? Math.random().toString(36).slice(2, 10) - let ctx = windows.get(id) - - if (!ctx || ctx.window.isDestroyed()) { - const win = params.createWindow(id) - ctx = await bindContext(id, payload, win) - windows.set(id, ctx) - } - - try { - await params.loadRoute(ctx.window, { ...payload, id }) - ctx.window.show() - ctx.window.focus() - } - catch (error) { - const wrapped = error ?? new Error('Failed to open referenced window') - console.error('[referenced-window] open failed', wrapped) - throw wrapped - } - - return { id, window: ctx.window, context: ctx.context, eventa: params.eventa } - } - - function close(id: string) { - const ctx = windows.get(id) - if (!ctx) - return - - safeClose(ctx.window) - windows.delete(id) - } - - return { open, close } -} diff --git a/apps/stage-tamagotchi/src/main/windows/shared/window.test.ts b/apps/stage-tamagotchi/src/main/windows/shared/window.test.ts deleted file mode 100644 index 8075209e6..000000000 --- a/apps/stage-tamagotchi/src/main/windows/shared/window.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { setWindowAlwaysOnTop } from './window' - -const mocks = vi.hoisted(() => ({ - isMacOS: false, - isWindows: false, -})) - -vi.mock('electron', () => ({ - shell: { - openExternal: vi.fn(), - }, - BrowserWindow: vi.fn(), -})) - -vi.mock('std-env', () => ({ - get isMacOS() { - return mocks.isMacOS - }, - get isWindows() { - return mocks.isWindows - }, -})) - -vi.mock('../../services/electron', () => ({ - createAppService: vi.fn(), - createPowerMonitorService: vi.fn(), - createScreenService: vi.fn(), - createSystemPreferencesService: vi.fn(), - createWindowService: vi.fn(), -})) - -describe('setWindowAlwaysOnTop', () => { - it('disables always-on-top when flag is false', () => { - const window = { - setAlwaysOnTop: vi.fn(), - } - - setWindowAlwaysOnTop(window, false) - - expect(window.setAlwaysOnTop).toHaveBeenCalledWith(false) - }) - - it('applies standard always-on-top on Linux', () => { - mocks.isMacOS = false - mocks.isWindows = false - - const window = { - setAlwaysOnTop: vi.fn(), - } - - setWindowAlwaysOnTop(window, true) - - expect(window.setAlwaysOnTop).toHaveBeenCalledWith(true) - }) - - it('applies screen-saver level and relative offset on macOS', () => { - mocks.isMacOS = true - mocks.isWindows = false - - const window = { - setAlwaysOnTop: vi.fn(), - } - - setWindowAlwaysOnTop(window, true, 1) - - expect(window.setAlwaysOnTop).toHaveBeenCalledWith(true, 'screen-saver', 1) - }) - - it('applies screen-saver level and relative offset on Windows', () => { - mocks.isMacOS = false - mocks.isWindows = true - - const window = { - setAlwaysOnTop: vi.fn(), - } - - setWindowAlwaysOnTop(window, true, 2) - - expect(window.setAlwaysOnTop).toHaveBeenCalledWith(true, 'screen-saver', 2) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/window.ts b/apps/stage-tamagotchi/src/main/windows/shared/window.ts deleted file mode 100644 index 6d14d204b..000000000 --- a/apps/stage-tamagotchi/src/main/windows/shared/window.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { createContext } from '@moeru/eventa/adapters/electron/main' -import type { ResizeDirection } from '@proj-airi/electron-eventa' -import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' - -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' - -import { isRendererUnavailable } from '@proj-airi/electron-vueuse/main' -import { shell } from 'electron' -import { isMacOS, isWindows } from 'std-env' - -import { createServerChannelService } from '../../services/airi/channel-server' -import { createI18nService } from '../../services/airi/i18n' -import { createAppService, createPowerMonitorService, createScreenService, createSystemPreferencesService, createWindowService } from '../../services/electron' - -export function toggleWindowShow(window?: BrowserWindow | null): void { - if (!window) { - return - } - if (isRendererUnavailable(window)) { - return - } - - if (window?.isMinimized()) { - window?.restore() - } - - window?.show() - window?.focus() -} - -export function transparentWindowConfig(): BrowserWindowConstructorOptions { - return { - frame: false, - titleBarStyle: isMacOS ? 'hidden' : undefined, - transparent: true, - hasShadow: false, - } -} - -/** - * Blocks renderer navigation while allowing safe links to open in the system browser. - * - * Use when: - * - Creating an Electron window that receives the shared privileged preload - * - * Expects: - * - The window loads only AIRI-controlled renderer content - * - * Returns: - * - Nothing; installs navigation and popup guards on the window's web contents - */ -export function protectPrivilegedWindowNavigation(window: BrowserWindow): void { - function openSafeExternalUrl(rawUrl: string): void { - try { - const url = new URL(rawUrl) - if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:') - void shell.openExternal(url.toString()) - } - catch { - // Ignore malformed navigation targets. - } - } - - window.webContents.setWindowOpenHandler((details) => { - openSafeExternalUrl(details.url) - return { action: 'deny' } - }) - window.webContents.on('will-navigate', (event, navigationUrl) => { - // Renderer-initiated reloads keep the exact current URL in both packaged - // (`file:`) and Vite (`http:`) builds. Let those through without widening - // navigation to other local files or development-server paths. - if (navigationUrl === window.webContents.getURL()) - return - - event.preventDefault() - openSafeExternalUrl(navigationUrl) - }) -} - -export function blurryWindowConfig(): BrowserWindowConstructorOptions { - return { - vibrancy: 'hud', - backgroundMaterial: 'acrylic', - } -} - -export function spotlightLikeWindowConfig(): BrowserWindowConstructorOptions { - return { - ...blurryWindowConfig(), - titleBarStyle: isMacOS ? 'hidden' : undefined, - } -} - -/** - * Sets the window always-on-top level according to the host platform. - * - * macOS and Windows support the screen-saver level and relative level layering, - * while Linux (X11/Wayland) works reliably with standard always-on-top. - */ -export function setWindowAlwaysOnTop( - window: Pick, - flag: boolean, - relativeLevel = 1, -): void { - if (!flag) { - window.setAlwaysOnTop(false) - return - } - - if (isMacOS || isWindows) { - window.setAlwaysOnTop(true, 'screen-saver', relativeLevel) - return - } - - window.setAlwaysOnTop(true) -} - -export function resizeWindowByDelta(params: { - window: BrowserWindow - deltaX: number - deltaY: number - direction: ResizeDirection - minWidth?: number - minHeight?: number -}): void { - const bounds = params.window.getBounds() - const minWidth = params.minWidth ?? 100 - const minHeight = params.minHeight ?? 200 - - let { x, y, width, height } = bounds - - if (params.direction.includes('e')) { - width = Math.max(minWidth, width + params.deltaX) - } - if (params.direction.includes('w')) { - const newWidth = Math.max(minWidth, width - params.deltaX) - if (newWidth !== width) { - x = x + (width - newWidth) - width = newWidth - } - } - - if (params.direction.includes('s')) { - height = Math.max(minHeight, height + params.deltaY) - } - if (params.direction.includes('n')) { - const newHeight = Math.max(minHeight, height - params.deltaY) - if (newHeight !== height) { - y = y + (height - newHeight) - height = newHeight - } - } - - params.window.setBounds({ x, y, width, height }) -} - -export async function setupBaseWindowElectronInvokes(params: { - context: ReturnType['context'] - window: BrowserWindow - serverChannel: ServerChannel - i18n: I18n -}) { - createScreenService({ context: params.context, window: params.window }) - createWindowService({ context: params.context, window: params.window }) - createAppService({ context: params.context, window: params.window }) - createPowerMonitorService({ context: params.context, window: params.window }) - createSystemPreferencesService({ context: params.context, window: params.window }) - - await createI18nService({ context: params.context, window: params.window, i18n: params.i18n }) - - createServerChannelService({ serverChannel: params.serverChannel }) -} diff --git a/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts b/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts deleted file mode 100644 index 4979f45b1..000000000 --- a/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { ShortcutAccelerator, ShortcutBinding } from '@proj-airi/stage-shared/global-shortcut' - -import type { globalAppConfigSchema } from '../../configs/global' -import type { Config } from '../../libs/electron/persistence' -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' -import type { GlobalShortcutService } from '../../services/electron/global-shortcut' - -import { join, resolve } from 'node:path' - -import { useLogg } from '@guiiai/logg' -import { defineInvokeHandler } from '@moeru/eventa' -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut' -import { BrowserWindow, ipcMain, Notification, screen } from 'electron' -import { isMacOS } from 'std-env' - -import icon from '../../../../resources/icon.png?asset' - -import { - electronSpotlightHide, - electronSpotlightShowResultNotification, -} from '../../../shared/eventa' -import { isSafeSpotlightAccelerator } from '../../../shared/spotlight-shortcut' -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createReusableWindow } from '../../libs/electron/window-manager' -import { protectPrivilegedWindowNavigation, setupBaseWindowElectronInvokes, transparentWindowConfig } from '../shared/window' - -const SPOTLIGHT_WINDOW_WIDTH = 720 -const SPOTLIGHT_WINDOW_HEIGHT = 100 -const SPOTLIGHT_SHORTCUT_ID = 'spotlight' -const defaultSpotlightAccelerator: ShortcutAccelerator = { modifiers: ['ctrl', 'shift'], key: 'KeyA' } - -export interface SpotlightWindowManager { - show: () => Promise - getShortcutAccelerator: () => ShortcutAccelerator - updateShortcutAccelerator: (accelerator: ShortcutAccelerator | null) => ReturnType -} - -function resolveSpotlightBounds() { - const cursorPoint = screen.getCursorScreenPoint() - const display = screen.getDisplayNearestPoint(cursorPoint) - const { x, y, width } = display.workArea - - return { - x: Math.round(x + (width - SPOTLIGHT_WINDOW_WIDTH) / 2), - y: Math.round(y + display.workArea.height * 0.22), - width: SPOTLIGHT_WINDOW_WIDTH, - height: SPOTLIGHT_WINDOW_HEIGHT, - } -} - -export function setupSpotlightWindowManager(params: { - serverChannel: ServerChannel - i18n: I18n - chatWindow: () => Promise - globalShortcut: GlobalShortcutService - appConfig: Config -}): SpotlightWindowManager { - const log = useLogg('spotlight-window').useGlobalConfig() - const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) - - // NOTICE: - // Electron may GC a `Notification` once the constructor scope returns, which - // silently drops its `click` handler before the user interacts. Hold a strong - // reference until the notification is dismissed (`click` / `close`) or fails. - const resultNotifications = new Set() - - async function openChatWindowFromNotification() { - try { - const window = await params.chatWindow() - if (window.isMinimized()) - window.restore() - window.show() - window.focus() - window.moveTop() - } - catch (error) { - log.withError(error).warn('Failed to open Chat window from Spotlight notification') - } - } - - function showNotification(body: string, onClick?: () => void) { - const notification = new Notification({ - title: 'AIRI', - body, - ...(onClick && !isMacOS ? { timeoutType: 'never' as const } : {}), - }) - resultNotifications.add(notification) - const release = () => resultNotifications.delete(notification) - - notification.once('close', release) - notification.once('failed', release) - notification.once('click', () => { - release() - onClick?.() - }) - notification.show() - } - - const reusable = createReusableWindow(async () => { - const window = new BrowserWindow({ - ...transparentWindowConfig(), - titleBarStyle: undefined, - title: 'Spotlight', - width: SPOTLIGHT_WINDOW_WIDTH, - height: SPOTLIGHT_WINDOW_HEIGHT, - show: false, - resizable: false, - maximizable: false, - minimizable: false, - skipTaskbar: true, - alwaysOnTop: true, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - }) - - protectPrivilegedWindowNavigation(window) - - window.on('blur', () => window.hide()) - - const { context } = createContext(ipcMain, window) - await setupBaseWindowElectronInvokes({ context, window, i18n: params.i18n, serverChannel: params.serverChannel }) - - // Only the Spotlight window may call these private invokes. - const isFromSpotlightWindow = (senderId?: number) => window.webContents.id === senderId - - defineInvokeHandler(context, electronSpotlightHide, (_, options) => { - if (isFromSpotlightWindow(options?.raw.ipcMainEvent.sender.id)) - window.hide() - }) - - defineInvokeHandler(context, electronSpotlightShowResultNotification, (payload, options) => { - if (!payload || !isFromSpotlightWindow(options?.raw.ipcMainEvent.sender.id)) - return - - showNotification(payload.body, () => void openChatWindowFromNotification()) - }) - - await load(window, withHashRoute(rendererBase, '/spotlight', { - query: { - 'stage-runtime': 'minimal', - 'synced-leader': 'false', - }, - })) - - return window - }) - - async function show() { - const window = await reusable.getWindow() - window.setBounds(resolveSpotlightBounds()) - window.show() - window.focus() - window.webContents.focus() - } - - function getShortcutAccelerator(): ShortcutAccelerator { - return params.appConfig.get()?.spotlightShortcutAccelerator ?? defaultSpotlightAccelerator - } - - function createShortcutBinding(accelerator = getShortcutAccelerator()): ShortcutBinding { - return { - id: SPOTLIGHT_SHORTCUT_ID, - accelerator, - scope: 'global', - description: 'Spotlight', - } - } - - function handleShortcutTriggered() { - void show().catch((error) => { - log.withError(error).warn('Failed to show Spotlight window') - }) - } - - function updateShortcutAccelerator(accelerator: ShortcutAccelerator | null) { - const nextAccelerator = accelerator ?? defaultSpotlightAccelerator - if (!isSafeSpotlightAccelerator(nextAccelerator)) - return { id: SPOTLIGHT_SHORTCUT_ID, ok: false as const, reason: ShortcutFailureReasons.Invalid } - - const registration = params.globalShortcut.registerMainShortcut({ - binding: createShortcutBinding(nextAccelerator), - onTriggered: handleShortcutTriggered, - }) - - if (registration.ok) { - params.appConfig.update({ - ...params.appConfig.get(), - spotlightShortcutAccelerator: nextAccelerator, - }) - } - else { - log.warn(`Failed to update Spotlight shortcut: ${registration.reason}`) - } - - return registration.ok ? { ...registration, actualAccelerator: nextAccelerator } : registration - } - - // Main-owned so renderer `unregisterAll` resets do not drop Spotlight. - const shortcutResult = params.globalShortcut.registerMainShortcut({ - binding: createShortcutBinding(), - onTriggered: handleShortcutTriggered, - }) - - if (!shortcutResult.ok) { - log.warn(`Failed to register Spotlight shortcut: ${shortcutResult.reason}`) - showNotification(params.i18n.t('tamagotchi.spotlight.errors.shortcutRegistrationFailed')) - } - - return { - getShortcutAccelerator, - show, - updateShortcutAccelerator, - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts b/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts deleted file mode 100644 index c62243b61..000000000 --- a/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { - WidgetsIframeRequestPayload, - WidgetsIframeRequestResultPayload, -} from '../../../shared/eventa' - -import { randomUUID } from 'node:crypto' - -const DEFAULT_WIDGET_IFRAME_REQUEST_TIMEOUT_MS = 30000 -const WIDGET_IFRAME_REQUEST_CLOSED_MESSAGE = 'Gamelet was closed before the request completed.' - -interface PendingWidgetIframeRequest { - id: string - resolve: (result: Record) => void - reject: (error: Error) => void - timeout: ReturnType -} - -/** - * Runtime hooks used by the widget iframe request coordinator. - */ -export interface WidgetIframeRequestCoordinatorOptions { - /** Emits the main-to-renderer iframe request event after pending state is registered. */ - emitRequest: (payload: WidgetsIframeRequestPayload) => void - /** Returns whether the widget id currently has a mounted main-process record. */ - hasWidget: (id: string) => boolean - /** Returns whether the widget id has a renderer relay for iframe request events. */ - hasRelay: (id: string) => boolean -} - -/** - * Coordinates pending request state for main-to-widget-iframe requests. - * - * The widgets renderer is an asynchronous relay between Electron main and the mounted iframe, - * so this helper owns the correlation, timeout, widget-id isolation, and close cleanup policy - * that would otherwise be hidden inside the window manager's Electron setup code. - */ -export function createWidgetIframeRequestCoordinator(options: WidgetIframeRequestCoordinatorOptions) { - const pendingRequests = new Map() - - function settlePendingRequest(requestId: string, settle: (pending: PendingWidgetIframeRequest) => void) { - const pending = pendingRequests.get(requestId) - if (!pending) - return undefined - - pendingRequests.delete(requestId) - clearTimeout(pending.timeout) - settle(pending) - return pending - } - - function requestWidgetIframe = Record>( - id: string, - payload: Record, - requestOptions?: { timeoutMs?: number }, - ): Promise { - if (!options.hasWidget(id)) - return Promise.reject(new Error(`Gamelet \`${id}\` is not open.`)) - if (!options.hasRelay(id)) - return Promise.reject(new Error('Gamelet iframe relay is not available.')) - - const requestId = randomUUID() - const timeoutMs = requestOptions?.timeoutMs ?? DEFAULT_WIDGET_IFRAME_REQUEST_TIMEOUT_MS - - const response = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - pendingRequests.delete(requestId) - reject(new Error(`Gamelet request timed out after ${timeoutMs}ms.`)) - }, timeoutMs) - - pendingRequests.set(requestId, { - id, - resolve: result => resolve(result as TResponse), - reject, - timeout, - }) - }) - - options.emitRequest({ - id, - requestId, - payload: payload as WidgetsIframeRequestPayload['payload'], - timeoutMs, - }) - - return response - } - - function publishWidgetIframeRequestResult(result: WidgetsIframeRequestResultPayload) { - const pending = pendingRequests.get(result.requestId) - if (!pending || pending.id !== result.id) - return - - settlePendingRequest(result.requestId, (settled) => { - if (result.ok) { - settled.resolve(result.result) - return - } - - settled.reject(new Error(result.error)) - }) - } - - function rejectPendingWidgetIframeRequests(id: string, message = WIDGET_IFRAME_REQUEST_CLOSED_MESSAGE) { - for (const [requestId, pending] of pendingRequests) { - if (pending.id !== id) - continue - - settlePendingRequest(requestId, settled => settled.reject(new Error(message))) - } - } - - function rejectAllPendingWidgetIframeRequests(message = WIDGET_IFRAME_REQUEST_CLOSED_MESSAGE) { - for (const requestId of pendingRequests.keys()) { - settlePendingRequest(requestId, settled => settled.reject(new Error(message))) - } - } - - return { - requestWidgetIframe, - publishWidgetIframeRequestResult, - rejectPendingWidgetIframeRequests, - rejectAllPendingWidgetIframeRequests, - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts deleted file mode 100644 index 3867ad11d..000000000 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { WidgetWindowSize } from '../../../shared/eventa' - -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { normalizeWidgetWindowSize } from '../../../shared/utils/electron/windows/window-size' -import { createWidgetIframeRequestCoordinator } from './iframe-request-coordinator' - -describe('normalizeWidgetWindowSize', () => { - it('returns undefined for missing or unusable base sizes', () => { - expect(normalizeWidgetWindowSize()).toBeUndefined() - expect(normalizeWidgetWindowSize({ width: 0, height: 320 })).toBeUndefined() - expect(normalizeWidgetWindowSize({ width: 320, height: -1 })).toBeUndefined() - expect(normalizeWidgetWindowSize({ width: Number.NaN, height: 320 })).toBeUndefined() - expect(normalizeWidgetWindowSize({ width: 320, height: Number.POSITIVE_INFINITY })).toBeUndefined() - }) - - it('floors valid dimensions and strips invalid optional constraints', () => { - const input: WidgetWindowSize = { - width: 620.9, - height: 480.4, - minWidth: -10, - minHeight: Number.NaN, - maxWidth: 1280.6, - maxHeight: 720.1, - } - - expect(normalizeWidgetWindowSize(input)).toEqual({ - width: 620, - height: 480, - maxWidth: 1280, - maxHeight: 720, - }) - }) - - it('keeps contradictory but numerically valid constraints for later display clamping', () => { - const input: WidgetWindowSize = { - width: 900, - height: 700, - minWidth: 1200, - maxWidth: 800, - minHeight: 900, - maxHeight: 600, - } - - expect(normalizeWidgetWindowSize(input)).toEqual({ - width: 900, - height: 700, - minWidth: 1200, - maxWidth: 800, - minHeight: 900, - maxHeight: 600, - }) - }) -}) - -describe('createWidgetIframeRequestCoordinator', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('rejects immediately when the target widget is not open', async () => { - const emitRequest = vi.fn() - const coordinator = createWidgetIframeRequestCoordinator({ - emitRequest, - hasWidget: () => false, - hasRelay: () => true, - }) - - await expect(coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet `kit-module:board` is not open.') - expect(emitRequest).not.toHaveBeenCalled() - }) - - it('emits a correlated iframe request and resolves only the matching successful result', async () => { - const emitRequest = vi.fn() - const coordinator = createWidgetIframeRequestCoordinator({ - emitRequest, - hasWidget: id => id === 'kit-module:board', - hasRelay: () => true, - }) - - const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }) - const emitted = emitRequest.mock.calls[0]?.[0] - - expect(emitted).toEqual({ - id: 'kit-module:board', - requestId: expect.any(String), - payload: { action: 'snapshot' }, - timeoutMs: 30000, - }) - - coordinator.publishWidgetIframeRequestResult({ - id: 'kit-module:other-board', - requestId: emitted.requestId, - ok: true, - result: { fen: 'wrong-board' }, - }) - coordinator.publishWidgetIframeRequestResult({ - id: 'kit-module:board', - requestId: 'unknown-request', - ok: true, - result: { fen: 'unknown-request' }, - }) - coordinator.publishWidgetIframeRequestResult({ - id: 'kit-module:board', - requestId: emitted.requestId, - ok: true, - result: { fen: 'fen-after-request' }, - }) - - await expect(request).resolves.toEqual({ fen: 'fen-after-request' }) - }) - - it('rejects a matching failed iframe result', async () => { - const emitRequest = vi.fn() - const coordinator = createWidgetIframeRequestCoordinator({ - emitRequest, - hasWidget: () => true, - hasRelay: () => true, - }) - - const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }) - const emitted = emitRequest.mock.calls[0]?.[0] - coordinator.publishWidgetIframeRequestResult({ - id: 'kit-module:board', - requestId: emitted.requestId, - ok: false, - error: 'Board rejected the snapshot request.', - }) - - await expect(request).rejects.toThrow('Board rejected the snapshot request.') - }) - - it('rejects timed out requests and removes their pending state', async () => { - vi.useFakeTimers() - const emitRequest = vi.fn() - const coordinator = createWidgetIframeRequestCoordinator({ - emitRequest, - hasWidget: () => true, - hasRelay: () => true, - }) - - const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 50 }) - const emitted = emitRequest.mock.calls[0]?.[0] - const rejection = expect(request).rejects.toThrow('Gamelet request timed out after 50ms.') - await vi.advanceTimersByTimeAsync(50) - await rejection - - coordinator.publishWidgetIframeRequestResult({ - id: 'kit-module:board', - requestId: emitted.requestId, - ok: true, - result: { fen: 'late-result' }, - }) - - await expect(request).rejects.toThrow('Gamelet request timed out after 50ms.') - }) - - it('rejects pending requests for a removed widget', async () => { - const emitRequest = vi.fn() - const coordinator = createWidgetIframeRequestCoordinator({ - emitRequest, - hasWidget: () => true, - hasRelay: () => true, - }) - - const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) - const rejection = expect(request).rejects.toThrow('Gamelet was closed before the request completed.') - coordinator.rejectPendingWidgetIframeRequests('kit-module:board') - - await rejection - }) - - it('rejects immediately when no renderer relay is available', async () => { - const emitRequest = vi.fn() - const coordinator = createWidgetIframeRequestCoordinator({ - emitRequest, - hasWidget: () => true, - hasRelay: () => false, - }) - - await expect(coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet iframe relay is not available.') - expect(emitRequest).not.toHaveBeenCalled() - }) - - it('rejects all pending requests when the widgets window closes', async () => { - const emitRequest = vi.fn() - const coordinator = createWidgetIframeRequestCoordinator({ - emitRequest, - hasWidget: () => true, - hasRelay: () => true, - }) - - const firstRequest = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) - const secondRequest = coordinator.requestWidgetIframe('kit-module:clock', { action: 'snapshot' }, { timeoutMs: 30000 }) - - const firstRejection = expect(firstRequest).rejects.toThrow('Gamelet was closed before the request completed.') - const secondRejection = expect(secondRequest).rejects.toThrow('Gamelet was closed before the request completed.') - coordinator.rejectAllPendingWidgetIframeRequests() - - await firstRejection - await secondRejection - }) -}) diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts deleted file mode 100644 index e6b48d2c5..000000000 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ /dev/null @@ -1,766 +0,0 @@ -import type { BrowserWindow, Rectangle } from 'electron' -import type { InferOutput } from 'valibot' - -import type { - WidgetsAddPayload, - WidgetsIframeRequestResultPayload, - WidgetSnapshot, - WidgetsUpdatePayload, -} from '../../../shared/eventa' -import type { PluginModuleWidgetPayload } from '../../../shared/eventa/plugin/host' -import type { I18n } from '../../libs/i18n' -import type { ServerChannel } from '../../services/airi/channel-server' - -import { join, resolve } from 'node:path' - -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { safeClose } from '@proj-airi/electron-vueuse/main' -import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen } from 'electron' -import { clamp } from 'es-toolkit/math' -import { isMacOS } from 'std-env' -import { number, object, optional } from 'valibot' - -import icon from '../../../../resources/icon.png?asset' - -import { widgetsClearEvent, widgetsIframeRequestEvent, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../../shared/eventa' -import { normalizeWidgetWindowSize } from '../../../shared/utils/electron/windows/window-size' -import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' -import { createConfig } from '../../libs/electron/persistence' -import { protectPrivilegedWindowNavigation, setWindowAlwaysOnTop, spotlightLikeWindowConfig, transparentWindowConfig } from '../shared/window' -import { createWidgetIframeRequestCoordinator } from './iframe-request-coordinator' -import { setupWidgetsWindowInvokes } from './rpc/index.electron' - -/** - * Controls each overlay widget instance and its Electron window. - * - * Use when: - * - Electron services need to spawn or update overlay widgets - * - Renderer invokes need one lifecycle owner for widget state and windows - * - * Expects: - * - Widget ids identify one widget instance and its window - * - * Returns: - * - A manager that opens, updates, and destroys widget instances - */ -export interface WidgetsWindowManager { - /** - * Resolves the default widgets window. - * - * Use when: - * - A caller needs direct access to the backing Electron window - * - * Expects: - * - The window manager has already been initialized - * - * Returns: - * - The live default widgets {@link BrowserWindow}, creating it if necessary - */ - getWindow: () => Promise - /** - * Opens the widgets window, optionally focusing a prepared widget route. - * - * Use when: - * - The caller wants to show the widgets surface without pushing a new widget payload yet - * - A prepared widget id should restore its dedicated route and layout - * - * Expects: - * - `params.id`, when provided, matches a widget prepared through {@link WidgetsWindowManager.prepareWidgetWindow} - * - * Returns: - * - Resolves after the target window route has been shown - */ - openWindow: (params?: { id?: string }) => Promise - /** - * Creates a widget instance and renders it in its own window. - * - * Use when: - * - A renderer or tool wants to spawn a new overlay widget - * - A caller has already prepared an id and wants to attach widget content - * - * Expects: - * - `payload.componentName` identifies a registered renderer widget - * - * Returns: - * - The resolved widget id used for subsequent updates or removal - */ - pushWidget: (payload: WidgetsAddPayload) => Promise - /** - * Applies partial widget changes to an existing widget snapshot. - * - * Use when: - * - A widget's props, size, or time-to-live must change without respawning it - * - * Expects: - * - `payload.id` references an existing widget managed by this instance - * - * Returns: - * - Resolves after in-memory state and renderer events have been updated - */ - updateWidget: (payload: WidgetsUpdatePayload) => Promise - /** - * Removes a single widget from the registry and renderer surface. - * - * Use when: - * - A specific widget should disappear immediately - * - * Expects: - * - `id` matches a widget previously created or prepared through this manager - * - * Returns: - * - Resolves after the widget record and its Electron window are destroyed - */ - removeWidget: (id: string) => Promise - /** - * Removes all widgets and closes any live widget windows. - * - * Use when: - * - The overlay surface should reset to an empty state - * - * Expects: - * - No additional input - * - * Returns: - * - Resolves after the registry, renderer, and child windows have been cleared - */ - clearWidgets: () => Promise - hideWindow: (params?: { id?: string }) => Promise - /** - * Reads the current snapshot for a single widget id. - * - * Use when: - * - Another service needs to inspect a widget before opening or mutating it - * - * Expects: - * - `id` is the widget identifier to inspect - * - * Returns: - * - The current snapshot, or `undefined` when the widget is unknown - */ - getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined - publishWidgetEvent: (id: string, event: Record) => void - onWidgetEvent: (listener: (event: { id: string, event: Record }) => void) => () => void - /** - * Sends a correlated request to a mounted widget iframe through the widgets renderer. - * - * Use when: - * - Main-process gamelet orchestration needs a response from iframe code - * - * Expects: - * - `id` references an open widget with a mounted iframe relay - * - * Returns: - * - Resolves with the iframe response record, or rejects on timeout, close, or iframe error - */ - requestWidgetIframe: = Record>( - id: string, - payload: Record, - options?: { timeoutMs?: number }, - ) => Promise - /** - * Publishes a renderer-to-main iframe request result into the pending request coordinator. - * - * Use when: - * - The widgets renderer reports a completed iframe request - * - * Expects: - * - `result.requestId` matches a request previously emitted by {@link WidgetsWindowManager.requestWidgetIframe} - * - * Returns: - * - Nothing; unknown or mismatched results are ignored - */ - publishWidgetIframeRequestResult: (result: WidgetsIframeRequestResultPayload) => void - /** - * Reserves a widget id before content is pushed into the widgets window. - * - * Use when: - * - The caller wants a stable route or window context before rendering - * - * Expects: - * - `options.id`, when provided, identifies the reserved widget instance - * - * Returns: - * - The prepared widget id bound to a future window context - */ - prepareWidgetWindow: (options?: { id?: string }) => string -} - -const widgetsWindowConfigSchema = object({ - bounds: optional(object({ - x: number(), - y: number(), - width: number(), - height: number(), - })), -}) - -type WidgetsWindowConfig = InferOutput - -function computeDefaultBounds(): Rectangle { - const primary = screen.getPrimaryDisplay().workArea - const width = Math.min(500, Math.floor(primary.width * 0.35)) - const height = Math.min(500, Math.floor(primary.height * 0.6)) - const x = primary.x + primary.width - width - 16 - const y = primary.y + 16 - return { x, y, width, height } -} - -function resolveWindowSizeFromPayload(payload: Pick) { - const explicitWindowSize = normalizeWidgetWindowSize(payload.windowSize) - if (explicitWindowSize) - return explicitWindowSize - - if (payload.componentName?.trim().toLowerCase() !== 'plugin-module') - return undefined - - const pluginModulePayload = payload.componentProps as PluginModuleWidgetPayload | undefined - return normalizeWidgetWindowSize(pluginModulePayload?.windowSize) -} - -function createWidgetsWindow() { - const window = new ElectronBrowserWindow({ - title: 'Widgets', - width: 620, - height: 760, - show: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - // Top-level overlay style like other overlay windows - type: isMacOS ? 'panel' : undefined, - ...transparentWindowConfig(), - ...spotlightLikeWindowConfig(), - }) - - window.setFullScreenable(false) - window.setVisibleOnAllWorkspaces(true) - if (isMacOS) - window.setWindowButtonVisibility(false) - - window.on('ready-to-show', () => window.show()) - protectPrivilegedWindowNavigation(window) - - return window -} - -interface WidgetRecord extends WidgetSnapshot { - timer?: ReturnType -} - -interface WidgetWindowContext { - widgetId: string - currentRoute?: string - disposeInvokes?: () => void - eventa?: ReturnType - persistBounds: boolean - window?: BrowserWindow - windowSetupPromise?: Promise -} - -/** - * Creates the Electron widgets window manager and its widget registry bridge. - * - * Use when: - * - Main-process services need to spawn, update, or remove overlay widgets - * - Widget window RPC handlers need a stable manager instance - * - * Expects: - * - `serverChannel` and `i18n` are already initialized for the main process - * - Renderer widget routes are available under the widgets page - * - * Returns: - * - A {@link WidgetsWindowManager} that owns widget state and per-instance windows - * - * Call stack: - * - * setupWidgetsWindowManager (./index) - * -> createWindowForContext (./index) - * -> {@link setupWidgetsWindowInvokes} - * -> {@link createContext} - */ -export function setupWidgetsWindowManager(params: { - serverChannel: ServerChannel - i18n: I18n -}): WidgetsWindowManager { - const { setup, get: getConfigRaw, update } = createConfig('windows-widgets', 'config.json', widgetsWindowConfigSchema, { - default: {}, - autoHeal: true, - }) - const getConfig = (): WidgetsWindowConfig => getConfigRaw() ?? {} - setup() - - const widgetRecords = new Map() - const widgetEventListeners = new Set<(event: { id: string, event: Record }) => void>() - const windowContexts = new Map() - const defaultWindowContext: WidgetWindowContext = { - widgetId: '', - persistBounds: true, - } - const iframeRequests = createWidgetIframeRequestCoordinator({ - hasWidget: id => widgetRecords.has(id), - hasRelay: id => Boolean(windowContexts.get(id)?.eventa), - emitRequest: payload => windowContexts.get(payload.id)?.eventa?.context.emit(widgetsIframeRequestEvent, payload), - }) - - const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) - const defaultRoute = '/widgets' - - let widgetsManager: WidgetsWindowManager | undefined - - /** - * Reserves a widget id and its window context before rendering. - * - * Use when: - * - The caller wants a stable route for a widget before pushing content - * - `openWindow({ id })` should target a dedicated widget route - * - * Expects: - * - `options.id`, when supplied, identifies the reserved widget instance - * - * Returns: - * - The prepared widget id - */ - function prepareWidgetWindow(options?: { id?: string }): string { - const id = options?.id ?? Math.random().toString(36).slice(2, 10) - if (!windowContexts.has(id)) { - windowContexts.set(id, { - widgetId: id, - persistBounds: true, - }) - } - return id - } - - function toSnapshot(record: WidgetRecord): WidgetSnapshot { - const { timer: _timer, ...snapshot } = record - return snapshot - } - - function scheduleDestruction(record: WidgetRecord) { - if (record.timer) - clearTimeout(record.timer) - - record.timer = record.ttlMs > 0 - ? setTimeout(destroyWidget, record.ttlMs, record.id) - : undefined - } - - function createRecord(snapshot: WidgetSnapshot) { - const record: WidgetRecord = { ...snapshot } - scheduleDestruction(record) - widgetRecords.set(snapshot.id, record) - } - - // Each widget id owns one record, one timer, and one window context. - // Delete the owned state before window.close() so the closed handler can safely re-enter this operation. - function destroyWidget(id: string) { - const record = widgetRecords.get(id) - const windowContext = windowContexts.get(id) - if (!record && !windowContext) - return - - if (record?.timer) - clearTimeout(record.timer) - - widgetRecords.delete(id) - windowContexts.delete(id) - iframeRequests.rejectPendingWidgetIframeRequests(id) - windowContext?.eventa?.context.emit(widgetsRemoveEvent, { id }) - - const window = windowContext?.window - if (window && !window.isDestroyed()) - safeClose(window) - } - - async function loadWithRoute(windowContext: WidgetWindowContext, window: BrowserWindow, route: string) { - await load(window, withHashRoute(rendererBase, route, { - query: { 'synced-leader': 'false' }, - })) - windowContext.currentRoute = route - } - - function applyStoredOrDefaultBounds(window: BrowserWindow) { - const saved = getConfig().bounds - if (saved) { - const work = screen.getDisplayMatching(saved).workArea - const width = Math.min(saved.width, work.width) - const height = Math.min(saved.height, work.height) - const clamped: Rectangle = { - x: clamp(saved.x, work.x, work.x + work.width - width), - y: clamp(saved.y, work.y, work.y + work.height - height), - width, - height, - } - window.setBounds(clamped) - return - } - - window.setBounds(computeDefaultBounds()) - } - - function applyWindowLayout(windowContext: WidgetWindowContext, window: BrowserWindow, snapshot?: Pick) { - const display = screen.getDisplayMatching(window.getBounds()) - const work = display.workArea - const windowSize = normalizeWidgetWindowSize(snapshot?.windowSize) - - if (!windowSize) { - windowContext.persistBounds = true - window.setMinimumSize(0, 0) - window.setMaximumSize(work.width, work.height) - applyStoredOrDefaultBounds(window) - return - } - - windowContext.persistBounds = false - const minWidth = clamp(windowSize.minWidth ?? 240, 1, work.width) - const minHeight = clamp(windowSize.minHeight ?? 160, 1, work.height) - const maxWidth = clamp(windowSize.maxWidth ?? work.width, minWidth, work.width) - const maxHeight = clamp(windowSize.maxHeight ?? work.height, minHeight, work.height) - const width = clamp(windowSize.width ?? minWidth, minWidth, maxWidth) - const height = clamp(windowSize.height ?? minHeight, minHeight, maxHeight) - const currentBounds = window.getBounds() - - window.setMinimumSize(minWidth, minHeight) - window.setMaximumSize(maxWidth, maxHeight) - window.setBounds({ - x: clamp(currentBounds.x, work.x, work.x + work.width - width), - y: clamp(currentBounds.y, work.y, work.y + work.height - height), - width, - height, - }) - } - - async function createWindowForContext(windowContext: WidgetWindowContext, initialRoute: string): Promise { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const window = createWidgetsWindow() - windowContext.window = window - windowContext.eventa = createContext(ipcMain, window) - - /** - * Releases the state owned by one closed widget window. - * - * Triggering workflow: - * - * {@link BrowserWindow} - * -> `window.on` - * -> `closed` - * -> handleWindowClosed - * - * Upstream: - * - The Electron `closed` event from this widget window - * - * Downstream: - * - {@link destroyWidget} - * - The Eventa adapter `dispose` operation - */ - function handleWindowClosed() { - windowContext.disposeInvokes?.() - windowContext.disposeInvokes = undefined - windowContext.eventa?.dispose() - windowContext.eventa = undefined - windowContext.currentRoute = undefined - windowContext.window = undefined - - if (windowContext.widgetId) - destroyWidget(windowContext.widgetId) - } - - window.on('closed', handleWindowClosed) - applyStoredOrDefaultBounds(window) - - const persist = () => { - if (windowContext.persistBounds) - update({ bounds: window.getBounds() }) - } - window.on('resize', persist) - window.on('move', persist) - - try { - const disposeInvokes = await setupWidgetsWindowInvokes({ - widgetWindow: window, - widgetsManager: widgetsManager!, - i18n: params.i18n, - serverChannel: params.serverChannel, - }) - if (window.isDestroyed()) { - disposeInvokes() - return window - } - windowContext.disposeInvokes = disposeInvokes - - await loadWithRoute(windowContext, window, initialRoute) - return window - } - catch (error) { - if (!window.isDestroyed()) - safeClose(window) - throw error - } - } - - async function getWindowFromContext(windowContext: WidgetWindowContext, initialRoute: string): Promise { - if (windowContext.window && !windowContext.window.isDestroyed()) - return windowContext.window - if (windowContext.windowSetupPromise) - return windowContext.windowSetupPromise - - windowContext.windowSetupPromise = createWindowForContext(windowContext, initialRoute) - .finally(() => { - windowContext.windowSetupPromise = undefined - }) - return windowContext.windowSetupPromise - } - - async function showWindowWithRoute(route: string, windowContext: WidgetWindowContext, snapshot?: Pick) { - const window = await getWindowFromContext(windowContext, route) - applyWindowLayout(windowContext, window, snapshot) - setWindowAlwaysOnTop(window, snapshot?.alwaysOnTop ?? false) - if (windowContext.currentRoute !== route) - await loadWithRoute(windowContext, window, route) - window.show() - return window - } - - /** - * Resolves the default widgets window for callers that need direct access. - * - * Use when: - * - Another service needs the backing Electron window without changing widget state - * - * Expects: - * - The renderer widgets route is available - * - * Returns: - * - The widgets {@link BrowserWindow} - */ - async function getWindow(): Promise { - return getWindowFromContext(defaultWindowContext, defaultRoute) - } - - /** - * Opens the widgets window and restores a prepared widget route when available. - * - * Use when: - * - The caller wants to reveal the widgets surface without pushing new content - * - * Expects: - * - `params.id`, when provided, references a prepared widget id - * - * Returns: - * - Resolves after the window has been shown - */ - async function openWindow(params?: { id?: string }) { - const id = params?.id ? prepareWidgetWindow({ id: params.id }) : undefined - const route = id ? `${defaultRoute}?id=${id}` : defaultRoute - const windowContext = id ? windowContexts.get(id)! : defaultWindowContext - const snapshot = id ? widgetRecords.get(id) : undefined - await showWindowWithRoute(route, windowContext, snapshot) - } - - /** - * Creates a widget instance and renders it in its own window. - * - * Use when: - * - A renderer or tool wants to spawn overlay content - * - * Expects: - * - `payload.componentName` matches a renderer component known by the widgets page - * - * Returns: - * - The widget instance id that was rendered - */ - async function pushWidget(payload: WidgetsAddPayload): Promise { - const id = payload.id ?? Math.random().toString(36).slice(2, 10) - if (widgetRecords.has(id)) - destroyWidget(id) - - prepareWidgetWindow({ id }) - const snapshot: WidgetSnapshot = { - id, - componentName: payload.componentName, - componentProps: payload.componentProps ?? {}, - alwaysOnTop: payload.alwaysOnTop ?? false, - size: payload.size ?? 'm', - windowSize: resolveWindowSizeFromPayload(payload), - ttlMs: payload.ttlMs ?? 0, - } - createRecord(snapshot) - const windowContext = windowContexts.get(id)! - await showWindowWithRoute(`${defaultRoute}?id=${id}`, windowContext, snapshot) - windowContext.eventa?.context.emit(widgetsRenderEvent, snapshot) - - return id - } - - /** - * Applies partial widget mutations to an existing widget snapshot. - * - * Use when: - * - Props, size, or time-to-live need to change without recreating the widget id - * - * Expects: - * - `payload.id` references an existing widget - * - * Returns: - * - Resolves after internal state and renderer events have been updated - */ - async function updateWidget(payload: WidgetsUpdatePayload) { - if (!payload?.id) - return - - const existing = widgetRecords.get(payload.id) - if (!existing) - return - - const nextSnapshot: WidgetSnapshot = { - ...toSnapshot(existing), - componentProps: payload.componentProps ?? existing.componentProps, - alwaysOnTop: payload.alwaysOnTop ?? existing.alwaysOnTop, - size: payload.size ?? existing.size, - windowSize: normalizeWidgetWindowSize(payload.windowSize) ?? existing.windowSize, - ttlMs: payload.ttlMs ?? existing.ttlMs, - } - - const nextRecord: WidgetRecord = { - ...nextSnapshot, - timer: existing.timer, - } - if (payload.ttlMs !== undefined) - scheduleDestruction(nextRecord) - widgetRecords.set(payload.id, nextRecord) - - const windowContext = windowContexts.get(payload.id) - const window = windowContext?.window - if (window && !window.isDestroyed()) { - applyWindowLayout(windowContext, window, nextSnapshot) - setWindowAlwaysOnTop(window, nextSnapshot.alwaysOnTop) - } - - windowContext?.eventa?.context.emit(widgetsUpdateEvent, { - id: nextSnapshot.id, - componentProps: nextSnapshot.componentProps, - alwaysOnTop: nextSnapshot.alwaysOnTop, - size: nextSnapshot.size, - windowSize: nextSnapshot.windowSize, - ttlMs: nextSnapshot.ttlMs, - }) - } - - /** - * Removes one widget and emits the corresponding renderer event. - * - * Use when: - * - A caller needs to dismiss a single widget immediately - * - * Expects: - * - `id` references a widget managed by this instance - * - * Returns: - * - Resolves after the widget record and its Electron window are destroyed - */ - async function removeWidget(id: string) { - if (!id) - return - destroyWidget(id) - } - - /** - * Clears every widget and closes all widget windows owned by this manager. - * - * Use when: - * - The overlay surface must reset completely - * - * Expects: - * - No input - * - * Returns: - * - Resolves after state, renderer events, and windows have been cleared - */ - async function clearWidgets() { - const ids = [...windowContexts.keys()] - for (const id of ids) - destroyWidget(id) - - defaultWindowContext.eventa?.context.emit(widgetsClearEvent, undefined) - const defaultWindow = defaultWindowContext.window - if (defaultWindow && !defaultWindow.isDestroyed()) - safeClose(defaultWindow) - } - - /** - * Reads the current widget snapshot without mutating widget state. - * - * Use when: - * - Another service needs to inspect a widget before deciding what to do next - * - * Expects: - * - `id` is the widget identifier to read - * - * Returns: - * - The widget snapshot, or `undefined` when not found - */ - function getWidgetSnapshot(id: string) { - const record = widgetRecords.get(id) - if (!record) - return undefined - - return toSnapshot(record) - } - - function publishWidgetEvent(id: string, event: Record) { - for (const listener of widgetEventListeners) { - listener({ id, event }) - } - } - - function onWidgetEvent(listener: (event: { id: string, event: Record }) => void) { - widgetEventListeners.add(listener) - return () => { - widgetEventListeners.delete(listener) - } - } - - function requestWidgetIframe = Record>( - id: string, - payload: Record, - options?: { timeoutMs?: number }, - ) { - return iframeRequests.requestWidgetIframe(id, payload, options) - } - - function publishWidgetIframeRequestResult(result: WidgetsIframeRequestResultPayload) { - iframeRequests.publishWidgetIframeRequestResult(result) - } - - async function hideWindow(params?: { id?: string }) { - const id = params?.id - const windowContext = id ? windowContexts.get(id) : defaultWindowContext - const window = windowContext?.window - if (window && !window.isDestroyed()) - window.hide() - } - - widgetsManager = { - getWindow, - openWindow, - pushWidget, - updateWidget, - removeWidget, - clearWidgets, - hideWindow, - getWidgetSnapshot, - publishWidgetEvent, - onWidgetEvent, - requestWidgetIframe, - publishWidgetIframeRequestResult, - prepareWidgetWindow, - } - - return widgetsManager! -} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/lifecycle.test.ts b/apps/stage-tamagotchi/src/main/windows/widgets/lifecycle.test.ts deleted file mode 100644 index 33eb398fd..000000000 --- a/apps/stage-tamagotchi/src/main/windows/widgets/lifecycle.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { BrowserWindow, Rectangle } from 'electron' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { setupWidgetsWindowManager } from './index' - -const mocks = vi.hoisted(() => { - let nextWebContentsId = 1 - const windows: FakeBrowserWindow[] = [] - - class FakeBrowserWindow { - readonly webContents = { - id: nextWebContentsId++, - isCrashed: vi.fn(() => false), - isDestroyed: vi.fn(() => false), - send: vi.fn(), - } - - readonly close = vi.fn(() => { - if (this.destroyed) - return - - this.destroyed = true - this.emit('closed') - }) - - readonly getBounds = vi.fn(() => ({ ...this.bounds })) - readonly hide = vi.fn() - readonly isDestroyed = vi.fn(() => this.destroyed) - readonly setAlwaysOnTop = vi.fn() - readonly setBounds = vi.fn((bounds: Rectangle) => { - this.bounds = { ...bounds } - }) - - readonly setFullScreenable = vi.fn() - readonly setMaximumSize = vi.fn() - readonly setMinimumSize = vi.fn() - readonly setVisibleOnAllWorkspaces = vi.fn() - readonly setWindowButtonVisibility = vi.fn() - readonly show = vi.fn() - - private bounds: Rectangle = { x: 0, y: 0, width: 620, height: 760 } - private destroyed = false - private readonly listeners = new Map void>>() - - constructor() { - windows.push(this) - } - - on(event: string, listener: () => void) { - const listeners = this.listeners.get(event) ?? [] - listeners.push(listener) - this.listeners.set(event, listeners) - return this - } - - private emit(event: string) { - for (const listener of this.listeners.get(event) ?? []) - listener() - } - } - - return { - FakeBrowserWindow, - reset() { - windows.length = 0 - }, - windows, - } -}) - -vi.mock('electron', () => ({ - BrowserWindow: mocks.FakeBrowserWindow, - ipcMain: { - off: vi.fn(), - on: vi.fn(), - setMaxListeners: vi.fn(), - }, - screen: { - getDisplayMatching: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })), - getPrimaryDisplay: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })), - }, -})) - -vi.mock('std-env', () => ({ isMacOS: false })) - -vi.mock('@moeru/eventa/adapters/electron/main', () => ({ - createContext: vi.fn(() => ({ - context: { - emit: vi.fn(), - on: vi.fn(() => vi.fn()), - }, - dispose: vi.fn(), - })), -})) - -vi.mock('@proj-airi/electron-vueuse/main', () => ({ - safeClose: vi.fn((window: BrowserWindow) => { - window.close() - return true - }), -})) - -vi.mock('../../libs/electron/location', () => ({ - baseUrl: vi.fn(() => 'http://localhost:5173'), - getElectronMainDirname: vi.fn(() => '/tmp/airi-main'), - load: vi.fn(async () => undefined), - withHashRoute: vi.fn((_base: string, route: string) => route), -})) - -vi.mock('../../libs/electron/persistence', () => ({ - createConfig: vi.fn(() => ({ - get: vi.fn(() => ({})), - setup: vi.fn(), - update: vi.fn(), - })), -})) - -vi.mock('../shared/window', () => ({ - protectPrivilegedWindowNavigation: vi.fn(), - setWindowAlwaysOnTop: vi.fn(), - spotlightLikeWindowConfig: vi.fn(() => ({})), - transparentWindowConfig: vi.fn(() => ({})), -})) - -vi.mock('./rpc/index.electron', () => ({ - setupWidgetsWindowInvokes: vi.fn(async () => vi.fn()), -})) - -function createManager() { - return setupWidgetsWindowManager({ - i18n: {} as never, - serverChannel: {} as never, - }) -} - -describe('widget window lifecycle', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - mocks.reset() - }) - - it('destroys the widget window when its TTL expires', async () => { - // ROOT CAUSE: - // - // The TTL removed only the widget record. The reusable BrowserWindow stayed open. - // The renderer then displayed a waiting state for a widget that no longer existed. - // - // We fixed this by making one manager operation destroy the record and its window. - const manager = createManager() - const id = await manager.pushWidget({ - componentName: 'weather', - ttlMs: 1000, - }) - const window = mocks.windows[0] - - await vi.advanceTimersByTimeAsync(1000) - - expect(manager.getWidgetSnapshot(id)).toBeUndefined() - expect(window.close).toHaveBeenCalledOnce() - }) - - it('does not restart the TTL for a content-only update', async () => { - // ROOT CAUSE: - // - // Each update cleared and recreated the timer, even when the update omitted ttlMs. - // Frequent content updates could keep a widget alive after its original expiry time. - // - // We fixed this by changing the timer only when an update includes ttlMs. - const manager = createManager() - const id = await manager.pushWidget({ - componentName: 'weather', - ttlMs: 1000, - }) - - await vi.advanceTimersByTimeAsync(600) - await manager.updateWidget({ id, componentProps: { temperature: '20°C' } }) - await vi.advanceTimersByTimeAsync(400) - - expect(manager.getWidgetSnapshot(id)).toBeUndefined() - expect(mocks.windows[0].close).toHaveBeenCalledOnce() - }) - - it('keeps widget instances and their windows isolated', async () => { - // ROOT CAUSE: - // - // All widget ids resolved to one reusable BrowserWindow. - // An expired record could not destroy its window without also closing another widget. - // - // We fixed this by assigning one window context to each generated widget id. - const manager = createManager() - const firstId = await manager.pushWidget({ componentName: 'weather', ttlMs: 1000 }) - const secondId = await manager.pushWidget({ componentName: 'weather', ttlMs: 2000 }) - - expect(firstId).not.toBe(secondId) - expect(mocks.windows).toHaveLength(2) - - await vi.advanceTimersByTimeAsync(1000) - - expect(mocks.windows[0].close).toHaveBeenCalledOnce() - expect(mocks.windows[1].close).not.toHaveBeenCalled() - expect(manager.getWidgetSnapshot(firstId)).toBeUndefined() - expect(manager.getWidgetSnapshot(secondId)).toBeDefined() - }) - - it('removes the widget record when the user closes its window', async () => { - const manager = createManager() - const id = await manager.pushWidget({ componentName: 'weather' }) - - mocks.windows[0].close() - - expect(manager.getWidgetSnapshot(id)).toBeUndefined() - }) -}) diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts deleted file mode 100644 index e0bab102c..000000000 --- a/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { BrowserWindow } from 'electron' - -import type { I18n } from '../../../libs/i18n' -import type { ServerChannel } from '../../../services/airi/channel-server' -import type { WidgetsWindowManager } from '../../widgets' - -import { createContext } from '@moeru/eventa/adapters/electron/main' -import { ipcMain } from 'electron' - -import { createWidgetsService } from '../../../services/airi/widgets' -import { setupBaseWindowElectronInvokes } from '../../shared/window' - -/** - * Registers Eventa invokes for one widget window and returns their cleanup operation. - * - * Call stack: - * - * setupWidgetsWindowInvokes (./index.electron) - * -> {@link setupBaseWindowElectronInvokes} - * -> {@link createWidgetsService} - * -> `dispose` - */ -export async function setupWidgetsWindowInvokes(params: { - widgetWindow: BrowserWindow - widgetsManager: WidgetsWindowManager - i18n: I18n - serverChannel: ServerChannel -}) { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const { context, dispose } = createContext(ipcMain, params.widgetWindow, { onlySameWindow: true }) - - setupBaseWindowElectronInvokes({ context, window: params.widgetWindow, i18n: params.i18n, serverChannel: params.serverChannel }) - - createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.widgetWindow }) - - return dispose -} diff --git a/apps/stage-tamagotchi/src/preload/beat-sync.ts b/apps/stage-tamagotchi/src/preload/beat-sync.ts deleted file mode 100644 index ebaf15783..000000000 --- a/apps/stage-tamagotchi/src/preload/beat-sync.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { expose } from './shared' - -expose() diff --git a/apps/stage-tamagotchi/src/preload/index.ts b/apps/stage-tamagotchi/src/preload/index.ts deleted file mode 100644 index ebaf15783..000000000 --- a/apps/stage-tamagotchi/src/preload/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { expose } from './shared' - -expose() diff --git a/apps/stage-tamagotchi/src/preload/shared.ts b/apps/stage-tamagotchi/src/preload/shared.ts deleted file mode 100644 index 8150b9f9e..000000000 --- a/apps/stage-tamagotchi/src/preload/shared.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ElectronWindow } from '@proj-airi/stage-shared' - -import { contextIsolated, platform } from 'node:process' - -import { electronAPI } from '@electron-toolkit/preload' -import { contextBridge, ipcRenderer } from 'electron' - -export function expose() { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcRenderer.setMaxListeners(0) - - // Use `contextBridge` APIs to expose Electron APIs to - // renderer only if context isolation is enabled, otherwise - // just add to the DOM global. - if (contextIsolated) { - try { - contextBridge.exposeInMainWorld('electron', electronAPI) - contextBridge.exposeInMainWorld('platform', platform) - } - catch (error) { - console.error(error) - } - } - else { - window.electron = electronAPI - window.platform = platform - } -} - -export function exposeWithCustomAPI(customAPI: CustomAPI) { - expose() - - // Use `contextBridge` APIs to expose Electron APIs to - // renderer only if context isolation is enabled, otherwise - // just add to the DOM global. - if (contextIsolated) { - try { - contextBridge.exposeInMainWorld('api', customAPI) - } - catch (error) { - console.error(error) - } - } - else { - (window as ElectronWindow).api = customAPI - } -} diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue deleted file mode 100644 index 67ca8170f..000000000 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ /dev/null @@ -1,395 +0,0 @@ - - - - - diff --git a/apps/stage-tamagotchi/src/renderer/assets/videos/tutorial/tutorial-fade-on-hover.dark.mp4 b/apps/stage-tamagotchi/src/renderer/assets/videos/tutorial/tutorial-fade-on-hover.dark.mp4 deleted file mode 100644 index 4132204d8..000000000 Binary files a/apps/stage-tamagotchi/src/renderer/assets/videos/tutorial/tutorial-fade-on-hover.dark.mp4 and /dev/null differ diff --git a/apps/stage-tamagotchi/src/renderer/assets/videos/tutorial/tutorial-fade-on-hover.light.mp4 b/apps/stage-tamagotchi/src/renderer/assets/videos/tutorial/tutorial-fade-on-hover.light.mp4 deleted file mode 100644 index 2b010473c..000000000 Binary files a/apps/stage-tamagotchi/src/renderer/assets/videos/tutorial/tutorial-fade-on-hover.light.mp4 and /dev/null differ diff --git a/apps/stage-tamagotchi/src/renderer/beat-sync.html b/apps/stage-tamagotchi/src/renderer/beat-sync.html deleted file mode 100644 index a6184c7c3..000000000 --- a/apps/stage-tamagotchi/src/renderer/beat-sync.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - BeatSync - AIRI - - - - - -
Open the DevTools to troubleshoot BeatSync
- - - diff --git a/apps/stage-tamagotchi/src/renderer/beat-sync.main.ts b/apps/stage-tamagotchi/src/renderer/beat-sync.main.ts deleted file mode 100644 index 5b74b391e..000000000 --- a/apps/stage-tamagotchi/src/renderer/beat-sync.main.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { defineInvoke, defineInvokeHandler } from '@moeru/eventa' -import { StageEnvironment } from '@proj-airi/stage-shared' -import { - beatSyncBeatSignaledInvokeEventa, - beatSyncGetInputByteFrequencyDataInvokeEventa, - beatSyncGetStateInvokeEventa, - beatSyncStateChangedInvokeEventa, - beatSyncToggleInvokeEventa, - beatSyncUpdateParametersInvokeEventa, - createBeatSyncDetector, - createContext, -} from '@proj-airi/stage-shared/beat-sync' - -const context = createContext() -const signalState = defineInvoke(context, beatSyncStateChangedInvokeEventa) -const signalBeat = defineInvoke(context, beatSyncBeatSignaledInvokeEventa) -const detector = createBeatSyncDetector({ env: StageEnvironment.Tamagotchi }) - -detector.on('stateChange', state => void signalState(state)) -detector.on('beat', event => void signalBeat(event)) - -defineInvokeHandler(context, beatSyncToggleInvokeEventa, async (enabled) => { - if (enabled) - await detector.startScreenCapture() - else - detector.stop() -}) -defineInvokeHandler(context, beatSyncGetStateInvokeEventa, async () => detector.state) -defineInvokeHandler(context, beatSyncUpdateParametersInvokeEventa, async params => detector.updateParameters(params)) -defineInvokeHandler(context, beatSyncGetInputByteFrequencyDataInvokeEventa, async () => detector.getInputByteFrequencyData()) diff --git a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts deleted file mode 100644 index b3e06656b..000000000 --- a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - electronAuthCallback, - electronAuthCallbackError, -} from '../../shared/eventa' -import { initializeElectronAuthCallbackBridge } from './electron-auth-callback' - -const authMocks = vi.hoisted(() => ({ - completeSignIn: vi.fn(), -})) - -const eventHandlers = vi.hoisted(() => new Map Promise | void>()) - -vi.mock('@proj-airi/electron-vueuse', () => ({ - getElectronEventaContext: () => ({ - on: (event: object, handler: (event: { body?: unknown }) => Promise | void) => { - eventHandlers.set(event, handler) - }, - }), -})) - -vi.mock('@proj-airi/stage-ui/stores/auth', () => ({ - useAuthStore: () => ({ - completeSignIn: authMocks.completeSignIn, - }), -})) - -vi.mock('vue-sonner', () => ({ - toast: { - error: vi.fn(), - }, -})) - -describe('electron auth callback bridge', () => { - beforeEach(() => { - eventHandlers.clear() - authMocks.completeSignIn.mockReset() - authMocks.completeSignIn.mockResolvedValue(true) - }) - - it('routes exchanged OIDC tokens through the auth store action', async () => { - // ROOT CAUSE: - // - // The callback wrote VueUse storage refs and queried the session at once. - // VueUse persisted the access token in the next microtask, so the session - // request could read the previous token and clear the complete auth state. - initializeElectronAuthCallbackBridge() - - const handler = eventHandlers.get(electronAuthCallback) - expect(handler).toBeTypeOf('function') - - await handler?.({ - body: { - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - idToken: 'new-id-token', - expiresIn: 3600, - }, - }) - - expect(authMocks.completeSignIn).toHaveBeenCalledWith({ - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - idToken: 'new-id-token', - expiresIn: 3600, - clientId: 'airi-stage-electron', - }) - expect(eventHandlers.has(electronAuthCallbackError)).toBe(true) - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts deleted file mode 100644 index 4c5fd874c..000000000 --- a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { errorMessageFrom } from '@moeru/std' -import { getElectronEventaContext } from '@proj-airi/electron-vueuse' -import { useAuthStore } from '@proj-airi/stage-ui/stores/auth' -import { toast } from 'vue-sonner' - -import { - electronAuthCallback, - electronAuthCallbackError, -} from '../../shared/eventa' - -/** - * Register auth callback listeners at the renderer service level so they - * persist for the window's lifetime, independent of any Vue component's - * mount/unmount lifecycle. - */ -export function initializeElectronAuthCallbackBridge() { - const context = getElectronEventaContext() - - context.on(electronAuthCallback, async (event) => { - const tokens = event.body - if (!tokens) - return - - try { - await useAuthStore().completeSignIn({ - ...tokens, - clientId: import.meta.env.VITE_OIDC_CLIENT_ID || 'airi-stage-electron', - }) - } - catch (error) { - toast.error(errorMessageFrom(error) ?? 'Sign-in failed') - } - }) - - context.on(electronAuthCallbackError, (event) => { - if (event.body) - toast.error(event.body.error) - }) -} diff --git a/apps/stage-tamagotchi/src/renderer/bridges/stage-three-runtime-trace.ts b/apps/stage-tamagotchi/src/renderer/bridges/stage-three-runtime-trace.ts deleted file mode 100644 index 01bbc3801..000000000 --- a/apps/stage-tamagotchi/src/renderer/bridges/stage-three-runtime-trace.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { Eventa } from '@moeru/eventa' - -import type { StageThreeRuntimeTraceEnvelope } from '../../shared/eventa' - -import { createContext as createBroadcastChannelContext } from '@moeru/eventa/adapters/broadcast-channel' -import { - acquireStageThreeRuntimeTrace, - getStageThreeRuntimeTraceContext, - stageThreeTraceHitTestReadEvent, - stageThreeTraceRenderInfoEvent, - stageThreeTraceVrmDisposeEndEvent, - stageThreeTraceVrmDisposeStartEvent, - stageThreeTraceVrmLoadEndEvent, - stageThreeTraceVrmLoadErrorEvent, - stageThreeTraceVrmLoadStartEvent, - stageThreeTraceVrmUpdateFrameEvent, -} from '@proj-airi/stage-ui-three/trace' - -import { - stageThreeRuntimeTraceForwardedEvent, - stageThreeRuntimeTraceRemoteDisableEvent, - stageThreeRuntimeTraceRemoteEnableEvent, -} from '../../shared/eventa' - -const STAGE_THREE_RUNTIME_TRACE_CHANNEL = 'airi::stage-three-runtime-trace' -const relayTraceLeaseToken = 'stage-three-runtime-trace:broadcast-relay' -const localTraceContext = getStageThreeRuntimeTraceContext() -const instanceId = Math.random().toString(36).slice(2, 10) -const replayOrder: Array = [ - 'vrm-load-start', - 'vrm-load-end', - 'vrm-load-error', - 'vrm-dispose-start', - 'vrm-dispose-end', - 'three-hit-test-read', - 'three-render-info', - 'vrm-update-frame', -] - -let initialized = false -let broadcastContext: ReturnType['context'] | undefined -let channel: BroadcastChannel | undefined -let releaseRelayTrace: (() => void) | undefined -const remoteSubscribers = new Set() -const latestEnvelopes = new Map() - -function getChannel() { - channel ??= new BroadcastChannel(STAGE_THREE_RUNTIME_TRACE_CHANNEL) - return channel -} - -export function getStageThreeRuntimeTraceBroadcastContext() { - broadcastContext ??= createBroadcastChannelContext(getChannel()).context - return broadcastContext -} - -export function getStageThreeRuntimeTraceBroadcastOriginId() { - return instanceId -} - -function applyCollectionState(active: boolean) { - if (active) { - releaseRelayTrace ??= acquireStageThreeRuntimeTrace(relayTraceLeaseToken) - return - } - - releaseRelayTrace?.() - releaseRelayTrace = undefined -} - -function emitTraceEnvelope(envelope: StageThreeRuntimeTraceEnvelope) { - latestEnvelopes.set(envelope.type, envelope) - getStageThreeRuntimeTraceBroadcastContext().emit(stageThreeRuntimeTraceForwardedEvent, { - envelope, - origin: instanceId, - }) -} - -function replayLatestTraceEnvelopes() { - const context = getStageThreeRuntimeTraceBroadcastContext() - - for (const type of replayOrder) { - const envelope = latestEnvelopes.get(type) - if (!envelope) - continue - - context.emit(stageThreeRuntimeTraceForwardedEvent, { - envelope, - origin: instanceId, - }) - } -} - -function subscribeTraceEvent(eventa: Eventa, createEnvelope: (payload: T) => StageThreeRuntimeTraceEnvelope) { - localTraceContext.on(eventa, (event) => { - if (!event?.body) - return - - emitTraceEnvelope(createEnvelope(event.body)) - }) -} - -export async function setStageThreeRuntimeTraceRemoteSubscription(active: boolean) { - const eventa = active ? stageThreeRuntimeTraceRemoteEnableEvent : stageThreeRuntimeTraceRemoteDisableEvent - getStageThreeRuntimeTraceBroadcastContext().emit(eventa, { origin: instanceId }) -} - -export function initializeStageThreeRuntimeTraceBridge() { - if (initialized) - return - - initialized = true - - const context = getStageThreeRuntimeTraceBroadcastContext() - - context.on(stageThreeRuntimeTraceRemoteEnableEvent, (event) => { - const origin = event?.body?.origin - if (!origin || origin === instanceId) - return - - remoteSubscribers.add(origin) - applyCollectionState(remoteSubscribers.size > 0) - replayLatestTraceEnvelopes() - }) - - context.on(stageThreeRuntimeTraceRemoteDisableEvent, (event) => { - const origin = event?.body?.origin - if (!origin || origin === instanceId) - return - - remoteSubscribers.delete(origin) - applyCollectionState(remoteSubscribers.size > 0) - }) - - subscribeTraceEvent(stageThreeTraceRenderInfoEvent, payload => ({ type: 'three-render-info', payload })) - subscribeTraceEvent(stageThreeTraceHitTestReadEvent, payload => ({ type: 'three-hit-test-read', payload })) - subscribeTraceEvent(stageThreeTraceVrmUpdateFrameEvent, payload => ({ type: 'vrm-update-frame', payload })) - subscribeTraceEvent(stageThreeTraceVrmLoadStartEvent, payload => ({ type: 'vrm-load-start', payload })) - subscribeTraceEvent(stageThreeTraceVrmLoadEndEvent, payload => ({ type: 'vrm-load-end', payload })) - subscribeTraceEvent(stageThreeTraceVrmLoadErrorEvent, payload => ({ type: 'vrm-load-error', payload })) - subscribeTraceEvent(stageThreeTraceVrmDisposeStartEvent, payload => ({ type: 'vrm-dispose-start', payload })) - subscribeTraceEvent(stageThreeTraceVrmDisposeEndEvent, payload => ({ type: 'vrm-dispose-end', payload })) -} diff --git a/apps/stage-tamagotchi/src/renderer/components/IconAnimation.vue b/apps/stage-tamagotchi/src/renderer/components/IconAnimation.vue deleted file mode 100644 index b44726579..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/IconAnimation.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts deleted file mode 100644 index eff0b5a90..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts +++ /dev/null @@ -1,1047 +0,0 @@ -import type { AuthorizationHandler } from '@proj-airi/stage-ui/libs/auth' -import type { ChatSessionMeta } from '@proj-airi/stage-ui/types/chat-session' -import type { Component } from 'vue' - -import SharedInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/InteractiveArea' -import MobileInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/MobileInteractiveArea' - -import { PiniaColada } from '@pinia/colada' -import { useThreeViewControl } from '@proj-airi/stage-ui-three' -import { browserAuthorizationHandler, registerAuthorizationHandler } from '@proj-airi/stage-ui/libs/auth' -import { useChatStore } from '@proj-airi/stage-ui/stores/chat' -import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store' -import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store' -import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d' -import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model' -import { createPinia, disposePinia } from 'pinia' -import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' -import { render } from 'vitest-browser-vue' -import { page, userEvent } from 'vitest/browser' -import { nextTick } from 'vue' -import { createI18n } from 'vue-i18n' -import { createMemoryHistory, createRouter } from 'vue-router' - -import InteractiveArea from './InteractiveArea.vue' - -import '@unocss/reset/tailwind.css' -import 'virtual:uno.css' - -function createTestI18n() { - return createI18n({ - legacy: false, - locale: 'en', - missingWarn: false, - fallbackWarn: false, - messages: { en: {} }, - }) -} - -async function renderArea(component: Component = InteractiveArea) { - useL2dViewControl().viewControlsEnabled.value = false - useThreeViewControl().viewControlsEnabled.value = false - const sessionB: ChatSessionMeta = { - sessionId: 'session-b', - userId: 'local', - characterId: 'default', - createdAt: 1, - updatedAt: 1, - } - const sessionA: ChatSessionMeta = { - ...sessionB, - sessionId: 'session-a', - createdAt: 2, - updatedAt: 2, - } - const pinia = createPinia() - onTestFinished(() => disposePinia(pinia)) - pinia.state.value = { - 'chat-session-selection': { activeSessionId: 'session-b' }, - 'chat-session': { - sessionMetas: { 'session-a': sessionA, 'session-b': sessionB }, - sessionMessages: { - 'session-a': [{ id: 'system-a', role: 'system', content: 'session A prompt' }], - 'session-b': [{ id: 'system', role: 'system', content: 'system prompt' }], - }, - }, - } - const router = createRouter({ - history: createMemoryHistory(), - routes: [{ path: '/', component: { template: '
' } }], - }) - await router.push('/') - await router.isReady() - - // These surfaces fill an app window. An auto-sized host lets percentage heights - // depend on the composer that ResizeObserver is measuring. - const container = document.createElement('div') - container.style.cssText = 'position: relative; width: 100vw; height: 100vh;' - document.body.appendChild(container) - onTestFinished(() => container.remove()) - - const screen = await render(component, { - container, - baseElement: document.body, - global: { plugins: [pinia, PiniaColada, createTestI18n(), router] }, - }) - onTestFinished(() => screen.unmount()) - await expect.element(screen.getByRole('textbox')).toBeVisible() - - return { - chat: useChatStore(pinia), - chatSession: useChatSessionStore(pinia), - chatStream: useChatStreamStore(pinia), - stageModel: useSettingsStageModel(pinia), - screen, - } -} - -async function submitDraft(screen: Awaited>['screen'], draft: string) { - const input = screen.getByRole('textbox') - await userEvent.fill(input, draft) - await userEvent.click(input) - await userEvent.keyboard('{Enter}') - return input -} - -async function attachImages(screen: Awaited>['screen'], count: number) { - const input = screen.container.querySelector('input[type="file"]') - if (!input) - throw new Error('Expected the chat image input.') - - const transfer = new DataTransfer() - for (let index = 0; index < count; index++) { - transfer.items.add(new File([`image-${index}`], `image-${index}.png`, { type: 'image/png' })) - } - - input.files = transfer.files - input.dispatchEvent(new Event('change', { bubbles: true })) - - await vi.waitFor(() => { - expect(screen.container.querySelectorAll('img[src^="blob:"]')).toHaveLength(count) - }) -} - -function dispatchHorizontalPan(element: HTMLElement, deltaX = 60) { - element.dispatchEvent(new WheelEvent('wheel', { - bubbles: true, - cancelable: true, - deltaX, - deltaY: 2, - })) -} - -async function expectElectronReplyBubble(screen: Awaited>['screen']) { - await vi.waitFor(() => { - expect(screen.container.querySelector('[data-swipeable]')).not.toBeNull() - }) - - const input = screen.getByRole('textbox').element() as HTMLTextAreaElement - const bubble = input.parentElement - const swipeSurface = screen.container.querySelector('[data-swipeable]') - expect(bubble).not.toBeNull() - expect(swipeSurface).not.toBeNull() - if (!bubble || !swipeSurface) - throw new Error('Expected the message input bubble and a swipe surface.') - - const collapsedHeight = bubble.getBoundingClientRect().height - dispatchHorizontalPan(swipeSurface) - - await vi.waitFor(() => { - const cancelButton = bubble.querySelector('[aria-label="stage.chat.reply.cancel"]') - expect(cancelButton?.parentElement?.getAttribute('aria-hidden')).toBe('false') - expect(bubble.getBoundingClientRect().height).toBeGreaterThan(collapsedHeight) - }) - - expect(getComputedStyle(input).backgroundColor).toBe('rgba(0, 0, 0, 0)') - expect(getComputedStyle(bubble).backgroundColor).not.toBe('rgba(0, 0, 0, 0)') - - const cancelButton = bubble.querySelector('[aria-label="stage.chat.reply.cancel"]') - const replyTransition = cancelButton?.parentElement?.parentElement - expect(replyTransition).not.toBeNull() - expect(cancelButton).not.toBeNull() - if (!replyTransition || !cancelButton) - throw new Error('Expected the reply transition and cancel button.') - - expect(Number.parseFloat(getComputedStyle(replyTransition).transitionDuration)).toBeGreaterThan(0) - const expandedHeight = bubble.getBoundingClientRect().height - cancelButton.click() - await nextTick() - expect(cancelButton.parentElement?.getAttribute('aria-hidden')).toBe('true') - expect(bubble.getBoundingClientRect().height).toBeGreaterThan(collapsedHeight) - expect(bubble.getBoundingClientRect().height).toBeCloseTo(expandedHeight, 0) - await new Promise(resolve => setTimeout(resolve, 50)) - expect(bubble.getBoundingClientRect().height).toBeGreaterThan(collapsedHeight) - expect(bubble.getBoundingClientRect().height).toBeLessThan(expandedHeight) - await vi.waitFor(() => { - expect(bubble.getBoundingClientRect().height).toBeCloseTo(collapsedHeight, 0) - }) -} - -describe('interactive area synchronized state', () => { - beforeEach(async () => { - await page.viewport(1280, 720) - }) - - it('centers the mobile textarea when no reply preview is visible', async () => { - // ROOT CAUSE: - // - // Without a reply preview, the 40px bubble has spare height around its - // 32px textarea and borders. Before the fix, justify-end put all spare - // height above the textarea: 6px above and 2px below. - // - // We fixed this with justify-center. The empty and single-line textarea - // now has 4px on each side, and multiline input stays centered. - await page.viewport(390, 844) - const { screen } = await renderArea(MobileInteractiveArea) - const bubble = screen.getByTestId('mobile-input-bubble').element() - const input = screen.getByRole('textbox') - - for (const draft of ['', 'Hello', 'First line\nSecond line', '']) { - await input.fill(draft) - await expect.poll(() => { - const outer = bubble.getBoundingClientRect() - const inner = input.element().getBoundingClientRect() - return Math.abs((inner.top - outer.top) - (outer.bottom - inner.bottom)) - }).toBeLessThanOrEqual(1) - } - }) - - it('opens mobile settings from an icon-only header and restores focus', async () => { - await page.viewport(390, 844) - const { screen } = await renderArea(MobileInteractiveArea) - const trigger = screen.getByRole('button', { name: 'stage.mobile-tools.title', exact: true }) - const bounds = trigger.element().getBoundingClientRect() - expect(bounds.width).toBeGreaterThanOrEqual(44) - expect(bounds.height).toBeGreaterThanOrEqual(44) - expect(bounds.right).toBeLessThanOrEqual(390) - expect(bounds.left).toBeGreaterThan(300) - expect(bounds.top).toBeLessThan(40) - expect(trigger.element().textContent?.trim()).toBe('') - await expect.element(screen.getByTestId('speech-mute-button')).not.toBeInTheDocument() - - await trigger.click() - await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).toBeVisible() - await expect.element(screen.getByText('stage.mobile-tools.sign-in', { exact: true })).toBeVisible() - const account = screen.getByRole('button', { name: 'stage.mobile-tools.sign-in stage.mobile-tools.account-description' }).element() - const drawerTitle = screen.getByRole('heading', { name: 'stage.mobile-tools.title' }).element() - const accountContent = account.querySelector('.basic-button-content') - // ROOT CAUSE: - // - // The account row relied on scoped descendant CSS to stretch - // BasicButton's content wrapper. The combined browser bundle could leave - // that wrapper at its content width, centering the label inward. Comparing - // text coordinates was also unstable while the drawer portal animated, so - // assert the owned row and content geometry directly. - expect(accountContent).not.toBeNull() - await expect.poll(() => getComputedStyle(account).paddingLeft).toBe('0px') - expect(account.getBoundingClientRect().left).toBe(drawerTitle.getBoundingClientRect().left) - expect(accountContent!.getBoundingClientRect().width).toBe(account.clientWidth) - expect(account.getBoundingClientRect().height).toBe(56) - expect(account.querySelector('[data-avatar-fallback], [data-avatar-image]')).toBeNull() - await expect.element(screen.getByText('stage.mobile-tools.cleanup', { exact: true })).not.toBeInTheDocument() - await expect.element(screen.getByRole('switch', { name: 'stage.mobile-tools.character-voice' })).toBeVisible() - const voice = screen.getByRole('switch', { name: 'stage.mobile-tools.character-voice' }) - const before = voice.element().getAttribute('aria-checked') - await voice.click() - await expect.element(voice).toHaveAttribute('aria-checked', before === 'true' ? 'false' : 'true') - await expect.element(screen.getByRole('button', { name: 'Close', exact: true })).not.toBeInTheDocument() - await userEvent.keyboard('{Escape}') - await expect.element(trigger).toHaveFocus() - }) - - it('removes the clear-messages action from desktop chat surfaces', async () => { - for (const component of [InteractiveArea, SharedInteractiveArea]) { - const { screen } = await renderArea(component) - expect(screen.container.querySelector('[class*="trash-bin-2-bold-duotone"]')).toBeNull() - screen.unmount() - } - }) - - it('places the conversation selector opposite settings in the mobile header', async () => { - await page.viewport(390, 844) - const { screen } = await renderArea(MobileInteractiveArea) - const composer = screen.getByTestId('mobile-message-composer').element() - const conversations = screen.getByTestId('conversation-selector-button').element() - const bounds = conversations.getBoundingClientRect() - const settingsBounds = screen.getByTestId('mobile-settings-button').element().getBoundingClientRect() - expect(composer.contains(conversations)).toBe(false) - expect(bounds.left).toBe(12) - expect(bounds.top).toBe(settingsBounds.top) - expect(bounds.width).toBe(44) - expect(bounds.height).toBe(44) - expect(bounds.left).toBe(390 - settingsBounds.right) - expect(conversations.textContent?.trim()).toBe('') - await screen.getByTestId('conversation-selector-button').click() - await expect.element(screen.getByRole('dialog')).toBeVisible() - }) - - it('uses the full mobile width for chat history after removing the action rail', async () => { - // ROOT CAUSE: - // - // The removed right action rail left a fixed 3.5rem reservation on the - // chat history. Long messages and the scrollbar still stopped before the - // right edge even though the controls no longer occupied that space. - await page.viewport(390, 844) - const { screen } = await renderArea(MobileInteractiveArea) - const history = screen.container.querySelector('.chat-history') - - expect(history).not.toBeNull() - expect(history!.getBoundingClientRect().width).toBe(390) - }) - - it('opens view controls on the stage and closes them from the top right', async () => { - await page.viewport(390, 844) - const viewControl = useL2dViewControl() - viewControl.viewControlsEnabled.value = false - - const { screen, stageModel } = await renderArea(MobileInteractiveArea) - stageModel.setStageModelRenderer('live2d') - - await screen.getByTestId('mobile-settings-button').click() - await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click() - - await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).not.toBeInTheDocument() - await expect.element(screen.getByTestId('mobile-message-composer')).not.toBeVisible() - await expect.element(screen.getByTestId('conversation-selector-button')).not.toBeInTheDocument() - await expect.element(screen.getByRole('button', { name: 'X', exact: true })).toBeVisible() - await expect.element(screen.getByRole('button', { name: 'Y', exact: true })).toBeVisible() - await expect.element(screen.getByRole('button', { name: 'Scale', exact: true })).toBeVisible() - - const close = screen.getByTestId('view-controls-close-button').element() - // ROOT CAUSE: - // - // The drawer suppressed focus restoration before emitting the mode change, - // but the Stage did not move focus into the newly rendered controls. - await expect.element(screen.getByTestId('view-controls-close-button')).toHaveFocus() - expect(close.getBoundingClientRect().right).toBe(378) - expect(close.getBoundingClientRect().top).toBe(12) - const toolbar = screen.getByTestId('view-controls-toolbar').element() - // ROOT CAUSE: - // - // Fixed horizontal padding ignored display cutouts in landscape viewports. - // The toolbar now uses the same left and right safe-area minimum as the header. - expect(toolbar.classList).toContain('pl-[max(0.75rem,env(safe-area-inset-left))]') - expect(toolbar.classList).toContain('pr-[max(0.75rem,env(safe-area-inset-right))]') - - await screen.getByRole('button', { name: 'stage.mobile-tools.close-view', exact: true }).click() - - await expect.element(screen.getByTestId('mobile-message-composer')).toBeVisible() - await expect.element(screen.getByTestId('conversation-selector-button')).toBeVisible() - await expect.element(screen.getByRole('button', { name: 'X', exact: true })).not.toBeInTheDocument() - // ROOT CAUSE: - // - // Closing view mode removed its focused header without moving focus to the - // newly mounted normal header, so keyboard users fell back to the document body. - await expect.element(screen.getByTestId('mobile-settings-button')).toHaveFocus() - expect(viewControl.viewControlsEnabled.value).toBe(false) - }) - - it('keeps a docked input bubble mounted while view controls are open', async () => { - // ROOT CAUSE: - // - // Entering view mode removed the composer subtree. Its dock animation stores - // opacity and position on the mounted elements, while the docked state survives. - // Recreating the subtree therefore lost the visual state when view mode closed. - await page.viewport(390, 844) - const { screen, stageModel } = await renderArea(MobileInteractiveArea) - stageModel.setStageModelRenderer('live2d') - const bubble = screen.getByTestId('mobile-input-bubble').element() - const input = screen.getByRole('textbox').element() - const icon = bubble.querySelector('[aria-hidden="true"]')! - const bounds = bubble.getBoundingClientRect() - const pointer = { - bubbles: true, - clientX: bounds.left + bounds.width / 2, - clientY: bounds.top + bounds.height / 2, - isPrimary: true, - pointerId: 1, - pointerType: 'touch', - } - vi.spyOn(bubble, 'setPointerCapture').mockImplementation(() => {}) - - bubble.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, button: 0, buttons: 1 })) - await new Promise(resolve => setTimeout(resolve, 550)) - bubble.dispatchEvent(new PointerEvent('pointermove', { ...pointer, buttons: 1, clientY: pointer.clientY - 80 })) - bubble.dispatchEvent(new PointerEvent('pointerup', { ...pointer, buttons: 0, clientY: pointer.clientY - 80 })) - await expect.poll(() => getComputedStyle(input).opacity).toBe('0') - expect(getComputedStyle(icon).opacity).toBe('1') - - await screen.getByTestId('mobile-settings-button').click() - await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click() - - expect(bubble.isConnected).toBe(true) - await expect.element(screen.getByTestId('mobile-message-composer')).not.toBeVisible() - - await screen.getByTestId('view-controls-close-button').click() - - expect(screen.getByTestId('mobile-input-bubble').element()).toBe(bubble) - expect(getComputedStyle(input).opacity).toBe('0') - expect(getComputedStyle(icon).opacity).toBe('1') - }) - - it('closes view controls with Escape and restores focus', async () => { - // ROOT CAUSE: - // - // Moving view controls out of the dismissible drawer removed its Escape - // behavior, leaving keyboard users in the focused Stage mode. - await page.viewport(390, 844) - const { screen, stageModel } = await renderArea(MobileInteractiveArea) - stageModel.setStageModelRenderer('live2d') - - await screen.getByTestId('mobile-settings-button').click() - await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click() - await expect.element(screen.getByTestId('view-controls-close-button')).toHaveFocus() - await userEvent.keyboard('{Escape}') - - await expect.element(screen.getByTestId('mobile-message-composer')).toBeVisible() - await expect.element(screen.getByTestId('view-controls-close-button')).not.toBeInTheDocument() - await expect.element(screen.getByTestId('mobile-settings-button')).toHaveFocus() - }) - - it('exits view controls when the active renderer changes', async () => { - // ROOT CAUSE: - // - // Mobile view mode combined both renderer flags. After a renderer switch, - // the previous flag kept the chat hidden while the new renderer had no controls. - // The active renderer now owns the visible mode, and transitions clear stale flags. - await page.viewport(390, 844) - const live2dViewControl = useL2dViewControl() - const threeViewControl = useThreeViewControl() - const { screen, stageModel } = await renderArea(MobileInteractiveArea) - stageModel.setStageModelRenderer('live2d') - - await screen.getByTestId('mobile-settings-button').click() - await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click() - await expect.element(screen.getByTestId('view-controls-close-button')).toBeVisible() - - stageModel.setStageModelRenderer('vrm') - - await expect.element(screen.getByTestId('mobile-message-composer')).toBeVisible() - await expect.element(screen.getByTestId('view-controls-close-button')).not.toBeInTheDocument() - expect(live2dViewControl.viewControlsEnabled.value).toBe(false) - expect(threeViewControl.viewControlsEnabled.value).toBe(false) - await expect.element(screen.getByTestId('mobile-settings-button')).toHaveFocus() - }) - - it('shows all five mobile view controls for VRM models', async () => { - await page.viewport(390, 844) - const { screen, stageModel } = await renderArea(MobileInteractiveArea) - stageModel.setStageModelRenderer('vrm') - - await screen.getByTestId('mobile-settings-button').click() - await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click() - - await expect.element(screen.getByRole('button', { name: 'X', exact: true })).toBeVisible() - await expect.element(screen.getByRole('button', { name: 'Y', exact: true })).toBeVisible() - await expect.element(screen.getByRole('button', { name: 'Z', exact: true })).toBeVisible() - await expect.element(screen.getByRole('button', { name: 'Dis', exact: true })).toBeVisible() - await expect.element(screen.getByRole('button', { name: 'FOV', exact: true })).toBeVisible() - - await screen.getByTestId('view-controls-close-button').click() - }) - - it('keeps the empty mobile input compact and aligns the send action with its bubble', async () => { - // ROOT CAUSE: - // - // The hierarchy redesign removed the input bubble's compact maximum width. - // The 40px bubble also top-aligned its 32px textarea while the send action - // aligned to the bottom of the same row. The reply container now owns the - // visible border, so the action aligns with the bubble instead of its inset textarea. - await page.viewport(390, 844) - const { screen } = await renderArea(MobileInteractiveArea) - const composer = screen.getByTestId('mobile-message-composer').element() - const bubble = screen.getByTestId('mobile-input-bubble').element() - const input = screen.getByRole('textbox').element() - const composerStyle = getComputedStyle(composer) - const composerContentWidth = composer.clientWidth - - Number.parseFloat(composerStyle.paddingLeft) - - Number.parseFloat(composerStyle.paddingRight) - - expect(Math.round(bubble.getBoundingClientRect().width)).toBe(Math.round(composerContentWidth * 0.7)) - - await userEvent.fill(input, 'hi') - const send = screen.getByRole('button', { name: 'stage.chat.actions.send' }).element() - await expect.poll(() => input.getBoundingClientRect().height).toBe(32) - expect(send.getBoundingClientRect().height).toBe(32) - expect(bubble.getBoundingClientRect().bottom).toBe(send.getBoundingClientRect().bottom) - const bubbleBounds = bubble.getBoundingClientRect() - const inputBounds = input.getBoundingClientRect() - expect(inputBounds.top - bubbleBounds.top).toBe(bubbleBounds.bottom - inputBounds.bottom) - }) - - it('closes mobile settings before requesting sign-in', async () => { - let openDialogAtSignIn = true - const authorize = vi.fn(async () => { - openDialogAtSignIn = document.querySelector('[role="dialog"][data-state="open"]') !== null - }) - registerAuthorizationHandler(authorize) - try { - const { screen } = await renderArea(MobileInteractiveArea) - await screen.getByTestId('mobile-settings-button').click() - await screen.getByRole('button', { name: 'stage.mobile-tools.sign-in stage.mobile-tools.account-description' }).click() - await expect.poll(() => authorize.mock.calls.length).toBe(1) - expect(openDialogAtSignIn).toBe(false) - await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).not.toBeInTheDocument() - } - finally { - registerAuthorizationHandler(browserAuthorizationHandler) - } - }) - - it('returns from hearing to mobile settings without stacked dialogs', async () => { - const { screen } = await renderArea(MobileInteractiveArea) - await screen.getByTestId('mobile-settings-button').click() - await screen.getByRole('button', { name: 'stage.mobile-tools.hearing' }).click() - await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.hearing' })).toBeVisible() - await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).not.toBeInTheDocument() - await userEvent.keyboard('{Escape}') - await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).toBeVisible() - await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.hearing' })).not.toBeInTheDocument() - }) - - it('expands the Electron input bubble around a reply preview', async () => { - const { chatSession, screen } = await renderArea() - chatSession.$patch((state) => { - state.sessionMessages['session-b'] = [{ id: 'reply-target', role: 'user', content: 'Reply target' }] - }) - - await expectElectronReplyBubble(screen) - }) - - // https://github.com/moeru-ai/airi/pull/2489#discussion_r3966523188 - // ROOT CAUSE: - // - // Clearing a reply hid the still-mounted preview without moving focus from - // its cancel button. Keyboard focus then remained in an aria-hidden subtree. - // - // Each composer owner now clears the reply and restores focus to its input. - it('restores composer focus after a keyboard user cancels a reply', async () => { - const { chatSession, screen } = await renderArea() - chatSession.$patch((state) => { - state.sessionMessages['session-b'] = [{ id: 'reply-target', role: 'user', content: 'Reply target' }] - }) - - await vi.waitFor(() => { - expect(screen.container.querySelector('[data-swipeable]')).not.toBeNull() - }) - const swipeable = screen.container.querySelector('[data-swipeable]') - if (!swipeable) - throw new Error('Expected a swipeable message.') - - dispatchHorizontalPan(swipeable) - await vi.waitFor(() => { - const button = screen.container.querySelector('[aria-label="stage.chat.reply.cancel"]') - expect(button?.parentElement?.getAttribute('aria-hidden')).toBe('false') - }) - const cancelButton = screen.container.querySelector('[aria-label="stage.chat.reply.cancel"]') - if (!cancelButton) - throw new Error('Expected a reply cancel button.') - cancelButton.focus() - expect(document.activeElement).toBe(cancelButton) - - await userEvent.keyboard('{Enter}') - - await expect.element(screen.getByRole('textbox')).toHaveFocus() - expect(cancelButton.closest('[aria-hidden="true"]')).not.toBeNull() - }) - - it('sends the Electron reply as a native message relation', async () => { - const { chat, chatSession, screen } = await renderArea() - chatSession.$patch((state) => { - state.sessionMessages['session-b'] = [{ id: 'reply-target', role: 'user', content: 'Reply target' }] - }) - const send = vi.spyOn(chat, 'send').mockResolvedValueOnce({ messages: [], sessionId: 'session-b' }) - - await vi.waitFor(() => { - expect(screen.container.querySelector('[data-swipeable]')).not.toBeNull() - }) - const swipeRoot = screen.container.querySelector('[data-swipeable]') - if (!swipeRoot) - throw new Error('Expected a message swipe root.') - - dispatchHorizontalPan(swipeRoot) - await vi.waitFor(() => { - const cancelButton = screen.container.querySelector('[aria-label="stage.chat.reply.cancel"]') - expect(cancelButton?.parentElement?.getAttribute('aria-hidden')).toBe('false') - }) - await submitDraft(screen, 'My answer') - - await vi.waitFor(() => { - expect(send).toHaveBeenCalledWith(expect.objectContaining({ - sessionId: 'session-b', - text: 'My answer', - replyToMessageId: 'reply-target', - })) - }) - }) - - // https://github.com/moeru-ai/airi/pull/2399 - it('keeps the input visible when a short window contains many attachments', async () => { - // ROOT CAUSE: - // - // The composer used its full intrinsic height in the fixed chat grid. - // Multiple attachment rows could exceed the window height, which moved - // the input below the clipped grid boundary. - const { screen } = await renderArea() - const layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement - layout.style.height = '240px' - layout.style.width = '320px' - - await attachImages(screen, 12) - - const input = screen.getByRole('textbox').element() as HTMLTextAreaElement - const layoutRect = layout.getBoundingClientRect() - const inputRect = input.getBoundingClientRect() - - expect(inputRect.top).toBeGreaterThanOrEqual(layoutRect.top) - expect(inputRect.bottom).toBeLessThanOrEqual(layoutRect.bottom) - }) - - // https://github.com/moeru-ai/airi/pull/2399 - it('connects the production history viewport to the fixed composer', async () => { - // ROOT CAUSE: - // - // Isolated layout tests used hand-built history and scrollbar elements. - // Those tests could pass after the production history stopped using the - // Reka viewport or moved the composer into the scroll owner. - const { chatSession, screen } = await renderArea() - const layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement - layout.style.height = '320px' - layout.style.width = '320px' - - chatSession.$patch((state) => { - state.sessionMessages['session-b'] = Array.from({ length: 100 }, (_, index) => ({ - id: `message-${index}`, - role: 'user', - content: `Message ${index}`, - createdAt: index, - })) - }) - - const viewport = screen.container.querySelector('.chat-history-list') - const composer = screen.getByTestId('chat-composer-layer').element() as HTMLElement - const input = screen.getByRole('textbox').element() as HTMLTextAreaElement - expect(viewport).not.toBeNull() - if (!viewport) - throw new Error('Expected the production chat history viewport.') - - await vi.waitFor(() => { - expect(viewport.matches('[data-reka-scroll-area-viewport]')).toBe(true) - expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight) - }) - - expect(composer.contains(input)).toBe(true) - const composerTop = composer.getBoundingClientRect().top - viewport.scrollTop = 120 - viewport.dispatchEvent(new Event('scroll')) - expect(composer.getBoundingClientRect().top).toBe(composerTop) - - const scrollOwners = [...layout.querySelectorAll('*')] - .filter((element) => { - return ['auto', 'scroll'].includes(getComputedStyle(element).overflowY) - && element.scrollHeight > element.clientHeight - }) - expect(scrollOwners).toEqual([viewport]) - }) - - // https://github.com/moeru-ai/airi/actions/runs/34448585692/job/102804067154 - // ROOT CAUSE: - // - // The test scrolled as soon as the reply started to expand. Each later resize - // queued a tail scroll, which could unmount the message selected by the test. - // Virtua then retried that tail scroll when older rows were first measured. - // Wait for the transition, measurements, and pending scroll before the jitter. - // A slower transition exposes this race without depending on CI load. - it.each(['200ms', '1s'])('keeps the history scrollport behind the floating composer (%s reply)', async (duration) => { - const { chatSession, screen } = await renderArea() - const layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement - layout.style.height = '320px' - layout.style.width = '320px' - const history = screen.getByTestId('chat-history-layer').element() as HTMLElement - const composer = screen.getByTestId('chat-composer-layer').element() as HTMLElement - const viewport = screen.container.querySelector('.chat-history-list') - expect(viewport).not.toBeNull() - if (!viewport) - throw new Error('Expected the production chat history viewport.') - - chatSession.$patch((state) => { - state.sessionMessages['session-b'] = Array.from({ length: 100 }, (_, index) => ({ - id: `overlay-message-${index}`, - role: 'user', - content: `Overlay message ${index}`, - createdAt: index, - })) - }) - - await vi.waitFor(() => { - expect(viewport.isConnected).toBe(true) - expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight) - expect(viewport.textContent).toContain('Overlay message 99') - }) - - const layoutRect = layout.getBoundingClientRect() - const historyRect = history.getBoundingClientRect() - const composerRect = composer.getBoundingClientRect() - expect(historyRect.top).toBeCloseTo(layoutRect.top, 0) - expect(historyRect.bottom).toBeCloseTo(layoutRect.bottom, 0) - expect(composerRect.top).toBeLessThan(historyRect.bottom) - - await vi.waitFor(() => { - const bottomPadding = Number.parseFloat(getComputedStyle(viewport).paddingBottom) - const composerSpacerHeight = Number.parseFloat(getComputedStyle(viewport, '::after').height) - expect(bottomPadding).toBeCloseTo(16, 0) - expect(composerSpacerHeight).toBeGreaterThan(composerRect.height) - }) - - const replyTransition = composer.querySelector('[aria-label="stage.chat.reply.cancel"]')?.parentElement?.parentElement - if (!replyTransition) - throw new Error('Expected the reply transition.') - replyTransition.style.transitionDuration = duration - - const collapsedComposerHeight = composer.getBoundingClientRect().height - const collapsedComposerSpacerHeight = Number.parseFloat(getComputedStyle(viewport, '::after').height) - const swipeSurface = screen.container.querySelector('[data-swipeable]') - expect(swipeSurface).not.toBeNull() - if (!swipeSurface) - throw new Error('Expected a message swipe surface.') - - dispatchHorizontalPan(swipeSurface) - - await vi.waitFor(() => { - const cancelButton = composer.querySelector('[aria-label="stage.chat.reply.cancel"]') - expect(cancelButton?.parentElement?.getAttribute('aria-hidden')).toBe('false') - expect(composer.getBoundingClientRect().height).toBeGreaterThan(collapsedComposerHeight) - expect(Number.parseFloat(getComputedStyle(viewport, '::after').height)).toBeGreaterThan(collapsedComposerSpacerHeight) - }) - - // Wait for the real transition and its ResizeObserver-driven tail scroll. - await Promise.all(replyTransition.getAnimations().map(animation => animation.finished)) - await expect.poll(() => Number.parseFloat(getComputedStyle(layout).getPropertyValue('--chat-composer-height'))) - .toBeCloseTo(composer.getBoundingClientRect().height, 0) - - // Virtua's createScrollScheduler (virtua/src/core/driver.ts) retries on - // measurements until 150ms pass. Require a quiet 200ms window before mounting - // unmeasured older rows, which would otherwise restart that tail scroll. - await vi.waitFor(async () => { - const tailPosition = viewport.scrollTop - const measuredScrollHeight = viewport.scrollHeight - await new Promise(resolve => setTimeout(resolve, 200)) - expect(viewport.scrollTop).toBe(tailPosition) - expect(viewport.scrollHeight).toBe(measuredScrollHeight) - }) - - // A vertical wheel expresses reader intent and stops automatic tail following. - viewport.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -241 })) - viewport.scrollTop = 241 - viewport.dispatchEvent(new Event('scroll')) - await expect.poll(() => viewport.scrollTop).toBe(241) - // scrollTop changes before Virtua replaces the mounted tail with this range. - await expect.element(screen.getByText('Overlay message 99', { exact: true })).not.toBeInTheDocument() - - let messageBehindComposer: HTMLElement | undefined - await vi.waitFor(() => { - const currentComposerRect = composer.getBoundingClientRect() - messageBehindComposer = Array.from(screen.container.querySelectorAll('.chat-message-item')) - .filter(message => message.textContent?.includes('Overlay message')) - .find((message) => { - const messageRect = message.getBoundingClientRect() - return messageRect.top < currentComposerRect.bottom && messageRect.bottom > currentComposerRect.top - }) - expect(messageBehindComposer).toBeDefined() - }) - if (!messageBehindComposer) - throw new Error('Expected a mounted message behind the composer.') - - const targetText = messageBehindComposer.textContent - const positionedScrollTop = viewport.scrollTop - expect(positionedScrollTop).toBe(241) - let targetWasUnmounted = false - const targetObserver = new MutationObserver(() => { - if (!messageBehindComposer?.isConnected) - targetWasUnmounted = true - }) - targetObserver.observe(viewport, { childList: true, subtree: true }) - onTestFinished(() => targetObserver.disconnect()) - - viewport.scrollTop = positionedScrollTop + 1 - viewport.dispatchEvent(new Event('scroll')) - await new Promise(resolve => setTimeout(resolve, 220)) - expect(viewport.scrollTop).toBe(positionedScrollTop + 1) - - expect(messageBehindComposer.isConnected).toBe(true) - expect(messageBehindComposer.textContent).toBe(targetText) - - viewport.scrollTop = positionedScrollTop - viewport.dispatchEvent(new Event('scroll')) - await new Promise(resolve => setTimeout(resolve, 220)) - expect(viewport.scrollTop).toBe(positionedScrollTop) - - expect(messageBehindComposer.isConnected).toBe(true) - expect(messageBehindComposer.textContent).toBe(targetText) - - viewport.scrollTop = positionedScrollTop - 1 - viewport.dispatchEvent(new Event('scroll')) - await new Promise(resolve => setTimeout(resolve, 220)) - expect(viewport.scrollTop).toBe(positionedScrollTop - 1) - - expect(messageBehindComposer.isConnected).toBe(true) - expect(messageBehindComposer.textContent).toBe(targetText) - - viewport.scrollTop = positionedScrollTop - viewport.dispatchEvent(new Event('scroll')) - await new Promise(resolve => setTimeout(resolve, 220)) - expect(viewport.scrollTop).toBe(positionedScrollTop) - targetObserver.disconnect() - - expect(targetWasUnmounted).toBe(false) - expect(messageBehindComposer.isConnected).toBe(true) - expect(messageBehindComposer.textContent).toBe(targetText) - }) - - // https://github.com/moeru-ai/airi/pull/2086#discussion_r3743121861 - it('renders the active synchronized stream through the real chat history for Issue #2085', async () => { - // ROOT CAUSE: - // - // A follower received the leader-owned active stream in the real chat - // store, but InteractiveArea passed its unrelated foreground stream to - // ChatHistory. Mocking either store or component hid that broken binding. - const { chat, chatStream, screen } = await renderArea() - chat.$patch({ - activeSendSessionId: 'session-b', - activeStreamingMessage: { - id: 'follower-b-stream', - role: 'assistant', - content: 'Follower B live response', - slices: [{ type: 'text', text: 'Follower B live response' }], - tool_results: [], - createdAt: 2, - }, - sending: true, - }) - chatStream.$patch({ - streamingMessage: { - id: 'leader-a-stream', - role: 'assistant', - content: 'Leader A foreground response', - slices: [{ type: 'text', text: 'Leader A foreground response' }], - tool_results: [], - createdAt: 3, - }, - }) - await nextTick() - - await expect.element(screen.getByText('Follower B live response')).toBeVisible() - await expect.element(screen.getByText('Leader A foreground response')).not.toBeInTheDocument() - }) - - // https://github.com/moeru-ai/airi/pull/2086#discussion_r3743309235 - it('scopes the mobile synchronized stream to its local session for Issue #2085', async () => { - // ROOT CAUSE: - // - // MobileInteractiveArea passed the synchronized global sending state and - // foreground stream directly to ChatHistory. A mobile window on session B - // therefore rendered the live response from a send targeting session A. - const { chat, chatStream, screen } = await renderArea(MobileInteractiveArea) - chat.$patch({ - activeSendSessionId: 'session-a', - activeStreamingMessage: { - id: 'session-a-stream', - role: 'assistant', - content: 'Session A live response', - slices: [{ type: 'text', text: 'Session A live response' }], - tool_results: [], - createdAt: 2, - }, - sending: true, - }) - chatStream.$patch({ - streamingMessage: { - id: 'session-a-foreground', - role: 'assistant', - content: 'Session A live response', - slices: [{ type: 'text', text: 'Session A live response' }], - tool_results: [], - createdAt: 2, - }, - }) - await nextTick() - await expect.element(screen.getByText('Session A live response')).not.toBeInTheDocument() - - chat.$patch({ - activeSendSessionId: 'session-b', - activeStreamingMessage: { - id: 'session-b-stream', - role: 'assistant', - content: 'Session B live response', - slices: [{ type: 'text', text: 'Session B live response' }], - tool_results: [], - createdAt: 3, - }, - }) - await nextTick() - await expect.element(screen.getByText('Session B live response')).toBeVisible() - }) - - // https://github.com/moeru-ai/airi/pull/2086#discussion_r3743366443 - it('scopes the stage-web desktop synchronized stream to its local session for Issue #2085', async () => { - // ROOT CAUSE: - // - // The shared desktop layout derived sending from the target session but - // still passed the leader foreground stream to ChatHistory. A web window - // on B could therefore append A's live response. - const { chat, chatStream, screen } = await renderArea(SharedInteractiveArea) - chat.$patch({ - activeSendSessionId: 'session-b', - activeStreamingMessage: { - id: 'session-b-web-stream', - role: 'assistant', - content: 'Session B web response', - slices: [{ type: 'text', text: 'Session B web response' }], - tool_results: [], - createdAt: 2, - }, - sending: true, - }) - chatStream.$patch({ - streamingMessage: { - id: 'session-a-web-foreground', - role: 'assistant', - content: 'Session A foreground response', - slices: [{ type: 'text', text: 'Session A foreground response' }], - tool_results: [], - createdAt: 3, - }, - }) - await nextTick() - - await expect.element(screen.getByText('Session B web response')).toBeVisible() - await expect.element(screen.getByText('Session A foreground response')).not.toBeInTheDocument() - }) - - it('routes a stage-web send through the synchronized chat action', async () => { - const { chat, screen } = await renderArea(SharedInteractiveArea) - const send = vi.spyOn(chat, 'send').mockResolvedValueOnce({ messages: [], sessionId: 'session-b' }) - - await submitDraft(screen, 'web follower message') - - await vi.waitFor(() => expect(send).toHaveBeenCalledWith({ - sessionId: 'session-b', - text: 'web follower message', - })) - }) - - it('routes a mobile send through the synchronized chat action', async () => { - const { chat, screen } = await renderArea(MobileInteractiveArea) - const send = vi.spyOn(chat, 'send').mockResolvedValueOnce({ messages: [], sessionId: 'session-b' }) - - await submitDraft(screen, 'mobile follower message') - - await vi.waitFor(() => expect(send).toHaveBeenCalledWith({ - sessionId: 'session-b', - text: 'mobile follower message', - })) - }) - - it('opts the mobile composer out of browser form assistance', async () => { - const { screen } = await renderArea(MobileInteractiveArea) - const input = screen.getByRole('textbox').element() as HTMLTextAreaElement - - expect(input.getAttribute('autocomplete')).toBe('off') - expect(input.getAttribute('autocapitalize')).toBe('off') - expect(input.getAttribute('autocorrect')).toBe('off') - expect(input.spellcheck).toBe(false) - }) - - // https://github.com/moeru-ai/airi/pull/2086#discussion_r3755530944 - it('keeps a failed mobile draft out of a newly selected session for Issue #2085', async () => { - // ROOT CAUSE: - // - // Shared layouts restored a rejected send into their component-wide input - // without checking whether the window still displayed the target session. - const { chat, chatSession, screen } = await renderArea(MobileInteractiveArea) - let rejectSend: ((error: Error) => void) | undefined - vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => { - rejectSend = reject - })) - - const input = await submitDraft(screen, 'mobile draft from B') - chatSession.activeSessionId = 'session-a' - rejectSend?.(new Error('send failed')) - - await expect.element(input).toHaveValue('') - }) - - it('does not restore a deleted-session draft in the shared chat widget', async () => { - const { chat, screen } = await renderArea(SharedInteractiveArea) - let rejectSend: ((error: Error) => void) | undefined - vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => { - rejectSend = reject - })) - - const input = await submitDraft(screen, 'deleted web draft') - rejectSend?.(new Error('Chat session was removed before send completed')) - - await expect.element(input).toHaveValue('') - }) - - // https://github.com/moeru-ai/airi/pull/2086#discussion_r3628804992 - it('does not restore a failed draft into a newly selected session for Issue #2085', async () => { - // ROOT CAUSE: - // - // Failure recovery used the reactive selection instead of the session - // captured by the send, so a late rejection could move a draft. - const { chat, chatSession, screen } = await renderArea() - let rejectSend: ((error: Error) => void) | undefined - vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => { - rejectSend = reject - })) - - const input = await submitDraft(screen, 'send from B') - await expect.element(input).toHaveValue('') - chatSession.activeSessionId = 'session-a' - rejectSend?.(new Error('hydrate failed')) - - await expect.element(input).toHaveValue('') - }) - - // https://github.com/moeru-ai/airi/pull/2086#discussion_r3629004140 - it('restores a failed draft when its captured session is still active for Issue #2085', async () => { - const { chat, screen } = await renderArea() - vi.spyOn(chat, 'send').mockRejectedValueOnce(new Error('send failed')) - - const input = await submitDraft(screen, 'retry this draft') - await expect.element(input).toHaveValue('retry this draft') - }) - - it('keeps a newer draft when an earlier send fails', async () => { - // ROOT CAUSE: - // - // Failure recovery replaced the textarea unconditionally. Text entered - // while the request was pending was lost with its attachment previews. - const { chat, screen } = await renderArea() - let rejectSend: ((error: Error) => void) | undefined - vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => { - rejectSend = reject - })) - - const input = await submitDraft(screen, 'first draft') - await userEvent.fill(input, 'newer draft') - rejectSend?.(new Error('send failed')) - - await expect.element(input).toHaveValue('first draft\nnewer draft') - }) - - // https://github.com/moeru-ai/airi/pull/2086#discussion_r3743366446 - it('discards a queued draft when deletion cancels its send for Issue #2085', async () => { - const { chat, screen } = await renderArea() - let rejectSend: ((error: Error) => void) | undefined - vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => { - rejectSend = reject - })) - - const input = await submitDraft(screen, 'discard this deleted draft') - rejectSend?.(new Error('Chat session was reset before send could start')) - - await expect.element(input).toHaveValue('') - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue deleted file mode 100644 index 86a75d279..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue +++ /dev/null @@ -1,465 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/ResizeHandler.vue b/apps/stage-tamagotchi/src/renderer/components/ResizeHandler.vue deleted file mode 100644 index ccf16576d..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/ResizeHandler.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/Window/TitleBar.vue b/apps/stage-tamagotchi/src/renderer/components/Window/TitleBar.vue deleted file mode 100644 index 78f72c2c2..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/Window/TitleBar.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/WindowRouterLink.vue b/apps/stage-tamagotchi/src/renderer/components/WindowRouterLink.vue deleted file mode 100644 index bb1b99849..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/WindowRouterLink.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/WithScreenCapture.vue b/apps/stage-tamagotchi/src/renderer/components/WithScreenCapture.vue deleted file mode 100644 index 60738fa75..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/WithScreenCapture.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/chat-image-attachment-preview.browser.test.ts b/apps/stage-tamagotchi/src/renderer/components/chat-image-attachment-preview.browser.test.ts deleted file mode 100644 index 64e847c2b..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/chat-image-attachment-preview.browser.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { render } from 'vitest-browser-vue' - -import ChatImageAttachmentPreview from './chat-image-attachment-preview.vue' - -describe('chat image attachment preview', () => { - // ROOT CAUSE: - // - // The parent created Object URLs but only released them after removal or send. - // Unmounting with a pending attachment left its URL alive. - // - // The preview now owns the URL through useObjectUrl. Component disposal - // revokes the URL. - it('releases its Object URL when the preview unmounts', async () => { - const file = new File(['preview-bytes'], 'preview.png', { type: 'image/png' }) - const screen = await render(ChatImageAttachmentPreview, { - props: { file }, - }) - const image = screen.container.querySelector('img') - - expect(image).not.toBeNull() - if (!image) - throw new Error('Expected an image attachment preview.') - - const previewUrl = image.src - await expect(fetch(previewUrl).then(response => response.text())).resolves.toBe('preview-bytes') - - screen.unmount() - - await expect(fetch(previewUrl)).rejects.toThrow() - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/components/chat-image-attachment-preview.vue b/apps/stage-tamagotchi/src/renderer/components/chat-image-attachment-preview.vue deleted file mode 100644 index f287ae40f..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/chat-image-attachment-preview.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/chat-tool-renderers/journal-tool-call-block.vue b/apps/stage-tamagotchi/src/renderer/components/chat-tool-renderers/journal-tool-call-block.vue deleted file mode 100644 index 2d38bb39b..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/chat-tool-renderers/journal-tool-call-block.vue +++ /dev/null @@ -1,191 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.browser.test.ts b/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.browser.test.ts deleted file mode 100644 index f913f3f5a..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.browser.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import type { ChatHistoryItem, StreamingAssistantMessage } from '@proj-airi/stage-ui/types/chat' - -import en from '@proj-airi/i18n/locales/en' - -import { ChatHistory } from '@proj-airi/stage-ui/components' -import { ScrollableArea } from '@proj-airi/ui' -import { describe, expect, it, vi } from 'vitest' -import { render } from 'vitest-browser-vue' -import { defineComponent, shallowRef } from 'vue' -import { createI18n } from 'vue-i18n' - -import ChatViewportLayout from './chat-viewport-layout.vue' - -import '@unocss/reset/tailwind.css' -import 'virtual:uno.css' - -function createEnglishI18n() { - return createI18n({ - legacy: false, - locale: 'en', - messages: { en }, - }) -} - -describe('desktop chat viewport layout', () => { - it('keeps history behind the fixed translucent composer', async () => { - const TestHost = defineComponent({ - components: { ChatViewportLayout, ScrollableArea }, - template: ` - - - - - `, - }) - - const screen = await render(TestHost) - const layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement - const historyLayer = screen.getByTestId('chat-history-layer').element() as HTMLElement - const composer = screen.getByTestId('chat-composer-layer').element() as HTMLElement - const history = screen.container.querySelector('.chat-history-list') - const scrollbar = screen.container.querySelector('.scrollable-area-scrollbar--vertical') - - expect(history).not.toBeNull() - expect(scrollbar).not.toBeNull() - if (!history || !scrollbar) - throw new Error('Expected the chat history viewport and its custom scrollbar.') - - await vi.waitFor(() => { - expect(getComputedStyle(history).paddingBottom).toBe('16px') - expect(Number.parseFloat(getComputedStyle(history, '::after').height)).toBeGreaterThan(80) - expect(history.scrollHeight).toBeGreaterThan(history.clientHeight) - }) - - const layoutRect = layout.getBoundingClientRect() - const historyRect = historyLayer.getBoundingClientRect() - const composerRect = composer.getBoundingClientRect() - expect(historyRect.top).toBe(layoutRect.top) - expect(historyRect.right).toBe(layoutRect.right) - expect(historyRect.bottom).toBe(layoutRect.bottom) - expect(composerRect.top).toBeLessThan(historyRect.bottom) - expect(getComputedStyle(history).borderRadius).toBe('0px') - - const scrollbarRect = scrollbar.getBoundingClientRect() - expect(scrollbarRect.top).toBe(historyRect.top) - expect(scrollbarRect.right).toBe(historyRect.right) - expect(scrollbarRect.bottom).toBe(historyRect.bottom) - expect(layoutRect.right - composerRect.right).toBe(16) - - const composerTop = composer.getBoundingClientRect().top - history.scrollTop = 120 - history.dispatchEvent(new Event('scroll')) - expect(composer.getBoundingClientRect().top).toBe(composerTop) - }) - - // https://github.com/moeru-ai/airi/pull/2489#discussion_r3967818100 - // ROOT CAUSE: - // - // The viewport spacer increases the native scroll range, but Virtua calculates - // end alignment from item offsets that do not include that spacer. An automatic - // tail scroll therefore leaves the final message behind the fixed composer. - // - // The Virtua scroll adapter must add the composer inset to end-aligned requests. - it('keeps an automatically scrolled long-history tail above the fixed composer', async () => { - const messages = shallowRef(Array.from({ length: 40 }, (_, index) => ({ - id: `message-${index}`, - role: 'user', - content: `Message ${index}`, - }))) - const sending = shallowRef(false) - const streamingMessage = shallowRef() - const TestHost = defineComponent({ - components: { ChatHistory, ChatViewportLayout }, - setup() { - return { messages, sending, streamingMessage } - }, - template: ` - - - - - `, - }) - - const screen = await render(TestHost, { - global: { - plugins: [createEnglishI18n()], - }, - }) - const composer = screen.getByTestId('chat-composer-layer').element() as HTMLElement - - async function expectVisibleTail(text: string) { - await vi.waitFor(() => { - const mountedMessages = screen.container.querySelectorAll('.chat-message-item') - const finalMessage = [...mountedMessages].find(message => message.textContent?.includes(text)) - expect(finalMessage).not.toBeUndefined() - expect(finalMessage!.getBoundingClientRect().bottom).toBeLessThanOrEqual(composer.getBoundingClientRect().top + 1) - }) - } - - await expectVisibleTail('Message 39') - - messages.value = [...messages.value, { - id: 'message-40', - role: 'user', - content: 'Appended tail', - }] - await expectVisibleTail('Appended tail') - - sending.value = true - streamingMessage.value = { - id: 'streaming-tail', - role: 'assistant', - content: 'Streaming tail', - slices: [{ type: 'text', text: 'Streaming tail' }], - tool_results: [], - } - await expectVisibleTail('Streaming tail') - - const expandedStreamText = 'Expanded streaming tail '.repeat(12) - streamingMessage.value = { - id: 'streaming-tail', - role: 'assistant', - content: expandedStreamText, - slices: [{ type: 'text', text: expandedStreamText }], - tool_results: [], - } - await expectVisibleTail('Expanded streaming tail') - }) - - // ROOT CAUSE: - // - // Virtua bottom-aligns a short list with transforms. Transforms do not add to - // the viewport's scroll size, so the composer spacer cannot reveal a message - // that the transform placed behind the composer. - // - // The short-list transform must reserve the composer inset. Long histories - // still use the viewport spacer and can scroll behind the translucent layer. - it('keeps a short virtualized history above the fixed composer', async () => { - const messages: ChatHistoryItem[] = [{ - id: 'short-history-message', - role: 'user', - content: 'Short message', - }] - const TestHost = defineComponent({ - components: { ChatHistory, ChatViewportLayout }, - setup() { - return { messages } - }, - template: ` - - - - - `, - }) - - const screen = await render(TestHost, { - global: { - plugins: [createEnglishI18n()], - }, - }) - const composer = screen.getByTestId('chat-composer-layer').element() as HTMLElement - - await vi.waitFor(() => { - const message = screen.container.querySelector('.chat-message-item') - expect(message).not.toBeNull() - expect(message!.getBoundingClientRect().bottom).toBeLessThanOrEqual(composer.getBoundingClientRect().top) - }) - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.vue b/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.vue deleted file mode 100644 index 071ae77a9..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/control-button-tooltip.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/control-button-tooltip.vue deleted file mode 100644 index 248afdec9..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/control-button-tooltip.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/control-button.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/control-button.vue deleted file mode 100644 index f70fc784e..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/control-button.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.test.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.test.ts deleted file mode 100644 index f8a64626d..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createApp, h, nextTick, ref } from 'vue' - -import ControlsIslandAuthButton from './controls-island-auth-button.vue' - -import { electronAuthStartLogin } from '../../../../shared/eventa' - -const subscriptions = vi.hoisted(() => ({ on: vi.fn(() => vi.fn()) })) -const invokes = vi.hoisted(() => ({ startLogin: vi.fn(), openSettings: vi.fn() })) - -const authState = { - isAuthenticated: ref(true), - user: ref<{ name: string, image?: string }>({ - name: 'Rainbow Bird', - image: 'https://example.com/broken-avatar.png', - }), - needsLogin: ref(false), - credits: ref(9620), -} - -vi.mock('@proj-airi/stage-ui/stores/auth', () => ({ - useAuthStore: () => authState, -})) - -vi.mock('@proj-airi/electron-vueuse', () => ({ - useElectronEventaContext: () => ref({ - on: subscriptions.on, - }), - useElectronEventaInvoke: (event: unknown) => event === electronAuthStartLogin ? invokes.startLogin : invokes.openSettings, -})) - -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ - t: (key: string) => key, - }), -})) - -describe('controlsIslandAuthButton', () => { - const mountedApps: Array<{ app: ReturnType, host: HTMLElement }> = [] - - afterEach(() => { - for (const { app, host } of mountedApps) { - app.unmount() - host.remove() - } - mountedApps.length = 0 - authState.isAuthenticated.value = true - authState.needsLogin.value = false - invokes.startLogin.mockReset() - invokes.openSettings.mockReset() - authState.user.value.image = 'https://example.com/broken-avatar.png' - }) - - function mountComponent(active = ref(true)) { - const host = document.createElement('div') - document.body.appendChild(host) - const app = createApp({ - render: () => h(ControlsIslandAuthButton, { active: active.value }), - }) - app.mount(host) - mountedApps.push({ app, host }) - return host - } - - it('starts a deferred login when the hidden menu becomes active', async () => { - authState.isAuthenticated.value = false - const active = ref(false) - mountComponent(active) - - authState.needsLogin.value = true - await nextTick() - expect(invokes.startLogin).not.toHaveBeenCalled() - expect(authState.needsLogin.value).toBe(true) - - active.value = true - await nextTick() - expect(invokes.startLogin).toHaveBeenCalledOnce() - expect(authState.needsLogin.value).toBe(false) - }) - - it('disposes both auth subscriptions when the menu unmounts', () => { - subscriptions.on.mockClear() - mountComponent() - expect(subscriptions.on).toHaveBeenCalledTimes(2) - const stops = subscriptions.on.mock.results.map(result => result.value) - mountedApps[0]!.app.unmount() - for (const stop of stops) - expect(stop).toHaveBeenCalledOnce() - mountedApps[0]!.host.remove() - mountedApps.length = 0 - }) - - it('renders the shared account fallback when no avatar is available', () => { - authState.user.value.image = undefined - const host = mountComponent() - - const fallback = host.querySelector('[data-avatar-fallback]') - expect(fallback).toBeTruthy() - expect(fallback?.firstElementChild?.classList.contains('i-solar:user-circle-bold-duotone')).toBe(true) - }) - - it('tries the next avatar URL after the authenticated user changes', async () => { - const host = mountComponent() - const previousImage = host.querySelector('[data-avatar-image]') - - authState.user.value.image = 'https://example.com/new-avatar.png' - await nextTick() - - const nextImage = host.querySelector('[data-avatar-image]') - expect(nextImage).not.toBe(previousImage) - expect(nextImage?.getAttribute('src')).toBe('https://example.com/new-avatar.png') - expect(nextImage?.getAttribute('alt')).toBe('') - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.vue deleted file mode 100644 index b047b6dcb..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.vue +++ /dev/null @@ -1,182 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-fade-on-hover.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-fade-on-hover.vue deleted file mode 100644 index efb9f25b3..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-fade-on-hover.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-hearing-config.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-hearing-config.vue deleted file mode 100644 index 31dcd4bf1..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-hearing-config.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-overflow.browser.test.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-overflow.browser.test.ts deleted file mode 100644 index 0fa3dd8db..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-overflow.browser.test.ts +++ /dev/null @@ -1,523 +0,0 @@ -import type { AiriCard } from '@proj-airi/stage-ui/stores/modules/airi-card' - -import type { ControlsIslandDock } from './use-controls-island-placement' - -import en from '@proj-airi/i18n/locales/en' - -import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' -import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry' -import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' -import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' -import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision/store' -import { useSettings } from '@proj-airi/stage-ui/stores/settings' -import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model' -import { createPinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render } from 'vitest-browser-vue' -import { page } from 'vitest/browser' -import { computed, defineComponent, h, nextTick, ref } from 'vue' -import { createI18n } from 'vue-i18n' - -import ControlsIsland from './index.vue' - -import { electronOpenSettings } from '../../../../shared/eventa' -import { controlsIslandPlacementKey } from './use-controls-island-placement' - -import '@unocss/reset/tailwind.css' -import 'virtual:uno.css' - -const isOutside = ref(false) -const openSettings = vi.fn().mockResolvedValue(undefined) -const authState = vi.hoisted(() => ({ - credits: { value: 0 }, - isAuthenticated: { value: false }, - needsLogin: { value: false }, - user: { value: null as { createdAt: Date, email: string, emailVerified: boolean, id: string, name: string, updatedAt: Date } | null }, -})) - -vi.mock('@proj-airi/electron-vueuse', () => ({ - useElectronEventaContext: () => ref({ on: vi.fn(() => vi.fn()), emit: vi.fn() }), - useElectronEventaInvoke: (event: unknown) => event === electronOpenSettings ? openSettings : vi.fn().mockResolvedValue(false), - useElectronMouseInElement: () => ({ isOutside }), -})) - -vi.mock('@moeru/eventa', async importOriginal => ({ - ...await importOriginal(), - defineInvoke: () => vi.fn(), -})) - -vi.mock('@proj-airi/stage-ui/stores/auth', async () => { - const { ref } = await import('vue') - authState.credits = ref(0) - authState.isAuthenticated = ref(false) - authState.needsLogin = ref(false) - authState.user = ref(null) - - return { useAuthStore: () => authState } -}) - -function scrollOwners(island: HTMLElement) { - return Array.from(island.querySelectorAll('[data-reka-scroll-area-viewport]')) - .filter(element => getComputedStyle(element).overflowY === 'scroll' && element.scrollHeight > element.clientHeight) -} - -const docks: ControlsIslandDock[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'] -const sizes = ['small', 'large', 'auto'] as const - -function mountControlsIsland(dock: ControlsIslandDock, size: typeof sizes[number] = 'auto', dockRef = ref(dock), initializeProfile = false) { - const pinia = createPinia() - const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) - const component = initializeProfile - ? defineComponent({ - setup() { - // Seed the profile store without invoking the stage's asynchronous - // runtime initialization. The profile form only needs an active card. - const cards = useAiriCardStore() - // Create the stores that card duplication reads while Vue still has - // a component setup context. The action itself can then reuse them. - useArtistryStore() - useConsciousnessStore() - useSpeechStore() - useSettingsStageModel() - useVisionStore() - const defaultCard = { - name: 'ReLU', - version: '1.0.0', - extensions: { - airi: { - modules: { - consciousness: { provider: '', model: '' }, - vision: { provider: '', model: '' }, - speech: { provider: '', model: '', voice_id: '' }, - }, - agents: {}, - }, - }, - } satisfies AiriCard - cards.cards.set('default', defaultCard) - return () => h(ControlsIsland) - }, - }) - : ControlsIsland - const screen = render(component, { - global: { - provide: { - [controlsIslandPlacementKey as symbol]: { - dock: dockRef, - isTop: computed(() => dockRef.value.startsWith('top')), - isLeft: computed(() => dockRef.value.endsWith('left')), - motionPhase: ref('idle'), - }, - }, - plugins: [pinia, i18n], - directives: { 'track-button': {} }, - }, - }) - useSettings(pinia).controlsIslandIconSize = size - - return { cards: useAiriCardStore(pinia), auth: authState, dock: dockRef, i18n, screen, settings: useSettings(pinia) } -} - -beforeEach(() => { - isOutside.value = false - openSettings.mockClear() - authState.credits.value = 0 - authState.isAuthenticated.value = false - authState.needsLogin.value = false - authState.user.value = null -}) - -describe('controls Island overflow', () => { - for (const dock of docks) { - for (const size of sizes) { - // ROOT CAUSE: - // The expanded panel had no viewport limit or scroll owner. Its first rows - // left the window when the panel and main controls exceeded its height. - // The menu now owns scrolling until the main controls fill the viewport. - // https://github.com/moeru-ai/airi/issues/2400 - it(`Issue #2400 keeps ${dock} ${size} controls reachable across measured boundaries`, async () => { - await page.viewport(450, 600) - const { i18n, screen } = mountControlsIsland(dock, size) - await nextTick() - const island = screen.getByTestId('controls-island').element() as HTMLElement - const main = screen.getByTestId('main-controls').element() as HTMLElement - const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`) - await expect.poll(() => Number.parseFloat(getComputedStyle(main.querySelector('div.size-3, div.size-5')!).width)).toBe(size === 'small' ? 12 : 20) - const mainHeight = main.getBoundingClientRect().height - const mainBefore = main.getBoundingClientRect() - await screen.getByLabelText(label('expand'), { exact: true }).click() - const menu = screen.getByTestId('controls-menu').element() as HTMLElement - await expect.poll(() => island.getBoundingClientRect().height).toBeGreaterThan(mainHeight) - expect(main.getBoundingClientRect().top).toBe(mainBefore.top) - expect(main.getBoundingClientRect().right).toBe(mainBefore.right) - await expect.poll(() => scrollOwners(island)).toHaveLength(0) - const naturalHeight = island.getBoundingClientRect().height - const naturalWidth = island.getBoundingClientRect().width - const menuHeight = menu.querySelector('.w-max')!.offsetHeight - const isTop = dock.startsWith('top') - const isLeft = dock.endsWith('left') - - for (const height of [naturalHeight + 17, naturalHeight + 16, naturalHeight + 15, mainHeight + 17, mainHeight + 16, mainHeight + 15, 600]) { - await page.viewport(450, Math.ceil(height)) - const sideways = height < naturalHeight + 16 - await expect.poll(() => island.dataset.direction).toBe(sideways ? (isLeft ? 'right' : 'left') : (isTop ? 'down' : 'up')) - await expect.poll(() => island.getBoundingClientRect().height).toBeLessThanOrEqual(Math.ceil(height) - 16) - expect(island.getBoundingClientRect().top).toBeGreaterThanOrEqual(8) - expect(island.getBoundingClientRect().bottom).toBeLessThanOrEqual(Math.ceil(height) - 8) - expect(main.getBoundingClientRect().height).toBe(mainHeight) - const expectedOwnerCount = Math.max(mainHeight, menuHeight) > Math.ceil(height) - 16 ? 1 : 0 - await expect.poll(() => scrollOwners(island).length).toBe(expectedOwnerCount) - if (expectedOwnerCount) { - const owner = scrollOwners(island)[0]! - expect(menu.contains(owner)).toBe(height >= mainHeight + 16) - owner.scrollTop = owner.scrollHeight - expect(owner.scrollTop).toBeGreaterThan(0) - } - } - - for (const width of [naturalWidth + 17, naturalWidth + 16, naturalWidth + 15, 40, 450]) { - await page.viewport(Math.ceil(width), 600) - await expect.poll(() => island.getBoundingClientRect().width).toBeLessThanOrEqual(Math.ceil(width) - 16) - expect(island.getBoundingClientRect().left).toBeGreaterThanOrEqual(8) - if (width < naturalWidth + 16) { - const owner = Array.from(island.querySelectorAll('[data-reka-scroll-area-viewport]')) - .find(viewport => viewport.scrollWidth > viewport.clientWidth)! - owner.scrollLeft = owner.scrollWidth - expect(owner.scrollLeft).toBeGreaterThan(0) - } - } - - await page.viewport(450, Math.ceil(mainHeight + 60)) - const settings = screen.getByLabelText(label('open-settings'), { exact: true }) - const settingsElement = settings.element() as HTMLElement - settingsElement.focus() - await expect.poll(() => settingsElement.getBoundingClientRect().top).toBeGreaterThanOrEqual(8) - await settings.click() - expect(openSettings).toHaveBeenCalledWith({ route: '/settings' }) - - await screen.getByLabelText(label('collapse'), { exact: true }).click() - await expect.poll(() => menu.closest('[aria-hidden]')?.getAttribute('aria-hidden')).toBe('true') - await screen.getByLabelText(label('expand'), { exact: true }).click() - const reopenedViewport = screen.getByTestId('controls-menu').element().querySelector('[data-reka-scroll-area-viewport]')! - expect(reopenedViewport.scrollTop).toBe(0) - }) - } - } - - for (const dock of ['top-right', 'bottom-right'] as const) { - it(`Issue #2400 aligns ${dock} controls to the visible right edge`, async () => { - await page.viewport(450, 600) - const { i18n, screen } = mountControlsIsland(dock) - const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`) - - await screen.getByLabelText(label('expand'), { exact: true }).click() - const island = screen.getByTestId('controls-island').element() as HTMLElement - const naturalWidth = island.getBoundingClientRect().width - await page.viewport(Math.max(40, Math.floor(naturalWidth / 2)), 600) - - const viewport = island.querySelector('[data-reka-scroll-area-viewport]')! - await expect.poll(() => screen.getByTestId('main-controls').element().getBoundingClientRect().right).toBeLessThanOrEqual(window.innerWidth - 8) - expect(viewport.scrollLeft).toBe(0) - }) - } - - // ROOT CAUSE: - // Icon size changes alter the Island geometry after a right-docked layout has - // been aligned. The old implementation did not realign after that change. - // - // Before the patch, the right dock kept a stale horizontal scroll position. - // - // We fixed this by observing the Island geometry and aligning after updates. - it('issue #2400 realigns the right dock after an icon size change', async () => { - await page.viewport(450, 600) - const { i18n, screen, settings } = mountControlsIsland('bottom-right', 'small') - const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`) - - await screen.getByLabelText(label('expand'), { exact: true }).click() - const island = screen.getByTestId('controls-island').element() as HTMLElement - const viewport = island.querySelector('[data-reka-scroll-area-viewport]')! - await page.viewport(40, 600) - await expect.poll(() => viewport.scrollLeft).toBeGreaterThan(0) - const previousScrollWidth = viewport.scrollWidth - - settings.controlsIslandIconSize = 'large' - await expect.poll(() => viewport.scrollWidth).toBeGreaterThan(previousScrollWidth) - await expect.poll(() => viewport.scrollLeft).toBe(viewport.scrollWidth - viewport.clientWidth) - }) - - // ROOT CAUSE: - // Tooltip content is portaled outside the Island and can render below the - // stage when it uses the default stacking order. - // - // Before the patch, a tooltip over a control could be hidden by the stage. - // - // We fixed this by keeping the control tooltip portal above the stage layer. - it('issue #2400 raises portaled control tooltips above the stage', async () => { - await page.viewport(450, 600) - const { i18n, screen } = mountControlsIsland('bottom-right') - const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`) - - await screen.getByLabelText(label('expand'), { exact: true }).click() - await screen.getByLabelText(label('open-settings'), { exact: true }).hover() - - const tooltipWrapper = '[data-reka-popper-content-wrapper]' - await expect.poll(() => document.querySelector(tooltipWrapper)).not.toBeNull() - expect(getComputedStyle(document.querySelector(tooltipWrapper)!).zIndex).toBe('1000') - }) - - // ROOT CAUSE: - // Authentication content can grow after the right-docked Island has been - // aligned, which changes the horizontal overflow range. - // - // Before the patch, the right edge moved out of view after the user signed in. - // - // We fixed this by observing content geometry and realigning the dock edge. - it('issue #2400 realigns the right dock after authentication content grows', async () => { - await page.viewport(450, 600) - const { auth, i18n, screen } = mountControlsIsland('bottom-right') - const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`) - - await screen.getByLabelText(label('expand'), { exact: true }).click() - const island = screen.getByTestId('controls-island').element() as HTMLElement - const viewport = island.querySelector('[data-reka-scroll-area-viewport]')! - await page.viewport(40, 600) - await expect.poll(() => viewport.scrollLeft).toBeGreaterThan(0) - const previousScrollWidth = viewport.scrollWidth - - auth.credits.value = 999999999 - auth.isAuthenticated.value = true - auth.user.value = { - createdAt: new Date('2020-01-01'), - email: 'user@example.com', - emailVerified: true, - id: 'user', - name: 'A very long authenticated user name that changes the island width', - updatedAt: new Date('2020-01-01'), - } - - await expect.poll(() => viewport.scrollWidth).toBeGreaterThan(previousScrollWidth) - await expect.poll(() => viewport.scrollLeft).toBe(viewport.scrollWidth - viewport.clientWidth) - }) - - // ROOT CAUSE: - // Dock changes reverse the horizontal edge that must remain visible, but the - // previous scroll offset belongs to the old dock. - // - // Before the patch, moving from right to left kept the old right-edge offset. - // - // We fixed this by aligning both axes whenever the dock changes. - it('issue #2400 resets horizontal scroll after moving from a right dock to a left dock', async () => { - await page.viewport(450, 600) - const { dock, i18n, screen } = mountControlsIsland('bottom-right') - const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`) - - await screen.getByLabelText(label('expand'), { exact: true }).click() - const island = screen.getByTestId('controls-island').element() as HTMLElement - const viewport = island.querySelector('[data-reka-scroll-area-viewport]')! - await page.viewport(40, 600) - await expect.poll(() => viewport.scrollLeft).toBeGreaterThan(0) - - dock.value = 'bottom-left' - await expect.poll(() => viewport.scrollLeft).toBe(0) - }) - - // ROOT CAUSE: - // A bottom-docked Island must use the lower scroll edge when its content is - // taller than the window, or the main controls can remain below the viewport. - // - // Before the patch, the bottom dock could open with its main controls clipped. - // - // We fixed this by aligning the outer viewport to the dock edge after layout changes. - it('issue #2400 aligns bottom docks to the visible vertical scroll end', async () => { - await page.viewport(450, 200) - const { i18n, screen } = mountControlsIsland('bottom-right') - const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`) - - await screen.getByLabelText(label('expand'), { exact: true }).click() - const island = screen.getByTestId('controls-island').element() as HTMLElement - const viewport = island.querySelector('[data-reka-scroll-area-viewport]')! - await expect.poll(() => viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight) - // Focused collapse remains reachable even when docking would clip it. - const collapse = screen.getByLabelText(label('collapse'), { exact: true }).element() as HTMLElement - expect(collapse.getBoundingClientRect().top).toBeGreaterThanOrEqual(8) - collapse.blur() - await page.viewport(450, 190) - await expect.poll(() => viewport.scrollTop).toBe(viewport.scrollHeight - viewport.clientHeight) - }) - - // ROOT CAUSE: - // A scrollbar drag can move the pointer outside the Island while the user is - // still interacting with it. - // - // Before the patch, the outside timer collapsed the menu during a scrollbar drag. - // - // We fixed this by treating pressed scrollbar interaction as a blocked state. - // The interaction path is independent from the size and dock matrix. - it('issue #2400 keeps the expanded menu open during a scrollbar drag', async () => { - await page.viewport(450, 300) - const { i18n, screen } = mountControlsIsland('bottom-right') - const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`) - await screen.getByLabelText(label('expand'), { exact: true }).click() - - const island = screen.getByTestId('controls-island').element() as HTMLElement - const settings = screen.getByLabelText(label('open-settings'), { exact: true }).element() as HTMLElement - settings.focus() - expect(island.contains(document.activeElement)).toBe(true) - expect(settings.getBoundingClientRect().bottom).toBeGreaterThan(8) - - island.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) - isOutside.value = true - await new Promise(resolve => setTimeout(resolve, 1700)) - expect(screen.getByTestId('controls-menu').element()).toBeInTheDocument() - window.dispatchEvent(new MouseEvent('mouseup')) - await expect.poll(() => screen.getByTestId('controls-menu').element().closest('[aria-hidden]')?.getAttribute('aria-hidden'), { timeout: 3500 }).toBe('true') - }) -}) - -// https://github.com/moeru-ai/airi/pull/2474 -it('measures the collapsed menu and opens inward when height is insufficient (PR #2474)', async () => { - // ROOT CAUSE: - // The menu only mounted after opening and always used the vertical axis. - // Natural content measurement now determines both placement and the arrow. - await page.viewport(600, 300) - const { i18n, screen } = mountControlsIsland('bottom-right') - const island = screen.getByTestId('controls-island').element() - await expect.poll(() => island.getAttribute('data-direction')).toBe('left') - await screen.getByLabelText(i18n.global.t('tamagotchi.stage.controls-island.expand'), { exact: true }).click() - const main = screen.getByTestId('main-controls').element() - const menu = screen.getByTestId('controls-menu').element() - await expect.poll(() => menu.getBoundingClientRect().right).toBeLessThanOrEqual(main.getBoundingClientRect().left - 12) -}) - -// https://github.com/moeru-ai/airi/pull/2474 -it('keeps the profile creation form open for pointer interaction (PR #2474)', async () => { - // ROOT CAUSE: - // Closing the selector canceled creation, and the body portal counted as an - // outside click. The selector and form must share one interaction lifecycle. - await page.viewport(600, 300) - const { cards, i18n, screen } = mountControlsIsland('bottom-right', 'auto', ref('bottom-right'), true) - await screen.getByLabelText(i18n.global.t('tamagotchi.stage.controls-island.expand'), { exact: true }).click() - await screen.getByRole('combobox').click() - await page.getByRole('option', { name: i18n.global.t('stage.profile-switcher.save-as-new') }).click() - const input = page.getByPlaceholder(i18n.global.t('stage.profile-switcher.new-profile-name')) - await input.click() - await input.fill('New profile') - await expect.element(input).toHaveValue('New profile') - isOutside.value = true - await new Promise(resolve => setTimeout(resolve, 1700)) - expect(screen.getByTestId('controls-menu').element().closest('[inert]')).toBeNull() - for (const [width, height] of [[160, 200], [100, 80], [600, 600]] as const) { - await page.viewport(width, height) - const form = page.getByTestId('profile-create-form').element() as HTMLElement - await expect.poll(() => form.getBoundingClientRect().right).toBeLessThanOrEqual(width - 8) - await expect.poll(() => form.getBoundingClientRect().bottom).toBeLessThanOrEqual(height - 8) - expect(form.getBoundingClientRect().left).toBeGreaterThanOrEqual(8) - expect(form.getBoundingClientRect().top).toBeGreaterThanOrEqual(8) - } - await page.getByRole('button', { name: i18n.global.t('stage.profile-switcher.save-as-new'), exact: true }).click() - await expect.poll(() => cards.activeCard?.name).toBe('New profile') - await expect.element(input).not.toBeInTheDocument() - isOutside.value = false - - // ROOT CAUSE: - // Reopening the selector while creation was active changed the selected - // card but left the old form state alive. A later save could clone the new - // card with the name entered for the previous card. - // - // We fixed this by canceling creation when a non-create option is selected. - // The close-to-create transition remains allowed. - await screen.getByRole('combobox').click() - await page.getByRole('option', { name: i18n.global.t('stage.profile-switcher.save-as-new') }).click() - await page.getByPlaceholder(i18n.global.t('stage.profile-switcher.new-profile-name')).fill('Stale profile name') - await screen.getByRole('combobox').click() - await page.getByRole('option', { name: 'ReLU' }).click() - await expect.element(page.getByTestId('profile-create-form')).not.toBeInTheDocument() - await expect.poll(() => cards.activeCard?.name).toBe('ReLU') - - await screen.getByRole('combobox').click() - await page.getByRole('option', { name: i18n.global.t('stage.profile-switcher.save-as-new') }).click() - const form = page.getByTestId('profile-create-form').element() as HTMLElement - form.querySelectorAll('button')[1]!.click() - await expect.element(input).not.toBeInTheDocument() -}) - -for (const dock of docks) { - // https://github.com/moeru-ai/airi/pull/2474 - it(`PR #2474 keeps one inert measured menu and rotates the ${dock} arrow before opening`, async () => { - await page.viewport(600, 600) - const { i18n, screen, settings } = mountControlsIsland(dock, 'small') - const island = screen.getByTestId('controls-island').element() as HTMLElement - const main = screen.getByTestId('main-controls').element() as HTMLElement - const menu = screen.getByTestId('controls-menu').element() as HTMLElement - const toggle = main.querySelector('[aria-controls]')! - const icon = toggle.querySelector('[i-solar\\:alt-arrow-up-line-duotone]')! - const isTop = dock.startsWith('top') - const isLeft = dock.endsWith('left') - await expect.poll(() => island.offsetHeight === main.offsetHeight).toBe(true) - expect(toggle.getAttribute('aria-controls')).toBe(menu.id) - expect(menu.closest('[inert]')).not.toBeNull() - const hiddenButton = menu.querySelector('button')! - hiddenButton.focus() - expect(document.activeElement).not.toBe(hiddenButton) - await expect.poll(() => icon.style.transform).toBe(`rotate(${isTop ? 180 : 0}deg)`) - await page.viewport(600, 120) - await expect.poll(() => island.dataset.direction).toBe(isLeft ? 'right' : 'left') - expect(icon.style.transform).toBe(`rotate(${isLeft ? 90 : 270}deg)`) - settings.controlsIslandIconSize = 'large' - await expect.poll(() => main.querySelector('.size-5')).not.toBeNull() - await page.viewport(600, 300) - await screen.getByLabelText(i18n.global.t('tamagotchi.stage.controls-island.expand'), { exact: true }).click() - expect(screen.getByTestId('controls-menu').element()).toBe(menu) - expect(menu.closest('[inert]')).toBeNull() - expect(icon.style.transform).toBe(`rotate(${isLeft ? 270 : 90}deg)`) - await page.viewport(600, 600) - await expect.poll(() => island.dataset.direction).toBe(isTop ? 'down' : 'up') - expect(screen.getByTestId('controls-menu').element()).toBe(menu) - const settingsButton = screen.getByLabelText(i18n.global.t('tamagotchi.stage.controls-island.open-settings'), { exact: true }).element() as HTMLElement - settingsButton.focus() - isOutside.value = true - await expect.poll(() => toggle.getAttribute('aria-expanded'), { timeout: 3500 }).toBe('false') - expect(document.activeElement).toBe(toggle) - await expect.poll(() => island.offsetHeight === main.offsetHeight).toBe(true) - expect(menu.closest('[inert]')).not.toBeNull() - }) -} - -// https://github.com/moeru-ai/airi/pull/2474 -it('assigns sideways overflow to the necessary menu axes without nested scrolling (PR #2474)', async () => { - await page.viewport(600, 600) - const { i18n, screen } = mountControlsIsland('top-left', 'small') - await screen.getByLabelText(i18n.global.t('tamagotchi.stage.controls-island.expand'), { exact: true }).click() - const island = screen.getByTestId('controls-island').element() as HTMLElement - const main = screen.getByTestId('main-controls').element() as HTMLElement - const menu = screen.getByTestId('controls-menu').element() as HTMLElement - const content = menu.querySelector('.w-max')! - const viewport = menu.querySelector('[data-reka-scroll-area-viewport]')! - const outer = island.querySelector('[data-reka-scroll-area-viewport]')! - await expect.poll(() => scrollOwners(island)).toHaveLength(0) - // Extra auth-row spacing exercises content growth with native layout intact. - const login = menu.querySelector('button')! - login.style.paddingBlock = '3rem' - await expect.poll(() => content.offsetHeight).toBeGreaterThan(main.offsetHeight + 20) - const menuHeight = content.offsetHeight - const narrowWidth = main.offsetWidth + 12 + content.offsetWidth - 20 + 16 - for (const [width, height, horizontal, vertical] of [ - [600, menuHeight + 16, false, false], - [narrowWidth, menuHeight + 16, true, false], - [600, menuHeight + 6, false, true], - [narrowWidth, menuHeight + 6, true, true], - ] as const) { - await page.viewport(width, height) - await expect.poll(() => island.dataset.direction).toBe('right') - await expect.poll(() => viewport.scrollWidth > viewport.clientWidth).toBe(horizontal) - await expect.poll(() => viewport.scrollHeight > viewport.clientHeight).toBe(vertical) - await expect.poll(() => outer.scrollWidth === outer.clientWidth).toBe(true) - await expect.poll(() => outer.scrollHeight === outer.clientHeight).toBe(true) - viewport.scrollTo(viewport.scrollWidth, viewport.scrollHeight) - await nextTick() - if (horizontal) - expect(viewport.scrollLeft).toBeGreaterThan(0) - if (vertical) - expect(viewport.scrollTop).toBeGreaterThan(0) - } -}) diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-profile-picker.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-profile-picker.vue deleted file mode 100644 index 94bdb0ea0..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-profile-picker.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.test.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.test.ts deleted file mode 100644 index fc518522f..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -// @vitest-environment jsdom - -import type { Display, Rectangle } from 'electron' - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createApp, defineComponent, h, nextTick, shallowRef } from 'vue' - -import ControlsIslandRoot from './controls-island-root.vue' - -import { resolveControlsIslandDock, useControlsIslandPlacement } from './use-controls-island-placement' - -const primaryDisplay = { - bounds: { x: 0, y: 0, width: 1920, height: 1080 }, - workArea: { x: 0, y: 25, width: 1920, height: 1055 }, -} as Display - -const displays = shallowRef([primaryDisplay]) -const windowBounds = { - x: shallowRef(1370), - y: shallowRef(430), - width: shallowRef(450), - height: shallowRef(600), -} -vi.mock('@proj-airi/electron-vueuse', () => ({ - useElectronAllDisplays: () => displays, - useElectronWindowBounds: () => windowBounds, -})) - -const mountedApps: Array<{ host: HTMLElement, unmount: () => void }> = [] - -function resolve(windowBounds: Rectangle) { - return resolveControlsIslandDock({ - displays: [primaryDisplay], - previousDock: 'bottom-right', - windowBounds, - }) -} - -function mountRoot() { - const frozen = shallowRef(false) - const ContextConsumer = defineComponent({ - setup() { - const placement = useControlsIslandPlacement() - - return () => h('output', { - 'data-dock': placement.dock.value, - 'data-phase': placement.motionPhase.value, - }) - }, - }) - const host = document.createElement('div') - const app = createApp({ - setup() { - return () => h(ControlsIslandRoot, { frozen: frozen.value }, { - default: () => h(ContextConsumer), - }) - }, - }) - - document.body.appendChild(host) - app.mount(host) - mountedApps.push({ - host, - unmount: () => app.unmount(), - }) - - return { frozen, host } -} - -function readPlacement(host: HTMLElement) { - const output = host.querySelector('[data-dock]') - - return { - dock: output?.getAttribute('data-dock'), - phase: output?.getAttribute('data-phase'), - } -} - -beforeEach(() => { - vi.stubGlobal('matchMedia', vi.fn((query: string): MediaQueryList => ({ - addEventListener: vi.fn(), - addListener: vi.fn(), - dispatchEvent: vi.fn(), - matches: false, - media: query, - onchange: null, - removeEventListener: vi.fn(), - removeListener: vi.fn(), - }))) -}) - -afterEach(() => { - for (const mounted of mountedApps) { - mounted.unmount() - mounted.host.remove() - } - mountedApps.length = 0 - displays.value = [primaryDisplay] - windowBounds.x.value = 1370 - windowBounds.y.value = 430 - windowBounds.width.value = 450 - windowBounds.height.value = 600 - vi.unstubAllGlobals() - vi.useRealTimers() -}) - -describe('resolveControlsIslandDock', () => { - it('places the island in the top-left screen quadrant', () => { - expect(resolve({ x: 100, y: 100, width: 450, height: 600 })).toBe('top-left') - }) - - it('places the island in the top-right screen quadrant', () => { - expect(resolve({ x: 1370, y: 100, width: 450, height: 600 })).toBe('top-right') - }) - - it('places the island in the bottom-left screen quadrant', () => { - expect(resolve({ x: 100, y: 430, width: 450, height: 600 })).toBe('bottom-left') - }) - - it('places the island in the bottom-right screen quadrant', () => { - expect(resolve({ x: 1370, y: 430, width: 450, height: 600 })).toBe('bottom-right') - }) - - it('uses the display that contains the largest window area', () => { - const secondaryDisplay = { - bounds: { x: -1600, y: -900, width: 1600, height: 900 }, - workArea: { x: -1600, y: -900, width: 1600, height: 860 }, - } as Display - - const dock = resolveControlsIslandDock({ - displays: [primaryDisplay, secondaryDisplay], - previousDock: 'bottom-right', - windowBounds: { x: -500, y: -300, width: 450, height: 600 }, - }) - - expect(dock).toBe('bottom-right') - }) - - it('keeps the previous dock inside the display center dead zone', () => { - const dock = resolveControlsIslandDock({ - displays: [primaryDisplay], - previousDock: 'top-left', - windowBounds: { x: 735, y: 253, width: 450, height: 600 }, - }) - - expect(dock).toBe('top-left') - }) - - it('keeps the current dock until display data is available', () => { - const dock = resolveControlsIslandDock({ - displays: [], - previousDock: 'top-right', - windowBounds: { x: 100, y: 100, width: 450, height: 600 }, - }) - - expect(dock).toBe('top-right') - }) - - it('keeps the default dock until window bounds are available', () => { - const dock = resolveControlsIslandDock({ - displays: [primaryDisplay], - previousDock: 'bottom-right', - windowBounds: { x: 0, y: 0, width: 0, height: 0 }, - }) - - expect(dock).toBe('bottom-right') - }) -}) - -describe('controlsIslandRoot', () => { - it('changes corners one second after the last window movement', async () => { - vi.useFakeTimers() - const { host } = mountRoot() - - expect(readPlacement(host)).toEqual({ - dock: 'bottom-right', - phase: 'idle', - }) - - windowBounds.x.value = 100 - await nextTick() - await vi.advanceTimersByTimeAsync(500) - - windowBounds.x.value = 120 - await nextTick() - await vi.advanceTimersByTimeAsync(999) - - expect(readPlacement(host)).toEqual({ - dock: 'bottom-right', - phase: 'idle', - }) - - await vi.advanceTimersByTimeAsync(1) - - expect(readPlacement(host).phase).toBe('leaving') - - await vi.advanceTimersByTimeAsync(149) - - expect(readPlacement(host)).toEqual({ - dock: 'bottom-right', - phase: 'leaving', - }) - - await vi.advanceTimersByTimeAsync(1) - - expect(readPlacement(host)).toEqual({ - dock: 'bottom-left', - phase: 'entering', - }) - - await vi.advanceTimersByTimeAsync(15) - - expect(readPlacement(host).phase).toBe('entering') - - await vi.advanceTimersByTimeAsync(1) - - expect(readPlacement(host).phase).toBe('arriving') - - await vi.advanceTimersByTimeAsync(149) - - expect(readPlacement(host).phase).toBe('arriving') - - await vi.advanceTimersByTimeAsync(1) - - expect(readPlacement(host).phase).toBe('idle') - }) - - it('waits for an active Island interaction to end before it moves', async () => { - vi.useFakeTimers() - const { frozen, host } = mountRoot() - - frozen.value = true - windowBounds.x.value = 100 - await nextTick() - await vi.advanceTimersByTimeAsync(1000) - - expect(readPlacement(host)).toEqual({ - dock: 'bottom-right', - phase: 'idle', - }) - - frozen.value = false - await nextTick() - - expect(readPlacement(host).phase).toBe('leaving') - - await vi.advanceTimersByTimeAsync(315) - - expect(readPlacement(host)).toEqual({ - dock: 'bottom-left', - phase: 'arriving', - }) - - await vi.advanceTimersByTimeAsync(1) - - expect(readPlacement(host)).toEqual({ - dock: 'bottom-left', - phase: 'idle', - }) - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.vue deleted file mode 100644 index d63d97af4..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.vue +++ /dev/null @@ -1,135 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts deleted file mode 100644 index e5012ef5e..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -// @vitest-environment jsdom -import type { ControlsIslandPlacement } from './use-controls-island-placement' - -import { describe, expect, it, vi } from 'vitest' -import { createApp, h, nextTick, shallowRef } from 'vue' - -import ControlsIslandStopSpeaking from './controls-island-stop-speaking.vue' - -import { controlsIslandPlacementKey } from './use-controls-island-placement' - -const nowSpeakingRef = { value: false } -const stopAllSpeakingMock = vi.fn() -const placement: ControlsIslandPlacement = { - dock: shallowRef('bottom-right'), - isLeft: shallowRef(false), - isTop: shallowRef(false), - motionPhase: shallowRef('idle'), -} - -vi.mock('@proj-airi/stage-ui/stores/audio', () => ({ - useSpeakingStore: () => ({ - nowSpeaking: nowSpeakingRef, - }), -})) - -vi.mock('@proj-airi/stage-layouts/composables/useStopSpeakingButton', () => ({ - useStopSpeakingButton: () => ({ - stopAllSpeaking: stopAllSpeakingMock, - showStopSpeakingButton: nowSpeakingRef, - stopSpeakingFromChat: vi.fn(), - }), -})) - -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ - t: (key: string) => key, - }), -})) - -vi.mock('pinia', () => ({ - storeToRefs: (store: object) => store, -})) - -vi.mock('reka-ui', () => ({ - TooltipContent: { template: '
', inheritAttrs: false }, - TooltipPortal: { template: '
' }, - TooltipProvider: { template: '
' }, - TooltipRoot: { template: '
' }, - TooltipTrigger: { template: '
' }, -})) - -describe('controlsIslandStopSpeaking', () => { - function mountComponent() { - const host = document.createElement('div') - document.body.appendChild(host) - const app = createApp({ - render: () => h(ControlsIslandStopSpeaking, { - buttonStyle: 'p-2', - iconClass: 'size-5', - }), - }) - app.provide(controlsIslandPlacementKey, placement) - app.mount(host) - return { host, app } - } - - it('renders idle state when not speaking', async () => { - nowSpeakingRef.value = false - const { host, app } = mountComponent() - await nextTick() - expect(host.querySelectorAll('button').length).toBeGreaterThan(0) - app.unmount() - host.remove() - }) - - it('renders active state when speaking', async () => { - nowSpeakingRef.value = true - const { host, app } = mountComponent() - await nextTick() - expect(host.querySelectorAll('button').length).toBeGreaterThan(0) - app.unmount() - host.remove() - }) - - it('calls stopAllSpeaking on click', async () => { - stopAllSpeakingMock.mockClear() - nowSpeakingRef.value = false - const { host, app } = mountComponent() - await nextTick() - const button = host.querySelector('button') - expect(button).toBeTruthy() - button!.click() - expect(stopAllSpeakingMock).toHaveBeenCalledTimes(1) - app.unmount() - host.remove() - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.vue deleted file mode 100644 index 47706c6da..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue deleted file mode 100644 index 346b487f6..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue +++ /dev/null @@ -1,519 +0,0 @@ - - - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/indicator-mic-volume.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/indicator-mic-volume.vue deleted file mode 100644 index 0afc96e23..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/indicator-mic-volume.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/use-controls-island-layout.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/use-controls-island-layout.ts deleted file mode 100644 index fcaec729f..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/use-controls-island-layout.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Ref } from 'vue' - -import type { ControlsIslandPlacement } from './use-controls-island-placement' - -import { useElementSize, useRafFn, useResizeObserver } from '@vueuse/core' -import { computed, nextTick, watch } from 'vue' - -interface LayoutElements { - main: Readonly> - menu: Readonly> - available: Readonly> - gap: Readonly> - viewport: Readonly> - menuViewport: Readonly> - content: Readonly> -} - -/** - * Owns renderer-local geometry and scroll alignment for one mounted Island. - * Measures unconstrained border boxes, so clipping and animation cannot change - * the direction decision. VueUse observers stop with the component scope. - */ -export function useControlsIslandLayout(elements: LayoutElements, expanded: Ref, placement: ControlsIslandPlacement) { - const { isLeft, isTop, dock } = placement - const main = useElementSize(elements.main, undefined, { box: 'border-box' }) - const menu = useElementSize(elements.menu, undefined, { box: 'border-box' }) - const available = useElementSize(elements.available) - const gap = useElementSize(elements.gap) - const sideways = computed(() => menu.height.value > 0 && available.height.value > 0 - && main.height.value + gap.width.value + menu.height.value > available.height.value) - const direction = computed(() => sideways.value - ? (isLeft.value ? 'right' : 'left') - : (isTop.value ? 'down' : 'up')) - const scrollWholeIsland = computed(() => main.height.value > available.height.value - || main.width.value > available.width.value - || (sideways.value && available.width.value - main.width.value - gap.width.value <= 0)) - const panelStyle = computed(() => ({ - maxWidth: scrollWholeIsland.value ? 'none' : `${Math.max(0, available.width.value - (sideways.value ? main.width.value + gap.width.value : 0))}px`, - maxHeight: scrollWholeIsland.value ? 'none' : `${Math.max(0, available.height.value - (sideways.value ? 0 : main.height.value + gap.width.value))}px`, - })) - const layoutClasses = computed(() => sideways.value - ? [isLeft.value ? 'flex-row-reverse' : 'flex-row', isTop.value ? 'items-start' : 'items-end'] - : [isTop.value ? 'flex-col-reverse' : 'flex-col', isLeft.value ? 'items-start' : 'items-end']) - const arrowRotation = computed(() => (({ up: 0, right: 90, down: 180, left: 270 }[direction.value]) + (expanded.value ? 180 : 0)) % 360) - const motionOffset = computed(() => ({ up: '0, 2rem', down: '0, -2rem', left: '2rem, 0', right: '-2rem, 0' }[direction.value])) - - function alignScrollPosition() { - const viewport = elements.viewport.value - if (!viewport) - return - - viewport.scrollTop = isTop.value ? 0 : viewport.scrollHeight - viewport.clientHeight - viewport.scrollLeft = isLeft.value ? 0 : viewport.scrollWidth - viewport.clientWidth - - // Focus visibility takes precedence over docking after a layout change. - const focused = document.activeElement - if (focused instanceof HTMLElement && viewport.contains(focused)) - focused.scrollIntoView({ block: 'nearest', inline: 'nearest' }) - } - - // NOTICE: - // Defer scroll alignment to the next animation frame after an observer runs. - // scrollIntoView can change scrollbar geometry during ResizeObserver delivery. - // Chromium then logs "ResizeObserver loop completed with undelivered notifications" - // during rapid Controls Island layout changes. - // Source/context: https://github.com/moeru-ai/airi/pull/2474#discussion_r3954626137 - // Removal condition: Remove this scheduling when Chromium no longer logs the - // warning and the Controls Island browser tests pass without deferred alignment. - const { pause, resume } = useRafFn(() => { - pause() - alignScrollPosition() - }, { immediate: false }) - - // Observe actual geometry, never scroll offsets. User scrolling must persist. - useResizeObserver(elements.viewport, resume) - useResizeObserver(elements.content, resume) - watch([dock, expanded, direction, main.width, main.height, menu.width, menu.height, available.width, available.height], async () => { - await nextTick() - resume() - }, { flush: 'post' }) - watch(expanded, async (open) => { - if (!open) - return - await nextTick() - elements.menuViewport.value?.scrollTo(0, 0) - }, { flush: 'post' }) - - return { direction, scrollWholeIsland, panelStyle, layoutClasses, arrowRotation, motionOffset } -} diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/use-controls-island-placement.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/use-controls-island-placement.ts deleted file mode 100644 index b683d88ce..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/use-controls-island-placement.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { Rectangle } from 'electron' -import type { InjectionKey, Ref } from 'vue' - -import type { DisplayArea } from '../../../../shared/utils/electron/display' - -import { inject } from 'vue' - -import { findDominantDisplayArea } from '../../../../shared/utils/electron/display' - -/** A corner of the AIRI window where the Controls Island can dock. */ -export type ControlsIslandDock = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' - -/** Inputs for the Controls Island quadrant policy. */ -export interface ResolveControlsIslandDockOptions { - /** Available displays in Electron logical coordinates. */ - displays: readonly DisplayArea[] - /** Dock that remains active while display data is missing or the window is near the display center. */ - previousDock: ControlsIslandDock - /** AIRI window bounds in Electron logical coordinates. Zero width or height means that the bounds are not available. */ - windowBounds: Rectangle -} - -/** The half-width of the center band that prevents repeated flips near an axis. */ -const displayCenterDeadZoneRatio = 0.05 - -/** - * Resolves the window corner that matches the current display quadrant. - * - * The screen geometry stays in Electron logical coordinates. The returned - * dock contains no DOM coordinates, so display scaling cannot affect layout. - */ -export function resolveControlsIslandDock(options: ResolveControlsIslandDockOptions): ControlsIslandDock { - if (options.windowBounds.width <= 0 || options.windowBounds.height <= 0) { - return options.previousDock - } - - const display = findDominantDisplayArea(options.windowBounds, options.displays) - if (!display) { - return options.previousDock - } - - const windowCenterX = options.windowBounds.x + options.windowBounds.width / 2 - const windowCenterY = options.windowBounds.y + options.windowBounds.height / 2 - const displayCenterX = display.workArea.x + display.workArea.width / 2 - const displayCenterY = display.workArea.y + display.workArea.height / 2 - const horizontalDeadZone = display.workArea.width * displayCenterDeadZoneRatio - const verticalDeadZone = display.workArea.height * displayCenterDeadZoneRatio - - let horizontalDock: 'left' | 'right' = options.previousDock.endsWith('left') ? 'left' : 'right' - let verticalDock: 'top' | 'bottom' = options.previousDock.startsWith('top') ? 'top' : 'bottom' - - if (windowCenterX < displayCenterX - horizontalDeadZone) { - horizontalDock = 'left' - } - else if (windowCenterX > displayCenterX + horizontalDeadZone) { - horizontalDock = 'right' - } - - if (windowCenterY < displayCenterY - verticalDeadZone) { - verticalDock = 'top' - } - else if (windowCenterY > displayCenterY + verticalDeadZone) { - verticalDock = 'bottom' - } - - if (verticalDock === 'top') { - return horizontalDock === 'left' ? 'top-left' : 'top-right' - } - - return horizontalDock === 'left' ? 'bottom-left' : 'bottom-right' -} - -/** Visual phase for a Controls Island corner change. */ -export type ControlsIslandMotionPhase = 'idle' | 'leaving' | 'entering' | 'arriving' - -/** Placement state shared by the Controls Island and its anchored surfaces. */ -export interface ControlsIslandPlacement { - /** Current corner inside the AIRI window. */ - dock: Readonly> - /** True when the Island uses the left edge of the AIRI window. */ - isLeft: Readonly> - /** True when the Island uses the top edge of the AIRI window. */ - isTop: Readonly> - /** Current phase of the fade and move animation. */ - motionPhase: Readonly> -} - -/** Placement contract provided by the Controls Island root. */ -export const controlsIslandPlacementKey: InjectionKey = Symbol('controls-island-placement') - -/** Returns the placement from the nearest Controls Island root. */ -export function useControlsIslandPlacement(): ControlsIslandPlacement { - const placement = inject(controlsIslandPlacementKey) - if (!placement) { - throw new Error('useControlsIslandPlacement() requires a parent ControlsIslandRoot') - } - - return placement -} diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/index.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/index.vue deleted file mode 100644 index 2a42f53b7..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/index.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/loading-component-detail.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/loading-component-detail.vue deleted file mode 100644 index ba4672f56..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/loading-component-detail.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/loading-component.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/loading-component.vue deleted file mode 100644 index 6088eea35..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/loading-component.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/loading-modules.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/loading-modules.vue deleted file mode 100644 index 890905dc6..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/resource-status-island/loading-modules.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/status-island/index.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/status-island/index.vue deleted file mode 100644 index 92e8a22fb..000000000 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/status-island/index.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/composables/icon-animation.ts b/apps/stage-tamagotchi/src/renderer/composables/icon-animation.ts deleted file mode 100644 index 3149a8e5b..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/icon-animation.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useSettings } from '@proj-airi/stage-ui/stores/settings' -import { computed, onMounted, onUnmounted, ref } from 'vue' - -export function useIconAnimation(icon: string) { - const iconAnimationStarted = ref(false) - const showAnimationComponent = ref(false) - const animationIcon = ref(icon) - - const settingsStore = useSettings() - const showIconAnimation = computed(() => showAnimationComponent.value && !settingsStore.disableTransitions && settingsStore.usePageSpecificTransitions) - - onMounted(() => { - showAnimationComponent.value = true - requestAnimationFrame(() => { - iconAnimationStarted.value = true - }) - }) - - onUnmounted(() => { - iconAnimationStarted.value = false - showAnimationComponent.value = false - }) - - return { - iconAnimationStarted, - showIconAnimation, - animationIcon, - } -} diff --git a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-owner.ts b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-owner.ts deleted file mode 100644 index a9ff76df6..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-owner.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Live2DExpressionSettingsCommand } from '@proj-airi/stage-ui-live2d/stores/expression-store' -import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' -import type { MaybeRefOrGetter } from 'vue' - -import type { ModelSettingsRuntimeContext } from '../../shared/model-settings-runtime' - -import { defineInvokeHandler } from '@moeru/eventa' -import { onBeforeUnmount, toValue, watch } from 'vue' - -import { - applyLive2DExpressionSettingsCommand, - getModelSettingsRuntimeContext, - modelSettingsRuntimeOwnerGone, - modelSettingsRuntimeSnapshotChanged, - modelSettingsRuntimeSnapshotRequested, -} from '../../shared/model-settings-runtime' - -interface UseModelSettingsRuntimeOwnerOptions { - ownerInstanceId: string - renderer: MaybeRefOrGetter - runtimeSnapshot: MaybeRefOrGetter - applyLive2DExpressionCommand: (command: Live2DExpressionSettingsCommand) => void - context?: ModelSettingsRuntimeContext -} - -/** - * Owns the model-settings channel for the renderer that controls the active model. - * - * The owner publishes runtime snapshots and accepts commands for its current owner ID. - * Commands for stale owners or non-Live2D renderers do not change the expression store. - */ -export function useModelSettingsRuntimeOwner(options: UseModelSettingsRuntimeOwnerOptions) { - const context = options.context ?? getModelSettingsRuntimeContext() - - function postSnapshot(snapshot: ModelSettingsRuntimeSnapshot) { - void context.emit(modelSettingsRuntimeSnapshotChanged, snapshot).catch((error) => { - console.warn('[Model Settings Runtime] Failed to publish the runtime snapshot:', error) - }) - } - - watch(() => toValue(options.runtimeSnapshot), (snapshot) => { - postSnapshot(snapshot) - }, { immediate: true }) - - const stopSnapshotRequests = context.on(modelSettingsRuntimeSnapshotRequested, () => { - postSnapshot(toValue(options.runtimeSnapshot)) - }) - const stopExpressionCommands = defineInvokeHandler(context, applyLive2DExpressionSettingsCommand, (request) => { - const currentSnapshot = toValue(options.runtimeSnapshot) - if (request.ownerInstanceId !== options.ownerInstanceId) { - return { - applied: false, - snapshot: currentSnapshot, - rejectionReason: 'owner-changed', - } - } - - if (!request.modelId || request.modelId !== currentSnapshot.modelId) { - return { - applied: false, - snapshot: currentSnapshot, - rejectionReason: 'model-changed', - } - } - - if (toValue(options.renderer) !== 'live2d' || currentSnapshot.controlsLocked) { - return { - applied: false, - snapshot: currentSnapshot, - rejectionReason: 'runtime-unavailable', - } - } - - options.applyLive2DExpressionCommand(request.command) - return { - applied: true, - snapshot: toValue(options.runtimeSnapshot), - } - }) - - onBeforeUnmount(() => { - stopSnapshotRequests() - stopExpressionCommands() - void context.emit(modelSettingsRuntimeOwnerGone, { - ownerInstanceId: options.ownerInstanceId, - }).catch((error) => { - console.warn('[Model Settings Runtime] Failed to publish owner shutdown:', error) - }) - }) -} diff --git a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts deleted file mode 100644 index 7c74ce6cb..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { Live2DExpressionSettingsCommand } from '@proj-airi/stage-ui-live2d/stores/expression-store' -import type { - ModelSettingsRuntimeSnapshot, -} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' - -import type { ModelSettingsRuntimeContext } from '../../shared/model-settings-runtime' - -import { defineInvoke } from '@moeru/eventa' -import { - createEmptyModelSettingsRuntimeSnapshot, -} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' -import { onMounted, onUnmounted, ref } from 'vue' - -import { - applyLive2DExpressionSettingsCommand, - getModelSettingsRuntimeContext, - modelSettingsRuntimeOwnerGone, - modelSettingsRuntimeSnapshotChanged, - modelSettingsRuntimeSnapshotRequested, -} from '../../shared/model-settings-runtime' - -interface UseModelSettingsRuntimeSnapshotOptions { - context?: ModelSettingsRuntimeContext - /** Maximum wait for the stage owner to respond. @default 1000 */ - commandTimeoutMs?: number -} - -export function useModelSettingsRuntimeSnapshot(options: UseModelSettingsRuntimeSnapshotOptions = {}) { - const runtimeSnapshot = ref(createEmptyModelSettingsRuntimeSnapshot()) - const context = options.context ?? getModelSettingsRuntimeContext() - const invokeExpressionCommand = defineInvoke(context, applyLive2DExpressionSettingsCommand) - - const requestCurrent = () => { - void context.emit(modelSettingsRuntimeSnapshotRequested, undefined) - } - - const sendLive2DExpressionCommand = async (command: Live2DExpressionSettingsCommand) => { - const snapshot = runtimeSnapshot.value - if (!snapshot.ownerInstanceId || !snapshot.modelId || snapshot.renderer !== 'live2d' || snapshot.controlsLocked) - return false - - try { - const response = await invokeExpressionCommand({ - ownerInstanceId: snapshot.ownerInstanceId, - modelId: snapshot.modelId, - command, - }, { - signal: AbortSignal.timeout(options.commandTimeoutMs ?? 1000), - }) - runtimeSnapshot.value = response.snapshot - return response.applied - } - catch (error) { - runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot() - requestCurrent() - console.warn('[Model Settings Runtime] Failed to apply the Live2D expression command:', error) - return false - } - } - - const syncFromOwner = () => { - requestCurrent() - } - const syncFromOwnerWhenVisible = () => { - if (document.visibilityState === 'visible') - requestCurrent() - } - - const stopSnapshots = context.on(modelSettingsRuntimeSnapshotChanged, (event) => { - if (event.body) - runtimeSnapshot.value = event.body - }) - const stopOwnerGone = context.on(modelSettingsRuntimeOwnerGone, (event) => { - if (!event.body || runtimeSnapshot.value.ownerInstanceId !== event.body.ownerInstanceId) - return - - runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot() - }) - - onMounted(() => { - requestCurrent() - window.addEventListener('focus', syncFromOwner) - document.addEventListener('visibilitychange', syncFromOwnerWhenVisible) - }) - - onUnmounted(() => { - window.removeEventListener('focus', syncFromOwner) - document.removeEventListener('visibilitychange', syncFromOwnerWhenVisible) - stopSnapshots() - stopOwnerGone() - }) - - return { - runtimeSnapshot, - requestCurrent, - sendLive2DExpressionCommand, - } -} diff --git a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime.browser.test.ts b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime.browser.test.ts deleted file mode 100644 index dabcd9380..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime.browser.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { ExpressionEntry, ExpressionGroupDefinition } from '@proj-airi/stage-ui-live2d/stores/expression-store' -import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' - -import { defineInvoke } from '@moeru/eventa' -import { createContext as createBroadcastChannelContext } from '@moeru/eventa/adapters/broadcast-channel' -import { useExpressionStore } from '@proj-airi/stage-ui-live2d/stores/expression-store' -import { createEmptyModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' -import { createPinia } from 'pinia' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from 'vitest-browser-vue' -import { computed, defineComponent, shallowRef } from 'vue' - -import { applyLive2DExpressionSettingsCommand, modelSettingsRuntimeChannelName } from '../../shared/model-settings-runtime' -import { useModelSettingsRuntimeOwner } from './model-settings-runtime-owner' -import { useModelSettingsRuntimeSnapshot } from './model-settings-runtime-snapshot' - -const expressionGroups: ExpressionGroupDefinition[] = [{ - name: 'happy', - parameters: [{ parameterId: 'ParamHappy', blend: 'Add', value: 1 }], -}] - -const expressionEntries: ExpressionEntry[] = [{ - name: 'ParamHappy', - parameterId: 'ParamHappy', - blend: 'Add', - currentValue: 0, - defaultValue: 0, - modelDefault: 0, - targetValue: 1, -}] - -describe('model settings runtime channel', () => { - const channelContexts: Array> = [] - - afterEach(() => { - cleanup() - for (const channelContext of channelContexts) - channelContext.dispose() - channelContexts.length = 0 - localStorage.clear() - }) - - // https://github.com/moeru-ai/airi/issues/2450 - it('applies an expression command through Eventa and rejects stale runtime identities for Issue #2450', async () => { - // ROOT CAUSE: - // - // The settings window and the stage window have separate Pinia stores. - // A component-only test did not cover the channel, owner check, store update, or returned snapshot. - // - // We fixed this with an Eventa RPC that returns the current owner snapshot. - const ownerInstanceId = 'stage-owner' - const ownerPinia = createPinia() - const settingsPinia = createPinia() - const ownerExpressionStore = useExpressionStore(ownerPinia) - ownerExpressionStore.registerExpressions('model-a', expressionGroups, expressionEntries) - - const ownerChannelContext = createBroadcastChannelContext(new BroadcastChannel(modelSettingsRuntimeChannelName), { closeOnDispose: true }) - const settingsChannelContext = createBroadcastChannelContext(new BroadcastChannel(modelSettingsRuntimeChannelName), { closeOnDispose: true }) - channelContexts.push(ownerChannelContext, settingsChannelContext) - - const renderer = shallowRef('live2d') - const ownerSnapshot = computed(() => createEmptyModelSettingsRuntimeSnapshot({ - ownerInstanceId, - modelId: ownerExpressionStore.modelId, - renderer: renderer.value, - phase: 'mounted', - controlsLocked: false, - previewAvailable: true, - canCapturePreview: false, - live2dExpressions: ownerExpressionStore.settingsSnapshot, - updatedAt: Date.now(), - })) - - let settingsRuntime: ReturnType | undefined - const TestHost = defineComponent({ - setup() { - useModelSettingsRuntimeOwner({ - ownerInstanceId, - renderer: () => renderer.value, - runtimeSnapshot: ownerSnapshot, - applyLive2DExpressionCommand: command => ownerExpressionStore.applySettingsCommand(command), - context: ownerChannelContext.context, - }) - settingsRuntime = useModelSettingsRuntimeSnapshot({ - context: settingsChannelContext.context, - }) - - return () => null - }, - }) - - await render(TestHost, { - global: { - plugins: [settingsPinia], - }, - }) - - if (!settingsRuntime) - throw new Error('The settings runtime did not mount.') - const mountedSettingsRuntime = settingsRuntime - - await vi.waitFor(() => expect(mountedSettingsRuntime.runtimeSnapshot.value.live2dExpressions?.groups).toEqual([{ - name: 'happy', - active: false, - exposedToLlm: false, - }])) - - const invokeExpressionCommand = defineInvoke(settingsChannelContext.context, applyLive2DExpressionSettingsCommand) - const staleOwnerResponse = await invokeExpressionCommand({ - ownerInstanceId: 'stale-owner', - modelId: 'model-a', - command: { type: 'toggle', name: 'happy' }, - }, { - signal: AbortSignal.timeout(1000), - }) - - expect(staleOwnerResponse.applied).toBe(false) - expect(staleOwnerResponse.rejectionReason).toBe('owner-changed') - expect(ownerExpressionStore.settingsSnapshot.groups[0].active).toBe(false) - - const applied = await mountedSettingsRuntime.sendLive2DExpressionCommand({ type: 'toggle', name: 'happy' }) - - await vi.waitFor(() => expect(ownerExpressionStore.settingsSnapshot.groups[0].active).toBe(true)) - await vi.waitFor(() => expect(mountedSettingsRuntime.runtimeSnapshot.value.live2dExpressions?.groups[0].active).toBe(true)) - expect(applied).toBe(true) - expect(useExpressionStore(settingsPinia).expressionGroups.size).toBe(0) - - ownerExpressionStore.registerExpressions('model-b', expressionGroups, expressionEntries) - const rejected = await mountedSettingsRuntime.sendLive2DExpressionCommand({ type: 'toggle', name: 'happy' }) - - expect(rejected).toBe(false) - expect(ownerExpressionStore.settingsSnapshot.groups[0].active).toBe(false) - await vi.waitFor(() => expect(mountedSettingsRuntime.runtimeSnapshot.value.modelId).toBe('model-b')) - }) - - // https://github.com/moeru-ai/airi/issues/2450 - it('clears a stale snapshot when the stage owner does not answer', async () => { - const settingsChannelContext = createBroadcastChannelContext(new BroadcastChannel(modelSettingsRuntimeChannelName), { closeOnDispose: true }) - channelContexts.push(settingsChannelContext) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - let settingsRuntime: ReturnType | undefined - const TestHost = defineComponent({ - setup() { - settingsRuntime = useModelSettingsRuntimeSnapshot({ - context: settingsChannelContext.context, - commandTimeoutMs: 20, - }) - return () => null - }, - }) - - await render(TestHost) - if (!settingsRuntime) - throw new Error('The settings runtime did not mount.') - - settingsRuntime.runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot({ - ownerInstanceId: 'stale-owner', - modelId: 'model-a', - renderer: 'live2d', - phase: 'mounted', - controlsLocked: false, - }) - - const applied = await settingsRuntime.sendLive2DExpressionCommand({ type: 'toggle', name: 'happy' }) - - expect(applied).toBe(false) - expect(settingsRuntime.runtimeSnapshot.value.ownerInstanceId).toBe('') - expect(warn).toHaveBeenCalledOnce() - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/composables/runtime.ts b/apps/stage-tamagotchi/src/renderer/composables/runtime.ts deleted file mode 100644 index bf3d1fa52..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/runtime.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { computedAsync } from '@vueuse/core' -import { computed, ref } from 'vue' - -export function useAppRuntime() { - const isInitialized = ref(false) - - const platform = computedAsync(async () => { - const res = 'electron' - if (!isInitialized.value) { - isInitialized.value = true - } - - return res - }, 'web') - - const isTauri = computed(() => { - return platform.value !== 'web' - }) - - return { - platform, - isInitialized, - isTauri, - } -} diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.test.ts b/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.test.ts deleted file mode 100644 index 5f50f405a..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { HearingInputChannelEvent } from '@proj-airi/stage-shared' - -import { hearingInputChannelName } from '@proj-airi/stage-shared' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick, ref, shallowRef } from 'vue' - -import { useHearingInputChannel } from './use-hearing-input-channel' - -const broadcastChannelMock = vi.hoisted(() => ({ - useBroadcastChannel: vi.fn(), -})) - -vi.mock('@vueuse/core', () => ({ - useBroadcastChannel: broadcastChannelMock.useBroadcastChannel, -})) - -describe('useHearingInputChannel', () => { - let data: ReturnType> - - beforeEach(() => { - data = shallowRef() - broadcastChannelMock.useBroadcastChannel.mockReset() - broadcastChannelMock.useBroadcastChannel.mockReturnValue({ data }) - }) - - it('listens on the shared Hearing input channel', () => { - useHearingInputChannel(ref('')) - - expect(broadcastChannelMock.useBroadcastChannel).toHaveBeenCalledWith({ - name: hearingInputChannelName, - }) - }) - - it('replaces Provider revisions and clears only the owned suffix', async () => { - const input = ref('manual note') - useHearingInputChannel(input) - - data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'hello' } - await nextTick() - expect(input.value).toBe('manual note hello') - - data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'hello world' } - await nextTick() - expect(input.value).toBe('manual note hello world') - - data.value = { operation: 'clear', sourceId: 'utterance-1' } - await nextTick() - expect(input.value).toBe('manual note') - }) - - it('ignores stale cleanup after a new Provider utterance starts', async () => { - const input = ref('') - useHearingInputChannel(input) - - data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'first' } - await nextTick() - data.value = { operation: 'replace', sourceId: 'utterance-2', text: 'second' } - await nextTick() - expect(input.value).toBe('second') - - data.value = { operation: 'clear', sourceId: 'utterance-1' } - await nextTick() - expect(input.value).toBe('second') - - data.value = { operation: 'clear', sourceId: 'utterance-2' } - await nextTick() - expect(input.value).toBe('') - }) - - it('does not replace text after the user edits the Provider-owned suffix', async () => { - const input = ref('') - useHearingInputChannel(input) - - data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'draft' } - await nextTick() - input.value = 'user edit' - - data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'provider revision' } - await nextTick() - expect(input.value).toBe('user edit') - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.ts b/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.ts deleted file mode 100644 index 8581d75f0..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { HearingInputChannelEvent } from '@proj-airi/stage-shared' -import type { Ref } from 'vue' - -import { hearingInputChannelName } from '@proj-airi/stage-shared' -import { useStreamingTranscriptionInput } from '@proj-airi/stage-ui/composables/use-streaming-transcription-input' -import { useBroadcastChannel } from '@vueuse/core' -import { watch } from 'vue' - -/** Applies cross-window Hearing updates to one editable chat input. */ -export function useHearingInputChannel(input: Ref) { - const streamingInput = useStreamingTranscriptionInput(input) - const { data } = useBroadcastChannel({ - name: hearingInputChannelName, - }) - let activeSourceId: string | undefined - - watch(data, (event) => { - if (!event) - return - - if (event.operation === 'replace') { - if (!event.text.trim()) - return - - if (activeSourceId && activeSourceId !== event.sourceId) - streamingInput.clear() - - activeSourceId = event.sourceId - streamingInput.replace(event.text) - return - } - - if (event.sourceId !== activeSourceId) - return - - streamingInput.clear() - activeSourceId = undefined - }) -} diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts b/apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts deleted file mode 100644 index 2d30094cd..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick, ref } from 'vue' - -import { useLanguage } from './use-language' - -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ - locale: { value: 'en' }, - }), -})) - -const localStorageMock = (() => { - let store: Record = {} - return { - getItem: (key: string) => store[key] ?? null, - setItem: (key: string, value: string) => { store[key] = value }, - removeItem: (key: string) => { delete store[key] }, - clear: () => { store = {} }, - } -})() - -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }) - -vi.mock('@proj-airi/stage-shared/composables', () => ({ - useLocalStorageManualReset: vi.fn((key: string, initialValue: string) => { - // Tests control persisted state by pre-seeding localStorage before each case - const stored = localStorageMock.getItem(key) - const value = stored !== null ? stored : initialValue - return ref(value) - }), -})) - -describe('useLanguage', () => { - beforeEach(() => { - vi.restoreAllMocks() - localStorageMock.clear() - }) - - // ROOT CAUSE: - // https://github.com/moeru-ai/airi/issues/1658 - // When Electron restarts, renderer localStorage may not be flushed. - // The store's onMounted hook falls back to navigator.language, then - // watch(language) propagates that wrong locale back to main config. - // useLanguage prevents this by guarding sync until the correct - // locale is restored from the main-process config. - it('issue #1658: restores correct locale from main process when store fallback is wrong', async () => { - const language = ref('zh-Hans') // simulate store fallback to OS locale - const getMainLocale = vi.fn(async () => 'zh-Hant') // main has user selection - const setLocale = vi.fn(async () => {}) - - // No persisted language in localStorage → renderer lost its setting - const { restore } = useLanguage(language, getMainLocale, setLocale) - await restore() - - expect(getMainLocale).toHaveBeenCalledTimes(1) - expect(language.value).toBe('zh-Hant') - expect(setLocale).toHaveBeenCalledWith('zh-Hant') - }) - - it('issue #1658: does not change language when main locale matches store', async () => { - const language = ref('zh-Hant') - const getMainLocale = vi.fn(async () => 'zh-Hant') - const setLocale = vi.fn(async () => {}) - - const { restore } = useLanguage(language, getMainLocale, setLocale) - await restore() - - expect(language.value).toBe('zh-Hant') - expect(setLocale).toHaveBeenCalledWith('zh-Hant') - }) - - it('does not overwrite valid renderer locale when persisted language exists', async () => { - localStorage.setItem('settings/language', 'ja') - - const language = ref('ja') // user explicitly set this before - const getMainLocale = vi.fn(async () => 'en') - const setLocale = vi.fn(async () => {}) - - const { restore } = useLanguage(language, getMainLocale, setLocale) - await restore() - - // Should NOT call getMainLocale because renderer has persisted value - expect(getMainLocale).not.toHaveBeenCalled() - expect(language.value).toBe('ja') - expect(setLocale).toHaveBeenCalledWith('ja') - }) - - it('preserves OS locale on first launch when main has no saved language', async () => { - const language = ref('zh-Hans') // OS-detected fallback - const getMainLocale = vi.fn(async () => undefined) // no config file yet - const setLocale = vi.fn(async () => {}) - - const { restore } = useLanguage(language, getMainLocale, setLocale) - await restore() - - expect(getMainLocale).toHaveBeenCalledTimes(1) - expect(language.value).toBe('zh-Hans') // keep OS fallback - expect(setLocale).toHaveBeenCalledWith('zh-Hans') - }) - - it('restores explicit English choice after localStorage loss', async () => { - const language = ref('zh-Hans') // store fallback to OS locale - const getMainLocale = vi.fn(async () => 'en') // user explicitly chose English - const setLocale = vi.fn(async () => {}) - - const { restore } = useLanguage(language, getMainLocale, setLocale) - await restore() - - expect(getMainLocale).toHaveBeenCalledTimes(1) - expect(language.value).toBe('en') - expect(setLocale).toHaveBeenCalledWith('en') - }) - - it('continues startup when getMainLocale fails', async () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const language = ref('zh-Hans') - const getMainLocale = vi.fn(async () => { - throw new Error('IPC timeout') - }) - const setLocale = vi.fn(async () => {}) - - const { restore } = useLanguage(language, getMainLocale, setLocale) - await restore() - - // Should not throw; should still enable sync and use current value - expect(language.value).toBe('zh-Hans') - expect(setLocale).toHaveBeenCalledWith('zh-Hans') - expect(consoleSpy).toHaveBeenCalledWith( - '[useLanguage] Failed to get locale from main process, using fallback:', - expect.any(Error), - ) - - consoleSpy.mockRestore() - }) - - it('does not sync to main before restore is called', () => { - const language = ref('en') - const getMainLocale = vi.fn(async () => 'en') - const setLocale = vi.fn(async () => {}) - - useLanguage(language, getMainLocale, setLocale) - - // Simulate the store's onMounted fallback changing language - language.value = 'zh-Hans' - - expect(setLocale).not.toHaveBeenCalled() - }) - - it('syncs to main after restore is called', async () => { - const language = ref('en') - const getMainLocale = vi.fn(async () => 'en') - const setLocale = vi.fn(async () => {}) - - const { restore } = useLanguage(language, getMainLocale, setLocale) - await restore() - - // Clear the restore() call so we only assert the post-restore sync - setLocale.mockClear() - - // Now changes should propagate - language.value = 'ja' - await nextTick() - - expect(setLocale).toHaveBeenCalledWith('ja') - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-language.ts b/apps/stage-tamagotchi/src/renderer/composables/use-language.ts deleted file mode 100644 index c56a3ef94..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/use-language.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Ref } from 'vue' - -import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' -import { watch } from 'vue' -import { useI18n } from 'vue-i18n' - -/** - * Manages language sync between renderer and main process, guarding - * against Electron localStorage flush issues on restart. - * - * Use when: - * - Electron restarts and renderer localStorage may not have been flushed - * - * Expects: - * - `language` is the reactive language ref from the settings store - * - `getMainLocale` returns the raw locale persisted in main-process config - * (`undefined` when no config exists yet, a string when user saved one) - * - `setLocale` syncs the renderer locale back to main process - * - * Returns: - * - `restore()` to be called during component onMounted - */ -export function useLanguage( - language: Ref, - getMainLocale: () => Promise, - setLocale: (locale: string) => Promise | unknown, -) { - const i18n = useI18n() - const persistedLanguage = useLocalStorageManualReset('settings/language', '') - const hasPersistedLanguage = persistedLanguage.value !== '' - let isLocaleSynced = false - - // Guard: do not propagate the store's navigator.language fallback back - // to main-process config before we have verified the correct locale. - watch(language, () => { - i18n.locale.value = language.value || 'en' - if (isLocaleSynced) { - void setLocale(language.value || 'en') - } - }) - - async function restore() { - // Only trust main-process locale when renderer has lost its own setting. - // When main returns undefined, no language has ever been explicitly saved - // (true first launch), so we keep the renderer's OS-detected fallback. - // When main returns a string, that is the user's explicit choice. - if (!hasPersistedLanguage) { - try { - const mainLocale = await getMainLocale() - if (typeof mainLocale === 'string' && mainLocale && mainLocale !== language.value) { - language.value = mainLocale - } - } - catch (error) { - console.warn('[useLanguage] Failed to get locale from main process, using fallback:', error) - } - } - isLocaleSynced = true - void setLocale(language.value || 'en') - } - - return { restore } -} diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.test.ts b/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.test.ts deleted file mode 100644 index 3b59aa452..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { effectScope, nextTick, shallowRef } from 'vue' - -import { useOnboardingAuthentication } from './use-onboarding-authentication' - -describe('useOnboardingAuthentication', () => { - it('keeps the initiating window open until the first sign-in completes', async () => { - const isAuthenticated = shallowRef(false) - const needsLogin = shallowRef(false) - const closeRequestId = shallowRef(0) - const startLogin = vi.fn<() => Promise>().mockResolvedValue() - const closeWindow = vi.fn<() => Promise>().mockResolvedValue() - const scope = effectScope() - - // ROOT CAUSE: - // - // The onboarding window closed as soon as the main process opened the browser. - // The main process later sent the token callback to that closed renderer, so the first sign-in was lost. - // The window must stay open until synchronized authentication state confirms the completed sign-in. - scope.run(() => useOnboardingAuthentication({ - closeRequestId, - closeWindow, - isAuthenticated, - needsLogin, - onCloseError: vi.fn(), - startLogin, - })) - - needsLogin.value = true - await nextTick() - await Promise.resolve() - - expect(startLogin).toHaveBeenCalledTimes(1) - expect(needsLogin.value).toBe(false) - expect(closeWindow).not.toHaveBeenCalled() - - isAuthenticated.value = true - await nextTick() - - expect(closeWindow).toHaveBeenCalledTimes(1) - scope.stop() - }) - - it('closes when another renderer publishes a close request', async () => { - const closeRequestId = shallowRef(0) - const closeWindow = vi.fn<() => Promise>().mockResolvedValue() - const scope = effectScope() - - scope.run(() => useOnboardingAuthentication({ - closeRequestId, - closeWindow, - isAuthenticated: shallowRef(false), - needsLogin: shallowRef(false), - onCloseError: vi.fn(), - startLogin: vi.fn<() => Promise>().mockResolvedValue(), - })) - - closeRequestId.value += 1 - await nextTick() - - expect(closeWindow).toHaveBeenCalledTimes(1) - scope.stop() - }) - - it('allows a close retry after Electron rejects the first request', async () => { - const closeWindow = vi.fn<() => Promise>() - .mockRejectedValueOnce(new Error('window unavailable')) - .mockResolvedValue() - const onCloseError = vi.fn() - const scope = effectScope() - const controls = scope.run(() => useOnboardingAuthentication({ - closeRequestId: shallowRef(0), - closeWindow, - isAuthenticated: shallowRef(false), - needsLogin: shallowRef(false), - onCloseError, - startLogin: vi.fn<() => Promise>().mockResolvedValue(), - })) - - await controls!.closeOnboardingWindow() - await controls!.closeOnboardingWindow() - - expect(closeWindow).toHaveBeenCalledTimes(2) - expect(onCloseError).toHaveBeenCalledTimes(1) - scope.stop() - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.ts b/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.ts deleted file mode 100644 index 1464a8433..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Ref } from 'vue' - -import { watch } from 'vue' - -interface UseOnboardingAuthenticationOptions { - closeRequestId: Readonly> - closeWindow: () => Promise - isAuthenticated: Readonly> - needsLogin: Ref - onCloseError: (error: unknown) => void - startLogin: () => Promise -} - -interface OnboardingAuthenticationControls { - closeOnboardingWindow: () => Promise -} - -/** - * Coordinates sign-in and window closure for the standalone onboarding renderer. - * - * The renderer that starts the external sign-in remains alive until synchronized - * authentication state confirms completion. Close requests are deduplicated while - * the Electron close operation is in flight. - */ -export function useOnboardingAuthentication(options: UseOnboardingAuthenticationOptions): OnboardingAuthenticationControls { - let closing = false - - /** Closes the onboarding window once and permits a retry after a failed close. */ - async function closeOnboardingWindow(): Promise { - if (closing) - return - - closing = true - try { - await options.closeWindow() - } - catch (error) { - closing = false - options.onCloseError(error) - } - } - - // The shared action publishes a close request from the renderer that finishes - // authentication. This renderer remains the sole owner of the Electron close - // side effect. The auth check also handles a window mounted after the request. - watch([options.isAuthenticated, options.closeRequestId], ([authenticated, requestId], previous) => { - const previousRequestId = previous?.[1] - if (authenticated || (previousRequestId !== undefined && requestId !== previousRequestId)) - void closeOnboardingWindow() - }, { immediate: true }) - - // The onboarding window is a separate Electron renderer with its own Pinia - // instance. It must initiate login itself and stay alive for the token callback. - watch(options.needsLogin, async (needsLogin) => { - if (!needsLogin || options.isAuthenticated.value) - return - - await options.startLogin() - options.needsLogin.value = false - }) - - return { closeOnboardingWindow } -} diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-restore-scroll.ts b/apps/stage-tamagotchi/src/renderer/composables/use-restore-scroll.ts deleted file mode 100644 index 6bab5e356..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/use-restore-scroll.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { MaybeRefOrGetter } from 'vue' - -import { nextTick, toRef, watch } from 'vue' -import { useRoute } from 'vue-router' - -const scrollPositions = new Map() - -export function useRestoreScroll(scrollContainer: MaybeRefOrGetter) { - const route = useRoute() - const scrollContainerRef = toRef(scrollContainer) - - watch( - () => route.fullPath, - async (newPath, oldPath) => { - if (!scrollContainerRef.value) { - return - } - - if (oldPath) { - scrollPositions.set(oldPath, scrollContainerRef.value.scrollTop) - } - - await nextTick() - - if (!scrollContainerRef.value) { - return - } - - const savedPosition = scrollPositions.get(newPath) || 0 - scrollContainerRef.value.scrollTop = savedPosition - }, - ) - - return { - scrollContainer, - } -} diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-vision-screen-capture.ts b/apps/stage-tamagotchi/src/renderer/composables/use-vision-screen-capture.ts deleted file mode 100644 index 571fa272c..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/use-vision-screen-capture.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { SerializableDesktopCapturerSource } from '@proj-airi/electron-screen-capture' -import type { SourcesOptions } from 'electron' -import type { MaybeRefOrGetter } from 'vue' - -import { useElectronScreenCapture } from '@proj-airi/electron-screen-capture/vue' -import { computed, ref, shallowRef, watch } from 'vue' - -import { createObjectUrlFromBytes } from '../utils/create-object-url-from-bytes' - -interface ScreenCaptureSource extends SerializableDesktopCapturerSource { - appIconURL?: string - thumbnailURL?: string -} - -/** - * Manages Electron-backed screen-capture sources and the active preview stream for vision workflows. - * - * Use when: - * - A renderer page needs to browse screen/window sources before capturing frames - * - The page should keep a single active `MediaStream` in sync with the selected source - * - * Expects: - * - The Electron screen-capture preload APIs to be available on `window.electron.ipcRenderer` - * - Callers to invoke `cleanup()` when the owning component unmounts - * - * Returns: - * - Reactive source lists, active stream state, and helpers for refetching, starting, stopping, and capturing frames - */ -export function useVisionScreenCapture(sourcesOptions: MaybeRefOrGetter) { - const sources = ref([]) - const isRefetching = ref(false) - const hasFetchedOnce = ref(false) - const activeSourceId = ref('') - const activeStream = shallowRef(null) - const activeStreamSourceId = ref('') - - watch(activeSourceId, (nextId) => { - if (activeStreamSourceId.value && activeStreamSourceId.value !== nextId) { - clearActiveStream() - } - }) - - const { - getSources, - selectWithSource, - } = useElectronScreenCapture(window.electron.ipcRenderer, sourcesOptions) - - const activeSource = computed(() => sources.value.find(source => source.id === activeSourceId.value) || null) - - function isActiveStream(stream: MediaStream | null | undefined) { - if (!stream) - return false - - return stream.getVideoTracks().some(track => track.readyState === 'live') - } - - function clearActiveStream() { - const stream = activeStream.value - if (!stream) { - activeStream.value = null - activeStreamSourceId.value = '' - return - } - - stream.getTracks().forEach(track => track.stop()) - activeStream.value = null - activeStreamSourceId.value = '' - } - - function revokeSourceObjectUrls(entries: ScreenCaptureSource[]) { - entries.forEach((source) => { - if (source.appIconURL) - URL.revokeObjectURL(source.appIconURL) - if (source.thumbnailURL) - URL.revokeObjectURL(source.thumbnailURL) - }) - } - - function attachStreamLifecycle(stream: MediaStream, sourceId: string) { - stream.getTracks().forEach((track) => { - track.addEventListener('ended', () => { - if (activeStream.value === stream && activeStreamSourceId.value === sourceId) { - activeStream.value = null - activeStreamSourceId.value = '' - } - }, { once: true }) - }) - } - - async function refetchSources() { - try { - isRefetching.value = true - const nextSources = (await getSources()) - .sort((a, b) => { - const aIsScreen = a.id.startsWith('screen:') - const bIsScreen = b.id.startsWith('screen:') - if (aIsScreen !== bIsScreen) - return aIsScreen ? -1 : 1 - return a.name.localeCompare(b.name) - }) - - revokeSourceObjectUrls(sources.value) - - sources.value = nextSources.map(source => ({ - ...source, - appIconURL: source.appIcon && source.appIcon.length > 0 ? createObjectUrlFromBytes(source.appIcon, 'image/png') : undefined, - thumbnailURL: source.thumbnail && source.thumbnail.length > 0 ? createObjectUrlFromBytes(source.thumbnail, 'image/jpeg') : undefined, - })) - - const hasActiveSource = sources.value.some(source => source.id === activeSourceId.value) - const nextActiveSourceId = hasActiveSource ? activeSourceId.value : sources.value[0]?.id || '' - activeSourceId.value = nextActiveSourceId - } - finally { - isRefetching.value = false - hasFetchedOnce.value = true - } - } - - async function startStream() { - const sourceId = activeSourceId.value - if (!sourceId) - throw new Error('No active source selected') - - if (isActiveStream(activeStream.value) && activeStreamSourceId.value === sourceId) - return activeStream.value! - - clearActiveStream() - - const stream = await selectWithSource( - () => sourceId, - async () => await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }), - ) - if (!isActiveStream(stream)) { - stream.getTracks().forEach(track => track.stop()) - throw new Error('Selected source did not provide a live video track') - } - - activeStream.value = stream - activeStreamSourceId.value = sourceId - attachStreamLifecycle(stream, sourceId) - - return stream - } - - function stopStream() { - clearActiveStream() - } - - function cleanup() { - stopStream() - revokeSourceObjectUrls(sources.value) - } - - function captureFrame(video: HTMLVideoElement, quality = 0.82, maxWidth = 1280, maxHeight = 720) { - if (!video || video.readyState < 2) - return null - - const canvas = document.createElement('canvas') - const sourceWidth = video.videoWidth - const sourceHeight = video.videoHeight - if (sourceWidth <= 0 || sourceHeight <= 0) - return null - - const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight, 1) - canvas.width = Math.round(sourceWidth * scale) - canvas.height = Math.round(sourceHeight * scale) - - const ctx = canvas.getContext('2d') - if (!ctx) - throw new Error('Failed to create canvas context') - - ctx.drawImage(video, 0, 0, canvas.width, canvas.height) - return canvas.toDataURL('image/jpeg', quality) - } - - return { - sources, - activeSourceId, - activeSource, - activeStream, - isRefetching, - hasFetchedOnce, - refetchSources, - startStream, - stopStream, - cleanup, - captureFrame, - } -} diff --git a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.test.ts b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.test.ts deleted file mode 100644 index 0e4b32fd0..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { useCaptionItems } from './useCaptionItems' - -describe('useCaptionItems', () => { - it('expires each caption event without cancelling earlier events of the same type', () => { - vi.useFakeTimers() - - try { - const captions = useCaptionItems({ ttlMs: 1000 }) - - captions.add({ type: 'caption-speaker', text: 'first' }) - vi.advanceTimersByTime(500) - captions.add({ type: 'caption-speaker', text: 'second' }) - - expect(captions.items.value.map(item => item.text)).toEqual(['first', 'second']) - - vi.advanceTimersByTime(500) - - expect(captions.items.value.map(item => item.text)).toEqual(['second']) - - vi.advanceTimersByTime(500) - - expect(captions.items.value).toEqual([]) - } - finally { - vi.useRealTimers() - } - }) - - it('clears caption items of the matching type when an empty event arrives', () => { - vi.useFakeTimers() - - try { - const captions = useCaptionItems({ ttlMs: 1000 }) - - captions.add({ type: 'caption-speaker', text: 'speaker' }) - captions.add({ type: 'caption-assistant', text: 'assistant' }) - captions.add({ type: 'caption-speaker', text: '' }) - - expect(captions.items.value.map(item => item.text)).toEqual(['assistant']) - - vi.advanceTimersByTime(1000) - - expect(captions.items.value).toEqual([]) - } - finally { - vi.useRealTimers() - } - }) - - // ROOT CAUSE: - // - // Streaming providers send a complete volatile sentence on each update. - // The caption overlay appended every correction as a separate item. - it('replaces volatile speaker captions without accumulating corrections', () => { - vi.useFakeTimers() - - try { - const captions = useCaptionItems({ ttlMs: 1000 }) - - captions.add({ operation: 'replace', type: 'caption-speaker', text: '今天天气很号' }) - vi.advanceTimersByTime(500) - captions.add({ operation: 'replace', type: 'caption-speaker', text: '今天天气很好' }) - - expect(captions.items.value).toHaveLength(1) - expect(captions.items.value[0]?.text).toBe('今天天气很好') - - vi.advanceTimersByTime(500) - - expect(captions.items.value).toHaveLength(1) - - vi.advanceTimersByTime(500) - - expect(captions.items.value).toEqual([]) - } - finally { - vi.useRealTimers() - } - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts deleted file mode 100644 index 8533b0216..000000000 --- a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { CaptionChannelEvent } from '@proj-airi/stage-shared' - -import { readonly, shallowRef } from 'vue' - -export interface CaptionItem { - /** Stable render key and timer owner for one broadcast caption event. */ - id: number - /** Caption source, used for styling and explicit type-level clears. */ - type: CaptionChannelEvent['type'] - /** Text payload rendered by the overlay. */ - text: string -} - -export interface UseCaptionItemsOptions { - /** - * How long one caption event should stay visible before removing itself. - * - * @default 5000 - */ - ttlMs?: number -} - -const defaultCaptionItemsOptions = { - ttlMs: 5_000, -} satisfies Required - -/** - * Manages caption overlay items with per-event expiry. - * - * Use when: - * - Broadcast caption updates should age out independently. - * - Empty caption events should clear only the matching caption source. - * - * Expects: - * - Callers pass plain caption broadcast events. - * - Callers call `dispose()` when the owner outlives Vue component cleanup. - * - * Returns: - * - Readonly caption items plus actions for adding events and clearing timers. - */ -export function useCaptionItems(options: UseCaptionItemsOptions = {}) { - const { ttlMs } = { ...defaultCaptionItemsOptions, ...options } - const items = shallowRef([]) - const expiryTimers = new Map>() - let nextId = 1 - - function clearTimer(id: CaptionItem['id']) { - const timer = expiryTimers.get(id) - if (!timer) - return - - clearTimeout(timer) - expiryTimers.delete(id) - } - - function remove(id: CaptionItem['id']) { - clearTimer(id) - items.value = items.value.filter(item => item.id !== id) - } - - function clearType(type: CaptionChannelEvent['type']) { - const matchedItems = items.value.filter(item => item.type === type) - for (const item of matchedItems) { - clearTimer(item.id) - } - items.value = items.value.filter(item => item.type !== type) - } - - function scheduleExpiry(item: CaptionItem) { - expiryTimers.set(item.id, setTimeout(() => { - remove(item.id) - }, ttlMs)) - } - - function replace(event: CaptionChannelEvent) { - const matchedItems = items.value.filter(item => item.type === event.type) - const currentItem = matchedItems.at(-1) - if (!currentItem) { - const item: CaptionItem = { - id: nextId++, - type: event.type, - text: event.text, - } - items.value = [...items.value, item] - scheduleExpiry(item) - return - } - - for (const item of matchedItems) - clearTimer(item.id) - - const replacement = { ...currentItem, text: event.text } - items.value = items.value - .filter(item => item.type !== event.type || item.id === currentItem.id) - .map(item => item.id === currentItem.id ? replacement : item) - scheduleExpiry(replacement) - } - - function add(event: CaptionChannelEvent) { - if (!event.text.trim()) { - clearType(event.type) - return - } - - if (event.operation === 'replace') { - replace(event) - return - } - - const item: CaptionItem = { - id: nextId++, - type: event.type, - text: event.text, - } - items.value = [...items.value, item] - scheduleExpiry(item) - } - - function dispose() { - for (const timer of expiryTimers.values()) { - clearTimeout(timer) - } - expiryTimers.clear() - items.value = [] - } - - return { - items: readonly(items), - add, - clearType, - dispose, - } -} diff --git a/apps/stage-tamagotchi/src/renderer/features/live2d/system-audio-lipsync.ts b/apps/stage-tamagotchi/src/renderer/features/live2d/system-audio-lipsync.ts deleted file mode 100644 index 58e899d85..000000000 --- a/apps/stage-tamagotchi/src/renderer/features/live2d/system-audio-lipsync.ts +++ /dev/null @@ -1,325 +0,0 @@ -import type { SerializableDesktopCapturerSource } from '@proj-airi/electron-screen-capture' -import type { Live2DLipSync } from '@proj-airi/model-driver-lipsync' -import type { Profile } from '@proj-airi/model-driver-lipsync/shared/wlipsync' -import type { - SystemAudioLipSyncCallbacks, - SystemAudioLipSyncDriver, - SystemAudioLipSyncOptions, - SystemAudioLipSyncOutput, -} from '@proj-airi/stage-ui/stores/system-audio-lipsync' - -import { setupElectronScreenCapture } from '@proj-airi/electron-screen-capture/renderer' -import { getElectronEventaContext } from '@proj-airi/electron-vueuse' -import { createLive2DLipSync } from '@proj-airi/model-driver-lipsync' -import { wlipsyncProfile } from '@proj-airi/model-driver-lipsync/shared/wlipsync' -import { clamp } from 'es-toolkit' - -const inputAnalyserFFTSize = 1024 - -/** Electron adapter that connects renderer-local system audio to Live2D lipsync. */ -export class Live2DSystemAudioLipSyncDriver implements SystemAudioLipSyncDriver { - private readonly screenCapture = setupElectronScreenCapture(getElectronEventaContext()) - private generation = 0 - private stream: MediaStream | undefined - private pendingStart: Promise | undefined - private processor: Live2DLipSyncProcessor | undefined - private callbacks: SystemAudioLipSyncCallbacks | undefined - - async start(options: SystemAudioLipSyncOptions, callbacks: SystemAudioLipSyncCallbacks): Promise { - if (this.stream) { - this.callbacks = callbacks - this.updateOptions(options) - return - } - if (this.pendingStart) { - await this.pendingStart - if (!this.stream) - await this.start(options, callbacks) - return - } - - this.callbacks = callbacks - const generation = ++this.generation - const processor = new Live2DLipSyncProcessor(output => this.callbacks?.onOutput(output)) - this.processor = processor - processor.updateOptions(options) - this.pendingStart = this.screenCapture.selectWithSource( - (sources: SerializableDesktopCapturerSource[]) => { - if (sources.length === 0) - throw new Error('No screen source available') - return sources[0].id - }, - () => navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }), - { sourcesOptions: { types: ['screen'] } }, - ) - .then(async (stream) => { - stream.getVideoTracks().forEach((track) => { - track.stop() - stream.removeTrack(track) - }) - if (stream.getAudioTracks().length === 0) { - stream.getTracks().forEach(track => track.stop()) - throw new Error('No audio track available in the system audio stream') - } - - if (generation !== this.generation) { - stream.getTracks().forEach(track => track.stop()) - return - } - - try { - await processor.start(stream) - if (generation !== this.generation) { - processor.stop() - stream.getTracks().forEach(track => track.stop()) - return - } - this.stream = stream - stream.getAudioTracks().forEach((track) => { - track.addEventListener('ended', () => { - if (this.stream !== stream) - return - if (stream.getAudioTracks().every(audioTrack => audioTrack.readyState === 'ended')) - this.handleInputEnded() - }, { once: true }) - }) - } - catch (error) { - processor.stop() - if (this.processor === processor) { - this.processor = undefined - this.callbacks = undefined - } - stream.getTracks().forEach(track => track.stop()) - throw error - } - }) - .catch((error) => { - if (this.processor === processor) { - processor.stop() - this.processor = undefined - this.callbacks = undefined - } - throw error - }) - .finally(() => { - this.pendingStart = undefined - }) - - return this.pendingStart - } - - updateOptions(options: SystemAudioLipSyncOptions): void { - this.processor?.updateOptions(options) - } - - stop(): void { - this.generation++ - const stream = this.stream - this.stream = undefined - this.processor?.stop() - this.processor = undefined - this.callbacks = undefined - stream?.getTracks().forEach(track => track.stop()) - } - - dispose(): void { - this.stop() - } - - private handleInputEnded(): void { - this.generation++ - this.stream = undefined - this.processor?.stop() - this.processor = undefined - this.callbacks?.onEnded() - this.callbacks = undefined - } -} - -/** Converts one system-audio stream into serializable Live2D mouth movement. */ -class Live2DLipSyncProcessor { - private context: AudioContext | undefined - private source: MediaStreamAudioSourceNode | undefined - private analyser: AnalyserNode | undefined - private frequencies: Uint8Array | undefined - private lipSync: Live2DLipSync | undefined - private outputFrameId = 0 - private mouthGateOpen = false - private outputMouthOpen = 0 - private lastMouthOutputMs = 0 - private highMouthStartedMs = 0 - private highMouthDurationMs = 0 - private forcedMouthCloseUntilMs = 0 - private options: SystemAudioLipSyncOptions = { - inputVolumeThreshold: 0.08, - randomCloseDelayMs: 300, - randomCloseProbability: 1, - } - - constructor(private readonly emitOutput: (output: SystemAudioLipSyncOutput) => void) {} - - async start(stream: MediaStream): Promise { - this.stop() - - try { - this.context = new AudioContext() - this.source = this.context.createMediaStreamSource(stream) - this.analyser = this.context.createAnalyser() - this.analyser.fftSize = inputAnalyserFFTSize - this.analyser.smoothingTimeConstant = 0.8 - this.frequencies = new Uint8Array(this.analyser.frequencyBinCount) - this.source.connect(this.analyser) - - this.lipSync = await createLive2DLipSync( - this.context, - wlipsyncProfile as Profile, - { - cap: 1, - volumeScale: 1.1, - volumeExponent: 0.6, - mouthUpdateIntervalMs: 20, - mouthLerpWindowMs: 0, - }, - ) - this.lipSync.connectSource(this.source) - this.updateOutput() - } - catch (error) { - this.stop() - throw error - } - } - - stop(): void { - cancelAnimationFrame(this.outputFrameId) - this.outputFrameId = 0 - this.lipSync?.node.disconnect() - this.lipSync = undefined - this.analyser?.disconnect() - this.analyser = undefined - this.frequencies = undefined - this.source?.disconnect() - this.source = undefined - void this.context?.close() - this.context = undefined - this.resetMouthOutput() - this.emitOutput({ inputLevel: 0, mouthOpen: 0 }) - } - - updateOptions(options: SystemAudioLipSyncOptions): void { - this.options = { - inputVolumeThreshold: clamp(options.inputVolumeThreshold, 0, 1), - randomCloseDelayMs: clamp(options.randomCloseDelayMs, 100, 1000), - randomCloseProbability: clamp(options.randomCloseProbability, 0, 1), - } - this.highMouthStartedMs = 0 - this.highMouthDurationMs = 0 - } - - private updateOutput = (): void => { - if (!this.analyser || !this.frequencies) - return - - this.analyser.getByteFrequencyData(this.frequencies) - const inputLevel = this.frequencies.length - ? this.frequencies.reduce((peak, value) => Math.max(peak, value), 0) / 255 - : 0 - const timestamp = performance.now() - const rawMouthOpen = inputLevel >= this.options.inputVolumeThreshold - ? this.lipSync?.getMouthOpen() ?? 0 - : 0 - const mouthOpen = this.smoothMouthClose( - this.applySustainedMouthClosure( - this.shapeMouthOpen(rawMouthOpen), - timestamp, - ), - timestamp, - ) - this.emitOutput({ inputLevel, mouthOpen }) - this.outputFrameId = requestAnimationFrame(this.updateOutput) - } - - private shapeMouthOpen(rawMouthOpen: number): number { - if (this.mouthGateOpen) { - if (rawMouthOpen <= 0.035) - this.mouthGateOpen = false - } - else if (rawMouthOpen >= 0.08) { - this.mouthGateOpen = true - } - - if (!this.mouthGateOpen) - return 0 - - const normalized = clamp((rawMouthOpen - 0.035) / (1 - 0.035), 0, 1) - const emphasized = clamp(normalized ** 0.72 * 1.65, 0, 1) - return (Math.sign(emphasized * 2 - 1) * Math.abs(emphasized * 2 - 1) ** 0.85 + 1) / 2 - } - - // NOTICE: - // This deliberate short closure breaks up unnaturally sustained system-audio mouth openings. - // The current analyzer can hold a high value across several spoken words without a visible consonant closure. - // This workaround is local to the Live2D lipsync processor and does not change phoneme detection. - // Remove it when the lipsync analyzer provides reliable short-term mouth-closure timing. - private applySustainedMouthClosure(mouthOpen: number, timestamp: number): number { - if (timestamp < this.forcedMouthCloseUntilMs) - return 0 - - if (mouthOpen < 0.72) { - this.highMouthStartedMs = 0 - this.highMouthDurationMs = 0 - return mouthOpen - } - - if (this.highMouthStartedMs === 0) { - this.highMouthStartedMs = timestamp - this.highMouthDurationMs = this.randomDuration(this.options.randomCloseDelayMs / 2, this.options.randomCloseDelayMs) - return mouthOpen - } - - if (timestamp - this.highMouthStartedMs < this.highMouthDurationMs) - return mouthOpen - - if (Math.random() > this.options.randomCloseProbability) { - this.highMouthStartedMs = timestamp - this.highMouthDurationMs = this.randomDuration(this.options.randomCloseDelayMs / 2, this.options.randomCloseDelayMs) - return mouthOpen - } - - this.forcedMouthCloseUntilMs = timestamp + this.randomDuration(40, 100) - this.highMouthStartedMs = 0 - this.highMouthDurationMs = 0 - return 0 - } - - private smoothMouthClose(target: number, timestamp: number): number { - if (this.lastMouthOutputMs === 0 || target >= this.outputMouthOpen) { - this.outputMouthOpen = target - this.lastMouthOutputMs = timestamp - return this.outputMouthOpen - } - - const alpha = 1 - Math.exp(-(timestamp - this.lastMouthOutputMs) / 18) - this.outputMouthOpen += (target - this.outputMouthOpen) * alpha - this.lastMouthOutputMs = timestamp - - if (this.outputMouthOpen < 0.01) - this.outputMouthOpen = target - - return this.outputMouthOpen - } - - private resetMouthOutput(): void { - this.mouthGateOpen = false - this.outputMouthOpen = 0 - this.lastMouthOutputMs = 0 - this.highMouthStartedMs = 0 - this.highMouthDurationMs = 0 - this.forcedMouthCloseUntilMs = 0 - } - - private randomDuration(minimumMs: number, maximumMs: number): number { - return minimumMs + Math.random() * (maximumMs - minimumMs) - } -} diff --git a/apps/stage-tamagotchi/src/renderer/index.html b/apps/stage-tamagotchi/src/renderer/index.html deleted file mode 100644 index 1813bd28b..000000000 --- a/apps/stage-tamagotchi/src/renderer/index.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - -
- - - diff --git a/apps/stage-tamagotchi/src/renderer/layouts/default.vue b/apps/stage-tamagotchi/src/renderer/layouts/default.vue deleted file mode 100644 index 83cede6ca..000000000 --- a/apps/stage-tamagotchi/src/renderer/layouts/default.vue +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/layouts/settings.vue b/apps/stage-tamagotchi/src/renderer/layouts/settings.vue deleted file mode 100644 index 97fbdd7f7..000000000 --- a/apps/stage-tamagotchi/src/renderer/layouts/settings.vue +++ /dev/null @@ -1,91 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/layouts/stage.vue b/apps/stage-tamagotchi/src/renderer/layouts/stage.vue deleted file mode 100644 index a78fb6fd3..000000000 --- a/apps/stage-tamagotchi/src/renderer/layouts/stage.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/apps/stage-tamagotchi/src/renderer/main.ts b/apps/stage-tamagotchi/src/renderer/main.ts deleted file mode 100644 index 8e1e6d68d..000000000 --- a/apps/stage-tamagotchi/src/renderer/main.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { Plugin } from 'vue' -import type { RouteRecordRaw } from 'vue-router' - -import Tres from '@tresjs/core' - -import { autoAnimatePlugin } from '@formkit/auto-animate/vue' -import { PiniaColada } from '@pinia/colada' -import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button' -import { browserAuthorizationHandler, registerAuthorizationHandler } from '@proj-airi/stage-ui/libs/auth' -import { piniaPluginTracing, setupSynced } from '@proj-airi/stage-ui/libs/pinia' -import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/product-signals' -import { MotionPlugin } from '@vueuse/motion' -import { createPinia } from 'pinia' -import { setupLayouts } from 'virtual:generated-layouts' -import { createApp } from 'vue' -import { createRouter, createWebHashHistory } from 'vue-router' -import { handleHotUpdate, routes } from 'vue-router/auto-routes' - -import App from './App.vue' - -import { i18n } from './modules/i18n' -import { resolveRendererWindowContext } from './window-context' - -import '@unocss/reset/tailwind.css' -import 'splitpanes/dist/splitpanes.css' -import 'vue-sonner/style.css' -import './styles/main.css' -import 'uno.css' -// Fonts -import '@proj-airi/font-cjkfonts-allseto/index.css' -import '@proj-airi/font-xiaolai/index.css' -import '@fontsource-variable/dm-sans/index.css' -import '@fontsource-variable/jura/index.css' -import '@fontsource-variable/quicksand/index.css' -import '@fontsource-variable/urbanist/index.css' -import '@fontsource-variable/comfortaa/index.css' -import '@fontsource/dm-mono/index.css' -import '@fontsource/dm-serif-display/index.css' -import '@fontsource/gugi/index.css' -import '@fontsource/kiwi-maru/index.css' -import '@fontsource/m-plus-rounded-1c/index.css' -import '@fontsource-variable/nunito/index.css' - -configureAnalyticsAdapter(async (options) => { - const { createOpenpanelAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/openpanel') - return createOpenpanelAdapter(options) -}) -registerAuthorizationHandler(browserAuthorizationHandler) - -const pinia = createPinia() -const synced = setupSynced({ - leadership: resolveRendererWindowContext().leadership, -}) -pinia.use(synced.pinia) -if (import.meta.env.DEV) - pinia.use(piniaPluginTracing) - -const router = createRouter({ - history: createWebHashHistory(), - // TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution - routes: setupLayouts(routes as RouteRecordRaw[]), -}) - -if (import.meta.hot) { - handleHotUpdate(router, (updatedRoutes) => { - router.clearRoutes() - for (const route of setupLayouts(updatedRoutes)) - router.addRoute(route) - }) -} - -createApp(App) - .use(synced.vue) - .use(MotionPlugin) - // TODO: Fix autoAnimatePlugin type error - .use(autoAnimatePlugin as unknown as Plugin) - .use(router) - .use(pinia) - .use(PiniaColada) - .use(i18n) - .use(Tres) - .use(trackButtonPlugin) - .mount('#app') diff --git a/apps/stage-tamagotchi/src/renderer/modules/i18n.ts b/apps/stage-tamagotchi/src/renderer/modules/i18n.ts deleted file mode 100644 index 438770ebe..000000000 --- a/apps/stage-tamagotchi/src/renderer/modules/i18n.ts +++ /dev/null @@ -1,22 +0,0 @@ -import messages from '@proj-airi/i18n/locales' - -import { resolveSupportedLocale } from '@proj-airi/i18n' -import { createI18n } from 'vue-i18n' - -function getLocale() { - let language = localStorage.getItem('settings/language') - - if (!language) { - // Fallback to browser language - language = navigator.language || 'en' - } - - return resolveSupportedLocale(language, Object.keys(messages!)) -} - -export const i18n = createI18n({ - legacy: false, - locale: getLocale(), - fallbackLocale: 'en', - messages, -}) diff --git a/apps/stage-tamagotchi/src/renderer/pages/about.vue b/apps/stage-tamagotchi/src/renderer/pages/about.vue deleted file mode 100644 index 68dff6190..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/about.vue +++ /dev/null @@ -1,493 +0,0 @@ - - - - - -meta: - layout: plain - diff --git a/apps/stage-tamagotchi/src/renderer/pages/caption.vue b/apps/stage-tamagotchi/src/renderer/pages/caption.vue deleted file mode 100644 index c20fe8465..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/caption.vue +++ /dev/null @@ -1,154 +0,0 @@ - - - - - - - -meta: - layout: stage - diff --git a/apps/stage-tamagotchi/src/renderer/pages/chat-page-shell.vue b/apps/stage-tamagotchi/src/renderer/pages/chat-page-shell.vue deleted file mode 100644 index 657489c5d..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/chat-page-shell.vue +++ /dev/null @@ -1,13 +0,0 @@ - diff --git a/apps/stage-tamagotchi/src/renderer/pages/chat.browser.test.ts b/apps/stage-tamagotchi/src/renderer/pages/chat.browser.test.ts deleted file mode 100644 index d712e98b4..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/chat.browser.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ScrollableArea } from '@proj-airi/ui' -import { describe, expect, it } from 'vitest' -import { render } from 'vitest-browser-vue' -import { defineComponent } from 'vue' - -import ChatPageShell from './chat-page-shell.vue' - -describe('desktop chat page scrolling', () => { - it('leaves scrolling to the rendered chat history viewport', async () => { - const TestHost = defineComponent({ - components: { ChatPageShell, ScrollableArea }, - template: ` - - -
Long chat history
-
-
- `, - }) - const screen = await render(TestHost) - const shell = screen.getByTestId('desktop-chat-page-shell').element() as HTMLElement - const viewport = screen.container.querySelector('[data-reka-scroll-area-viewport]') - - expect(getComputedStyle(shell).overflowY).toBe('hidden') - expect(getComputedStyle(viewport!).overflowY).toBe('scroll') - expect(viewport!.scrollHeight).toBeGreaterThan(viewport!.clientHeight) - - const verticalScrollOwners = [shell, viewport].filter((element) => { - if (!element) - return false - - return ['auto', 'scroll'].includes(getComputedStyle(element).overflowY) - && element.scrollHeight > element.clientHeight - }) - expect(verticalScrollOwners).toEqual([viewport]) - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/pages/chat.vue b/apps/stage-tamagotchi/src/renderer/pages/chat.vue deleted file mode 100644 index 1800d2e95..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/chat.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - - - -meta: - layout: stage - diff --git a/apps/stage-tamagotchi/src/renderer/pages/dashboard/index.vue b/apps/stage-tamagotchi/src/renderer/pages/dashboard/index.vue deleted file mode 100644 index 346dee4c8..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/dashboard/index.vue +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.test.ts b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.test.ts deleted file mode 100644 index f2ff64f3d..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - pointInOverlay, - rectIntersectsOverlay, - screenRectToLocal, - screenToLocal, -} from './desktop-overlay-coordinates' - -// --------------------------------------------------------------------------- -// screenToLocal -// --------------------------------------------------------------------------- - -describe('screenToLocal', () => { - it('subtracts overlay origin from screen point', () => { - const result = screenToLocal({ x: 500, y: -800 }, { x: 0, y: -1080 }) - expect(result).toEqual({ x: 500, y: 280 }) - }) - - it('is identity when overlay origin is (0,0)', () => { - const result = screenToLocal({ x: 100, y: 200 }, { x: 0, y: 0 }) - expect(result).toEqual({ x: 100, y: 200 }) - }) - - it('handles negative overlay origin', () => { - const result = screenToLocal({ x: 441, y: -1037 }, { x: 0, y: -1080 }) - expect(result).toEqual({ x: 441, y: 43 }) - }) -}) - -// --------------------------------------------------------------------------- -// screenRectToLocal -// --------------------------------------------------------------------------- - -describe('screenRectToLocal', () => { - it('shifts rect origin, preserves size', () => { - const result = screenRectToLocal( - { x: 100, y: -1000, width: 80, height: 30 }, - { x: 0, y: -1080 }, - ) - expect(result).toEqual({ x: 100, y: 80, width: 80, height: 30 }) - }) - - it('is identity when overlay origin is (0,0)', () => { - const rect = { x: 50, y: 100, width: 200, height: 150 } - const result = screenRectToLocal(rect, { x: 0, y: 0 }) - expect(result).toEqual(rect) - }) -}) - -// --------------------------------------------------------------------------- -// rectIntersectsOverlay -// --------------------------------------------------------------------------- - -describe('rectIntersectsOverlay', () => { - const overlay = { x: 0, y: -1080, width: 1440, height: 900 } - - it('returns true for rect fully inside overlay', () => { - expect(rectIntersectsOverlay( - { x: 100, y: -1000, width: 80, height: 30 }, - overlay, - )).toBe(true) - }) - - it('returns true for rect partially overlapping', () => { - expect(rectIntersectsOverlay( - { x: 1400, y: -1080, width: 100, height: 50 }, - overlay, - )).toBe(true) - }) - - it('returns false for rect entirely above overlay', () => { - expect(rectIntersectsOverlay( - { x: 100, y: -2000, width: 80, height: 30 }, - overlay, - )).toBe(false) - }) - - it('returns false for rect entirely below overlay', () => { - expect(rectIntersectsOverlay( - { x: 100, y: 0, width: 80, height: 30 }, - overlay, - )).toBe(false) - }) - - it('returns false for rect entirely to the right', () => { - expect(rectIntersectsOverlay( - { x: 1500, y: -500, width: 80, height: 30 }, - overlay, - )).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// pointInOverlay -// --------------------------------------------------------------------------- - -describe('pointInOverlay', () => { - const overlay = { x: 0, y: -1080, width: 1440, height: 900 } - - it('returns true for point inside', () => { - expect(pointInOverlay({ x: 720, y: -540 }, overlay)).toBe(true) - }) - - it('returns true for point at top-left corner', () => { - expect(pointInOverlay({ x: 0, y: -1080 }, overlay)).toBe(true) - }) - - it('returns false for point outside (below)', () => { - expect(pointInOverlay({ x: 720, y: 0 }, overlay)).toBe(false) - }) - - it('returns false for point outside (above)', () => { - expect(pointInOverlay({ x: 720, y: -1200 }, overlay)).toBe(false) - }) - - it('returns false for point outside (right)', () => { - expect(pointInOverlay({ x: 1500, y: -540 }, overlay)).toBe(false) - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.ts b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.ts deleted file mode 100644 index 3c57870be..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Desktop Overlay Coordinates — screen-absolute to overlay-local mapping. - * - * The computer-use-mcp returns all bounding boxes and points in - * screen-absolute logical pixels. The overlay window covers a single - * display whose origin may be non-zero (e.g. y = -1080 when a display - * is stacked above the primary). - * - * This module provides pure functions to: - * 1. Convert screen-absolute coords to overlay-local coords - * 2. Filter out candidates whose bounds don't intersect the overlay - */ - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface Rect { - x: number - y: number - width: number - height: number -} - -export interface Point { - x: number - y: number -} - -// --------------------------------------------------------------------------- -// Coordinate mapping -// --------------------------------------------------------------------------- - -/** - * Convert a screen-absolute point to overlay-local coordinates. - */ -export function screenToLocal(point: Point, overlayOrigin: Point): Point { - return { - x: point.x - overlayOrigin.x, - y: point.y - overlayOrigin.y, - } -} - -/** - * Convert a screen-absolute rect to overlay-local coordinates. - * Size is preserved; only the origin is shifted. - */ -export function screenRectToLocal(rect: Rect, overlayOrigin: Point): Rect { - return { - x: rect.x - overlayOrigin.x, - y: rect.y - overlayOrigin.y, - width: rect.width, - height: rect.height, - } -} - -/** - * Check whether a screen-absolute rect intersects the overlay bounds. - * Used to filter out candidates that are entirely on another display. - */ -export function rectIntersectsOverlay(rect: Rect, overlayBounds: Rect): boolean { - return ( - rect.x < overlayBounds.x + overlayBounds.width - && rect.x + rect.width > overlayBounds.x - && rect.y < overlayBounds.y + overlayBounds.height - && rect.y + rect.height > overlayBounds.y - ) -} - -/** - * Check whether a screen-absolute point is within the overlay bounds. - */ -export function pointInOverlay(point: Point, overlayBounds: Rect): boolean { - return ( - point.x >= overlayBounds.x - && point.x < overlayBounds.x + overlayBounds.width - && point.y >= overlayBounds.y - && point.y < overlayBounds.y + overlayBounds.height - ) -} diff --git a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.test.ts b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.test.ts deleted file mode 100644 index aed14d71b..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.test.ts +++ /dev/null @@ -1,539 +0,0 @@ -import type { McpCallToolResult } from '@proj-airi/stage-ui/stores/mcp-tool-bridge' - -import type { OverlayState } from './desktop-overlay-polling' - -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { - createEmptyOverlayState, - createOverlayPollController, - extractOverlayState, - extractRunStateFromResult, - MCP_TOOL_NAME, -} from './desktop-overlay-polling' - -// --------------------------------------------------------------------------- -// extractOverlayState -// --------------------------------------------------------------------------- - -describe('extractOverlayState', () => { - it('returns empty state when runState has no grounding data', () => { - const result = extractOverlayState({}) - expect(result.hasSnapshot).toBe(false) - expect(result.snapshotId).toBe('') - expect(result.candidates).toEqual([]) - expect(result.pointerIntent).toBeNull() - expect(result.staleFlags).toEqual({ screenshot: false, ax: false, chromeSemantic: false }) - expect(result.bootstrapState).toBe('booting') - }) - - it('extracts candidates from lastGroundingSnapshot', () => { - const result = extractOverlayState({ - lastGroundingSnapshot: { - snapshotId: 'dg_42', - targetCandidates: [ - { id: 't_0', source: 'chrome_dom', role: 'button', label: 'Submit', bounds: { x: 100, y: 200, width: 80, height: 30 }, confidence: 0.95 }, - { id: 't_1', source: 'ax', role: 'link', label: 'Help', bounds: { x: 300, y: 100, width: 40, height: 20 }, confidence: 0.7 }, - ], - staleFlags: { screenshot: false, ax: false, chromeSemantic: false }, - }, - }) - - expect(result.hasSnapshot).toBe(true) - expect(result.snapshotId).toBe('dg_42') - expect(result.candidates).toHaveLength(2) - expect(result.candidates[0].id).toBe('t_0') - expect(result.candidates[1].source).toBe('ax') - }) - - it('extracts pointer intent from lastPointerIntent', () => { - const result = extractOverlayState({ - lastPointerIntent: { - snappedPoint: { x: 140, y: 215 }, - candidateId: 't_0', - source: 'chrome_dom', - confidence: 0.95, - mode: 'execute', - }, - }) - - expect(result.pointerIntent).not.toBeNull() - expect(result.pointerIntent!.snappedPoint).toEqual({ x: 140, y: 215 }) - expect(result.pointerIntent!.candidateId).toBe('t_0') - expect(result.pointerIntent!.mode).toBe('execute') - }) - - it('detects stale flags', () => { - const result = extractOverlayState({ - lastGroundingSnapshot: { - snapshotId: 'dg_1', - targetCandidates: [], - staleFlags: { screenshot: true, ax: false, chromeSemantic: true }, - }, - }) - - expect(result.staleFlags.screenshot).toBe(true) - expect(result.staleFlags.ax).toBe(false) - expect(result.staleFlags.chromeSemantic).toBe(true) - }) - - it('handles snapshot with missing targetCandidates gracefully', () => { - const result = extractOverlayState({ - lastGroundingSnapshot: { - snapshotId: 'dg_1', - // targetCandidates intentionally missing - }, - }) - - expect(result.hasSnapshot).toBe(true) - expect(result.candidates).toEqual([]) - }) -}) - -// --------------------------------------------------------------------------- -// extractRunStateFromResult -// --------------------------------------------------------------------------- - -describe('extractRunStateFromResult', () => { - it('returns undefined for error results', () => { - const result = extractRunStateFromResult({ - isError: true, - content: [{ type: 'text', text: 'fail' }], - }) - expect(result).toBeUndefined() - }) - - it('extracts runState from structuredContent.runState', () => { - const result = extractRunStateFromResult({ - structuredContent: { - runState: { - lastGroundingSnapshot: { snapshotId: 'dg_1' }, - }, - }, - }) - expect(result).toBeDefined() - expect((result as any).lastGroundingSnapshot.snapshotId).toBe('dg_1') - }) - - it('falls back to structuredContent directly when no runState key', () => { - const result = extractRunStateFromResult({ - structuredContent: { - lastGroundingSnapshot: { snapshotId: 'dg_2' }, - }, - }) - expect(result).toBeDefined() - expect((result as any).lastGroundingSnapshot.snapshotId).toBe('dg_2') - }) - - it('returns undefined when structuredContent is missing', () => { - const result = extractRunStateFromResult({}) - expect(result).toBeUndefined() - }) -}) - -// --------------------------------------------------------------------------- -// createEmptyOverlayState -// --------------------------------------------------------------------------- - -describe('createEmptyOverlayState', () => { - it('returns consistent empty shape', () => { - const a = createEmptyOverlayState() - const b = createEmptyOverlayState() - - expect(a).toEqual(b) - expect(a.hasSnapshot).toBe(false) - expect(a.candidates).toEqual([]) - expect(a.pointerIntent).toBeNull() - expect(a.bootstrapState).toBe('booting') - - // Should not be the same reference (no shared mutation) - a.candidates.push({ id: 'x', source: 'raw', role: 'button', label: 'X', bounds: { x: 0, y: 0, width: 10, height: 10 }, confidence: 1 }) - expect(b.candidates).toHaveLength(0) - }) -}) - -// --------------------------------------------------------------------------- -// createOverlayPollController -// --------------------------------------------------------------------------- - -describe('createOverlayPollController', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('calls tool and delivers state on successful poll', async () => { - vi.useFakeTimers() - - const mockResult: McpCallToolResult = { - structuredContent: { - runState: { - lastGroundingSnapshot: { - snapshotId: 'dg_poll', - targetCandidates: [ - { id: 't_0', source: 'chrome_dom', role: 'button', label: 'OK', bounds: { x: 10, y: 20, width: 50, height: 25 }, confidence: 0.9 }, - ], - staleFlags: { screenshot: false, ax: false, chromeSemantic: false }, - }, - }, - }, - } - - const callTool = vi.fn<(name: string) => Promise>() - .mockResolvedValue(mockResult) - - const received: OverlayState[] = [] - - const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' }) - - const controller = createOverlayPollController({ - callTool, - getReadiness, - onState: (s) => { received.push(s) }, - intervalMs: 100, - fallbackIntervalMs: 200, - }) - - controller.start() - - // Let the first poll resolve - await vi.advanceTimersByTimeAsync(0) - - expect(callTool).toHaveBeenCalledWith(MCP_TOOL_NAME) - expect(received).toHaveLength(2) - expect(received[0].bootstrapState).toBe('ready') - expect(received[0].hasSnapshot).toBe(false) - expect(received[1].hasSnapshot).toBe(true) - expect(received[1].candidates[0].id).toBe('t_0') - - controller.stop() - }) - - it('stops polling after stop() is called', async () => { - vi.useFakeTimers() - - const callTool = vi.fn<(name: string) => Promise>() - .mockResolvedValue({ structuredContent: {} }) - - const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' }) - - const controller = createOverlayPollController({ - callTool, - getReadiness, - onState: () => {}, - intervalMs: 100, - }) - - controller.start() - await vi.advanceTimersByTimeAsync(0) - expect(callTool).toHaveBeenCalledTimes(1) - - controller.stop() - expect(controller.isRunning()).toBe(false) - - // Advance past when next poll would have fired - await vi.advanceTimersByTimeAsync(200) - expect(callTool).toHaveBeenCalledTimes(1) // No additional calls - }) - - it('continues polling after a single failure', async () => { - vi.useFakeTimers() - - const callTool = vi.fn<(name: string) => Promise>() - .mockRejectedValueOnce(new Error('MCP down')) - .mockResolvedValue({ - structuredContent: { - runState: { - lastGroundingSnapshot: { - snapshotId: 'dg_recover', - targetCandidates: [], - staleFlags: { screenshot: false, ax: false, chromeSemantic: false }, - }, - }, - }, - }) - - const received: OverlayState[] = [] - - const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' }) - - const controller = createOverlayPollController({ - callTool, - getReadiness, - onState: (s) => { received.push(s) }, - intervalMs: 100, - fallbackIntervalMs: 200, - }) - - controller.start() - - // First poll: fails (but empty ready state was emitted) - await vi.advanceTimersByTimeAsync(0) - expect(callTool).toHaveBeenCalledTimes(1) - expect(received).toHaveLength(1) - expect(received[0].bootstrapState).toBe('ready') - - // Wait for fallback interval - await vi.advanceTimersByTimeAsync(200) - expect(callTool).toHaveBeenCalledTimes(2) - expect(received).toHaveLength(2) - expect(received[1].snapshotId).toBe('dg_recover') - - controller.stop() - }) - - it('is a no-op to call start() twice', async () => { - vi.useFakeTimers() - - const callTool = vi.fn<(name: string) => Promise>() - .mockResolvedValue({ structuredContent: {} }) - - const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' }) - - const controller = createOverlayPollController({ - callTool, - getReadiness, - onState: () => {}, - intervalMs: 100, - }) - - controller.start() - controller.start() // Should not double-start - - await vi.advanceTimersByTimeAsync(0) - expect(callTool).toHaveBeenCalledTimes(1) // Not 2 - - controller.stop() - }) - - it('recovers from a hanging callTool via per-call timeout', async () => { - vi.useFakeTimers() - - // First call hangs forever (simulates startup race when RPC not ready) - const callTool = vi.fn<(name: string) => Promise>() - .mockImplementationOnce(() => new Promise(() => {})) // never resolves - .mockResolvedValue({ - structuredContent: { - runState: { - lastGroundingSnapshot: { - snapshotId: 'dg_after_timeout', - targetCandidates: [], - staleFlags: { screenshot: false, ax: false, chromeSemantic: false }, - }, - }, - }, - }) - - const received: OverlayState[] = [] - - const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' }) - - const controller = createOverlayPollController({ - callTool, - getReadiness, - onState: (s) => { received.push(s) }, - intervalMs: 100, - fallbackIntervalMs: 200, - callTimeoutMs: 500, - }) - - controller.start() - - // First poll fires immediately (emits ready state), callTool hangs - await vi.advanceTimersByTimeAsync(0) - expect(callTool).toHaveBeenCalledTimes(1) - expect(received).toHaveLength(1) - - // Advance past the 500ms timeout → catch triggers, schedules fallback - await vi.advanceTimersByTimeAsync(500) - expect(received).toHaveLength(1) - - // Advance past the 200ms fallback interval → second poll fires and succeeds - await vi.advanceTimersByTimeAsync(200) - expect(callTool).toHaveBeenCalledTimes(2) - expect(received).toHaveLength(2) - expect(received[1].snapshotId).toBe('dg_after_timeout') - - controller.stop() - }) - - it('caps outstanding timed-out polls to avoid unbounded buildup', async () => { - vi.useFakeTimers() - - const callTool = vi.fn<(name: string) => Promise>() - .mockImplementation(() => new Promise(() => {})) - - const controller = createOverlayPollController({ - callTool, - getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }), - onState: () => {}, - intervalMs: 100, - fallbackIntervalMs: 200, - callTimeoutMs: 500, - }) - - controller.start() - - await vi.advanceTimersByTimeAsync(0) - expect(callTool).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(500) - await vi.advanceTimersByTimeAsync(200) - expect(callTool).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(500) - await vi.advanceTimersByTimeAsync(1000) - expect(callTool).toHaveBeenCalledTimes(2) - - controller.stop() - }) - - it('issues a low-frequency recovery probe when all tracked polls are permanently hung', async () => { - vi.useFakeTimers() - - const callTool = vi.fn<(name: string) => Promise>() - .mockImplementation(() => new Promise(() => {})) - - const controller = createOverlayPollController({ - callTool, - getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }), - onState: () => {}, - intervalMs: 100, - fallbackIntervalMs: 200, - callTimeoutMs: 500, - }) - - controller.start() - - await vi.advanceTimersByTimeAsync(0) - expect(callTool).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(500) - await vi.advanceTimersByTimeAsync(200) - expect(callTool).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(500) - await vi.advanceTimersByTimeAsync(1000) - expect(callTool).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(10_000) - expect(callTool).toHaveBeenCalledTimes(3) - - controller.stop() - }) - - it('releases timed-out poll slots only when the original promise settles', async () => { - vi.useFakeTimers() - - let resolveFirst: (value: McpCallToolResult) => void = () => {} - const callTool = vi.fn<(name: string) => Promise>() - .mockImplementationOnce(() => new Promise((resolve) => { - resolveFirst = resolve - })) - .mockImplementationOnce(() => new Promise(() => {})) - .mockResolvedValue({ - structuredContent: { - runState: { - lastGroundingSnapshot: { - snapshotId: 'dg_after_lease', - targetCandidates: [], - staleFlags: { screenshot: false, ax: false, chromeSemantic: false }, - }, - }, - }, - }) - - const received: OverlayState[] = [] - - const controller = createOverlayPollController({ - callTool, - getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }), - onState: (state) => { - received.push(state) - }, - intervalMs: 100, - fallbackIntervalMs: 200, - callTimeoutMs: 500, - }) - - controller.start() - - await vi.advanceTimersByTimeAsync(0) - expect(callTool).toHaveBeenCalledTimes(1) - expect(received).toHaveLength(1) - - await vi.advanceTimersByTimeAsync(500) - await vi.advanceTimersByTimeAsync(200) - expect(callTool).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(500) - await vi.advanceTimersByTimeAsync(1000) - expect(callTool).toHaveBeenCalledTimes(2) - expect(received).toHaveLength(1) - - resolveFirst({ structuredContent: {} }) - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(200) - expect(callTool).toHaveBeenCalledTimes(3) - expect(received).toHaveLength(2) - expect(received[1].snapshotId).toBe('dg_after_lease') - - controller.stop() - }) - - it('waits for readiness before entering main poll loop', async () => { - vi.useFakeTimers() - const callTool = vi.fn() - const getReadiness = vi.fn() - .mockResolvedValueOnce({ state: 'booting' }) - .mockResolvedValueOnce({ state: 'booting' }) - .mockResolvedValueOnce({ state: 'ready' }) - const received: OverlayState[] = [] - - const controller = createOverlayPollController({ - callTool, - getReadiness, - onState: s => received.push(s), - intervalMs: 100, - fallbackIntervalMs: 200, - }) - controller.start() - - await vi.advanceTimersByTimeAsync(0) - expect(callTool).not.toHaveBeenCalled() - expect(received[0].bootstrapState).toBe('booting') - - // First retry - await vi.advanceTimersByTimeAsync(200) - expect(callTool).not.toHaveBeenCalled() - - // Second retry triggers ready and immediately polls - await vi.advanceTimersByTimeAsync(200) - expect(callTool).toHaveBeenCalledTimes(1) - expect(received.at(-1)?.bootstrapState).toBe('ready') - - controller.stop() - }) - - it('reports degraded state if getReadiness throws', async () => { - vi.useFakeTimers() - const callTool = vi.fn() - const getReadiness = vi.fn().mockRejectedValue(new Error('RPC failed')) - const received: OverlayState[] = [] - - const controller = createOverlayPollController({ - callTool, - getReadiness, - onState: s => received.push(s), - intervalMs: 100, - fallbackIntervalMs: 200, - }) - controller.start() - - await vi.advanceTimersByTimeAsync(0) - expect(callTool).not.toHaveBeenCalled() - expect(received[0].bootstrapState).toBe('degraded') - expect(received[0].lastBootstrapError).toBe('RPC failed') - - controller.stop() - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.ts b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.ts deleted file mode 100644 index 6adf2e39d..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.ts +++ /dev/null @@ -1,392 +0,0 @@ -/** - * Desktop Overlay Polling — pure logic for MCP state polling and data extraction. - * - * Extracted from desktop-overlay.vue so the core logic can be tested - * without a DOM environment or Vue test-utils. - */ - -import type { McpCallToolResult } from '@proj-airi/stage-ui/stores/mcp-tool-bridge' - -import { errorMessageFromValue } from '@proj-airi/stage-shared' - -import { desktopOverlayPollHeartbeatMarker, desktopOverlayPollHeartbeatQueryParam } from '../../shared/desktop-overlay-heartbeat' - -// --------------------------------------------------------------------------- -// Types — minimal shapes matching RunState fields the overlay consumes -// --------------------------------------------------------------------------- - -export interface OverlayTargetCandidate { - id: string - source: string - role: string - label: string - bounds: { x: number, y: number, width: number, height: number } - confidence: number -} - -export interface OverlayPointerIntent { - snappedPoint: { x: number, y: number } - candidateId?: string - source: string - confidence: number - mode: string - phase?: 'preview' | 'executing' | 'completed' - executionResult?: 'success' | 'fallback' | 'error' -} - -export interface OverlayStaleFlags { - screenshot: boolean - ax: boolean - chromeSemantic: boolean -} - -export interface OverlayState { - hasSnapshot: boolean - snapshotId: string - candidates: OverlayTargetCandidate[] - staleFlags: OverlayStaleFlags - pointerIntent: OverlayPointerIntent | null - bootstrapState: 'booting' | 'ready' | 'degraded' - lastBootstrapError?: string -} - -export interface OverlayPollHeartbeat { - snapshotId: string - candidateCount: number - hasPointerIntent: boolean -} - -// --------------------------------------------------------------------------- -// State extraction -// --------------------------------------------------------------------------- - -const EMPTY_STALE: OverlayStaleFlags = { screenshot: false, ax: false, chromeSemantic: false } - -/** - * Create a default empty overlay state. - */ -export function createEmptyOverlayState(): OverlayState { - return { - hasSnapshot: false, - snapshotId: '', - candidates: [], - staleFlags: { ...EMPTY_STALE }, - pointerIntent: null, - bootstrapState: 'booting', - } -} - -/** - * Extract overlay-relevant data from MCP runState. - * Returns a new OverlayState — does not mutate input. - * - * This is the single source of truth for "what does the overlay show?" - */ -export function extractOverlayState(runState: Record): OverlayState { - const result = createEmptyOverlayState() - - // Extract grounding snapshot - const snapshot = runState.lastGroundingSnapshot as Record | undefined - if (snapshot) { - result.hasSnapshot = true - result.snapshotId = (snapshot.snapshotId as string) || '' - result.candidates = (snapshot.targetCandidates as OverlayTargetCandidate[]) ?? [] - result.staleFlags = (snapshot.staleFlags as OverlayStaleFlags) ?? { ...EMPTY_STALE } - } - - // Extract pointer intent - const rawIntent = runState.lastPointerIntent as OverlayPointerIntent | undefined - result.pointerIntent = rawIntent ?? null - - return result -} - -/** - * Extract runState from an MCP call result. - * Returns undefined if the result is an error or has no structured content. - */ -export function extractRunStateFromResult(result: McpCallToolResult): Record | undefined { - if (result.isError) - return undefined - - const sc = result.structuredContent - if (!sc || typeof sc !== 'object') - return undefined - - // desktop_get_state returns { runState: { ... } } or the state directly - if ('runState' in sc && sc.runState && typeof sc.runState === 'object') { - return sc.runState as Record - } - - return sc as Record -} - -export function createOverlayPollHeartbeat(state: OverlayState): OverlayPollHeartbeat | undefined { - if (!state.hasSnapshot || !state.snapshotId) - return undefined - - return { - snapshotId: state.snapshotId, - candidateCount: state.candidates.length, - hasPointerIntent: state.pointerIntent !== null, - } -} - -export function formatOverlayPollHeartbeat(heartbeat: OverlayPollHeartbeat): string { - return [ - desktopOverlayPollHeartbeatMarker, - `snapshotId=${heartbeat.snapshotId}`, - `candidates=${heartbeat.candidateCount}`, - `pointerIntent=${heartbeat.hasPointerIntent ? 'yes' : 'no'}`, - ].join(' ') -} - -export function isOverlayPollHeartbeatEnabled(locationLike: Pick = window.location): boolean { - const hashQuery = locationLike.hash.includes('?') - ? locationLike.hash.slice(locationLike.hash.indexOf('?') + 1) - : '' - const hashParams = new URLSearchParams(hashQuery) - const searchParams = new URLSearchParams(locationLike.search) - - return hashParams.get(desktopOverlayPollHeartbeatQueryParam) === '1' - || searchParams.get(desktopOverlayPollHeartbeatQueryParam) === '1' -} - -// --------------------------------------------------------------------------- -// Polling controller (framework-agnostic) -// --------------------------------------------------------------------------- - -export interface OverlayPollController { - /** Start polling. No-op if already running. */ - start: () => void - /** Stop polling. */ - stop: () => void - /** Whether the controller is actively polling. */ - isRunning: () => boolean -} - -export interface OverlayPollConfig { - /** Function to call MCP tool. */ - callTool: (name: string) => Promise - /** Callback with extracted state on each successful poll. */ - onState: (state: OverlayState) => void - /** Optional debug-only callback with a small heartbeat marker. */ - onHeartbeat?: (heartbeat: OverlayPollHeartbeat) => void - /** Function to ping main process readiness contract via Eventa. */ - getReadiness: () => Promise<{ state: 'booting' | 'ready' | 'degraded', error?: string }> - /** Normal poll interval in ms. Default: 250. */ - intervalMs?: number - /** Fallback interval on error in ms. Default: 500. */ - fallbackIntervalMs?: number - /** Per-call timeout in ms. Default: 5000. Prevents poll loop hang on startup race. */ - callTimeoutMs?: number -} - -const DEFAULT_INTERVAL = 250 -const DEFAULT_FALLBACK_INTERVAL = 500 -const DEFAULT_CALL_TIMEOUT = 5000 -const MAX_BACKGROUND_HUNG_CALLS = 2 -const HUNG_CALL_RECOVERY_INTERVAL_MS = 10_000 - -/** - * MCP server name for computer-use-mcp. Matches the key in mcp.json. - */ -export const MCP_TOOL_NAME = 'computer_use::desktop_get_state' - -/** - * Create a polling controller that periodically calls desktop_get_state - * and extracts overlay state. - */ -export function createOverlayPollController(config: OverlayPollConfig): OverlayPollController { - const normalInterval = config.intervalMs ?? DEFAULT_INTERVAL - const fallbackInterval = config.fallbackIntervalMs ?? DEFAULT_FALLBACK_INTERVAL - - let timer: ReturnType | null = null - let bootstrapTimer: ReturnType | null = null - let running = false - let inFlightCall: Promise | null = null - let backgroundHungCalls: Array<{ - call: Promise - timedOutAt: number - }> = [] - let lastHungRecoveryProbeAt: number | null = null - - let currentBootstrapState: 'booting' | 'ready' | 'degraded' = 'booting' - let currentBootstrapError: string | undefined - - function scheduleNext(nextInterval: number) { - if (running) { - timer = setTimeout(poll, nextInterval) - } - } - - function emitEmptyState() { - const empty = createEmptyOverlayState() - empty.bootstrapState = currentBootstrapState - empty.lastBootstrapError = currentBootstrapError - config.onState(empty) - } - - function removeHungCall(call: Promise) { - backgroundHungCalls = backgroundHungCalls.filter(slot => slot.call !== call) - if (backgroundHungCalls.length < MAX_BACKGROUND_HUNG_CALLS) { - lastHungRecoveryProbeAt = null - } - } - - function canStartPoll(now: number) { - if (inFlightCall) - return false - - if (backgroundHungCalls.length < MAX_BACKGROUND_HUNG_CALLS) - return true - - if (lastHungRecoveryProbeAt === null) { - lastHungRecoveryProbeAt = now - return false - } - - if ((now - lastHungRecoveryProbeAt) < HUNG_CALL_RECOVERY_INTERVAL_MS) - return false - - // NOTICE: Eventa does not expose abort semantics for callTool here. If all - // tracked calls are permanently hung, waiting for settlement also makes the - // overlay permanently stale. Drop one old tracking slot only after a long - // recovery interval so the overlay can probe again without returning to a - // per-poll unbounded RPC backlog. - backgroundHungCalls = backgroundHungCalls.slice(1) - lastHungRecoveryProbeAt = now - return true - } - - async function bootstrapPoll() { - try { - const res = await config.getReadiness() - currentBootstrapState = res.state - currentBootstrapError = res.error - } - catch (e) { - currentBootstrapState = 'degraded' - currentBootstrapError = errorMessageFromValue(e) - } - - if (!running) - return - - if (currentBootstrapState === 'ready') { - emitEmptyState() - poll() - } - else { - emitEmptyState() - bootstrapTimer = setTimeout(bootstrapPoll, fallbackInterval) - } - } - - async function poll() { - if (!canStartPoll(Date.now())) { - scheduleNext(fallbackInterval) - return - } - - let nextInterval = normalInterval - let timeoutId: ReturnType | undefined - - try { - // NOTICE: Wrap callTool with a timeout to prevent the poll loop from - // hanging forever if the eventa invoke never resolves (e.g. during - // startup when the main-process RPC handlers may not be ready yet). - // NOTICE: Eventa does not expose abort semantics here, so a timed-out - // invoke can still be unresolved in the background. Track timed-out calls - // and allow only a low-frequency recovery probe when all tracked slots - // are hung, balancing bounded IPC pressure with eventual overlay recovery. - let timedOut = false - const currentCall = config.callTool(MCP_TOOL_NAME) - inFlightCall = currentCall - currentCall.then(() => { - if (timedOut) { - removeHungCall(currentCall) - } - else if (inFlightCall === currentCall) { - inFlightCall = null - } - }, () => { - if (timedOut) { - removeHungCall(currentCall) - } - else if (inFlightCall === currentCall) { - inFlightCall = null - } - }) - - const result = await Promise.race([ - currentCall, - new Promise((_, reject) => - timeoutId = setTimeout(() => { - timedOut = true - backgroundHungCalls = [...backgroundHungCalls, { - call: currentCall, - timedOutAt: Date.now(), - }] - if (inFlightCall === currentCall) { - inFlightCall = null - } - reject(new Error('callTool timeout')) - }, config.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT), - ), - ]) - const runState = extractRunStateFromResult(result) - - if (runState) { - const state = extractOverlayState(runState) - state.bootstrapState = currentBootstrapState - state.lastBootstrapError = currentBootstrapError - config.onState(state) - const heartbeat = createOverlayPollHeartbeat(state) - if (heartbeat && config.onHeartbeat) { - config.onHeartbeat(heartbeat) - } - } - else { - nextInterval = fallbackInterval - } - } - catch { - // MCP server not running, bridge disconnected, or timeout — graceful degradation - nextInterval = fallbackInterval - } - finally { - if (timeoutId !== undefined) { - clearTimeout(timeoutId) - } - } - - scheduleNext(nextInterval) - } - - return { - start() { - if (running) - return - running = true - // First handshake with the host before starting actual MCP polling - bootstrapPoll() - }, - - stop() { - running = false - if (timer !== null) { - clearTimeout(timer) - timer = null - } - if (bootstrapTimer !== null) { - clearTimeout(bootstrapTimer) - bootstrapTimer = null - } - }, - - isRunning() { - return running - }, - } -} diff --git a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay.vue b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay.vue deleted file mode 100644 index bab7adfbb..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay.vue +++ /dev/null @@ -1,418 +0,0 @@ - - - - - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/global-shortcut.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/global-shortcut.vue deleted file mode 100644 index aad55ea1a..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/global-shortcut.vue +++ /dev/null @@ -1,401 +0,0 @@ - - - - - -meta: - layout: settings - title: Global Shortcut - subtitleKey: tamagotchi.settings.devtools.title - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/index.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/index.vue deleted file mode 100644 index 0b749bbf7..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/index.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - - - -meta: - layout: settings - titleKey: tamagotchi.settings.devtools.title - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/live2d-motion.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/live2d-motion.vue deleted file mode 100644 index 8f5e71511..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/live2d-motion.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - - - -meta: - layout: plain - titleKey: tamagotchi.settings.devtools.pages.live2d-motion.title - subtitleKey: tamagotchi.settings.devtools.title - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/performance-visualizer.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/performance-visualizer.vue deleted file mode 100644 index c897307d8..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/performance-visualizer.vue +++ /dev/null @@ -1,198 +0,0 @@ - - - - - -meta: - layout: settings - titleKey: tamagotchi.settings.devtools.pages.performance-visualizer.title - subtitleKey: tamagotchi.settings.devtools.title - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/screen-capture.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/screen-capture.vue deleted file mode 100644 index eda555174..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/screen-capture.vue +++ /dev/null @@ -1,383 +0,0 @@ - - - - - -meta: - layout: settings - title: Screen Capture - subtitleKey: tamagotchi.settings.devtools.title - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/updater.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/updater.vue deleted file mode 100644 index e082c73b1..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/updater.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - - - -meta: - layout: settings - title: Updater - subtitleKey: tamagotchi.settings.devtools.title - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/use-electron-all-displays.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/use-electron-all-displays.vue deleted file mode 100644 index 6dc1aa5bb..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/use-electron-all-displays.vue +++ /dev/null @@ -1,146 +0,0 @@ - - - - - -meta: - layout: settings - title: useElectronAllDisplays - subtitleKey: tamagotchi.settings.devtools.title - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/use-electron-relative-mouse.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/use-electron-relative-mouse.vue deleted file mode 100644 index 5c80f0770..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/use-electron-relative-mouse.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - - - -meta: - layout: settings - title: useElectronRelativeMouse - subtitleKey: tamagotchi.settings.devtools.title - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/use-magic-keys.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/use-magic-keys.vue deleted file mode 100644 index 54f318512..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/use-magic-keys.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - -meta: - layout: settings - title: useMagicKeys - subtitleKey: tamagotchi.settings.devtools.title - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/use-window-mouse.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/use-window-mouse.vue deleted file mode 100644 index d10b87a08..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/use-window-mouse.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - - - -meta: - layout: settings - title: useWindowMouse - subtitleKey: tamagotchi.settings.devtools.title - diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/vision.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/vision.vue deleted file mode 100644 index 149557038..000000000 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/vision.vue +++ /dev/null @@ -1,634 +0,0 @@ - - -