diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 79974183..5c6305b8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,7 +10,7 @@ body: id: version attributes: label: T4 Code version - placeholder: "0.1.18" + placeholder: "0.1.19" validations: required: true - type: dropdown diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 193a5a85..6f811354 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,11 @@ permissions: contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - verify: + core: runs-on: ubuntu-24.04 timeout-minutes: 25 steps: @@ -40,9 +40,6 @@ jobs: - name: Check source and types run: pnpm check - - name: Run tooling regression tests - run: pnpm test:tooling - - name: Run tests run: pnpm test @@ -54,3 +51,86 @@ jobs: - name: Check packaging contract run: pnpm test:packaging + + tooling: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Check out source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tooling regression tests + run: pnpm test:tooling + + android-debug: + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - name: Check out source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + cache: pnpm + + - name: Install Java + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Install Android SDK + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 + + - name: Install pinned Android platform and build tools + run: sdkmanager --install "platforms;android-36" "build-tools;36.0.0" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build web application + run: pnpm build:web + + - name: Verify unsigned Android debug application + run: pnpm --filter @t4-code/mobile check:android:debug + + verify: + name: verify + if: ${{ always() }} + needs: [core, tooling, android-debug] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Require every CI leg + shell: bash + env: + CORE_RESULT: ${{ needs.core.result }} + TOOLING_RESULT: ${{ needs.tooling.result }} + ANDROID_RESULT: ${{ needs.android-debug.result }} + run: | + set -euo pipefail + test "$CORE_RESULT" = success + test "$TOOLING_RESULT" = success + test "$ANDROID_RESULT" = success diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 2a7e5652..ae12e47b 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -1,11 +1,18 @@ name: Deploy project site +run-name: Deploy project site ${{ inputs.release_tag || github.ref_name }} ${{ inputs.dispatch_nonce || github.sha }} on: push: branches: [main, master] paths: - "apps/site/**" + - "scripts/check-release-publication.mjs" - "scripts/deploy-site.mjs" + - "scripts/dispatch-site-deployment.mjs" + - "scripts/generate-release-manifest.mjs" + - "scripts/inspect-linux-update.mjs" + - "scripts/read-bounded-response.mjs" + - "scripts/reconcile-release-assets.mjs" - "scripts/wait-for-release-assets.mjs" - "package.json" - "pnpm-lock.yaml" @@ -16,6 +23,10 @@ on: description: Published release tag whose immutable source must be deployed. required: true type: string + dispatch_nonce: + description: Unique release-workflow dispatch identity. + required: true + type: string permissions: contents: read @@ -27,7 +38,7 @@ concurrency: jobs: deploy: - if: ${{ github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' }} + if: ${{ github.event_name != 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-24.04 timeout-minutes: 50 environment: @@ -41,15 +52,15 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Read trusted main release version + - name: Read trusted workflow source version id: source shell: bash env: - MAIN_SHA: ${{ github.sha }} + TRUSTED_SHA: ${{ github.sha }} run: | set -euo pipefail version=$(node -p "require('./package.json').version") - printf 'version=%s\nmain_sha=%s\n' "$version" "$MAIN_SHA" >> "$GITHUB_OUTPUT" + printf 'version=%s\ntrusted_sha=%s\n' "$version" "$TRUSTED_SHA" >> "$GITHUB_OUTPUT" - name: Install pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -68,16 +79,23 @@ jobs: RELEASE_VERSION: ${{ steps.source.outputs.version }} run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 2400000 --interval-ms 15000 - - name: Check whether an ordinary main push references an existing release - id: existing_release + - name: Classify the stable release referenced by an ordinary main push + id: release_state if: ${{ github.event_name == 'push' }} - continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.source.outputs.version }} + run: node scripts/check-release-publication.mjs --version "$RELEASE_VERSION" --github-output "$GITHUB_OUTPUT" + + - name: Confirm assets for an existing stable release + id: existing_release + if: ${{ github.event_name == 'push' && steps.release_state.outputs.state == 'published' }} env: RELEASE_VERSION: ${{ steps.source.outputs.version }} run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 15000 --interval-ms 3000 - name: Defer a release-version site update until publication - if: ${{ github.event_name == 'push' && steps.existing_release.outcome == 'failure' }} + if: ${{ github.event_name == 'push' && steps.release_state.outputs.state == 'not-published' }} run: echo "The referenced release is not public yet; the release workflow will deploy this site after publication." - name: Resolve immutable deployment source @@ -87,7 +105,7 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} GH_TOKEN: ${{ github.token }} - MAIN_SHA: ${{ steps.source.outputs.main_sha }} + TRUSTED_SHA: ${{ steps.source.outputs.trusted_sha }} REQUESTED_RELEASE_TAG: ${{ inputs.release_tag }} TRUSTED_VERSION: ${{ steps.source.outputs.version }} run: | @@ -99,6 +117,10 @@ jobs: echo "release_tag must be the current release ${expected_tag}" >&2 exit 1 fi + if [[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]; then + echo "workflow_dispatch must run from the immutable release tag ${expected_tag}" >&2 + exit 1 + fi release_tag="$REQUESTED_RELEASE_TAG" fi release_flags=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${release_tag}" --jq '[.draft, .prerelease] | @tsv') @@ -110,11 +132,16 @@ jobs: source_sha=$(git rev-parse "${release_tag}^{commit}") tag_version=$(git show "${source_sha}:package.json" | jq -er '.version') if [[ "$tag_version" != "$TRUSTED_VERSION" ]]; then - echo "release tag package version ${tag_version} does not match trusted main ${TRUSTED_VERSION}" >&2 + echo "release tag package version ${tag_version} does not match trusted source ${TRUSTED_VERSION}" >&2 exit 1 fi - if ! git merge-base --is-ancestor "$source_sha" "$MAIN_SHA"; then - echo "release tag source is not reachable from trusted main" >&2 + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$source_sha" != "$TRUSTED_SHA" ]]; then + echo "release tag moved after this immutable deployment was dispatched" >&2 + exit 1 + fi + elif ! git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"; then + echo "release tag source is not reachable from the trusted workflow source" >&2 exit 1 fi printf 'release_tag=%s\nsource_sha=%s\n' "$release_tag" "$source_sha" >> "$GITHUB_OUTPUT" @@ -140,6 +167,7 @@ jobs: - name: Build and deploy static site if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} env: + GH_TOKEN: ${{ github.token }} T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }} T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }} run: pnpm deploy:site diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bebf7cc..b106d5cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: verify: if: ${{ github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' }} runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 10 outputs: source_sha: ${{ steps.source.outputs.source_sha }} version: ${{ steps.source.outputs.version }} @@ -42,31 +42,30 @@ jobs: shell: bash env: EVENT_NAME: ${{ github.event_name }} - MAIN_SHA: ${{ github.sha }} + TRUSTED_CONTROL_SHA: ${{ github.sha }} run: | set -euo pipefail - trusted_version=$(node -p "require('./package.json').version") - expected_tag="v${trusted_version}" - if [[ "$RELEASE_TAG" != "$expected_tag" ]]; then - echo "release tag must be the current package tag ${expected_tag}" >&2 - exit 1 - fi git fetch --force --no-tags origin "refs/heads/main:refs/remotes/origin/main" git fetch --force origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" source_sha=$(git rev-parse "${RELEASE_TAG}^{commit}") tag_version=$(git show "${source_sha}:package.json" | jq -er '.version') - if [[ "$tag_version" != "$trusted_version" ]]; then - echo "release tag package version ${tag_version} does not match trusted source ${trusted_version}" >&2 + expected_tag="v${tag_version}" + if [[ "$RELEASE_TAG" != "$expected_tag" ]]; then + echo "release tag ${RELEASE_TAG} does not match its package version ${expected_tag}" >&2 exit 1 fi if ! git merge-base --is-ancestor "$source_sha" refs/remotes/origin/main; then echo "release tag source is not reachable from main" >&2 exit 1 fi - if [[ "$EVENT_NAME" == "workflow_dispatch" && "$MAIN_SHA" != "$(git rev-parse HEAD)" ]]; then + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$TRUSTED_CONTROL_SHA" != "$(git rev-parse HEAD)" ]]; then echo "manual releases must run from the checked-out main commit" >&2 exit 1 fi + if [[ "$EVENT_NAME" == "push" && "$TRUSTED_CONTROL_SHA" != "$source_sha" ]]; then + echo "pushed release tag no longer resolves to the triggering commit" >&2 + exit 1 + fi printf 'source_sha=%s\nversion=%s\n' "$source_sha" "$tag_version" >> "$GITHUB_OUTPUT" - name: Check out immutable release source @@ -75,43 +74,44 @@ jobs: ref: ${{ steps.source.outputs.source_sha }} persist-credentials: false - - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - version: 11.10.0 - - name: Install Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24.13.1 - cache: pnpm - name: Verify tag, packages, clients, docs, and downloads agree run: node scripts/check-release-consistency.mjs --tag "$RELEASE_TAG" - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Install browser for end-to-end tests - run: pnpm exec playwright install --with-deps chromium - - - name: Check source and types - run: pnpm check - - - name: Run tooling regression tests - run: pnpm test:tooling - - - name: Run workspace tests - run: pnpm test - - - name: Run built-app end-to-end tests - run: pnpm test:e2e + ci-authority: + needs: verify + runs-on: ubuntu-24.04 + timeout-minutes: 50 + permissions: + actions: read + contents: read + steps: + - name: Check out trusted CI-authority source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + # Manual repair must use current trusted release-control code even + # when the immutable artifact source predates the CI waiter. + ref: ${{ github.sha }} + persist-credentials: false - - name: Build all workspaces - run: pnpm build + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 - - name: Check packaging contract - run: pnpm test:packaging + - name: Require successful exact-SHA main CI + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} + run: >- + node scripts/wait-for-exact-ci.mjs + --commit "$SOURCE_SHA" + --timeout-ms 2700000 + --interval-ms 5000 build-linux: needs: verify @@ -144,12 +144,23 @@ jobs: - name: Inspect Linux packages run: pnpm inspect:package -- release/*.deb release/*.AppImage + - name: Inspect Linux updater metadata + env: + VERSION: ${{ needs.verify.outputs.version }} + run: >- + node scripts/inspect-linux-update.mjs + --version "$VERSION" + --metadata release/latest-linux.yml + --artifact "release/T4-Code-${VERSION}-linux-amd64.deb" + --artifact "release/T4-Code-${VERSION}-linux-x86_64.AppImage" + - name: Stage Linux artifacts shell: bash run: | mkdir -p artifacts - cp release/T4-Code-*.deb release/T4-Code-*.AppImage artifacts/ + cp release/T4-Code-*.deb release/T4-Code-*.AppImage release/latest-linux.yml artifacts/ (cd artifacts && sha256sum T4-Code-* > SHA256SUMS-linux.txt) + (cd artifacts && sha256sum latest-linux.yml >> SHA256SUMS-linux.txt) - name: Upload Linux artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -197,6 +208,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build web application for Android + run: pnpm build:web + + - name: Verify unsigned Android application + run: pnpm --filter @t4-code/mobile check:android:debug + - name: Restore and validate Android release signing key shell: bash env: @@ -232,7 +249,6 @@ jobs: T4_ANDROID_KEY_ALIAS: ${{ secrets.T4_ANDROID_KEY_ALIAS }} T4_ANDROID_KEY_PASSWORD: ${{ secrets.T4_ANDROID_KEY_PASSWORD }} run: | - pnpm build:web pnpm --filter @t4-code/mobile build:android:release - name: Inspect signed Android APK @@ -316,7 +332,7 @@ jobs: retention-days: 7 publish: - needs: [verify, build-android, build-linux, build-macos] + needs: [verify, ci-authority, build-android, build-linux, build-macos] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -345,11 +361,43 @@ jobs: path: artifacts merge-multiple: true + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + - name: Assemble checksums shell: bash run: cat artifacts/SHA256SUMS-android.txt artifacts/SHA256SUMS-linux.txt artifacts/SHA256SUMS-macos.txt | sort -k2 > artifacts/SHA256SUMS.txt + - name: Verify exact public release bundle + shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + expected=$(printf '%s\n' \ + "SHA256SUMS.txt" \ + "T4-Code-${VERSION}-android.apk" \ + "T4-Code-${VERSION}-linux-amd64.deb" \ + "T4-Code-${VERSION}-linux-x86_64.AppImage" \ + "T4-Code-${VERSION}-mac-arm64.dmg" \ + "T4-Code-${VERSION}-mac-arm64.zip" \ + "latest-linux.yml" | sort) + actual=$(find artifacts -maxdepth 1 -type f ! -name 'SHA256SUMS-*.txt' -printf '%f\n' | sort) + [[ "$actual" == "$expected" ]] + [[ $(wc -l < artifacts/SHA256SUMS.txt) == 6 ]] + (cd artifacts && sha256sum --check SHA256SUMS.txt) + + - name: Preserve an exact release or prepare an incomplete release for repair + id: release-assets + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.verify.outputs.version }} + run: node scripts/reconcile-release-assets.mjs --mode prepare --version "$VERSION" + - name: Publish GitHub release + if: steps.release-assets.outputs.publish_required == 'true' uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ env.RELEASE_TAG }} @@ -362,22 +410,40 @@ jobs: artifacts/T4-Code-*.AppImage artifacts/T4-Code-*.dmg artifacts/T4-Code-*.zip + artifacts/latest-linux.yml artifacts/SHA256SUMS.txt + - name: Verify the exact remote release bundle + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.verify.outputs.version }} + run: node scripts/reconcile-release-assets.mjs --mode verify --version "$VERSION" + dispatch-site: - name: Dispatch site deployment after release publication - needs: publish + name: Deploy and verify the production site after release publication + needs: [verify, publish] runs-on: ubuntu-24.04 - timeout-minutes: 5 + timeout-minutes: 60 permissions: actions: write contents: read steps: - - name: Dispatch the production workflow from main with exact release source + - name: Check out verified release source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.verify.outputs.source_sha }} + persist-credentials: false + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + + - name: Dispatch and await the exact immutable production deployment env: GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} run: >- - gh workflow run deploy-site.yml - --ref main - -f release_tag="$RELEASE_TAG" + node scripts/dispatch-site-deployment.mjs + --tag "$RELEASE_TAG" + --commit "$SOURCE_SHA" diff --git a/README.md b/README.md index 59f6fe85..cb2e6980 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c ![T4 Code main window](docs/assets/t4-code-main.png) -[**Download v0.1.18**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.18) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) +[**Download v0.1.19**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.19) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) ## Requirements -T4 Code needs an OMP build with desktop appserver support. For v0.1.18, use the public integration build below. +T4 Code needs an OMP build with desktop appserver support. For v0.1.19, use the public integration build below. -T4 Code v0.1.18 was verified with OMP 17.0.0 built from [`6e2f2350`](https://github.com/lyc-aon/oh-my-pi/commit/6e2f2350cfe9e6f5db691c311333cae33cdb62ba), tagged [`t4code-17.0.0-appserver-1`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.0-appserver-1). That public integration is based on the official upstream [`v17.0.0`](https://github.com/can1357/oh-my-pi/tree/v17.0.0) tag at [`d5cd24f3`](https://github.com/can1357/oh-my-pi/commit/d5cd24f39a951bfbd50dc8f50bcf095d59694d6c). It carries T4's bounded transcript replay, structured tool-result details, child-agent transcript and image projection, session lifecycle controls, ordered outbound frames, and atomic maintenance drain onto the 17.0.0 codebase. Fork CI rechecks the exact upstream ancestry before publishing binaries. The official upstream v17.0.0 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source; T4 Code does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.5 from integration commit [`6a87fa64`](https://github.com/lyc-aon/oh-my-pi/commit/6a87fa6407ebff20417b4d52885a6bb3091003ea), source tree `a2495fe8781c979184fe7fb9a6d37d8f33bad30f`. +T4 Code v0.1.19 was verified with OMP 17.0.0 built from [`3cba4bda`](https://github.com/lyc-aon/oh-my-pi/commit/3cba4bda41d2b8e4d304c43471735657893d3b62), tagged [`t4code-17.0.0-appserver-2`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.0-appserver-2). That public integration is based on the official upstream [`v17.0.0`](https://github.com/can1357/oh-my-pi/tree/v17.0.0) tag at [`d5cd24f3`](https://github.com/can1357/oh-my-pi/commit/d5cd24f39a951bfbd50dc8f50bcf095d59694d6c). It adds accepted-prompt lifecycle replay, custom message metadata preservation, and normalization for xdev tool envelopes while retaining T4's bounded transcript, image, subagent, lifecycle, and maintenance integration. Fork CI rechecks the exact upstream ancestry and release gates before publishing binaries. The official upstream v17.0.0 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source; T4 Code does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.5 from integration commit [`6a87fa64`](https://github.com/lyc-aon/oh-my-pi/commit/6a87fa6407ebff20417b4d52885a6bb3091003ea), source tree `a2495fe8781c979184fe7fb9a6d37d8f33bad30f`. | Platform | Arch | Package | | -------- | --------------------- | ---------------------------------------- | @@ -18,20 +18,22 @@ T4 Code v0.1.18 was verified with OMP 17.0.0 built from [`6e2f2350`](https://git | Linux | x86_64 | `.deb`, AppImage | | macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**unsigned, see below**) | -No Windows build and no Intel Mac build in v0.1.18. The iOS TestFlight build is coming soon. +No Windows build and no Intel Mac build in v0.1.19. The iOS TestFlight build is coming soon. -## What changed in v0.1.18 +## What changed in v0.1.19 -- The verified runtime now uses the exact official OMP 17.0.0 base at `d5cd24f3`. -- The public appserver integration keeps bounded replay, image prompts and transcript images, two-client session control, reconnect-safe history, and atomic maintenance drain. -- The app-wire contract remains at 0.5.5, so this compatibility release does not introduce a client protocol migration. +- Accepted prompts appear in the transcript as soon as the host accepts them and survive reconnects, snapshots, and context compaction without duplicate sends. +- Session activity now follows authoritative host state. T4 shows context compaction explicitly, clears stale Working labels after a completed inventory, and avoids duplicate recovery banners. +- Tool calls, plans, todos, collaboration messages, and child-agent output use semantic transcript cards instead of raw JSON where the host identifies the event. +- Desktop and Android builds can check for, verify, and install a published T4 update from the app. Updates remain user-initiated. +- Transcript, tool, terminal, image, and child-agent retention stays bounded so long sessions cannot grow the client heap without limit. ## Install ### Android 1. On the Android phone, sign in to Tailscale with an account that can reach the T4 Code host. -2. Download [`T4-Code-0.1.18-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.18/T4-Code-0.1.18-android.apk). +2. Download [`T4-Code-0.1.19-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.19/T4-Code-0.1.19-android.apk). 3. If Android asks, allow your browser or file manager to install unknown apps, then install the APK. 4. Open T4 Code and enter the host's HTTPS Tailscale address, including its port. @@ -40,8 +42,8 @@ The APK does not contain an appserver or expose one to the public internet. It c ### Linux (Debian/Ubuntu) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.18/T4-Code-0.1.18-linux-amd64.deb -sudo apt install ./T4-Code-0.1.18-linux-amd64.deb +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.19/T4-Code-0.1.19-linux-amd64.deb +sudo apt install ./T4-Code-0.1.19-linux-amd64.deb ``` Use `apt install` rather than `dpkg -i` so system dependencies resolve automatically. @@ -49,17 +51,17 @@ Use `apt install` rather than `dpkg -i` so system dependencies resolve automatic ### Linux (AppImage) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.18/T4-Code-0.1.18-linux-x86_64.AppImage -chmod +x T4-Code-0.1.18-linux-x86_64.AppImage -./T4-Code-0.1.18-linux-x86_64.AppImage +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.19/T4-Code-0.1.19-linux-x86_64.AppImage +chmod +x T4-Code-0.1.19-linux-x86_64.AppImage +./T4-Code-0.1.19-linux-x86_64.AppImage ``` ### macOS (Apple Silicon) > [!WARNING] -> **The macOS v0.1.18 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead. +> **The macOS v0.1.19 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead. -1. Download [`T4-Code-0.1.18-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.18/T4-Code-0.1.18-mac-arm64.dmg) (or [`T4-Code-0.1.18-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.18/T4-Code-0.1.18-mac-arm64.zip)). +1. Download [`T4-Code-0.1.19-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.19/T4-Code-0.1.19-mac-arm64.dmg) (or [`T4-Code-0.1.19-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.19/T4-Code-0.1.19-mac-arm64.zip)). 2. Drag `T4 Code.app` into `/Applications`. 3. If Gatekeeper blocks the app and you choose to proceed, remove the quarantine attributes from the copied app bundle: diff --git a/SECURITY.md b/SECURITY.md index 0c9923ac..4bef5de6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,4 +26,4 @@ We read every report and will reply to tell you what happens next. This is a sma - T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at . - Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome. -- The macOS v0.1.18 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. +- The macOS v0.1.19 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cc3d33f1..8536c52f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/desktop", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/electron-update-controller.ts b/apps/desktop/src/electron-update-controller.ts new file mode 100644 index 00000000..ada56a3e --- /dev/null +++ b/apps/desktop/src/electron-update-controller.ts @@ -0,0 +1,31 @@ +import { app, net, shell } from "electron"; +import { autoUpdater } from "electron-updater"; +import { DesktopUpdateController, type NativeUpdaterPort } from "./update-controller.ts"; +import { detectNativeLinuxPackage } from "./linux-update-package.ts"; + +export function createElectronUpdateController(): DesktopUpdateController { + const nativeLinuxPackage = detectNativeLinuxPackage({ + platform: process.platform, + isPackaged: app.isPackaged, + ...(process.env.APPIMAGE === undefined ? {} : { appImagePath: process.env.APPIMAGE }), + resourcesPath: process.resourcesPath, + }); + return new DesktopUpdateController({ + currentVersion: app.getVersion(), + platform: process.platform === "darwin" ? "darwin" : "linux", + isPackaged: app.isPackaged, + ...(nativeLinuxPackage === undefined ? {} : { nativeLinuxPackage }), + nativeUpdater: autoUpdater as NativeUpdaterPort, + fetchManifest: async (url, options) => { + const response = await net.fetch(url, { signal: options.signal }); + const body = response.body; + return { + ok: response.ok, + status: response.status, + headers: response.headers, + body: body === null ? null : { getReader: () => body.getReader() }, + }; + }, + openExternal: (url) => shell.openExternal(url), + }); +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 5b883c88..69c5b76c 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -2,6 +2,8 @@ import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from "electron"; import { decodeDesktopEvent, decodeDesktopInvokeRequest, + decodeDesktopUpdateRendererReadyResult, + decodeDesktopUpdateState, type BootstrapResult, type CommandRequest, type CommandResult, @@ -12,6 +14,8 @@ import { type DesktopEventChannel, type DesktopInvokeChannel, type DesktopInvokeRequest, + type DesktopUpdateRendererReadyResult, + type DesktopUpdateState, type DisconnectResult, type PairLinkEvent, type PairRequest, @@ -46,6 +50,14 @@ export interface IpcRuntime { readonly acquireServiceManager?: () => Promise; readonly getServiceAvailabilityIssue?: () => ServiceAvailabilityIssue | undefined; readonly drainPairLinks?: () => readonly PairLinkEvent[]; + readonly drainPendingUpdateOpen?: () => boolean; + readonly updateController?: { + readonly getState: () => DesktopUpdateState; + readonly checkForUpdate: (interactive?: boolean) => Promise; + readonly downloadUpdate: () => Promise; + readonly restartToUpdate: () => DesktopUpdateState; + readonly subscribe: (listener: (state: DesktopUpdateState) => void) => () => void; + }; } export class RemotePairingUnavailableError extends Error { readonly code = "remote_pairing_unavailable" as const; @@ -77,6 +89,7 @@ export class DesktopIpcRegistry { private readonly runtime: IpcRuntime; private readonly serviceQueue = { tail: Promise.resolve() }; private serviceInspectionPromise: Promise | undefined; + private updateUnsubscribe: (() => void) | undefined; private readonly ipc: IpcMainLike; constructor(runtime: IpcRuntime, ipc: IpcMainLike = ipcMain) { this.runtime = runtime; @@ -171,14 +184,48 @@ export class DesktopIpcRegistry { return { completed: true }; }); } + this.ipc.handle("app:update:get-state", (event, payload: unknown): DesktopUpdateState => { + this.assertSender(event); + decodeRequest("app:update:get-state", payload); + return decodeDesktopUpdateState(this.updateController().getState()); + }); + this.ipc.handle("app:update:check", async (event, payload: unknown): Promise => { + this.assertSender(event); + decodeRequest("app:update:check", payload); + return decodeDesktopUpdateState(await this.updateController().checkForUpdate(true)); + }); + this.ipc.handle("app:update:download", async (event, payload: unknown): Promise => { + this.assertSender(event); + decodeRequest("app:update:download", payload); + return decodeDesktopUpdateState(await this.updateController().downloadUpdate()); + }); + this.ipc.handle("app:update:restart", (event, payload: unknown): DesktopUpdateState => { + this.assertSender(event); + decodeRequest("app:update:restart", payload); + return decodeDesktopUpdateState(this.updateController().restartToUpdate()); + }); + this.ipc.handle("app:update:renderer-ready", (event, payload: unknown): DesktopUpdateRendererReadyResult => { + this.assertSender(event); + decodeRequest("app:update:renderer-ready", payload); + return decodeDesktopUpdateRendererReadyResult({ + openSettings: this.runtime.drainPendingUpdateOpen?.() ?? false, + }); + }); + this.updateUnsubscribe = this.runtime.updateController?.subscribe((state) => { + this.emit("app:update:state", state); + }); } uninstall(): void { + this.updateUnsubscribe?.(); + this.updateUnsubscribe = undefined; for (const channel of [ "omp:bootstrap", "omp:connect", "omp:disconnect", "omp:command", "omp:confirm", "omp:terminal:input", "omp:terminal:resize", "omp:terminal:close", "omp:pair", "omp:pair-links:drain", "omp:targets:list", "omp:targets:add", "omp:targets:remove", "omp:service:inspect", "omp:service:install", "omp:service:start", "omp:service:stop", "omp:service:restart", "omp:service:uninstall", + "app:update:get-state", "app:update:check", "app:update:download", "app:update:restart", + "app:update:renderer-ready", ] as const) this.ipc.removeHandler(channel); this.installed = false; } @@ -194,6 +241,9 @@ export class DesktopIpcRegistry { emitPairLink(event: PairLinkEvent): void { this.emit("omp:pair-link", event); } + emitOpenUpdateSettings(): void { + this.emit("app:update:open", { source: "menu" }); + } private assertSender(event: IpcMainInvokeEvent): void { if (!validEvent(event, this.runtime)) throw new Error("untrusted desktop sender"); @@ -254,6 +304,12 @@ export class DesktopIpcRegistry { this.serviceQueue.tail = result.then(() => undefined, () => undefined); return result; } + private updateController(): NonNullable { + if (this.runtime.updateController === undefined) { + throw new Error("Desktop updates are unavailable in this runtime"); + } + return this.runtime.updateController; + } private emit(channel: DesktopEventChannel, payload: unknown): void { const decoded = decodeDesktopEvent({ channel, payload }); if (this.runtime.window.isDestroyed() || this.runtime.window.webContents.isDestroyed()) return; diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts index cc5ca058..7e99426c 100644 --- a/apps/desktop/src/lifecycle.ts +++ b/apps/desktop/src/lifecycle.ts @@ -12,6 +12,9 @@ import { VersionedRemoteTargetRegistry, DeviceCredentialStore } from "./remote-r import { LocalTargetManager, type TargetManagerOptions } from "./target-manager.ts"; import { parsePairDeepLink, PendingPairQueue, type PendingPair } from "./deep-link.ts"; import { createAppserverServiceManager, discoverOmpExecutable, OmpAppserverCompatibilityError, probeOmpAppserver, NodeServiceFileSystem } from "./service.ts"; +import { createElectronUpdateController } from "./electron-update-controller.ts"; +import { installApplicationMenu, type ApplicationMenuOptions } from "./menu.ts"; +import { DesktopUpdateController } from "./update-controller.ts"; export function appserverLogsDirectory( homeDirectory: string, @@ -39,6 +42,8 @@ export interface DesktopLifecycleOptions { readonly probeAppserver?: (executable: string) => Promise; readonly createServiceManager?: (options: Parameters[0]) => ServiceManager; readonly createTargetManager?: (options: TargetManagerOptions) => LocalTargetManager; + readonly createUpdateController?: () => DesktopUpdateController; + readonly installMenu?: (options: ApplicationMenuOptions) => void; } class ServiceRecoveryCancelledError extends Error { @@ -62,13 +67,18 @@ export class DesktopLifecycle { private readonly serviceFactory: (options: Parameters[0]) => ServiceManager; private readonly appserverProbe: (executable: string) => Promise; private readonly targetManagerFactory: (options: TargetManagerOptions) => LocalTargetManager; + private readonly updateControllerFactory: () => DesktopUpdateController; + private readonly menuInstaller: (options: ApplicationMenuOptions) => void; private mainWindow: BrowserWindow | undefined; private ipc: DesktopIpcRegistry | undefined; private manager: LocalTargetManager | undefined; private serviceManager: ServiceManager | undefined; private serviceAvailabilityIssue: ServiceAvailabilityIssue | undefined; private serviceRecoveryPromise: Promise | undefined; + private updateController: DesktopUpdateController | undefined; + private pendingUpdateOpen = false; private rendererLoaded = false; + private updateRendererReady = false; private startupPromise: Promise | undefined; private stopPromise: Promise | undefined; private startupServiceError: unknown; @@ -92,6 +102,8 @@ export class DesktopLifecycle { this.appserverProbe = options.probeAppserver ?? ((executable) => probeOmpAppserver(executable)); this.serviceFactory = options.createServiceManager ?? createAppserverServiceManager; this.targetManagerFactory = options.createTargetManager ?? ((managerOptions) => new LocalTargetManager(managerOptions)); + this.updateControllerFactory = options.createUpdateController ?? createElectronUpdateController; + this.menuInstaller = options.installMenu ?? installApplicationMenu; } async start(): Promise { if (this.startupPromise !== undefined) return this.startupPromise; @@ -130,6 +142,8 @@ export class DesktopLifecycle { }); await this.electronApp.whenReady(); if (process.platform === "darwin") this.electronApp.setAsDefaultProtocolClient("t4-code"); + this.updateController = this.updateControllerFactory(); + this.menuInstaller({ onOpenUpdates: () => this.openUpdatesFromMenu() }); const identity = this.identityFactory(); const remoteRegistry = this.remoteRegistryFactory(); const credentials = this.credentialsFactory(); @@ -170,6 +184,8 @@ export class DesktopLifecycle { this.ipc?.uninstall(); this.ipc = undefined; this.mainWindow = undefined; + this.updateController?.dispose(); + this.updateController = undefined; const manager = this.manager; this.manager = undefined; const recovery = this.serviceRecoveryPromise; @@ -298,6 +314,7 @@ export class DesktopLifecycle { private bindWindow(handle: DesktopWindowHandle): void { this.rendererLoaded = false; + this.updateRendererReady = false; const manager = this.manager; if (manager === undefined) return; this.mainWindow = handle.window; @@ -310,8 +327,13 @@ export class DesktopLifecycle { acquireServiceManager: () => this.acquireServiceManager(), getServiceAvailabilityIssue: () => this.serviceAvailabilityIssue, drainPairLinks: () => this.pendingPairs.drain(), + drainPendingUpdateOpen: () => this.markUpdateRendererReady(), + ...(this.updateController === undefined ? {} : { updateController: this.updateController }), }); this.ipc.install(); + handle.window.webContents.on("did-start-loading", () => { + this.updateRendererReady = false; + }); handle.window.webContents.once("did-finish-load", () => { this.rendererLoaded = true; if (this.startupServiceError !== undefined) { @@ -320,14 +342,34 @@ export class DesktopLifecycle { } const links = this.pendingPairs.drain(); for (const link of links) this.ipc?.emitPairLink(link); + this.updateController?.schedulePassiveCheck(); }); handle.window.on("closed", () => { if (this.mainWindow === handle.window) { this.mainWindow = undefined; this.rendererLoaded = false; + this.updateRendererReady = false; this.ipc?.uninstall(); this.ipc = undefined; } }); } + + private openUpdatesFromMenu(): void { + if (this.stopping) return; + if (this.mainWindow === undefined && this.manager !== undefined) { + this.bindWindow(this.windowFactory()); + } + this.mainWindow?.show(); + this.mainWindow?.focus(); + if (this.updateRendererReady) this.ipc?.emitOpenUpdateSettings(); + else this.pendingUpdateOpen = true; + } + + private markUpdateRendererReady(): boolean { + this.updateRendererReady = true; + const openSettings = this.pendingUpdateOpen; + this.pendingUpdateOpen = false; + return openSettings; + } } diff --git a/apps/desktop/src/linux-update-package.ts b/apps/desktop/src/linux-update-package.ts new file mode 100644 index 00000000..5914e018 --- /dev/null +++ b/apps/desktop/src/linux-update-package.ts @@ -0,0 +1,26 @@ +import { readFileSync, statSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; + +function isAppImageRuntime(value: string | undefined): boolean { + return value !== undefined && value.length > 0 && !value.includes("\0") && isAbsolute(value); +} + +/** Mirror electron-updater's package selection without enabling unsupported runtimes. */ +export function detectNativeLinuxPackage(options: { + readonly platform: NodeJS.Platform; + readonly isPackaged: boolean; + readonly appImagePath?: string; + readonly resourcesPath: string; +}): "appimage" | "deb" | undefined { + if (!options.isPackaged || options.platform !== "linux") return undefined; + try { + const packageTypePath = join(options.resourcesPath, "package-type"); + if (statSync(packageTypePath).size > 32) return undefined; + const packageType = readFileSync(packageTypePath, "utf8").trim(); + if (packageType === "deb") return "deb"; + if (["rpm", "pacman"].includes(packageType)) return undefined; + } catch { + // AppImage distributions do not require a package-type marker. + } + return isAppImageRuntime(options.appImagePath) ? "appimage" : undefined; +} diff --git a/apps/desktop/src/menu.ts b/apps/desktop/src/menu.ts new file mode 100644 index 00000000..e7d75765 --- /dev/null +++ b/apps/desktop/src/menu.ts @@ -0,0 +1,42 @@ +import { Menu, type MenuItemConstructorOptions } from "electron"; +import { strings } from "./strings.ts"; + +export interface ApplicationMenuOptions { + readonly platform?: NodeJS.Platform; + readonly onOpenUpdates: () => void; + readonly menu?: Pick; +} + +export function installApplicationMenu(options: ApplicationMenuOptions): void { + const platform = options.platform ?? process.platform; + const menu = options.menu ?? Menu; + const updateItem: MenuItemConstructorOptions = { + label: strings.menu.app.updates, + click: options.onOpenUpdates, + }; + const template: MenuItemConstructorOptions[] = + platform === "darwin" + ? [ + { + label: strings.menu.app.label, + submenu: [ + { role: "about", label: strings.menu.app.about }, + { type: "separator" }, + updateItem, + { type: "separator" }, + { role: "quit", label: strings.menu.app.quit }, + ], + }, + { role: "editMenu" }, + { role: "viewMenu" }, + { role: "windowMenu" }, + ] + : [ + { role: "fileMenu" }, + { role: "editMenu" }, + { role: "viewMenu" }, + { role: "windowMenu" }, + { label: strings.menu.help.label, submenu: [updateItem] }, + ]; + menu.setApplicationMenu(menu.buildFromTemplate(template)); +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 6d2288e0..e8cd6255 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,6 +1,8 @@ import { contextBridge, ipcRenderer } from "electron"; import { decodeDesktopEvent, + decodeDesktopUpdateRendererReadyResult, + decodeDesktopUpdateState, type BootstrapResult, type CommandRequest, type CommandResult, @@ -9,6 +11,9 @@ import { type ConnectionStateEvent, type ConnectResult, type DisconnectResult, + type DesktopUpdateOpenEvent, + type DesktopUpdateRendererReadyResult, + type DesktopUpdateState, type PairLinkEvent, type PairLinksDrainResult, type PairRequest, @@ -47,6 +52,11 @@ export interface OmpShellBridge { readonly serviceStop: () => Promise; readonly serviceRestart: () => Promise; readonly serviceUninstall: () => Promise; + readonly getUpdateState: () => Promise; + readonly checkForUpdate: () => Promise; + readonly downloadUpdate: () => Promise; + readonly restartToUpdate: () => Promise; + readonly updateRendererReady: () => Promise; readonly listTargets: () => Promise; readonly addTarget: (request: TargetAddRequest) => Promise; readonly removeTarget: (request: TargetRequest) => Promise; @@ -56,20 +66,34 @@ export interface OmpShellBridge { readonly onConnectionState: (listener: (event: ConnectionStateEvent) => void) => () => void; readonly onRuntimeError: (listener: (event: RuntimeErrorEvent) => void) => () => void; readonly onPairLink: (listener: (event: PairLinkEvent) => void) => () => void; + readonly onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + readonly onOpenUpdateSettings: (listener: (event: DesktopUpdateOpenEvent) => void) => () => void; } -function invoke(channel: C, payload: unknown): Promise { +function invoke(channel: C, payload: unknown): Promise { return ipcRenderer.invoke(channel, { channel, payload }) as Promise; } -function subscribe( +type SubscriptionPayload = C extends "omp:server-frame" + ? RendererServerFrameEvent + : C extends "omp:connection-state" + ? ConnectionStateEvent + : C extends "omp:runtime-error" + ? RuntimeErrorEvent + : C extends "omp:pair-link" + ? PairLinkEvent + : C extends "app:update:state" + ? DesktopUpdateState + : DesktopUpdateOpenEvent; + +function subscribe( channel: C, - listener: (payload: C extends "omp:server-frame" ? RendererServerFrameEvent : C extends "omp:connection-state" ? ConnectionStateEvent : C extends "omp:runtime-error" ? RuntimeErrorEvent : PairLinkEvent) => void, + listener: (payload: SubscriptionPayload) => void, ): () => void { const wrapped = (_event: Electron.IpcRendererEvent, value: unknown) => { try { const decoded = decodeDesktopEvent({ channel, payload: value }); - listener(decoded.payload as C extends "omp:server-frame" ? RendererServerFrameEvent : C extends "omp:connection-state" ? ConnectionStateEvent : C extends "omp:runtime-error" ? RuntimeErrorEvent : PairLinkEvent); + listener(decoded.payload as SubscriptionPayload); } catch { // Invalid renderer events are dropped at the preload boundary. } @@ -97,6 +121,11 @@ const bridge: OmpShellBridge = { serviceStop: () => invoke("omp:service:stop", {}), serviceRestart: () => invoke("omp:service:restart", {}), serviceUninstall: () => invoke("omp:service:uninstall", {}), + getUpdateState: () => invoke<"app:update:get-state", unknown>("app:update:get-state", {}).then(decodeDesktopUpdateState), + checkForUpdate: () => invoke<"app:update:check", unknown>("app:update:check", {}).then(decodeDesktopUpdateState), + downloadUpdate: () => invoke<"app:update:download", unknown>("app:update:download", {}).then(decodeDesktopUpdateState), + restartToUpdate: () => invoke<"app:update:restart", unknown>("app:update:restart", {}).then(decodeDesktopUpdateState), + updateRendererReady: () => invoke<"app:update:renderer-ready", unknown>("app:update:renderer-ready", {}).then(decodeDesktopUpdateRendererReadyResult), listTargets: () => invoke("omp:targets:list", {}), addTarget: (request) => invoke("omp:targets:add", request), removeTarget: (request) => invoke("omp:targets:remove", request), @@ -106,6 +135,8 @@ const bridge: OmpShellBridge = { onConnectionState: (listener) => subscribe("omp:connection-state", listener), onRuntimeError: (listener) => subscribe("omp:runtime-error", listener), onPairLink: (listener) => subscribe("omp:pair-link", listener), + onUpdateState: (listener) => subscribe("app:update:state", listener), + onOpenUpdateSettings: (listener) => subscribe("app:update:open", listener), }; contextBridge.exposeInMainWorld("ompShell", bridge); diff --git a/apps/desktop/src/strings.ts b/apps/desktop/src/strings.ts index 437c95b8..892811b8 100644 --- a/apps/desktop/src/strings.ts +++ b/apps/desktop/src/strings.ts @@ -24,7 +24,7 @@ export const strings = { label: APP_NAME, about: `About ${APP_NAME}`, settings: 'Settings\u2026', - checkForUpdates: 'Check for Updates\u2026', + updates: 'Updates\u2026', quit: `Quit ${APP_NAME}`, }, file: { diff --git a/apps/desktop/src/target-manager.ts b/apps/desktop/src/target-manager.ts index d5dc6d3d..161e309d 100644 --- a/apps/desktop/src/target-manager.ts +++ b/apps/desktop/src/target-manager.ts @@ -339,7 +339,7 @@ export class DesktopTargetManager { capabilities: requestedCapabilities, requestedFeatures: REQUESTED_FEATURES, compatibilityRequestedFeatures: COMPATIBILITY_FEATURES, - client: { name: "T4 Code", version: "0.1.18", build: "desktop", platform: process.platform }, + client: { name: "T4 Code", version: "0.1.19", build: "desktop", platform: process.platform }, reconnect: { attemptCap: 12, baseMs: 250, maxMs: 10_000 }, }; const client = createOmpClient(clientOptions); diff --git a/apps/desktop/src/update-controller.ts b/apps/desktop/src/update-controller.ts new file mode 100644 index 00000000..754b88b1 --- /dev/null +++ b/apps/desktop/src/update-controller.ts @@ -0,0 +1,683 @@ +import { redactedMessage } from "@t4-code/client"; +import { decodeDesktopUpdateState, type DesktopUpdateState } from "@t4-code/protocol/desktop-ipc"; + +export const UPDATE_MANIFEST_URL = "https://t4code.net/releases/latest.json"; +const UPDATE_MANIFEST_MAX_BYTES = 256 * 1024; +const UPDATE_CHECK_TIMEOUT_MS = 10_000; +const PASSIVE_UPDATE_DELAY_MS = 15_000; +const RELEASE_DOWNLOAD_ROOT = "https://github.com/LycaonLLC/t4-code/releases/download"; + +interface NativeUpdateInfo { + readonly version: string; +} + +interface NativeUpdateCheckResult { + readonly isUpdateAvailable: boolean; + readonly updateInfo: NativeUpdateInfo; +} + +interface NativeProgressInfo { + readonly percent: number; +} + +type NativeUpdateEvent = "error" | "download-progress" | "update-downloaded"; + +export interface NativeUpdaterPort { + autoDownload: boolean; + autoInstallOnAppQuit: boolean; + allowPrerelease: boolean; + allowDowngrade: boolean; + checkForUpdates(): Promise; + downloadUpdate(): Promise; + quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void; + on(event: "error", listener: (error: Error) => void): unknown; + on(event: "download-progress", listener: (progress: NativeProgressInfo) => void): unknown; + on(event: "update-downloaded", listener: (info: NativeUpdateInfo) => void): unknown; + removeListener(event: NativeUpdateEvent, listener: (...args: unknown[]) => void): unknown; +} + +export interface UpdateFetchResponse { + readonly ok: boolean; + readonly status: number; + readonly headers?: { readonly get: (name: string) => string | null }; + readonly body: { + getReader(): { + read(): Promise< + | { readonly done: false; readonly value: Uint8Array } + | { readonly done: true; readonly value?: Uint8Array | undefined } + >; + cancel(reason?: unknown): Promise; + releaseLock?(): void; + }; + } | null; +} + +export interface DesktopUpdateControllerOptions { + readonly currentVersion: string; + readonly platform: "linux" | "darwin"; + readonly isPackaged: boolean; + readonly nativeLinuxPackage?: "appimage" | "deb"; + readonly nativeUpdater: NativeUpdaterPort; + readonly fetchManifest: ( + url: string, + options: { readonly signal: AbortSignal }, + ) => Promise; + readonly openExternal: (url: string) => Promise; + readonly now?: () => number; + readonly setTimer?: (callback: () => void, delayMs: number) => unknown; + readonly clearTimer?: (handle: unknown) => void; +} + +interface ReleaseAsset { + readonly platform: "android" | "linux" | "mac"; + readonly kind: "apk" | "deb" | "appimage" | "dmg" | "zip"; + readonly arch: "universal" | "x86_64" | "arm64"; + readonly name: string; + readonly url: string; + readonly size: number; + readonly sha256: string; +} + +interface ReleaseManifest { + readonly version: string; + readonly assets: readonly ReleaseAsset[]; +} + +type StateListener = (state: DesktopUpdateState) => void; + +function record(value: unknown, name: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`invalid ${name}`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) throw new Error(`invalid ${name}`); + return value as Record; +} + +function exact(value: Record, keys: readonly string[]): void { + const allowed = new Set(keys); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`unknown ${key}`); + } +} + +function version(value: unknown, name = "version"): string { + try { + return decodeDesktopUpdateState({ + version: 1, + currentVersion: value, + phase: "idle", + }).currentVersion; + } catch { + throw new Error(`invalid ${name}`); + } +} + +function requiredString(value: unknown, name: string, maxLength: number): string { + if (typeof value !== "string" || value.length === 0 || value.length > maxLength) { + throw new Error(`invalid ${name}`); + } + return value; +} + +function releaseAsset(value: unknown, releaseVersion: string): ReleaseAsset { + const item = record(value, "release asset"); + exact(item, ["platform", "kind", "arch", "name", "url", "size", "sha256"]); + if (!["android", "linux", "mac"].includes(item.platform as string)) { + throw new Error("invalid release asset platform"); + } + if (!["apk", "deb", "appimage", "dmg", "zip"].includes(item.kind as string)) { + throw new Error("invalid release asset kind"); + } + if (!["universal", "x86_64", "arm64"].includes(item.arch as string)) { + throw new Error("invalid release asset architecture"); + } + const name = requiredString(item.name, "release asset name", 160); + const expectedUrl = `${RELEASE_DOWNLOAD_ROOT}/v${releaseVersion}/${name}`; + if (item.url !== expectedUrl) throw new Error("invalid release asset URL"); + if ( + typeof item.size !== "number" || + !Number.isSafeInteger(item.size) || + item.size <= 0 || + item.size > 2 * 1024 * 1024 * 1024 + ) { + throw new Error("invalid release asset size"); + } + if (typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(item.sha256)) { + throw new Error("invalid release asset digest"); + } + return Object.freeze({ + platform: item.platform as ReleaseAsset["platform"], + kind: item.kind as ReleaseAsset["kind"], + arch: item.arch as ReleaseAsset["arch"], + name, + url: expectedUrl, + size: item.size, + sha256: item.sha256, + }); +} + +export function decodeReleaseManifest(value: unknown): ReleaseManifest { + const root = record(value, "release manifest"); + exact(root, [ + "schemaVersion", + "channel", + "version", + "tag", + "publishedAt", + "releaseUrl", + "assets", + ]); + if (root.schemaVersion !== 1 || root.channel !== "stable") { + throw new Error("unsupported release manifest"); + } + const releaseVersion = version(root.version, "release version"); + if (root.tag !== `v${releaseVersion}`) throw new Error("invalid release tag"); + if (root.releaseUrl !== `https://github.com/LycaonLLC/t4-code/releases/tag/v${releaseVersion}`) { + throw new Error("invalid release URL"); + } + const publishedAt = requiredString(root.publishedAt, "publishedAt", 64); + if (!Number.isFinite(Date.parse(publishedAt))) throw new Error("invalid publishedAt"); + if (!Array.isArray(root.assets) || root.assets.length !== 5) { + throw new Error("invalid release assets"); + } + const assets = Object.freeze(root.assets.map((asset) => releaseAsset(asset, releaseVersion))); + const names = new Set(assets.map((asset) => asset.name)); + if (names.size !== assets.length) throw new Error("duplicate release asset"); + const canonical = [ + ["android", "apk", "universal", `T4-Code-${releaseVersion}-android.apk`], + ["linux", "deb", "x86_64", `T4-Code-${releaseVersion}-linux-amd64.deb`], + ["linux", "appimage", "x86_64", `T4-Code-${releaseVersion}-linux-x86_64.AppImage`], + ["mac", "dmg", "arm64", `T4-Code-${releaseVersion}-mac-arm64.dmg`], + ["mac", "zip", "arm64", `T4-Code-${releaseVersion}-mac-arm64.zip`], + ] as const; + for (const [platform, kind, arch, name] of canonical) { + if ( + assets.filter( + (asset) => + asset.platform === platform && + asset.kind === kind && + asset.arch === arch && + asset.name === name, + ).length !== 1 + ) { + throw new Error("invalid canonical release assets"); + } + } + return Object.freeze({ version: releaseVersion, assets }); +} + +interface ParsedVersion { + readonly core: readonly number[]; + readonly prerelease: readonly string[]; +} + +function parsedVersion(value: string): ParsedVersion { + const [base = "", suffix] = value.split("-", 2); + return { + core: base.split(".").map((part) => Number.parseInt(part, 10)), + prerelease: suffix === undefined ? [] : suffix.split("."), + }; +} + +export function compareVersions(left: string, right: string): number { + const a = parsedVersion(version(left)); + const b = parsedVersion(version(right)); + for (let index = 0; index < 3; index += 1) { + const difference = (a.core[index] ?? 0) - (b.core[index] ?? 0); + if (difference !== 0) return Math.sign(difference); + } + if (a.prerelease.length === 0 || b.prerelease.length === 0) { + return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1; + } + const length = Math.max(a.prerelease.length, b.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftPart = a.prerelease[index]; + const rightPart = b.prerelease[index]; + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + if (leftPart === rightPart) continue; + const leftNumeric = /^\d+$/u.test(leftPart); + const rightNumeric = /^\d+$/u.test(rightPart); + if (leftNumeric && rightNumeric) { + return Math.sign(Number.parseInt(leftPart, 10) - Number.parseInt(rightPart, 10)); + } + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1; + return leftPart < rightPart ? -1 : 1; + } + return 0; +} + +function selectedManualAsset( + manifest: ReleaseManifest, + platform: DesktopUpdateControllerOptions["platform"], +): ReleaseAsset { + const expected = + platform === "darwin" + ? { + platform: "mac", + kind: "dmg", + arch: "arm64", + name: `T4-Code-${manifest.version}-mac-arm64.dmg`, + } + : { + platform: "linux", + kind: "deb", + arch: "x86_64", + name: `T4-Code-${manifest.version}-linux-amd64.deb`, + }; + const matches = manifest.assets.filter( + (asset) => + asset.platform === expected.platform && + asset.kind === expected.kind && + asset.arch === expected.arch && + asset.name === expected.name, + ); + if (matches.length !== 1 || matches[0] === undefined) { + throw new Error("required release asset is missing"); + } + return matches[0]; +} + +function safeError(error: unknown): string { + const message = error instanceof Error ? error.message : "Update check failed"; + return redactedMessage(message, 512) || "Update check failed"; +} + +export class DesktopUpdateController { + private readonly options: DesktopUpdateControllerOptions; + private readonly listeners = new Set(); + private readonly now: () => number; + private readonly setTimer: (callback: () => void, delayMs: number) => unknown; + private readonly clearTimer: (handle: unknown) => void; + private readonly nativeEligible: boolean; + private state: DesktopUpdateState; + private manualUrl: string | undefined; + private checkPromise: Promise | undefined; + private downloadPromise: Promise | undefined; + private surfaceCheckErrors = false; + private passiveTimer: unknown; + private passiveAttempted = false; + private disposed = false; + + private readonly onNativeError = (error: Error): void => { + if ( + this.disposed || + (this.state.phase !== "downloading" && this.state.phase !== "ready") + ) { + return; + } + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "error", + ...(this.state.checkedAt === undefined ? {} : { checkedAt: this.state.checkedAt }), + ...(this.state.availableVersion === undefined + ? {} + : { availableVersion: this.state.availableVersion }), + message: safeError(error), + }); + }; + + private readonly onNativeProgress = (progress: NativeProgressInfo): void => { + if (this.disposed || this.state.phase !== "downloading") return; + const percent = Number.isFinite(progress.percent) + ? Math.max(0, Math.min(100, progress.percent)) + : 0; + this.setState({ ...this.state, progressPercent: percent }); + }; + + private readonly onNativeDownloaded = (info: NativeUpdateInfo): void => { + if (this.disposed || this.state.phase !== "downloading") return; + let availableVersion: string; + try { + availableVersion = version(info.version, "downloaded version"); + } catch (error) { + this.onNativeError(error instanceof Error ? error : new Error("invalid downloaded version")); + return; + } + if ( + this.state.availableVersion === undefined || + availableVersion !== this.state.availableVersion + ) { + this.onNativeError(new Error("Downloaded update version did not match the selected release")); + return; + } + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "ready", + ...(this.state.checkedAt === undefined ? {} : { checkedAt: this.state.checkedAt }), + availableVersion, + progressPercent: 100, + message: "Restart T4 Code to finish updating.", + }); + }; + + constructor(options: DesktopUpdateControllerOptions) { + this.options = options; + this.now = options.now ?? Date.now; + this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); + this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as NodeJS.Timeout)); + this.nativeEligible = + options.isPackaged && + options.platform === "linux" && + options.nativeLinuxPackage !== undefined; + this.state = decodeDesktopUpdateState({ + version: 1, + currentVersion: options.currentVersion, + phase: "idle", + }); + + options.nativeUpdater.autoDownload = false; + options.nativeUpdater.autoInstallOnAppQuit = false; + options.nativeUpdater.allowPrerelease = false; + options.nativeUpdater.allowDowngrade = false; + options.nativeUpdater.on("error", this.onNativeError); + options.nativeUpdater.on("download-progress", this.onNativeProgress); + options.nativeUpdater.on("update-downloaded", this.onNativeDownloaded); + } + + getState(): DesktopUpdateState { + return this.state; + } + + subscribe(listener: StateListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + schedulePassiveCheck(delayMs = PASSIVE_UPDATE_DELAY_MS): void { + if (!this.options.isPackaged || this.disposed || this.passiveAttempted) return; + this.passiveAttempted = true; + this.passiveTimer = this.setTimer(() => { + this.passiveTimer = undefined; + void this.checkForUpdate(false); + }, delayMs); + } + + checkForUpdate(interactive = true): Promise { + if (this.disposed) return Promise.resolve(this.state); + if (interactive && this.passiveTimer !== undefined) { + this.clearTimer(this.passiveTimer); + this.passiveTimer = undefined; + } + if (this.state.phase === "downloading" || this.state.phase === "ready") { + return Promise.resolve(this.state); + } + if (interactive) this.surfaceCheckErrors = true; + if (this.checkPromise !== undefined) return this.checkPromise; + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "checking", + ...(this.state.availableVersion === undefined + ? {} + : { availableVersion: this.state.availableVersion }), + }); + const operation = this.nativeEligible ? this.checkNative() : this.checkManual(); + this.checkPromise = operation.catch((error: unknown) => { + if (this.surfaceCheckErrors) { + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "error", + checkedAt: this.now(), + message: safeError(error), + }); + } else { + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "idle", + }); + } + return this.state; + }); + const clear = (): void => { + this.checkPromise = undefined; + this.surfaceCheckErrors = false; + }; + void this.checkPromise.then(clear, clear); + return this.checkPromise; + } + + downloadUpdate(): Promise { + if (this.disposed) return Promise.resolve(this.state); + if (this.downloadPromise !== undefined) return this.downloadPromise; + if (this.state.phase === "manual" && this.manualUrl !== undefined) { + const operation = this.options.openExternal(this.manualUrl).then(() => { + this.setState({ + ...this.state, + message: "The official release download opened in your browser.", + }); + return this.state; + }); + this.downloadPromise = this.withActionError(operation); + } else if (this.state.phase === "available" && this.nativeEligible) { + this.setState({ + ...this.state, + phase: "downloading", + progressPercent: 0, + message: "Downloading update…", + }); + const operation = this.options.nativeUpdater.downloadUpdate().then(() => { + if (this.state.phase === "downloading") { + this.setState({ + ...this.state, + phase: "ready", + progressPercent: 100, + message: "Restart T4 Code to finish updating.", + }); + } + return this.state; + }); + this.downloadPromise = this.withActionError(operation); + } else { + return Promise.resolve(this.state); + } + const clear = (): void => { + this.downloadPromise = undefined; + }; + void this.downloadPromise.then(clear, clear); + return this.downloadPromise; + } + + restartToUpdate(): DesktopUpdateState { + if (!this.disposed && this.nativeEligible && this.state.phase === "ready") { + try { + this.options.nativeUpdater.quitAndInstall(false, true); + } catch (error) { + this.onNativeError( + error instanceof Error ? error : new Error("The update installer could not start"), + ); + } + } + return this.state; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.passiveTimer !== undefined) this.clearTimer(this.passiveTimer); + this.passiveTimer = undefined; + this.listeners.clear(); + this.options.nativeUpdater.removeListener( + "error", + this.onNativeError as (...args: unknown[]) => void, + ); + this.options.nativeUpdater.removeListener( + "download-progress", + this.onNativeProgress as (...args: unknown[]) => void, + ); + this.options.nativeUpdater.removeListener( + "update-downloaded", + this.onNativeDownloaded as (...args: unknown[]) => void, + ); + } + + private async checkNative(): Promise { + this.manualUrl = undefined; + const result = await this.options.nativeUpdater.checkForUpdates(); + const checkedAt = this.now(); + if (result === null || !result.isUpdateAvailable) { + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "current", + checkedAt, + message: "T4 Code is up to date.", + }); + return this.state; + } + const availableVersion = version(result.updateInfo.version, "available version"); + if (compareVersions(availableVersion, this.options.currentVersion) <= 0) { + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "current", + checkedAt, + message: "T4 Code is up to date.", + }); + return this.state; + } + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "available", + checkedAt, + availableVersion, + message: `T4 Code ${availableVersion} is ready to download.`, + }); + return this.state; + } + + private async checkManual(): Promise { + const manifest = await this.fetchReleaseManifest(); + const checkedAt = this.now(); + if (compareVersions(manifest.version, this.options.currentVersion) <= 0) { + this.manualUrl = undefined; + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "current", + checkedAt, + message: "T4 Code is up to date.", + }); + return this.state; + } + const asset = selectedManualAsset(manifest, this.options.platform); + this.manualUrl = asset.url; + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "manual", + checkedAt, + availableVersion: manifest.version, + message: `T4 Code ${manifest.version} is available from the official release.`, + }); + return this.state; + } + + private async fetchReleaseManifest(): Promise { + const abort = new AbortController(); + const timeout = this.setTimer(() => abort.abort(), UPDATE_CHECK_TIMEOUT_MS); + try { + const response = await this.options.fetchManifest(UPDATE_MANIFEST_URL, { + signal: abort.signal, + }); + if (!response.ok) throw new Error(`Update service returned ${response.status}`); + const declaredLength = response.headers?.get("content-length"); + if (declaredLength !== null && declaredLength !== undefined) { + if (!/^(?:0|[1-9]\d*)$/u.test(declaredLength)) { + abort.abort(); + throw new Error("Update manifest Content-Length is invalid"); + } + const parsedLength = Number(declaredLength); + if (!Number.isSafeInteger(parsedLength)) { + abort.abort(); + throw new Error("Update manifest Content-Length is invalid"); + } + if (parsedLength > UPDATE_MANIFEST_MAX_BYTES) { + abort.abort(); + throw new Error("Update manifest is too large"); + } + } + if (response.body === null) throw new Error("Update manifest body is missing"); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let byteLength = 0; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + if (!(result.value instanceof Uint8Array)) { + throw new Error("Update manifest body is invalid"); + } + if (result.value.byteLength > UPDATE_MANIFEST_MAX_BYTES - byteLength) { + abort.abort(); + try { + await reader.cancel("Update manifest is too large"); + } catch { + // The aborted Electron response may already have cancelled its reader. + } + throw new Error("Update manifest is too large"); + } + byteLength += result.value.byteLength; + chunks.push(result.value); + } + } finally { + reader.releaseLock?.(); + } + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("Update manifest is not valid UTF-8"); + } + let decoded: unknown; + try { + decoded = JSON.parse(text); + } catch { + throw new Error("Update manifest is invalid"); + } + return decodeReleaseManifest(decoded); + } finally { + this.clearTimer(timeout); + } + } + + private withActionError(operation: Promise): Promise { + return operation.catch((error: unknown) => { + this.setState({ + version: 1, + currentVersion: this.options.currentVersion, + phase: "error", + ...(this.state.checkedAt === undefined ? {} : { checkedAt: this.state.checkedAt }), + ...(this.state.availableVersion === undefined + ? {} + : { availableVersion: this.state.availableVersion }), + message: safeError(error), + }); + return this.state; + }); + } + + private setState(value: DesktopUpdateState): void { + this.state = decodeDesktopUpdateState(value); + // eslint-disable-next-line unicorn/no-useless-spread -- preserve listener snapshot during dispatch. + for (const listener of [...this.listeners]) { + try { + listener(this.state); + } catch { + // A renderer/window listener must not interrupt update state transitions. + } + } + } +} diff --git a/apps/desktop/test/ipc-lifecycle.test.ts b/apps/desktop/test/ipc-lifecycle.test.ts index a01a0408..ffd0cf3c 100644 --- a/apps/desktop/test/ipc-lifecycle.test.ts +++ b/apps/desktop/test/ipc-lifecycle.test.ts @@ -183,4 +183,55 @@ describe("desktop IPC lifecycle proof", () => { registry.emitPairLink({ hostHint: "host", code: "123456", issuedAt: 1 }); expect(view.sent).toEqual([]); }); + it("keeps update actions behind exact trusted IPC and emits each state once", async () => { + const ipc = new FakeIpc(); + const { runtime: baseRuntime, view } = makeRuntime(); + const idle = Object.freeze({ version: 1 as const, currentVersion: "0.1.17", phase: "idle" as const }); + const available = Object.freeze({ version: 1 as const, currentVersion: "0.1.17", phase: "available" as const, availableVersion: "0.1.18", checkedAt: 123 }); + let checks = 0; + let downloads = 0; + let restarts = 0; + let pendingUpdateOpen = true; + let stateListener: ((state: typeof available) => void) | undefined; + const runtime: IpcRuntime = { + ...baseRuntime, + drainPendingUpdateOpen: () => { + const pending = pendingUpdateOpen; + pendingUpdateOpen = false; + return pending; + }, + updateController: { + getState: () => idle, + checkForUpdate: async () => { checks += 1; return available; }, + downloadUpdate: async () => { downloads += 1; return available; }, + restartToUpdate: () => { restarts += 1; return available; }, + subscribe: (listener) => { + stateListener = listener as (state: typeof available) => void; + return () => { stateListener = undefined; }; + }, + }, + }; + const registry = new DesktopIpcRegistry(runtime, ipc); + registry.install(); + const event = { sender: runtime.window.webContents, senderFrame: runtime.window.webContents.mainFrame }; + + expect(await ipc.handlers.get("app:update:get-state")!(event, request("app:update:get-state"))).toEqual(idle); + expect(await ipc.handlers.get("app:update:check")!(event, request("app:update:check"))).toEqual(available); + expect(await ipc.handlers.get("app:update:download")!(event, request("app:update:download"))).toEqual(available); + expect(await ipc.handlers.get("app:update:restart")!(event, request("app:update:restart"))).toEqual(available); + expect(await ipc.handlers.get("app:update:renderer-ready")!(event, request("app:update:renderer-ready"))).toEqual({ openSettings: true }); + expect(await ipc.handlers.get("app:update:renderer-ready")!(event, request("app:update:renderer-ready"))).toEqual({ openSettings: false }); + expect({ checks, downloads, restarts }).toEqual({ checks: 1, downloads: 1, restarts: 1 }); + await expect(ipc.handlers.get("app:update:check")!(event, request("app:update:check", { url: "https://attacker.invalid" }))).rejects.toThrow("unknown key"); + expect(() => ipc.handlers.get("app:update:renderer-ready")!(event, request("app:update:renderer-ready", { openSettings: true }))).toThrow("unknown key"); + + stateListener?.(available); + registry.emitOpenUpdateSettings(); + expect(view.sent).toEqual([ + ["app:update:state", available], + ["app:update:open", { source: "menu" }], + ]); + registry.uninstall(); + expect(stateListener).toBeUndefined(); + }); }); diff --git a/apps/desktop/test/lifecycle-runtime.test.ts b/apps/desktop/test/lifecycle-runtime.test.ts index 197738b1..65c625f6 100644 --- a/apps/desktop/test/lifecycle-runtime.test.ts +++ b/apps/desktop/test/lifecycle-runtime.test.ts @@ -8,6 +8,7 @@ import { discoverOmpExecutable } from "../src/service.ts"; import type { TargetManagerOptions } from "../src/target-manager.ts"; import type { ServiceManager } from "@t4-code/service-manager"; import type { ProcessRunner } from "@t4-code/remote"; +import type { ApplicationMenuOptions } from "../src/menu.ts"; describe("appserver log authority", () => { it("stays independent from Electron user-data overrides", () => { @@ -43,6 +44,7 @@ class FakeWindow { mainFrame: this.frame, isDestroyed: () => this.destroyed, send: (...args: unknown[]) => this.sent.push(args), + on: (event: string, listener: (...args: never[]) => void) => { this.listeners.set(event, listener); }, once: (event: string, listener: (...args: never[]) => void) => { this.onceListeners.set(event, listener); }, }; showCount = 0; @@ -79,6 +81,19 @@ function setup( const runtimes: unknown[] = []; let managerOptions: TargetManagerOptions | undefined; let closeCount = 0; + let updateScheduleCount = 0; + let updateDisposeCount = 0; + let menuOptions: ApplicationMenuOptions | undefined; + const updateState = { version: 1 as const, currentVersion: "0.1.17", phase: "idle" as const }; + const updateController = { + getState: () => updateState, + checkForUpdate: async () => updateState, + downloadUpdate: async () => updateState, + restartToUpdate: () => updateState, + subscribe: () => () => {}, + schedulePassiveCheck: () => { updateScheduleCount += 1; }, + dispose: () => { updateDisposeCount += 1; }, + }; const manager = { isConnected: () => false, close: async () => { closeCount += 1; }, connect: async () => "connecting", disconnect: async () => {}, command: async () => ({ targetId: "local", requestId: "1", commandId: "1", accepted: true }), pairStart: async () => ({ targetId: "remote", paired: false }), listTargets: async () => [], addRemoteTarget: async (target: never) => target, removeTarget: async () => {} }; const lifecycle = new DesktopLifecycle({ app: app as never, @@ -99,8 +114,10 @@ function setup( : { createServiceManager: overrides.createServiceManager ?? (() => serviceManager!), probeAppserver } ), createTargetManager: (options) => { managerOptions = options; return manager as never; }, + createUpdateController: () => updateController as never, + installMenu: (options) => { menuOptions = options; }, }); - return { app, windows, ipc, registries, runtimes, lifecycle, manager, get managerOptions() { return managerOptions; }, get closeCount() { return closeCount; } }; + return { app, windows, ipc, registries, runtimes, lifecycle, manager, get managerOptions() { return managerOptions; }, get closeCount() { return closeCount; }, get updateScheduleCount() { return updateScheduleCount; }, get updateDisposeCount() { return updateDisposeCount; }, get menuOptions() { return menuOptions; } }; } describe("desktop Electron lifecycle", () => { @@ -139,6 +156,40 @@ describe("desktop Electron lifecycle", () => { expect(fixture.managerOptions).toBeDefined(); await fixture.lifecycle.stop(); }); + it("routes the native update menu to the trusted renderer and schedules one passive check", async () => { + const fixture = setup(); + await fixture.lifecycle.start(); + fixture.menuOptions?.onOpenUpdates(); + const window = fixture.windows[0]!; + expect(window.sent).toEqual([]); + window.finishLoad(); + expect(window.sent).toEqual([]); + const rendererReady = fixture.ipc.handlers.get("app:update:renderer-ready") as ( + event: unknown, + payload: unknown, + ) => unknown; + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }; + expect(rendererReady(event, { channel: "app:update:renderer-ready", payload: {} })).toEqual({ + openSettings: true, + }); + expect(rendererReady(event, { channel: "app:update:renderer-ready", payload: {} })).toEqual({ + openSettings: false, + }); + fixture.menuOptions?.onOpenUpdates(); + expect(window.sent).toEqual([["app:update:open", { source: "menu" }]]); + + window.emit("did-start-loading"); + fixture.menuOptions?.onOpenUpdates(); + expect(window.sent).toHaveLength(1); + expect(rendererReady(event, { channel: "app:update:renderer-ready", payload: {} })).toEqual({ + openSettings: true, + }); + expect(window.showCount).toBe(3); + expect(window.focusCount).toBe(3); + expect(fixture.updateScheduleCount).toBe(1); + await fixture.lifecycle.stop(); + expect(fixture.updateDisposeCount).toBe(1); + }); it("closes the target manager exactly once across before-quit and stop", async () => { const fixture = setup(); await fixture.lifecycle.start(); diff --git a/apps/desktop/test/menu.test.ts b/apps/desktop/test/menu.test.ts new file mode 100644 index 00000000..79fac38b --- /dev/null +++ b/apps/desktop/test/menu.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { MenuItemConstructorOptions } from "electron"; +import { installApplicationMenu } from "../src/menu.ts"; +import { strings } from "../src/strings.ts"; + +function capture(platform: NodeJS.Platform) { + let template: readonly MenuItemConstructorOptions[] = []; + let installed: unknown; + const marker = { menu: true }; + let opened = 0; + installApplicationMenu({ + platform, + onOpenUpdates: () => { + opened += 1; + }, + menu: { + buildFromTemplate: (value) => { + template = value as readonly MenuItemConstructorOptions[]; + return marker as never; + }, + setApplicationMenu: (value) => { + installed = value; + }, + }, + }); + return { + template, + installed, + marker, + get opened() { + return opened; + }, + }; +} + +function updateItem(template: readonly MenuItemConstructorOptions[]): MenuItemConstructorOptions { + for (const top of template) { + if (!Array.isArray(top.submenu)) continue; + const item = top.submenu.find( + (candidate) => candidate.label === strings.menu.app.updates, + ); + if (item !== undefined) return item; + } + throw new Error("update menu item missing"); +} + +describe("desktop update menu", () => { + it("installs a macOS app-menu update entry that only requests the update surface", () => { + const result = capture("darwin"); + expect(result.installed).toBe(result.marker); + expect(result.template[0]?.label).toBe(strings.menu.app.label); + const item = updateItem(result.template); + expect(item.label).toBe("Updates\u2026"); + item.click?.(undefined as never, undefined as never, undefined as never); + expect(result.opened).toBe(1); + }); + + it("installs the same explicit action under Help on Linux", () => { + const result = capture("linux"); + const help = result.template.find((item) => item.label === strings.menu.help.label); + expect(help).toBeDefined(); + const item = updateItem(result.template); + expect(item.label).toBe("Updates\u2026"); + item.click?.(undefined as never, undefined as never, undefined as never); + expect(result.opened).toBe(1); + }); +}); diff --git a/apps/desktop/test/update-controller.test.ts b/apps/desktop/test/update-controller.test.ts new file mode 100644 index 00000000..e8a7f399 --- /dev/null +++ b/apps/desktop/test/update-controller.test.ts @@ -0,0 +1,496 @@ +import { EventEmitter } from "node:events"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { + compareVersions, + decodeReleaseManifest, + DesktopUpdateController, + UPDATE_MANIFEST_URL, + type NativeUpdaterPort, + type UpdateFetchResponse, +} from "../src/update-controller.ts"; +import { detectNativeLinuxPackage } from "../src/linux-update-package.ts"; + +const digest = "a".repeat(64); + +function asset( + version: string, + platform: "android" | "linux" | "mac", + kind: "apk" | "deb" | "appimage" | "dmg" | "zip", + arch: "universal" | "x86_64" | "arm64", + name: string, +) { + return { + platform, + kind, + arch, + name, + url: `https://github.com/LycaonLLC/t4-code/releases/download/v${version}/${name}`, + size: 1024, + sha256: digest, + }; +} + +function manifest(version: string) { + return { + schemaVersion: 1, + channel: "stable", + version, + tag: `v${version}`, + publishedAt: "2026-07-15T20:00:00.000Z", + releaseUrl: `https://github.com/LycaonLLC/t4-code/releases/tag/v${version}`, + assets: [ + asset(version, "android", "apk", "universal", `T4-Code-${version}-android.apk`), + asset(version, "linux", "deb", "x86_64", `T4-Code-${version}-linux-amd64.deb`), + asset(version, "linux", "appimage", "x86_64", `T4-Code-${version}-linux-x86_64.AppImage`), + asset(version, "mac", "dmg", "arm64", `T4-Code-${version}-mac-arm64.dmg`), + asset(version, "mac", "zip", "arm64", `T4-Code-${version}-mac-arm64.zip`), + ], + }; +} + +class FakeUpdater extends EventEmitter implements NativeUpdaterPort { + autoDownload = true; + autoInstallOnAppQuit = true; + allowPrerelease = true; + allowDowngrade = true; + checkCalls = 0; + downloadCalls = 0; + restartCalls = 0; + checkResult: { + readonly isUpdateAvailable: boolean; + readonly updateInfo: { readonly version: string }; + } | null = null; + checkGate: + | Promise<{ + readonly isUpdateAvailable: boolean; + readonly updateInfo: { readonly version: string }; + } | null> + | undefined; + + async checkForUpdates() { + this.checkCalls += 1; + return this.checkGate === undefined ? this.checkResult : this.checkGate; + } + + async downloadUpdate(): Promise { + this.downloadCalls += 1; + return ["/private/update-path-must-not-cross-ipc"]; + } + + quitAndInstall(): void { + this.restartCalls += 1; + } +} + +function response( + value: unknown, + options: { + readonly contentLength?: string | null; + readonly chunkBytes?: number; + readonly onCancel?: () => void; + readonly onRead?: () => void; + } = {}, +): UpdateFetchResponse { + const text = JSON.stringify(value); + const bytes = new TextEncoder().encode(text); + let offset = 0; + let cancelled = false; + return { + ok: true, + status: 200, + headers: { + get: (name) => + name.toLowerCase() === "content-length" + ? options.contentLength === undefined + ? String(bytes.byteLength) + : options.contentLength + : null, + }, + body: { + getReader: () => ({ + read: async () => { + options.onRead?.(); + if (cancelled || offset >= bytes.byteLength) return { done: true }; + const chunkSize = options.chunkBytes ?? bytes.byteLength; + const value = bytes.subarray(offset, offset + chunkSize); + offset += value.byteLength; + return { done: false, value }; + }, + cancel: async () => { + cancelled = true; + options.onCancel?.(); + }, + }), + }, + }; +} + +function controller( + options: { + readonly updater?: FakeUpdater; + readonly platform?: "linux" | "darwin"; + readonly isPackaged?: boolean; + readonly nativeLinuxPackage?: "appimage" | "deb"; + readonly fetchManifest?: () => Promise; + readonly opened?: string[]; + readonly timers?: Array<() => void>; + } = {}, +) { + const updater = options.updater ?? new FakeUpdater(); + const opened = options.opened ?? []; + const timers = options.timers ?? []; + const instance = new DesktopUpdateController({ + currentVersion: "0.1.17", + platform: options.platform ?? "linux", + isPackaged: options.isPackaged ?? true, + ...(options.nativeLinuxPackage === undefined + ? {} + : { nativeLinuxPackage: options.nativeLinuxPackage }), + nativeUpdater: updater, + fetchManifest: async (url) => { + expect(url).toBe(UPDATE_MANIFEST_URL); + return options.fetchManifest?.() ?? response(manifest("0.1.18")); + }, + openExternal: async (url) => { + opened.push(url); + }, + now: () => 1234, + setTimer: (callback) => { + timers.push(callback); + return callback; + }, + clearTimer: () => {}, + }); + return { instance, updater, opened, timers }; +} + +describe("desktop update controller", () => { + it("orders stable and prerelease versions", () => { + expect(compareVersions("1.2.3", "1.2.2")).toBe(1); + expect(compareVersions("1.2.3", "1.2.3")).toBe(0); + expect(compareVersions("1.2.3-beta.2", "1.2.3-beta.10")).toBe(-1); + expect(compareVersions("1.2.3", "1.2.3-rc.1")).toBe(1); + }); + + it("strictly validates immutable release assets and rejects injected URLs", () => { + const decoded = decodeReleaseManifest(manifest("0.1.18")); + expect(decoded.version).toBe("0.1.18"); + expect(Object.isFrozen(decoded)).toBe(true); + expect(Object.isFrozen(decoded.assets)).toBe(true); + + const hostile = manifest("0.1.18"); + hostile.assets[1] = { + ...hostile.assets[1]!, + url: "https://attacker.invalid/T4-Code-0.1.18-linux-amd64.deb", + }; + expect(() => decodeReleaseManifest(hostile)).toThrow("invalid release asset URL"); + const substituted = manifest("0.1.18"); + substituted.assets[1] = { + ...substituted.assets[1]!, + platform: "linux", + kind: "appimage", + arch: "x86_64", + name: "T4-Code-0.1.18-linux-amd64.deb", + }; + expect(() => decodeReleaseManifest(substituted)).toThrow("invalid canonical release assets"); + expect(() => decodeReleaseManifest({ ...manifest("0.1.18"), extra: true })).toThrow( + "unknown extra", + ); + }); + + it("keeps unpackaged Linux builds manual and opens only the exact verified deb asset", async () => { + const { instance, updater, opened } = controller({ isPackaged: false }); + const state = await instance.checkForUpdate(); + expect(state).toMatchObject({ + phase: "manual", + currentVersion: "0.1.17", + availableVersion: "0.1.18", + checkedAt: 1234, + }); + expect(Object.isFrozen(state)).toBe(true); + expect(updater.checkCalls).toBe(0); + + const openedState = await instance.downloadUpdate(); + expect(opened).toEqual([ + "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.18/T4-Code-0.1.18-linux-amd64.deb", + ]); + expect(openedState.phase).toBe("manual"); + expect(updater.downloadCalls).toBe(0); + instance.dispose(); + }); + + it("keeps unsigned mac builds manual without consulting native mac metadata", async () => { + const { instance, updater, opened } = controller({ platform: "darwin" }); + expect((await instance.checkForUpdate()).phase).toBe("manual"); + await instance.downloadUpdate(); + expect(opened).toEqual([ + "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.18/T4-Code-0.1.18-mac-arm64.dmg", + ]); + expect(updater.checkCalls).toBe(0); + instance.dispose(); + }); + + it("uses electron-updater only for packaged AppImage and never auto-downloads or auto-installs", async () => { + const updater = new FakeUpdater(); + updater.checkResult = { isUpdateAvailable: true, updateInfo: { version: "0.1.18" } }; + const { instance } = controller({ updater, nativeLinuxPackage: "appimage" }); + expect(updater.autoDownload).toBe(false); + expect(updater.autoInstallOnAppQuit).toBe(false); + expect(updater.allowPrerelease).toBe(false); + expect(updater.allowDowngrade).toBe(false); + + expect(await instance.checkForUpdate()).toMatchObject({ + phase: "available", + availableVersion: "0.1.18", + }); + expect(updater.checkCalls).toBe(1); + expect(updater.downloadCalls).toBe(0); + expect(updater.restartCalls).toBe(0); + + expect((await instance.downloadUpdate()).phase).toBe("ready"); + expect(updater.downloadCalls).toBe(1); + expect(updater.restartCalls).toBe(0); + instance.restartToUpdate(); + expect(updater.restartCalls).toBe(1); + instance.dispose(); + }); + + it("uses electron-updater for packaged deb while keeping every action user-driven", async () => { + const updater = new FakeUpdater(); + updater.checkResult = { isUpdateAvailable: true, updateInfo: { version: "0.1.18" } }; + const { instance, opened } = controller({ updater, nativeLinuxPackage: "deb" }); + expect((await instance.checkForUpdate()).phase).toBe("available"); + expect(updater.checkCalls).toBe(1); + expect(updater.downloadCalls).toBe(0); + expect(opened).toEqual([]); + await instance.downloadUpdate(); + expect(updater.downloadCalls).toBe(1); + expect(updater.restartCalls).toBe(0); + instance.restartToUpdate(); + expect(updater.restartCalls).toBe(1); + instance.dispose(); + }); + + it("surfaces a native installer launch failure instead of remaining ready", async () => { + const updater = new FakeUpdater(); + updater.checkResult = { isUpdateAvailable: true, updateInfo: { version: "0.1.18" } }; + updater.quitAndInstall = () => { + updater.restartCalls += 1; + updater.emit("error", new Error("native installer could not start")); + }; + const { instance } = controller({ updater, nativeLinuxPackage: "deb" }); + + await instance.checkForUpdate(); + await instance.downloadUpdate(); + expect(instance.restartToUpdate()).toMatchObject({ + phase: "error", + availableVersion: "0.1.18", + message: "native installer could not start", + }); + expect(updater.restartCalls).toBe(1); + instance.dispose(); + }); + + it("detects only packaged AppImage or exact deb package identities", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-update-package-")); + await mkdir(join(root, "deb")); + await writeFile(join(root, "deb", "package-type"), "deb\n"); + await mkdir(join(root, "rpm")); + await writeFile(join(root, "rpm", "package-type"), "rpm\n"); + expect( + detectNativeLinuxPackage({ + platform: "linux", + isPackaged: true, + appImagePath: "/opt/T4-Code.AppImage", + resourcesPath: join(root, "appimage"), + }), + ).toBe("appimage"); + expect( + detectNativeLinuxPackage({ + platform: "linux", + isPackaged: true, + appImagePath: "/opt/T4-Code.AppImage", + resourcesPath: join(root, "deb"), + }), + ).toBe("deb"); + expect( + detectNativeLinuxPackage({ + platform: "linux", + isPackaged: true, + resourcesPath: join(root, "rpm"), + }), + ).toBeUndefined(); + expect( + detectNativeLinuxPackage({ + platform: "darwin", + isPackaged: true, + resourcesPath: join(root, "deb"), + }), + ).toBeUndefined(); + expect( + detectNativeLinuxPackage({ + platform: "linux", + isPackaged: false, + resourcesPath: join(root, "deb"), + }), + ).toBeUndefined(); + }); + + it("coalesces concurrent checks and downloads while forwarding bounded progress", async () => { + const updater = new FakeUpdater(); + let resolveCheck!: (value: { + readonly isUpdateAvailable: boolean; + readonly updateInfo: { readonly version: string }; + }) => void; + updater.checkGate = new Promise((resolve) => { + resolveCheck = resolve; + }); + const { instance } = controller({ updater, nativeLinuxPackage: "appimage" }); + const states: string[] = []; + instance.subscribe((state) => states.push(`${state.phase}:${state.progressPercent ?? ""}`)); + const first = instance.checkForUpdate(false); + const second = instance.checkForUpdate(true); + expect(first).toBe(second); + expect(updater.checkCalls).toBe(1); + resolveCheck({ isUpdateAvailable: true, updateInfo: { version: "0.1.18" } }); + await first; + + updater.downloadUpdate = async () => { + updater.downloadCalls += 1; + updater.emit("download-progress", { percent: 37.5 }); + updater.emit("update-downloaded", { version: "0.1.18" }); + return []; + }; + const download = instance.downloadUpdate(); + const sameDownload = instance.downloadUpdate(); + expect(download).toBe(sameDownload); + expect((await download).phase).toBe("ready"); + expect(updater.downloadCalls).toBe(1); + expect(states).toContain("downloading:37.5"); + expect(states).toContain("ready:100"); + instance.dispose(); + }); + + it("does one quiet packaged passive check and never schedules one in dev", async () => { + const packagedTimers: Array<() => void> = []; + const { instance: packaged } = controller({ + timers: packagedTimers, + fetchManifest: async () => { + throw new Error("offline https://private.invalid/token=secret"); + }, + }); + packaged.schedulePassiveCheck(); + packaged.schedulePassiveCheck(); + expect(packagedTimers).toHaveLength(1); + packagedTimers[0]!(); + await new Promise((resolve) => setImmediate(resolve)); + expect(packaged.getState()).toEqual({ + version: 1, + currentVersion: "0.1.17", + phase: "idle", + }); + const timerCountAfterCheck = packagedTimers.length; + packaged.schedulePassiveCheck(); + expect(packagedTimers).toHaveLength(timerCountAfterCheck); + packaged.dispose(); + + const devTimers: Array<() => void> = []; + const { instance: dev } = controller({ isPackaged: false, timers: devTimers }); + dev.schedulePassiveCheck(); + expect(devTimers).toHaveLength(0); + dev.dispose(); + }); + + it("never lets a stale passive check interrupt a native download or ready update", async () => { + const updater = new FakeUpdater(); + updater.checkResult = { isUpdateAvailable: true, updateInfo: { version: "0.1.18" } }; + let resolveDownload!: (paths: readonly string[]) => void; + updater.downloadUpdate = () => { + updater.downloadCalls += 1; + return new Promise((resolve) => { + resolveDownload = resolve; + }); + }; + const timers: Array<() => void> = []; + const { instance } = controller({ updater, nativeLinuxPackage: "appimage", timers }); + + instance.schedulePassiveCheck(); + expect(timers).toHaveLength(1); + expect((await instance.checkForUpdate(true)).phase).toBe("available"); + const download = instance.downloadUpdate(); + expect(instance.getState().phase).toBe("downloading"); + + // Model a timer callback that was already queued when the interactive check cancelled it. + timers[0]!(); + await Promise.resolve(); + expect(updater.checkCalls).toBe(1); + expect(instance.getState().phase).toBe("downloading"); + + resolveDownload([]); + expect((await download).phase).toBe("ready"); + expect((await instance.checkForUpdate(false)).phase).toBe("ready"); + expect(updater.checkCalls).toBe(1); + instance.dispose(); + }); + + it("surfaces interactive failures without leaking URLs, paths, or secrets", async () => { + const { instance } = controller({ + fetchManifest: async () => { + throw new Error( + "Bearer LIVE_TOKEN failed at /home/alice/update.json https://private.invalid/token=SECRET", + ); + }, + }); + const state = await instance.checkForUpdate(); + expect(state.phase).toBe("error"); + expect(state.message).not.toContain("LIVE_TOKEN"); + expect(state.message).not.toContain("alice"); + expect(state.message).not.toContain("private.invalid"); + expect(state.message).not.toContain("SECRET"); + instance.dispose(); + }); + + it("stops oversized manifest streams with missing or underreported Content-Length", async () => { + for (const contentLength of [null, "1"] as const) { + let cancelled = 0; + const oversized = { payload: "x".repeat(256 * 1024) }; + const { instance } = controller({ + fetchManifest: async () => + response(oversized, { + contentLength, + chunkBytes: 64 * 1024, + onCancel: () => { + cancelled += 1; + }, + }), + }); + const state = await instance.checkForUpdate(); + expect(state).toMatchObject({ phase: "error", message: "Update manifest is too large" }); + expect(cancelled).toBe(1); + instance.dispose(); + } + }); + + it("rejects malformed Content-Length before reading an update body", async () => { + let reads = 0; + const { instance } = controller({ + fetchManifest: async () => + response(manifest("0.1.18"), { + contentLength: "12junk", + onRead: () => { + reads += 1; + }, + }), + }); + const state = await instance.checkForUpdate(); + expect(state).toMatchObject({ + phase: "error", + message: "Update manifest Content-Length is invalid", + }); + expect(reads).toBe(0); + instance.dispose(); + }); +}); diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 62f1c0f9..9e683c68 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -26,6 +26,9 @@ def hasReleaseSigning = [ android { namespace = "com.lycaonsolutions.t4code" compileSdk = rootProject.ext.compileSdkVersion + buildFeatures { + buildConfig = true + } defaultConfig { applicationId "com.lycaonsolutions.t4code" minSdkVersion rootProject.ext.minSdkVersion diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index c96bbd17..e7136e88 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + + android:resource="@xml/file_paths" /> - - - - diff --git a/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java index d89da696..ef69592e 100644 --- a/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java +++ b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java @@ -5,9 +5,20 @@ import com.getcapacitor.BridgeActivity; public class MainActivity extends BridgeActivity { + private static final String APP_RESUME_EVENT = "t4:native-resume"; + @Override public void onCreate(Bundle savedInstanceState) { registerPlugin(T4SecureStoragePlugin.class); + registerPlugin(T4UpdatePlugin.class); super.onCreate(savedInstanceState); } + + @Override + public void onResume() { + super.onResume(); + if (getBridge() != null) { + getBridge().triggerWindowJSEvent(APP_RESUME_EVENT); + } + } } diff --git a/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4FileProvider.java b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4FileProvider.java new file mode 100644 index 00000000..612c1081 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4FileProvider.java @@ -0,0 +1,6 @@ +package com.lycaonsolutions.t4code; + +import androidx.core.content.FileProvider; + +/** App-scoped provider for handing one verified update to Android's installer. */ +public final class T4FileProvider extends FileProvider {} diff --git a/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateFileStore.java b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateFileStore.java new file mode 100644 index 00000000..2c720a98 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateFileStore.java @@ -0,0 +1,228 @@ +package com.lycaonsolutions.t4code; + +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Owns the app-private APK cache used by the updater. + * + * A normal verified APK is temporary. An {@code -installer.apk} file is the + * one exception: it is retained while Android's package installer owns a + * read-only content URI, then removed when T4 returns to the foreground. + */ +final class T4UpdateFileStore { + private static final String PARTIAL_SUFFIX = ".apk.partial"; + private static final String APK_SUFFIX = ".apk"; + private static final String HANDOFF_SUFFIX = "-installer.apk"; + private static final Object OWNERSHIP_LOCK = new Object(); + private static final Map ACTIVE_OWNERS = new HashMap<>(); + + private final File directory; + private final String ownershipKey; + private final Object ownerToken = new Object(); + private File activeHandoff; + + T4UpdateFileStore(File directory) { + this.directory = directory; + ownershipKey = normalizedPath(directory); + synchronized (OWNERSHIP_LOCK) { + ACTIVE_OWNERS.put(ownershipKey, ownerToken); + } + } + + /** + * Removes interrupted downloads and unhanded verified packages. At most + * one prior installer handoff survives until the activity is foregrounded. + */ + synchronized File prepareOnStartup() throws IOException { + synchronized (OWNERSHIP_LOCK) { + requireOwnership(); + ensureDirectory(); + File keep = newestHandoff(); + cleanExcept(keep); + activeHandoff = keep != null && keep.isFile() ? keep : null; + return activeHandoff; + } + } + + /** The user is foregrounded and starting a new download, so no old handoff is live. */ + synchronized void prepareForDownload() throws IOException { + synchronized (OWNERSHIP_LOCK) { + requireOwnership(); + ensureDirectory(); + cleanExcept(null); + activeHandoff = null; + } + } + + synchronized File createPartial(String version) throws IOException { + synchronized (OWNERSHIP_LOCK) { + requireOwnership(); + ensureDirectory(); + return File.createTempFile("T4-Code-" + version + "-", PARTIAL_SUFFIX, directory); + } + } + + synchronized File finalizeVerified(File partial) throws IOException { + synchronized (OWNERSHIP_LOCK) { + requireOwnership(); + requireManagedFile(partial, PARTIAL_SUFFIX); + String partialName = partial.getName(); + File verified = new File(directory, partialName.substring(0, partialName.length() - ".partial".length())); + deleteIfPresent(verified); + if (!partial.renameTo(verified)) { + throw new IOException("could not finalize verified update"); + } + if (!verified.setReadOnly()) { + deleteIfPresent(verified); + throw new IOException("could not protect verified update"); + } + return verified; + } + } + + /** + * Renames the verified file before its URI is granted. The distinctive + * suffix is the process-death marker that lets a new plugin instance keep + * exactly this one file until T4 is foregrounded again. + */ + synchronized File beginInstallerHandoff(File verified) throws IOException { + synchronized (OWNERSHIP_LOCK) { + requireOwnership(); + requireManagedFile(verified, APK_SUFFIX); + if (isHandoff(verified)) throw new IOException("update is already handed to the installer"); + String name = verified.getName(); + File handoff = new File(directory, name.substring(0, name.length() - APK_SUFFIX.length()) + HANDOFF_SUFFIX); + cleanHandoffsExcept(null); + deleteIfPresent(handoff); + if (!verified.renameTo(handoff)) { + throw new IOException("could not prepare update for the installer"); + } + activeHandoff = handoff; + return handoff; + } + } + + synchronized void finishInstallerHandoff(File handoff) throws IOException { + synchronized (OWNERSHIP_LOCK) { + requireOwnership(); + requireManagedFileOrMissing(handoff, HANDOFF_SUFFIX); + deleteIfPresent(handoff); + if (sameFile(activeHandoff, handoff)) activeHandoff = null; + } + } + + synchronized void discard(File file) { + synchronized (OWNERSHIP_LOCK) { + if (!ownsDirectory() || file == null || !isDirectChild(file)) return; + file.delete(); + if (sameFile(activeHandoff, file)) activeHandoff = null; + } + } + + /** Destroy may interrupt a worker, but must not revoke a URI already owned by the installer. */ + synchronized void cleanupForDestroy() { + synchronized (OWNERSHIP_LOCK) { + if (!ownsDirectory()) return; + try { + ensureDirectory(); + cleanExcept(activeHandoff); + } catch (IOException ignored) { + // Startup and pre-download sweeps retry cleanup on the next plugin instance. + } + } + } + + synchronized File activeHandoff() { + synchronized (OWNERSHIP_LOCK) { + return ownsDirectory() ? activeHandoff : null; + } + } + + private void requireOwnership() throws IOException { + if (!ownsDirectory()) throw new IOException("update storage belongs to a newer Android activity"); + } + + private boolean ownsDirectory() { + return ACTIVE_OWNERS.get(ownershipKey) == ownerToken; + } + + private static String normalizedPath(File directory) { + try { + return directory.getCanonicalPath(); + } catch (IOException ignored) { + return directory.getAbsoluteFile().toURI().normalize().getPath(); + } + } + + private void ensureDirectory() throws IOException { + if ((!directory.isDirectory() && !directory.mkdirs()) || !directory.isDirectory()) { + throw new IOException("could not create private update directory"); + } + } + + private File newestHandoff() throws IOException { + File newest = null; + for (File entry : entries()) { + if (!entry.isFile() || !isHandoff(entry)) continue; + if ( + newest == null || + entry.lastModified() > newest.lastModified() || + (entry.lastModified() == newest.lastModified() && entry.getName().compareTo(newest.getName()) > 0) + ) { + newest = entry; + } + } + return newest; + } + + private void cleanExcept(File keep) throws IOException { + for (File entry : entries()) { + if (sameFile(entry, keep)) continue; + deleteIfPresent(entry); + } + } + + private void cleanHandoffsExcept(File keep) throws IOException { + for (File entry : entries()) { + if (!isHandoff(entry) || sameFile(entry, keep)) continue; + deleteIfPresent(entry); + } + } + + private File[] entries() throws IOException { + File[] entries = directory.listFiles(); + if (entries == null) throw new IOException("could not inspect private update directory"); + return entries; + } + + private void requireManagedFile(File file, String suffix) throws IOException { + requireManagedFileOrMissing(file, suffix); + if (!file.isFile()) throw new IOException("update file is missing"); + } + + private void requireManagedFileOrMissing(File file, String suffix) throws IOException { + if (file == null || !isDirectChild(file) || !file.getName().endsWith(suffix)) { + throw new IOException("update file is outside the private update directory"); + } + } + + private boolean isDirectChild(File file) { + File parent = file.getAbsoluteFile().getParentFile(); + return parent != null && parent.equals(directory.getAbsoluteFile()); + } + + private boolean isHandoff(File file) { + return file.getName().endsWith(HANDOFF_SUFFIX); + } + + private boolean sameFile(File first, File second) { + return first != null && second != null && first.getAbsoluteFile().equals(second.getAbsoluteFile()); + } + + private void deleteIfPresent(File file) throws IOException { + if (file.exists() && !file.delete()) throw new IOException("could not remove stale update file"); + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdatePlugin.java b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdatePlugin.java new file mode 100644 index 00000000..4563f4ea --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdatePlugin.java @@ -0,0 +1,696 @@ +package com.lycaonsolutions.t4code; + +import android.content.ClipData; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.Signature; +import android.content.pm.SigningInfo; +import android.net.Uri; +import android.os.Build; + +import androidx.core.content.FileProvider; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import javax.net.ssl.HttpsURLConnection; + +/** + * User-driven Android release checks. The WebView never supplies a URL: this + * plugin validates T4's first-party manifest, downloads only its exact APK, + * verifies the bytes and Android package identity, and then asks the system + * package installer to prompt the user. + */ +@CapacitorPlugin(name = "T4Update") +public final class T4UpdatePlugin extends Plugin { + private static final String MANIFEST_URL = "https://t4code.net/releases/latest.json"; + private static final String EXPECTED_PACKAGE_ID = "com.lycaonsolutions.t4code"; + private static final String UPDATE_CACHE_DIRECTORY = "t4-updates"; + private static final String APK_MIME_TYPE = "application/vnd.android.package-archive"; + private static final String STATE_CHANGED_EVENT = "stateChanged"; + private static final int NETWORK_TIMEOUT_MS = 8_000; + private static final int MAX_MANIFEST_BYTES = 64 * 1024; + private static final long MAX_ASSET_BYTES = 1024L * 1024L * 1024L; + private static final int MAX_ASSET_REDIRECTS = 4; + private static final int MAX_ERROR_LENGTH = 240; + private final T4UpdateStateMachine updateState = new T4UpdateStateMachine(); + private String latestVersion; + private Long checkedAt; + private String errorMessage; + private String statusMessage; + private ManifestRelease validatedRelease; + private T4UpdateFileStore updateFiles; + private File installerHandoff; + private boolean installerWasPaused; + private boolean recoveredHandoff; + private volatile boolean destroyed; + private final ExecutorService downloadExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "T4VerifiedUpdate"); + thread.setDaemon(true); + return thread; + }); + + @Override + public void load() { + T4UpdateFileStore files = new T4UpdateFileStore( + new File(getContext().getCacheDir(), UPDATE_CACHE_DIRECTORY) + ); + File recovered = null; + try { + recovered = files.prepareOnStartup(); + } catch (Exception ignored) { + // The foreground download path retries the sweep and reports a safe failure if storage is unavailable. + } + synchronized (this) { + destroyed = false; + updateFiles = files; + installerHandoff = recovered; + recoveredHandoff = recovered != null; + installerWasPaused = false; + } + } + + @PluginMethod + public void getState(PluginCall call) { + synchronized (this) { + call.resolve(statePayload()); + } + } + + @PluginMethod + public void checkForUpdate(PluginCall call) { + JSObject checkingState; + synchronized (this) { + if (!updateState.beginCheck()) { + call.resolve(statePayload()); + return; + } + statusMessage = "Checking the published Android release."; + errorMessage = null; + checkingState = statePayload(); + } + notifyStateChanged(checkingState); + try { + ManifestRelease release = fetchRelease(); + String currentVersion = currentVersion(); + int comparison = T4UpdateVerifier.compareVersions(release.version, currentVersion); + JSObject resultState; + synchronized (this) { + String resultPhase = comparison > 0 ? "available" : "current"; + if (!updateState.finishCheck(resultPhase)) { + call.resolve(statePayload()); + return; + } + latestVersion = release.version; + checkedAt = System.currentTimeMillis(); + errorMessage = null; + statusMessage = null; + if (comparison > 0) { + validatedRelease = release; + } else { + validatedRelease = null; + } + resultState = statePayload(); + } + call.resolve(resultState); + notifyStateChanged(resultState); + } catch (Exception ignored) { + JSObject resultState; + synchronized (this) { + if (!updateState.finishCheck("error")) { + call.resolve(statePayload()); + return; + } + latestVersion = null; + checkedAt = System.currentTimeMillis(); + validatedRelease = null; + statusMessage = null; + errorMessage = boundedError( + "T4 Code could not verify the latest Android release. Check your connection and try again." + ); + resultState = statePayload(); + } + call.resolve(resultState); + notifyStateChanged(resultState); + } + } + + @PluginMethod + public void openUpdate(PluginCall call) { + final ManifestRelease release; + final JSObject startedState; + synchronized (this) { + release = validatedRelease; + T4UpdateStateMachine.DownloadStart start = updateState.beginDownload(release != null); + if (start != T4UpdateStateMachine.DownloadStart.STARTED) { + if ( + start == T4UpdateStateMachine.DownloadStart.BUSY || + start == T4UpdateStateMachine.DownloadStart.HANDED_OFF + ) { + call.resolve(statePayload()); + } else { + call.reject("Check for an available update first."); + } + return; + } + errorMessage = null; + statusMessage = "Downloading the published APK for verification."; + startedState = statePayload(); + } + call.resolve(startedState); + notifyStateChanged(startedState); + + try { + downloadExecutor.execute(() -> { + File packageFile = null; + try { + if (destroyed) return; + packageFile = downloadVerifiedPackage(release); + verifyAndroidPackage(packageFile, release.version); + if (destroyed) { + discardUpdateFile(packageFile); + return; + } + File verifiedPackage = packageFile; + getBridge().executeOnMainThread(() -> openPackageInstaller(verifiedPackage)); + } catch (Exception ignored) { + discardUpdateFile(packageFile); + publishDownloadFailure(); + } + }); + } catch (RuntimeException ignored) { + publishDownloadFailure(); + } + } + + @Override + protected void handleOnPause() { + synchronized (this) { + if (installerHandoff != null && "installer".equals(updateState.phase())) { + installerWasPaused = true; + } + } + } + + @Override + protected void handleOnResume() { + final File completedHandoff; + final JSObject resumedState; + synchronized (this) { + if (installerHandoff == null || (!recoveredHandoff && !installerWasPaused)) return; + completedHandoff = installerHandoff; + installerHandoff = null; + recoveredHandoff = false; + installerWasPaused = false; + if ("installer".equals(updateState.phase())) { + boolean canRetry = validatedRelease != null; + updateState.installerReturned(canRetry); + errorMessage = null; + statusMessage = canRetry + ? "Android's installer closed. You can download the release again." + : null; + resumedState = statePayload(); + } else { + resumedState = null; + } + } + finishInstallerHandoff(completedHandoff); + if (resumedState != null) notifyStateChanged(resumedState); + } + + @Override + protected void handleOnDestroy() { + destroyed = true; + downloadExecutor.shutdownNow(); + T4UpdateFileStore files; + synchronized (this) { + files = updateFiles; + validatedRelease = null; + statusMessage = null; + errorMessage = null; + installerHandoff = null; + recoveredHandoff = false; + installerWasPaused = false; + updateState.reset(); + } + if (files != null) files.cleanupForDestroy(); + } + + private File downloadVerifiedPackage(ManifestRelease release) throws Exception { + T4UpdateFileStore files = requireUpdateFiles(); + files.prepareForDownload(); + File partial = files.createPartial(release.version); + + HttpsURLConnection connection = null; + try { + connection = openAssetConnection(release.apkUrl); + long responseSize = connection.getContentLengthLong(); + if (responseSize >= 0 && responseSize != release.apkSize) { + throw new IllegalStateException("release response size does not match its manifest"); + } + try ( + InputStream input = new BufferedInputStream(connection.getInputStream()); + FileOutputStream fileOutput = new FileOutputStream(partial, false); + BufferedOutputStream output = new BufferedOutputStream(fileOutput) + ) { + T4UpdateVerifier.copyExact(input, output, release.apkSize, release.apkSha256); + fileOutput.getFD().sync(); + } + return files.finalizeVerified(partial); + } catch (Exception error) { + files.discard(partial); + throw error; + } finally { + if (connection != null) connection.disconnect(); + } + } + + private HttpsURLConnection openAssetConnection(String validatedUrl) throws Exception { + URL current = new URL(validatedUrl); + for (int redirects = 0; redirects <= MAX_ASSET_REDIRECTS; redirects += 1) { + T4UpdateVerifier.requireAllowedAssetUrl(current, redirects == 0); + if (redirects == 0 && !validatedUrl.equals(current.toString())) { + throw new IllegalStateException("release asset URL changed before download"); + } + HttpsURLConnection connection = (HttpsURLConnection) current.openConnection(); + connection.setConnectTimeout(NETWORK_TIMEOUT_MS); + connection.setReadTimeout(NETWORK_TIMEOUT_MS); + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Accept", APK_MIME_TYPE); + connection.setRequestProperty("Accept-Encoding", "identity"); + connection.setUseCaches(false); + int status = connection.getResponseCode(); + if (status == HttpsURLConnection.HTTP_OK) return connection; + if (!T4UpdateVerifier.isRedirectStatus(status) || redirects == MAX_ASSET_REDIRECTS) { + connection.disconnect(); + throw new IllegalStateException("release asset response was not successful"); + } + String location = connection.getHeaderField("Location"); + connection.disconnect(); + if (location == null || location.isEmpty() || location.length() > 8192) { + throw new IllegalStateException("release asset redirect is invalid"); + } + current = new URL(current, location); + } + throw new IllegalStateException("release asset redirect limit exceeded"); + } + + private void verifyAndroidPackage(File packageFile, String expectedVersion) throws Exception { + PackageManager manager = getContext().getPackageManager(); + PackageInfo candidate = archivePackageInfo(manager, packageFile); + if (candidate == null || !EXPECTED_PACKAGE_ID.equals(candidate.packageName)) { + throw new IllegalStateException("update package identity does not match T4 Code"); + } + if (!expectedVersion.equals(candidate.versionName)) { + throw new IllegalStateException("update package version does not match its manifest"); + } + PackageInfo installed = installedPackageInfo(manager); + if (!EXPECTED_PACKAGE_ID.equals(installed.packageName)) { + throw new IllegalStateException("installed package identity does not match T4 Code"); + } + boolean trustedSigner; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + SigningEvidence installedSigning = signingEvidence(installed); + SigningEvidence candidateSigning = signingEvidence(candidate); + trustedSigner = T4UpdateVerifier.isTrustedSignerTransition( + installedSigning.current, + installedSigning.history, + installedSigning.multiple, + candidateSigning.current, + candidateSigning.history, + candidateSigning.multiple + ); + } else { + trustedSigner = T4UpdateVerifier.sameSignerSet(legacySigners(installed), legacySigners(candidate)); + } + if (!trustedSigner) { + throw new IllegalStateException("update package signer does not match this installation"); + } + } + + @SuppressWarnings("deprecation") + private PackageInfo installedPackageInfo(PackageManager manager) throws Exception { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return manager.getPackageInfo( + EXPECTED_PACKAGE_ID, + PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES) + ); + } + int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ? PackageManager.GET_SIGNING_CERTIFICATES + : PackageManager.GET_SIGNATURES; + return manager.getPackageInfo(EXPECTED_PACKAGE_ID, flags); + } + + @SuppressWarnings("deprecation") + private PackageInfo archivePackageInfo(PackageManager manager, File packageFile) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return manager.getPackageArchiveInfo( + packageFile.getAbsolutePath(), + PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES) + ); + } + int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ? PackageManager.GET_SIGNING_CERTIFICATES + : PackageManager.GET_SIGNATURES; + return manager.getPackageArchiveInfo(packageFile.getAbsolutePath(), flags); + } + + @android.annotation.TargetApi(Build.VERSION_CODES.P) + private SigningEvidence signingEvidence(PackageInfo packageInfo) { + SigningInfo signingInfo = packageInfo.signingInfo; + if (signingInfo == null) return new SigningEvidence(new ArrayList<>(), new ArrayList<>(), false); + boolean multiple = signingInfo.hasMultipleSigners(); + List current = signatureBytes(signingInfo.getApkContentsSigners()); + List history = multiple ? new ArrayList<>() : signatureBytes(signingInfo.getSigningCertificateHistory()); + return new SigningEvidence(current, history, multiple); + } + + @SuppressWarnings("deprecation") + private List legacySigners(PackageInfo packageInfo) { + return signatureBytes(packageInfo.signatures); + } + + private List signatureBytes(Signature[] signatures) { + if (signatures == null) return new ArrayList<>(); + List result = new ArrayList<>(signatures.length); + for (Signature signature : signatures) result.add(signature.toByteArray()); + return result; + } + + private void openPackageInstaller(File verifiedPackage) { + synchronized (this) { + if (destroyed || !"downloading".equals(updateState.phase())) { + discardUpdateFile(verifiedPackage); + return; + } + } + final T4UpdateFileStore files; + final File handoff; + try { + files = requireUpdateFiles(); + handoff = files.beginInstallerHandoff(verifiedPackage); + synchronized (this) { + installerHandoff = handoff; + recoveredHandoff = false; + installerWasPaused = false; + } + Uri contentUri = FileProvider.getUriForFile( + getContext(), + getContext().getPackageName() + ".fileprovider", + handoff + ); + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(contentUri, APK_MIME_TYPE); + intent.setClipData(ClipData.newRawUri("T4 Code update", contentUri)); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + getActivity().startActivity(intent); + } catch (Exception ignored) { + synchronized (this) { + installerHandoff = null; + recoveredHandoff = false; + installerWasPaused = false; + } + discardUpdateFile(verifiedPackage); + T4UpdateFileStore currentFiles; + synchronized (this) { + currentFiles = updateFiles; + } + if (currentFiles != null && currentFiles.activeHandoff() != null) { + finishInstallerHandoff(currentFiles.activeHandoff()); + } + publishDownloadFailure(); + return; + } + + JSObject state; + synchronized (this) { + updateState.installerOpened(); + errorMessage = null; + statusMessage = "The verified APK is open in Android's installer. Android will ask before replacing this installation."; + state = statePayload(); + } + notifyListeners(STATE_CHANGED_EVENT, state); + } + + private T4UpdateFileStore requireUpdateFiles() { + synchronized (this) { + if (updateFiles == null) throw new IllegalStateException("Android update storage is unavailable"); + return updateFiles; + } + } + + private void discardUpdateFile(File file) { + T4UpdateFileStore files; + synchronized (this) { + files = updateFiles; + } + if (files != null) files.discard(file); + else if (file != null) file.delete(); + } + + private void finishInstallerHandoff(File handoff) { + T4UpdateFileStore files; + synchronized (this) { + files = updateFiles; + } + if (files == null || handoff == null) return; + try { + files.finishInstallerHandoff(handoff); + } catch (Exception ignored) { + // The next startup or foreground download retries this bounded one-file cleanup. + } + } + + private void publishDownloadFailure() { + JSObject state; + synchronized (this) { + if (!"downloading".equals(updateState.phase())) return; + updateState.downloadFailed(); + validatedRelease = null; + statusMessage = null; + errorMessage = boundedError( + "T4 Code could not verify and open the Android update. Your current installation is unchanged." + ); + state = statePayload(); + } + notifyStateChanged(state); + } + + private void notifyStateChanged(JSObject state) { + getBridge().executeOnMainThread(() -> notifyListeners(STATE_CHANGED_EVENT, state)); + } + + private JSObject statePayload() { + JSObject result = new JSObject(); + result.put("currentVersion", currentVersion()); + result.put("phase", updateState.phase()); + result.put("revision", updateState.revision()); + if (latestVersion != null) result.put("latestVersion", latestVersion); + if (checkedAt != null) result.put("checkedAt", checkedAt); + if (errorMessage != null) result.put("error", errorMessage); + if (statusMessage != null) result.put("message", statusMessage); + return result; + } + + private String currentVersion() { + if (!EXPECTED_PACKAGE_ID.equals(BuildConfig.APPLICATION_ID)) { + throw new IllegalStateException("Android application identity is invalid"); + } + String version = BuildConfig.VERSION_NAME; + if (!T4UpdateVerifier.isValidVersion(version)) { + throw new IllegalStateException("Android application version is invalid"); + } + return version; + } + + private ManifestRelease fetchRelease() throws Exception { + HttpsURLConnection connection = (HttpsURLConnection) new URL(MANIFEST_URL).openConnection(); + connection.setConnectTimeout(NETWORK_TIMEOUT_MS); + connection.setReadTimeout(NETWORK_TIMEOUT_MS); + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Accept", "application/json"); + connection.setUseCaches(false); + try { + if (connection.getResponseCode() != HttpsURLConnection.HTTP_OK) { + throw new IllegalStateException("update manifest response was not successful"); + } + long declaredLength = connection.getContentLengthLong(); + if (declaredLength > MAX_MANIFEST_BYTES) { + throw new IllegalStateException("update manifest is too large"); + } + byte[] bytes; + try (InputStream input = connection.getInputStream()) { + bytes = readBounded(input); + } + return parseManifest(new JSONObject(new String(bytes, StandardCharsets.UTF_8))); + } finally { + connection.disconnect(); + } + } + + private byte[] readBounded(InputStream input) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8 * 1024]; + int count; + while ((count = input.read(buffer)) != -1) { + if (output.size() + count > MAX_MANIFEST_BYTES) { + throw new IllegalStateException("update manifest is too large"); + } + output.write(buffer, 0, count); + } + return output.toByteArray(); + } + + private ManifestRelease parseManifest(JSONObject manifest) throws Exception { + requireExactKeys( + manifest, + "schemaVersion", + "channel", + "version", + "tag", + "publishedAt", + "releaseUrl", + "assets" + ); + if (requireJsonInteger(manifest, "schemaVersion") != 1 || !"stable".equals(requireJsonString(manifest, "channel"))) { + throw new IllegalStateException("unsupported update manifest"); + } + + String version = requireJsonString(manifest, "version"); + T4UpdateVerifier.requireManifestReleaseIdentity( + version, + requireJsonString(manifest, "tag"), + requireJsonString(manifest, "releaseUrl"), + requireJsonString(manifest, "publishedAt") + ); + + Object assetsValue = manifest.get("assets"); + if (!(assetsValue instanceof JSONArray)) throw new IllegalStateException("release assets must be an array"); + JSONArray assets = (JSONArray) assetsValue; + if (assets.length() != 5) throw new IllegalStateException("invalid release asset count"); + Set identities = new HashSet<>(); + String apkUrl = null; + Long apkSize = null; + String apkSha256 = null; + for (int index = 0; index < assets.length(); index += 1) { + JSONObject asset = assets.getJSONObject(index); + requireExactKeys(asset, "platform", "kind", "arch", "name", "url", "size", "sha256"); + String platform = requireJsonString(asset, "platform"); + String kind = requireJsonString(asset, "kind"); + String arch = requireJsonString(asset, "arch"); + String name = requireJsonString(asset, "name"); + String url = requireJsonString(asset, "url"); + long size = requireJsonInteger(asset, "size"); + String sha256 = requireJsonString(asset, "sha256"); + String identity = T4UpdateVerifier.requireManifestAsset( + version, + platform, + kind, + arch, + name, + url, + size, + sha256, + MAX_ASSET_BYTES + ); + if (!identities.add(identity)) throw new IllegalStateException("duplicate release asset"); + if ("android:apk:universal".equals(identity)) { + apkUrl = url; + apkSize = size; + apkSha256 = sha256; + } + } + if (identities.size() != 5 || apkUrl == null || apkSize == null || apkSha256 == null) { + throw new IllegalStateException("Android release asset is missing"); + } + return new ManifestRelease(version, apkUrl, apkSize, apkSha256); + } + + private String requireJsonString(JSONObject object, String key) throws Exception { + Object value = object.get(key); + if (!(value instanceof String)) throw new IllegalStateException(key + " must be a string"); + return (String) value; + } + + private long requireJsonInteger(JSONObject object, String key) throws Exception { + Object value = object.get(key); + if (!(value instanceof Number)) throw new IllegalStateException(key + " must be an integer"); + Number number = (Number) value; + long integer = number.longValue(); + double numeric = number.doubleValue(); + if (Double.isNaN(numeric) || Double.isInfinite(numeric) || numeric != (double) integer) { + throw new IllegalStateException(key + " must be an integer"); + } + return integer; + } + + private void requireExactKeys(JSONObject object, String... expected) { + Set keys = new HashSet<>(); + Iterator iterator = object.keys(); + while (iterator.hasNext()) keys.add(iterator.next()); + Set allowed = new HashSet<>(); + for (String key : expected) allowed.add(key); + if (!keys.equals(allowed)) throw new IllegalStateException("unexpected update manifest fields"); + } + + private String boundedError(String message) { + StringBuilder output = new StringBuilder(); + for (int index = 0; index < message.length() && output.length() < MAX_ERROR_LENGTH; index += 1) { + char character = message.charAt(index); + output.append(character <= 0x1f || character == 0x7f ? ' ' : character); + } + return output.toString(); + } + + private static final class ManifestRelease { + private final String version; + private final String apkUrl; + private final long apkSize; + private final String apkSha256; + + private ManifestRelease(String version, String apkUrl, long apkSize, String apkSha256) { + this.version = version; + this.apkUrl = apkUrl; + this.apkSize = apkSize; + this.apkSha256 = apkSha256; + } + } + + private static final class SigningEvidence { + private final List current; + private final List history; + private final boolean multiple; + + private SigningEvidence(List current, List history, boolean multiple) { + this.current = current; + this.history = history; + this.multiple = multiple; + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateStateMachine.java b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateStateMachine.java new file mode 100644 index 00000000..af87bd32 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateStateMachine.java @@ -0,0 +1,82 @@ +package com.lycaonsolutions.t4code; + +/** Small synchronized state machine so bridge calls and the download worker cannot race. */ +final class T4UpdateStateMachine { + enum DownloadStart { + STARTED, + BUSY, + HANDED_OFF, + UNAVAILABLE, + } + + private String phase = "idle"; + private long revision; + private boolean downloadInProgress; + + synchronized String phase() { + return phase; + } + + synchronized long revision() { + return revision; + } + + synchronized boolean beginCheck() { + if (downloadInProgress || "checking".equals(phase) || "installer".equals(phase)) return false; + transition("checking"); + return true; + } + + synchronized boolean finishCheck(String resultPhase) { + if (!("available".equals(resultPhase) || "current".equals(resultPhase) || "error".equals(resultPhase))) { + throw new IllegalArgumentException("invalid check result phase"); + } + if (downloadInProgress || !"checking".equals(phase)) return false; + transition(resultPhase); + return true; + } + + synchronized DownloadStart beginDownload(boolean hasValidatedRelease) { + if (downloadInProgress || "downloading".equals(phase)) return DownloadStart.BUSY; + if ("installer".equals(phase)) return DownloadStart.HANDED_OFF; + if (!hasValidatedRelease || !"available".equals(phase)) return DownloadStart.UNAVAILABLE; + downloadInProgress = true; + transition("downloading"); + return DownloadStart.STARTED; + } + + synchronized void installerOpened() { + requireActiveDownload(); + downloadInProgress = false; + transition("installer"); + } + + synchronized void installerReturned(boolean updateStillAvailable) { + if (!"installer".equals(phase) || downloadInProgress) { + throw new IllegalStateException("no installer handoff is active"); + } + transition(updateStillAvailable ? "available" : "idle"); + } + + synchronized void downloadFailed() { + requireActiveDownload(); + downloadInProgress = false; + transition("error"); + } + + synchronized void reset() { + downloadInProgress = false; + transition("idle"); + } + + private void requireActiveDownload() { + if (!downloadInProgress || !"downloading".equals(phase)) { + throw new IllegalStateException("no verified update download is active"); + } + } + + private void transition(String nextPhase) { + phase = nextPhase; + revision += 1; + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateVerifier.java b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateVerifier.java new file mode 100644 index 00000000..f3c8d3da --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateVerifier.java @@ -0,0 +1,251 @@ +package com.lycaonsolutions.t4code; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** Pure-Java verification primitives shared by the Android updater and JVM tests. */ +final class T4UpdateVerifier { + private static final int COPY_BUFFER_BYTES = 64 * 1024; + private static final String RELEASE_DOWNLOAD_ROOT = "https://github.com/LycaonLLC/t4-code/releases/download/"; + private static final String RELEASE_PAGE_ROOT = "https://github.com/LycaonLLC/t4-code/releases/tag/"; + private static final Pattern VERSION_PATTERN = Pattern.compile( + "^(?:0|[1-9][0-9]{0,5})\\.(?:0|[1-9][0-9]{0,5})\\.(?:0|[1-9][0-9]{0,5})$" + ); + private static final Pattern SHA256_PATTERN = Pattern.compile("^[0-9a-f]{64}$"); + private static final Pattern PUBLISHED_AT_PATTERN = Pattern.compile( + "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{1,9})?Z$" + ); + + private T4UpdateVerifier() {} + + static void copyExact( + InputStream input, + OutputStream output, + long expectedSize, + String expectedSha256 + ) throws Exception { + if (expectedSize <= 0) throw new IllegalArgumentException("expected size must be positive"); + if (!isValidSha256(expectedSha256)) { + throw new IllegalArgumentException("expected SHA-256 is invalid"); + } + + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[COPY_BUFFER_BYTES]; + long total = 0; + int count; + while ((count = input.read(buffer)) != -1) { + if (count == 0) continue; + if (count > expectedSize - total) { + throw new IllegalStateException("downloaded package exceeds its declared size"); + } + output.write(buffer, 0, count); + digest.update(buffer, 0, count); + total += count; + } + if (total != expectedSize) { + throw new IllegalStateException("downloaded package size does not match its manifest"); + } + String actualSha256 = lowercaseHex(digest.digest()); + if (!MessageDigest.isEqual( + expectedSha256.getBytes(java.nio.charset.StandardCharsets.US_ASCII), + actualSha256.getBytes(java.nio.charset.StandardCharsets.US_ASCII) + )) { + throw new IllegalStateException("downloaded package digest does not match its manifest"); + } + output.flush(); + } + + static boolean sameSignerSet(List installed, List candidate) throws Exception { + if (installed == null || candidate == null || installed.isEmpty() || candidate.isEmpty()) return false; + Set installedFingerprints = signerFingerprints(installed); + Set candidateFingerprints = signerFingerprints(candidate); + return installedFingerprints.size() == installed.size() && + candidateFingerprints.size() == candidate.size() && + installedFingerprints.equals(candidateFingerprints); + } + + /** + * Accepts the same signer, or a forward single-signer rotation whose + * PackageManager-verified candidate history contains the installed current + * signer. Multi-signer packages cannot rotate and must match exactly. + */ + static boolean isTrustedSignerTransition( + List installedCurrent, + List installedHistory, + boolean installedHasMultipleSigners, + List candidateCurrent, + List candidateHistory, + boolean candidateHasMultipleSigners + ) throws Exception { + if (installedHasMultipleSigners || candidateHasMultipleSigners) { + return installedHasMultipleSigners && + candidateHasMultipleSigners && + sameSignerSet(installedCurrent, candidateCurrent); + } + if (installedCurrent == null || candidateCurrent == null || installedCurrent.size() != 1 || candidateCurrent.size() != 1) { + return false; + } + + List installedLineage = orderedSignerFingerprints(installedHistory); + List candidateLineage = orderedSignerFingerprints(candidateHistory); + if (installedLineage.isEmpty() || candidateLineage.isEmpty()) return false; + String installedSigner = signerFingerprint(installedCurrent.get(0)); + String candidateSigner = signerFingerprint(candidateCurrent.get(0)); + if (installedSigner == null || candidateSigner == null) return false; + if (!installedSigner.equals(installedLineage.get(installedLineage.size() - 1))) return false; + if (!candidateSigner.equals(candidateLineage.get(candidateLineage.size() - 1))) return false; + if (new HashSet<>(installedLineage).size() != installedLineage.size()) return false; + if (new HashSet<>(candidateLineage).size() != candidateLineage.size()) return false; + if (installedSigner.equals(candidateSigner)) return true; + + int installedSignerInCandidateHistory = candidateLineage.indexOf(installedSigner); + return installedSignerInCandidateHistory >= 0 && + installedSignerInCandidateHistory < candidateLineage.size() - 1; + } + + static int compareVersions(String left, String right) { + int[] leftParts = strictVersionParts(left); + int[] rightParts = strictVersionParts(right); + for (int index = 0; index < leftParts.length; index += 1) { + int comparison = Integer.compare(leftParts[index], rightParts[index]); + if (comparison != 0) return comparison; + } + return 0; + } + + static String expectedAssetName(String version, String platform, String kind, String arch) { + strictVersionParts(version); + String identity = platform + ":" + kind + ":" + arch; + switch (identity) { + case "android:apk:universal": + return "T4-Code-" + version + "-android.apk"; + case "linux:deb:x86_64": + return "T4-Code-" + version + "-linux-amd64.deb"; + case "linux:appimage:x86_64": + return "T4-Code-" + version + "-linux-x86_64.AppImage"; + case "mac:dmg:arm64": + return "T4-Code-" + version + "-mac-arm64.dmg"; + case "mac:zip:arm64": + return "T4-Code-" + version + "-mac-arm64.zip"; + default: + throw new IllegalArgumentException("unknown release asset"); + } + } + + static void requireManifestReleaseIdentity( + String version, + String tag, + String releaseUrl, + String publishedAt + ) { + strictVersionParts(version); + String expectedTag = "v" + version; + if (!expectedTag.equals(tag)) throw new IllegalArgumentException("release tag mismatch"); + if (!(RELEASE_PAGE_ROOT + expectedTag).equals(releaseUrl)) { + throw new IllegalArgumentException("release page mismatch"); + } + if (publishedAt == null || publishedAt.length() > 64 || !PUBLISHED_AT_PATTERN.matcher(publishedAt).matches()) { + throw new IllegalArgumentException("invalid release timestamp"); + } + } + + static String requireManifestAsset( + String version, + String platform, + String kind, + String arch, + String name, + String url, + long size, + String sha256, + long maximumSize + ) { + String identity = platform + ":" + kind + ":" + arch; + String expectedName = expectedAssetName(version, platform, kind, arch); + if (!expectedName.equals(name)) throw new IllegalArgumentException("release asset name mismatch"); + String expectedUrl = RELEASE_DOWNLOAD_ROOT + "v" + version + "/" + expectedName; + if (!expectedUrl.equals(url)) throw new IllegalArgumentException("release asset URL mismatch"); + if (size <= 0 || size > maximumSize) throw new IllegalArgumentException("invalid release asset size"); + if (!isValidSha256(sha256)) throw new IllegalArgumentException("invalid release asset digest"); + return identity; + } + + static void requireAllowedAssetUrl(URL url, boolean initial) { + if (!"https".equals(url.getProtocol()) || url.getUserInfo() != null || (url.getPort() != -1 && url.getPort() != 443)) { + throw new IllegalArgumentException("release asset connection is not secure"); + } + String host = url.getHost().toLowerCase(java.util.Locale.ROOT); + boolean trustedDownloadHost = "release-assets.githubusercontent.com".equals(host) || + "objects.githubusercontent.com".equals(host); + if (initial ? !"github.com".equals(host) : !("github.com".equals(host) || trustedDownloadHost)) { + throw new IllegalArgumentException("release asset host is not allowed"); + } + } + + static boolean isRedirectStatus(int status) { + return status == HttpURLConnection.HTTP_MOVED_PERM || + status == HttpURLConnection.HTTP_MOVED_TEMP || + status == HttpURLConnection.HTTP_SEE_OTHER || + status == 307 || + status == 308; + } + + static boolean isValidSha256(String value) { + return value != null && SHA256_PATTERN.matcher(value).matches(); + } + + static boolean isValidVersion(String value) { + return value != null && VERSION_PATTERN.matcher(value).matches(); + } + + private static Set signerFingerprints(List certificates) throws Exception { + Set fingerprints = new HashSet<>(); + for (byte[] certificate : certificates) { + if (certificate == null || certificate.length == 0) return new HashSet<>(); + fingerprints.add(lowercaseHex(MessageDigest.getInstance("SHA-256").digest(certificate))); + } + return fingerprints; + } + + private static List orderedSignerFingerprints(List certificates) throws Exception { + List fingerprints = new ArrayList<>(); + if (certificates == null) return fingerprints; + for (byte[] certificate : certificates) { + String fingerprint = signerFingerprint(certificate); + if (fingerprint == null) return new ArrayList<>(); + fingerprints.add(fingerprint); + } + return fingerprints; + } + + private static String signerFingerprint(byte[] certificate) throws Exception { + if (certificate == null || certificate.length == 0) return null; + return lowercaseHex(MessageDigest.getInstance("SHA-256").digest(certificate)); + } + + private static int[] strictVersionParts(String version) { + if (version == null || !VERSION_PATTERN.matcher(version).matches()) { + throw new IllegalArgumentException("release version is invalid"); + } + String[] values = version.split("\\."); + return new int[] { + Integer.parseInt(values[0]), + Integer.parseInt(values[1]), + Integer.parseInt(values[2]), + }; + } + + private static String lowercaseHex(byte[] value) { + StringBuilder result = new StringBuilder(value.length * 2); + for (byte item : value) result.append(String.format(java.util.Locale.ROOT, "%02x", item & 0xff)); + return result.toString(); + } +} diff --git a/apps/mobile/android/app/src/main/res/xml/file_paths.xml b/apps/mobile/android/app/src/main/res/xml/file_paths.xml index bd0c4d80..b904c664 100644 --- a/apps/mobile/android/app/src/main/res/xml/file_paths.xml +++ b/apps/mobile/android/app/src/main/res/xml/file_paths.xml @@ -1,5 +1,4 @@ - - - \ No newline at end of file + + diff --git a/apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateFileStoreTest.java b/apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateFileStoreTest.java new file mode 100644 index 00000000..8e2ffa0b --- /dev/null +++ b/apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateFileStoreTest.java @@ -0,0 +1,147 @@ +package com.lycaonsolutions.t4code; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public final class T4UpdateFileStoreTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void startupRemovesPartialsAndVerifiedFilesButKeepsOneHandoff() throws Exception { + File directory = temporaryFolder.newFolder("updates"); + File partial = write(directory, "T4-Code-1.2.3-a.apk.partial"); + File verified = write(directory, "T4-Code-1.2.3-b.apk"); + File olderHandoff = write(directory, "T4-Code-1.2.2-a-installer.apk"); + File newerHandoff = write(directory, "T4-Code-1.2.3-b-installer.apk"); + assertTrue(olderHandoff.setLastModified(1_000)); + assertTrue(newerHandoff.setLastModified(2_000)); + + T4UpdateFileStore store = new T4UpdateFileStore(directory); + File retained = store.prepareOnStartup(); + + assertEquals(newerHandoff.getAbsoluteFile(), retained.getAbsoluteFile()); + assertFalse(partial.exists()); + assertFalse(verified.exists()); + assertFalse(olderHandoff.exists()); + assertTrue(newerHandoff.exists()); + assertEquals(1, fileCount(directory)); + } + + @Test + public void foregroundDownloadClearsAProcessDeathHandoff() throws Exception { + File directory = temporaryFolder.newFolder("updates"); + File oldHandoff = write(directory, "T4-Code-1.2.2-a-installer.apk"); + T4UpdateFileStore store = new T4UpdateFileStore(directory); + assertNotNull(store.prepareOnStartup()); + + store.prepareForDownload(); + + assertFalse(oldHandoff.exists()); + assertNull(store.activeHandoff()); + assertEquals(0, fileCount(directory)); + } + + @Test + public void installerHandoffSurvivesDestroyThenIsRemovedOnReturn() throws Exception { + File directory = temporaryFolder.newFolder("updates"); + T4UpdateFileStore store = new T4UpdateFileStore(directory); + store.prepareForDownload(); + File partial = store.createPartial("1.2.3"); + writeBytes(partial); + File verified = store.finalizeVerified(partial); + File handoff = store.beginInstallerHandoff(verified); + File interruptedAfterHandoff = store.createPartial("1.2.4"); + writeBytes(interruptedAfterHandoff); + + assertFalse(partial.exists()); + assertFalse(verified.exists()); + assertTrue(handoff.exists()); + assertTrue(handoff.getName().endsWith("-installer.apk")); + + store.cleanupForDestroy(); + assertTrue(handoff.exists()); + assertFalse(interruptedAfterHandoff.exists()); + assertEquals(1, fileCount(directory)); + + T4UpdateFileStore recreated = new T4UpdateFileStore(directory); + File recovered = recreated.prepareOnStartup(); + assertEquals(handoff.getAbsoluteFile(), recovered.getAbsoluteFile()); + recreated.finishInstallerHandoff(recovered); + + assertFalse(handoff.exists()); + assertNull(recreated.activeHandoff()); + assertEquals(0, fileCount(directory)); + } + + @Test + public void destroyRemovesInterruptedAndUnhandedPackages() throws Exception { + File directory = temporaryFolder.newFolder("updates"); + T4UpdateFileStore store = new T4UpdateFileStore(directory); + store.prepareForDownload(); + File partial = store.createPartial("1.2.3"); + writeBytes(partial); + File verified = store.finalizeVerified(partial); + File secondPartial = store.createPartial("1.2.4"); + writeBytes(secondPartial); + + store.cleanupForDestroy(); + + assertFalse(partial.exists()); + assertFalse(verified.exists()); + assertFalse(secondPartial.exists()); + assertEquals(0, fileCount(directory)); + } + + @Test + public void staleActivityCannotSweepANewerInstallerHandoff() throws Exception { + File directory = temporaryFolder.newFolder("updates"); + T4UpdateFileStore stale = new T4UpdateFileStore(directory); + stale.prepareOnStartup(); + + T4UpdateFileStore current = new T4UpdateFileStore(directory); + current.prepareOnStartup(); + current.prepareForDownload(); + File partial = current.createPartial("1.2.3"); + writeBytes(partial); + File handoff = current.beginInstallerHandoff(current.finalizeVerified(partial)); + + assertThrows(IOException.class, stale::prepareForDownload); + stale.cleanupForDestroy(); + stale.discard(handoff); + + assertTrue(handoff.exists()); + assertEquals(handoff.getAbsoluteFile(), current.activeHandoff().getAbsoluteFile()); + assertEquals(1, fileCount(directory)); + } + + private static File write(File directory, String name) throws Exception { + File file = new File(directory, name); + writeBytes(file); + return file; + } + + private static void writeBytes(File file) throws Exception { + try (FileOutputStream output = new FileOutputStream(file)) { + output.write(new byte[] { 1, 2, 3, 4 }); + output.getFD().sync(); + } + } + + private static int fileCount(File directory) { + File[] files = directory.listFiles(); + return files == null ? 0 : files.length; + } +} diff --git a/apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateStateMachineTest.java b/apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateStateMachineTest.java new file mode 100644 index 00000000..930e7e9c --- /dev/null +++ b/apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateStateMachineTest.java @@ -0,0 +1,130 @@ +package com.lycaonsolutions.t4code; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.junit.Test; + +public final class T4UpdateStateMachineTest { + @Test + public void onlyOneConcurrentDownloadCanStart() throws Exception { + T4UpdateStateMachine state = new T4UpdateStateMachine(); + assertTrue(state.beginCheck()); + assertTrue(state.finishCheck("available")); + + int workers = 12; + ExecutorService executor = Executors.newFixedThreadPool(workers); + CountDownLatch start = new CountDownLatch(1); + List> attempts = new ArrayList<>(); + for (int index = 0; index < workers; index += 1) { + attempts.add(executor.submit(() -> { + start.await(); + return state.beginDownload(true); + })); + } + start.countDown(); + + int started = 0; + int busy = 0; + for (Future attempt : attempts) { + T4UpdateStateMachine.DownloadStart result = attempt.get(); + if (result == T4UpdateStateMachine.DownloadStart.STARTED) started += 1; + else if (result == T4UpdateStateMachine.DownloadStart.BUSY) busy += 1; + } + executor.shutdownNow(); + + assertEquals(1, started); + assertEquals(workers - 1, busy); + assertEquals("downloading", state.phase()); + assertFalse(state.beginCheck()); + } + + @Test + public void concurrentChecksProduceOneNativeTransition() throws Exception { + T4UpdateStateMachine state = new T4UpdateStateMachine(); + int workers = 12; + ExecutorService executor = Executors.newFixedThreadPool(workers); + CountDownLatch start = new CountDownLatch(1); + List> attempts = new ArrayList<>(); + for (int index = 0; index < workers; index += 1) { + attempts.add(executor.submit(() -> { + start.await(); + return state.beginCheck(); + })); + } + start.countDown(); + + int started = 0; + for (Future attempt : attempts) { + if (attempt.get()) started += 1; + } + executor.shutdownNow(); + + assertEquals(1, started); + assertEquals("checking", state.phase()); + assertEquals(1, state.revision()); + } + + @Test + public void staleCheckCompletionCannotReplaceAResetState() { + T4UpdateStateMachine state = new T4UpdateStateMachine(); + assertTrue(state.beginCheck()); + state.reset(); + long resetRevision = state.revision(); + + assertFalse(state.finishCheck("available")); + assertEquals("idle", state.phase()); + assertEquals(resetRevision, state.revision()); + } + + @Test + public void installerHandoffCannotStartAReplacementDownload() { + T4UpdateStateMachine state = new T4UpdateStateMachine(); + assertTrue(state.beginCheck()); + assertTrue(state.finishCheck("available")); + assertEquals(T4UpdateStateMachine.DownloadStart.STARTED, state.beginDownload(true)); + state.installerOpened(); + + assertEquals("installer", state.phase()); + long handoffRevision = state.revision(); + assertEquals(T4UpdateStateMachine.DownloadStart.HANDED_OFF, state.beginDownload(true)); + assertEquals(handoffRevision, state.revision()); + assertFalse(state.beginCheck()); + } + + @Test + public void installerReturnAllowsTheVerifiedReleaseToBeRetried() { + T4UpdateStateMachine state = new T4UpdateStateMachine(); + assertTrue(state.beginCheck()); + assertTrue(state.finishCheck("available")); + assertEquals(T4UpdateStateMachine.DownloadStart.STARTED, state.beginDownload(true)); + state.installerOpened(); + state.installerReturned(true); + + assertEquals("available", state.phase()); + assertEquals(T4UpdateStateMachine.DownloadStart.STARTED, state.beginDownload(true)); + } + + @Test + public void failedDownloadRequiresANewSuccessfulCheck() { + T4UpdateStateMachine state = new T4UpdateStateMachine(); + assertTrue(state.beginCheck()); + assertTrue(state.finishCheck("available")); + assertEquals(T4UpdateStateMachine.DownloadStart.STARTED, state.beginDownload(true)); + state.downloadFailed(); + + assertEquals("error", state.phase()); + assertEquals(T4UpdateStateMachine.DownloadStart.UNAVAILABLE, state.beginDownload(true)); + assertTrue(state.beginCheck()); + assertTrue(state.finishCheck("current")); + assertEquals("current", state.phase()); + } +} diff --git a/apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateVerifierTest.java b/apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateVerifierTest.java new file mode 100644 index 00000000..82404196 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateVerifierTest.java @@ -0,0 +1,355 @@ +package com.lycaonsolutions.t4code; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.net.URL; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Collections; + +import org.junit.Test; + +public final class T4UpdateVerifierTest { + @Test + public void exactStreamAcceptsOnlyDeclaredBytesAndDigest() throws Exception { + byte[] packageBytes = "verified android package".getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + T4UpdateVerifier.copyExact( + new ByteArrayInputStream(packageBytes), + output, + packageBytes.length, + sha256(packageBytes) + ); + + assertArrayEquals(packageBytes, output.toByteArray()); + } + + @Test + public void exactStreamRejectsOversizedUndersizedAndAlteredPackages() throws Exception { + byte[] packageBytes = "package".getBytes(StandardCharsets.UTF_8); + + assertThrows( + IllegalStateException.class, + () -> T4UpdateVerifier.copyExact( + new ByteArrayInputStream(packageBytes), + new ByteArrayOutputStream(), + packageBytes.length - 1, + sha256(packageBytes) + ) + ); + assertThrows( + IllegalStateException.class, + () -> T4UpdateVerifier.copyExact( + new ByteArrayInputStream(packageBytes), + new ByteArrayOutputStream(), + packageBytes.length + 1, + sha256(packageBytes) + ) + ); + assertThrows( + IllegalStateException.class, + () -> T4UpdateVerifier.copyExact( + new ByteArrayInputStream(packageBytes), + new ByteArrayOutputStream(), + packageBytes.length, + sha256("different".getBytes(StandardCharsets.UTF_8)) + ) + ); + } + + @Test + public void signerSetRequiresTheSameNonEmptyCertificatesRegardlessOfOrder() throws Exception { + byte[] first = "first certificate".getBytes(StandardCharsets.UTF_8); + byte[] second = "second certificate".getBytes(StandardCharsets.UTF_8); + byte[] other = "other certificate".getBytes(StandardCharsets.UTF_8); + + assertTrue(T4UpdateVerifier.sameSignerSet(Arrays.asList(first, second), Arrays.asList(second, first))); + assertFalse(T4UpdateVerifier.sameSignerSet(Arrays.asList(first, second), Arrays.asList(first, other))); + assertFalse(T4UpdateVerifier.sameSignerSet(Arrays.asList(first, second), Collections.singletonList(first))); + assertFalse(T4UpdateVerifier.sameSignerSet(Collections.emptyList(), Collections.singletonList(first))); + } + + @Test + public void signerTransitionAcceptsVerifiedForwardRotationAndRejectsRollback() throws Exception { + byte[] first = "first certificate".getBytes(StandardCharsets.UTF_8); + byte[] second = "second certificate".getBytes(StandardCharsets.UTF_8); + byte[] third = "third certificate".getBytes(StandardCharsets.UTF_8); + + assertTrue(T4UpdateVerifier.isTrustedSignerTransition( + Collections.singletonList(first), + Collections.singletonList(first), + false, + Collections.singletonList(second), + Arrays.asList(first, second), + false + )); + assertTrue(T4UpdateVerifier.isTrustedSignerTransition( + Collections.singletonList(second), + Arrays.asList(first, second), + false, + Collections.singletonList(second), + Collections.singletonList(second), + false + )); + assertFalse(T4UpdateVerifier.isTrustedSignerTransition( + Collections.singletonList(second), + Arrays.asList(first, second), + false, + Collections.singletonList(first), + Collections.singletonList(first), + false + )); + assertTrue(T4UpdateVerifier.isTrustedSignerTransition( + Collections.singletonList(second), + Arrays.asList(first, second), + false, + Collections.singletonList(third), + Arrays.asList(first, second, third), + false + )); + } + + @Test + public void signerTransitionRejectsMalformedOrUnprovenLineage() throws Exception { + byte[] first = "first certificate".getBytes(StandardCharsets.UTF_8); + byte[] second = "second certificate".getBytes(StandardCharsets.UTF_8); + byte[] third = "third certificate".getBytes(StandardCharsets.UTF_8); + + assertFalse(T4UpdateVerifier.isTrustedSignerTransition( + Collections.singletonList(first), + Collections.emptyList(), + false, + Collections.singletonList(second), + Arrays.asList(first, second), + false + )); + assertFalse(T4UpdateVerifier.isTrustedSignerTransition( + Collections.singletonList(first), + Collections.singletonList(first), + false, + Collections.singletonList(second), + Arrays.asList(second, first), + false + )); + assertFalse(T4UpdateVerifier.isTrustedSignerTransition( + Collections.singletonList(first), + Collections.singletonList(first), + false, + Collections.singletonList(second), + Arrays.asList(first, first, second), + false + )); + assertFalse(T4UpdateVerifier.isTrustedSignerTransition( + Collections.singletonList(first), + Collections.singletonList(second), + false, + Collections.singletonList(second), + Arrays.asList(first, second), + false + )); + assertFalse(T4UpdateVerifier.isTrustedSignerTransition( + Collections.singletonList(second), + Arrays.asList(first, second), + false, + Collections.singletonList(third), + Arrays.asList(first, third), + false + )); + } + + @Test + public void multiSignerTransitionRequiresTheSameCompleteSignerSet() throws Exception { + byte[] first = "first certificate".getBytes(StandardCharsets.UTF_8); + byte[] second = "second certificate".getBytes(StandardCharsets.UTF_8); + + assertTrue(T4UpdateVerifier.isTrustedSignerTransition( + Arrays.asList(first, second), + Collections.emptyList(), + true, + Arrays.asList(second, first), + Collections.emptyList(), + true + )); + assertFalse(T4UpdateVerifier.isTrustedSignerTransition( + Arrays.asList(first, second), + Collections.emptyList(), + true, + Collections.singletonList(first), + Collections.singletonList(first), + false + )); + } + + @Test + public void versionsAssetsAndRedirectsAreStrict() throws Exception { + assertTrue(T4UpdateVerifier.compareVersions("1.2.4", "1.2.3") > 0); + assertEquals(0, T4UpdateVerifier.compareVersions("1.2.3", "1.2.3")); + assertTrue(T4UpdateVerifier.compareVersions("1.2.3", "2.0.0") < 0); + assertThrows(IllegalArgumentException.class, () -> T4UpdateVerifier.compareVersions("1.2", "1.2.0")); + assertThrows(IllegalArgumentException.class, () -> T4UpdateVerifier.compareVersions("01.2.3", "1.2.3")); + assertThrows(IllegalArgumentException.class, () -> T4UpdateVerifier.compareVersions("1.2.3-beta", "1.2.3")); + assertEquals( + "T4-Code-1.2.3-android.apk", + T4UpdateVerifier.expectedAssetName("1.2.3", "android", "apk", "universal") + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.expectedAssetName("1.2.3", "android", "aab", "universal") + ); + + T4UpdateVerifier.requireAllowedAssetUrl( + new URL("https://github.com/LycaonLLC/t4-code/releases/download/v1.2.3/T4-Code-1.2.3-android.apk"), + true + ); + T4UpdateVerifier.requireAllowedAssetUrl( + new URL("https://release-assets.githubusercontent.com/github-production-release-asset/file?token=signed"), + false + ); + T4UpdateVerifier.requireAllowedAssetUrl( + new URL("https://objects.githubusercontent.com/github-production-release-asset/file"), + false + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireAllowedAssetUrl(new URL("https://example.com/update.apk"), false) + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireAllowedAssetUrl(new URL("http://github.com/update.apk"), true) + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireAllowedAssetUrl(new URL("https://github.com.evil.example/update.apk"), true) + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireAllowedAssetUrl(new URL("https://user@github.com/update.apk"), true) + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireAllowedAssetUrl(new URL("https://github.com:444/update.apk"), true) + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireAllowedAssetUrl( + new URL("https://release-assets.githubusercontent.com/update.apk"), + true + ) + ); + assertTrue(T4UpdateVerifier.isRedirectStatus(302)); + assertTrue(T4UpdateVerifier.isRedirectStatus(308)); + assertFalse(T4UpdateVerifier.isRedirectStatus(200)); + } + + @Test + public void manifestIdentityAndEveryPublishedAssetAreExact() { + String version = "1.2.3"; + String digest = String.join("", Collections.nCopies(64, "a")); + T4UpdateVerifier.requireManifestReleaseIdentity( + version, + "v1.2.3", + "https://github.com/LycaonLLC/t4-code/releases/tag/v1.2.3", + "2026-07-15T12:30:00.000Z" + ); + + String[][] assets = new String[][] { + { "android", "apk", "universal", "T4-Code-1.2.3-android.apk" }, + { "linux", "deb", "x86_64", "T4-Code-1.2.3-linux-amd64.deb" }, + { "linux", "appimage", "x86_64", "T4-Code-1.2.3-linux-x86_64.AppImage" }, + { "mac", "dmg", "arm64", "T4-Code-1.2.3-mac-arm64.dmg" }, + { "mac", "zip", "arm64", "T4-Code-1.2.3-mac-arm64.zip" }, + }; + for (String[] asset : assets) { + assertEquals( + asset[0] + ":" + asset[1] + ":" + asset[2], + T4UpdateVerifier.requireManifestAsset( + version, + asset[0], + asset[1], + asset[2], + asset[3], + "https://github.com/LycaonLLC/t4-code/releases/download/v1.2.3/" + asset[3], + 1024, + digest, + 2048 + ) + ); + } + + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireManifestReleaseIdentity( + version, + "v1.2.4", + "https://github.com/LycaonLLC/t4-code/releases/tag/v1.2.3", + "2026-07-15T12:30:00Z" + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireManifestReleaseIdentity( + version, + "v1.2.3", + "https://example.com/v1.2.3", + "not-a-timestamp" + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireManifestAsset( + version, + "android", + "apk", + "universal", + "T4-Code-1.2.3-android.apk", + "https://example.com/T4-Code-1.2.3-android.apk", + 1024, + digest, + 2048 + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireManifestAsset( + version, + "android", + "apk", + "universal", + "T4-Code-1.2.3-android.apk", + "https://github.com/LycaonLLC/t4-code/releases/download/v1.2.3/T4-Code-1.2.3-android.apk", + 2049, + digest, + 2048 + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> T4UpdateVerifier.requireManifestAsset( + version, + "android", + "apk", + "universal", + "T4-Code-1.2.3-android.apk", + "https://github.com/LycaonLLC/t4-code/releases/download/v1.2.3/T4-Code-1.2.3-android.apk", + 1024, + digest.toUpperCase(java.util.Locale.ROOT), + 2048 + ) + ); + } + + private static String sha256(byte[] input) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(input); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte item : digest) result.append(String.format(java.util.Locale.ROOT, "%02x", item & 0xff)); + return result.toString(); + } +} diff --git a/apps/mobile/capacitor.config.json b/apps/mobile/capacitor.config.json index 76ba06a8..7968749d 100644 --- a/apps/mobile/capacitor.config.json +++ b/apps/mobile/capacitor.config.json @@ -3,7 +3,7 @@ "appName": "T4 Code", "webDir": "dist", "loggingBehavior": "debug", - "appendUserAgent": " T4CodeMobile/0.1.18", + "appendUserAgent": " T4CodeMobile/0.1.19", "android": { "path": "android", "minWebViewVersion": 60, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 365dedad..9cfc921a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,12 +1,13 @@ { "name": "@t4-code/mobile", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "scripts": { "prepare:web": "node ./scripts/prepare-web.mjs", "sync:android": "pnpm prepare:web && cap sync android", "build:android:debug": "pnpm sync:android && node ./scripts/run-gradle.mjs assembleDebug", + "check:android:debug": "pnpm sync:android && node ./scripts/run-gradle.mjs testDebugUnitTest assembleDebug lintDebug", "build:android:release": "pnpm sync:android && node ./scripts/run-gradle.mjs assembleRelease", "test": "node --test ./scripts/*.test.mjs" }, diff --git a/apps/mobile/scripts/prepare-web.test.mjs b/apps/mobile/scripts/prepare-web.test.mjs index 9dab46a7..bf24df28 100644 --- a/apps/mobile/scripts/prepare-web.test.mjs +++ b/apps/mobile/scripts/prepare-web.test.mjs @@ -43,6 +43,80 @@ test("Android credentials are encrypted by a registered Keystore plugin", async assert.doesNotMatch(plugin, /putString\([^,]+,\s*deviceToken\)/); }); +test("Android foreground resume wakes the browser connection immediately", async () => { + const activity = await readFile( + resolve( + mobileRoot, + "android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java", + ), + "utf8", + ); + + assert.match(activity, /void onResume\(\)/); + assert.match(activity, /super\.onResume\(\)/); + assert.match(activity, /triggerWindowJSEvent\(APP_RESUME_EVENT\)/); + assert.match(activity, /APP_RESUME_EVENT = "t4:native-resume"/); +}); + +test("Android updates use a registered native bridge with no renderer-supplied URL", async () => { + const sourceRoot = resolve( + mobileRoot, + "android/app/src/main/java/com/lycaonsolutions/t4code", + ); + const activity = await readFile(resolve(sourceRoot, "MainActivity.java"), "utf8"); + const fileProvider = await readFile(resolve(sourceRoot, "T4FileProvider.java"), "utf8"); + const fileStore = await readFile(resolve(sourceRoot, "T4UpdateFileStore.java"), "utf8"); + const plugin = await readFile(resolve(sourceRoot, "T4UpdatePlugin.java"), "utf8"); + const verifier = await readFile(resolve(sourceRoot, "T4UpdateVerifier.java"), "utf8"); + const manifest = await readFile(resolve(mobileRoot, "android/app/src/main/AndroidManifest.xml"), "utf8"); + const providerPaths = await readFile( + resolve(mobileRoot, "android/app/src/main/res/xml/file_paths.xml"), + "utf8", + ); + + assert.match(activity, /registerPlugin\(T4UpdatePlugin\.class\)/); + assert.match(plugin, /@CapacitorPlugin\(name = "T4Update"\)/); + assert.match(plugin, /https:\/\/t4code\.net\/releases\/latest\.json/); + assert.match(verifier, /https:\/\/github\.com\/LycaonLLC\/t4-code\/releases\/download\//); + assert.match(plugin, /checkForUpdate\(PluginCall call\)/); + assert.match(plugin, /openUpdate\(PluginCall call\)/); + assert.match(plugin, /T4UpdateVerifier\.copyExact\(input, output, release\.apkSize, release\.apkSha256\)/); + assert.match(plugin, /EXPECTED_PACKAGE_ID = "com\.lycaonsolutions\.t4code"/); + assert.match(plugin, /expectedVersion\.equals\(candidate\.versionName\)/); + assert.match(plugin, /PackageManager\.GET_SIGNING_CERTIFICATES/); + assert.match(plugin, /PackageManager\.GET_SIGNATURES/); + assert.match(plugin, /getSigningCertificateHistory\(\)/); + assert.match(plugin, /T4UpdateVerifier\.isTrustedSignerTransition\(/); + assert.match(plugin, /T4UpdateVerifier\.sameSignerSet\(legacySigners\(installed\), legacySigners\(candidate\)\)/); + assert.match(fileStore, /File\.createTempFile\("T4-Code-" \+ version/); + assert.match(fileStore, /verified\.setReadOnly\(\)/); + assert.match(fileStore, /ACTIVE_OWNERS\.put\(ownershipKey, ownerToken\)/); + assert.match(fileStore, /requireOwnership\(\)/); + assert.match(plugin, /updateState\.installerOpened\(\)/); + assert.match(plugin, /notifyListeners\(STATE_CHANGED_EVENT, state\)/); + assert.match(plugin, /result\.put\("revision", updateState\.revision\(\)\)/); + assert.match(plugin, /BuildConfig\.APPLICATION_ID/); + assert.match(plugin, /BuildConfig\.VERSION_NAME/); + assert.match(plugin, /FileProvider\.getUriForFile/); + assert.match(plugin, /new Intent\(Intent\.ACTION_VIEW\)/); + assert.match(plugin, /Intent\.FLAG_GRANT_READ_URI_PERMISSION/); + assert.match(verifier, /count > expectedSize - total/); + assert.match(verifier, /total != expectedSize/); + assert.match(verifier, /MessageDigest\.getInstance\("SHA-256"\)/); + assert.match(manifest, /android\.permission\.REQUEST_INSTALL_PACKAGES/); + assert.match(fileProvider, /final class T4FileProvider extends FileProvider/); + assert.match(manifest, /android:name="\.T4FileProvider"/); + assert.match(providerPaths, //); + assert.doesNotMatch(providerPaths, / { const prepareScript = await readFile(resolve(mobileRoot, "scripts/prepare-web.mjs"), "utf8"); const hostedIndex = await readFile(resolve(mobileRoot, "../web/index.html"), "utf8"); diff --git a/apps/site/package.json b/apps/site/package.json index 14e35610..e5a4501b 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/site", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "scripts": { diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index 0f6d544e..a948d865 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -6,16 +6,17 @@ export const DOCS_URL = `${SITE_URL}/docs`; export const REPO_URL = "https://github.com/LycaonLLC/t4-code"; export const OMP_URL = "https://github.com/can1357/oh-my-pi"; export const OMP_RUNTIME_VERSION = "17.0.0"; -export const OMP_RUNTIME_COMMIT = "6e2f2350cfe9e6f5db691c311333cae33cdb62ba"; -export const OMP_RUNTIME_TAG = "t4code-17.0.0-appserver-1"; +export const OMP_RUNTIME_COMMIT = "3cba4bda41d2b8e4d304c43471735657893d3b62"; +export const OMP_RUNTIME_TAG = "t4code-17.0.0-appserver-2"; export const OMP_RUNTIME_URL = `https://github.com/lyc-aon/oh-my-pi/tree/${OMP_RUNTIME_TAG}`; export const OMP_UPSTREAM_TAG = "v17.0.0"; export const OMP_UPSTREAM_COMMIT = "d5cd24f39a951bfbd50dc8f50bcf095d59694d6c"; export const OMP_UPSTREAM_URL = `${OMP_URL}/tree/${OMP_UPSTREAM_TAG}`; export const APP_WIRE_VERSION = "0.5.5"; -export const RELEASE_TAG = "v0.1.18"; -export const RELEASE_VERSION = "0.1.18"; +export const RELEASE_TAG = "v0.1.19"; +export const RELEASE_VERSION = "0.1.19"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; +export const RELEASE_MANIFEST_URL = `${SITE_URL}/releases/latest.json`; export type Platform = "android" | "linux" | "mac"; export type DesktopPlatform = Exclude; @@ -48,11 +49,11 @@ function asset( } export const RELEASE_ASSETS: readonly ReleaseAsset[] = [ - asset("android", "apk", "universal", "T4-Code-0.1.18-android.apk", "Android APK"), - asset("linux", "deb", "x86_64", "T4-Code-0.1.18-linux-amd64.deb", "Linux .deb"), - asset("linux", "appimage", "x86_64", "T4-Code-0.1.18-linux-x86_64.AppImage", "Linux AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-0.1.18-mac-arm64.dmg", "macOS .dmg"), - asset("mac", "zip", "arm64", "T4-Code-0.1.18-mac-arm64.zip", "macOS .zip"), + asset("android", "apk", "universal", "T4-Code-0.1.19-android.apk", "Android APK"), + asset("linux", "deb", "x86_64", "T4-Code-0.1.19-linux-amd64.deb", "Linux .deb"), + asset("linux", "appimage", "x86_64", "T4-Code-0.1.19-linux-x86_64.AppImage", "Linux AppImage"), + asset("mac", "dmg", "arm64", "T4-Code-0.1.19-mac-arm64.dmg", "macOS .dmg"), + asset("mac", "zip", "arm64", "T4-Code-0.1.19-mac-arm64.zip", "macOS .zip"), ]; export function assetsFor(platform: Platform): readonly ReleaseAsset[] { diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index c7be4ad1..a28a4438 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -1,4 +1,4 @@ -// Release contract guard: exact v0.1.18 asset names and URLs, and the +// Release contract guard: exact v0.1.19 asset names and URLs, and the // platform-detection rule the hero download button relies on. import { describe, expect, it } from "vite-plus/test"; import { @@ -13,19 +13,20 @@ import { OMP_UPSTREAM_URL, primaryAsset, RELEASE_ASSETS, + RELEASE_MANIFEST_URL, RELEASE_TAG, RELEASE_VERSION, REPO_URL, } from "../src/release.ts"; describe("release assets", () => { - it("carries the five contracted v0.1.18 filenames", () => { + it("carries the five contracted v0.1.19 filenames", () => { expect(RELEASE_ASSETS.map((a) => a.filename)).toEqual([ - "T4-Code-0.1.18-android.apk", - "T4-Code-0.1.18-linux-amd64.deb", - "T4-Code-0.1.18-linux-x86_64.AppImage", - "T4-Code-0.1.18-mac-arm64.dmg", - "T4-Code-0.1.18-mac-arm64.zip", + "T4-Code-0.1.19-android.apk", + "T4-Code-0.1.19-linux-amd64.deb", + "T4-Code-0.1.19-linux-x86_64.AppImage", + "T4-Code-0.1.19-mac-arm64.dmg", + "T4-Code-0.1.19-mac-arm64.zip", ]); }); @@ -37,8 +38,9 @@ describe("release assets", () => { it("targets the public LycaonLLC repo", () => { expect(REPO_URL).toBe("https://github.com/LycaonLLC/t4-code"); - expect(RELEASE_TAG).toBe("v0.1.18"); - expect(RELEASE_VERSION).toBe("0.1.18"); + expect(RELEASE_TAG).toBe("v0.1.19"); + expect(RELEASE_VERSION).toBe("0.1.19"); + expect(RELEASE_MANIFEST_URL).toBe("https://t4code.net/releases/latest.json"); }); it("splits assets by platform with correct architectures", () => { @@ -59,10 +61,10 @@ describe("release assets", () => { describe("OMP integration contract", () => { it("pins the verified runtime tag, commit, and app-wire package", () => { - expect(OMP_RUNTIME_TAG).toBe("t4code-17.0.0-appserver-1"); - expect(OMP_RUNTIME_COMMIT).toBe("6e2f2350cfe9e6f5db691c311333cae33cdb62ba"); + expect(OMP_RUNTIME_TAG).toBe("t4code-17.0.0-appserver-2"); + expect(OMP_RUNTIME_COMMIT).toBe("3cba4bda41d2b8e4d304c43471735657893d3b62"); expect(OMP_RUNTIME_URL).toBe( - "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.0-appserver-1", + "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.0-appserver-2", ); expect(OMP_UPSTREAM_TAG).toBe("v17.0.0"); expect(OMP_UPSTREAM_COMMIT).toBe("d5cd24f39a951bfbd50dc8f50bcf095d59694d6c"); diff --git a/apps/web/index.html b/apps/web/index.html index 4cdc1a58..f4c9038b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -8,11 +8,13 @@ /> + + T4 Code diff --git a/apps/web/package.json b/apps/web/package.json index fd7a3ccd..708b2f49 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/web", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/Titlebar.tsx b/apps/web/src/components/Titlebar.tsx index 88ea9abe..ef4ba18c 100644 --- a/apps/web/src/components/Titlebar.tsx +++ b/apps/web/src/components/Titlebar.tsx @@ -4,7 +4,11 @@ import { Badge, BrandLockup, IconButton, Tooltip, TooltipPopup, TooltipTrigger } from "@t4-code/ui"; import { useNavigate } from "@tanstack/react-router"; import { Command, Moon, PanelLeft, Settings, Sun } from "lucide-react"; +import { useEffect } from "react"; +import { updateIsAvailable } from "../features/updates/update-model.ts"; +import { subscribeNativeUpdateSettingsOpen } from "../features/updates/update-navigation.ts"; +import { useAppUpdateState } from "../features/updates/update-store.ts"; import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts"; import { resolveTheme } from "../theme/theme.ts"; import { HostedAppAction } from "./HostedAppAction.tsx"; @@ -42,21 +46,39 @@ function ThemeToggle() { function SettingsButton() { const navigate = useNavigate(); + const update = useAppUpdateState(); + const hasUpdate = updateIsAvailable(update.phase); + + useEffect( + () => subscribeNativeUpdateSettingsOpen(() => void navigate({ to: "/settings" })), + [navigate], + ); + return ( void navigate({ to: "/settings" })} size="icon-sm" > - + + + {hasUpdate && ( + } /> - Settings (Ctrl+,) + + {hasUpdate ? "Settings · T4 Code update available" : "Settings (Ctrl+,)"} + ); } diff --git a/apps/web/src/features/composer/slash.ts b/apps/web/src/features/composer/slash.ts index 97e3837b..1d0dc933 100644 --- a/apps/web/src/features/composer/slash.ts +++ b/apps/web/src/features/composer/slash.ts @@ -45,7 +45,8 @@ export function slashCommandsFromCatalog( const commands: SlashCommand[] = []; for (const item of items) { if (item.kind !== "command") continue; - const name = `/${item.name.replace(/^\/+/, "")}`; + const bareName = item.name.replace(/^\/+/, ""); + const name = `/${bareName}`; const metadata = item.metadata ?? {}; const rawAliases = Array.isArray(metadata.aliases) ? metadata.aliases : []; const aliases = rawAliases @@ -63,7 +64,11 @@ export function slashCommandsFromCatalog( ? missingCapability === "terminal.io" ? "Needs terminal access on this host" : "Not granted on this host" - : null); + : context.turnActive && bareName === "compact" + ? "Wait for the turn to finish" + : context.turnActive && bareName === "retry" + ? "A turn is already running" + : null); commands.push({ name, aliases, @@ -252,7 +257,8 @@ export function searchSlashCommands( if (score !== null) ranked.push({ command, score }); } ranked.sort( - (left, right) => left.score - right.score || left.command.name.localeCompare(right.command.name), + (left, right) => + left.score - right.score || left.command.name.localeCompare(right.command.name), ); return ranked.map((entry) => entry.command); } diff --git a/apps/web/src/features/panes/live-inspector.ts b/apps/web/src/features/panes/live-inspector.ts index 3ddd9ce4..d7e3089c 100644 --- a/apps/web/src/features/panes/live-inspector.ts +++ b/apps/web/src/features/panes/live-inspector.ts @@ -140,10 +140,7 @@ export function deriveActionAvailability( }; } -function sameAvailability( - a: InspectorActionAvailability, - b: InspectorActionAvailability, -): boolean { +function sameAvailability(a: InspectorActionAvailability, b: InspectorActionAvailability): boolean { const pairs: ReadonlyArray = [ [a.agentSteer, b.agentSteer], [a.agentCancel, b.agentCancel], @@ -151,7 +148,9 @@ function sameAvailability( [a.reviewApply, b.reviewApply], [a.reviewDiscard, b.reviewDiscard], ]; - return pairs.every(([left, right]) => left.enabled === right.enabled && left.reason === right.reason); + return pairs.every( + ([left, right]) => left.enabled === right.enabled && left.reason === right.reason, + ); } interface LiveSessionAddress { @@ -247,7 +246,9 @@ export function createLiveInspectorStore( performControl(scope) { if (scope.action !== "cancel") return; const snapshot = runtime.getSnapshot(); - if (!commandAvailability(snapshot, address.targetId, address.hostId, "agent.cancel").enabled) { + if ( + !commandAvailability(snapshot, address.targetId, address.hostId, "agent.cancel").enabled + ) { return; } void sendCommand("agent.cancel", { agentId: scope.agentId }, true).catch(() => { @@ -257,7 +258,9 @@ export function createLiveInspectorStore( performReview(action, path) { if (action !== "apply") return; const snapshot = runtime.getSnapshot(); - if (!commandAvailability(snapshot, address.targetId, address.hostId, "review.apply").enabled) { + if ( + !commandAvailability(snapshot, address.targetId, address.hostId, "review.apply").enabled + ) { return; } const reviewId = reviewIdByPath.get(path); @@ -282,7 +285,12 @@ export function createLiveInspectorStore( }, loadDir(path) { const snapshot = runtime.getSnapshot(); - const listable = commandAvailability(snapshot, address.targetId, address.hostId, "files.list"); + const listable = commandAvailability( + snapshot, + address.targetId, + address.hostId, + "files.list", + ); if (listable.enabled) { void sendCommand("files.list", path === "" ? {} : { path }, false) .then((result) => { @@ -312,7 +320,12 @@ export function createLiveInspectorStore( resolvePreview(api, { kind: "offline", path }); return; } - const readable = commandAvailability(snapshot, address.targetId, address.hostId, "files.read"); + const readable = commandAvailability( + snapshot, + address.targetId, + address.hostId, + "files.read", + ); if (readable.enabled) { void sendCommand("files.read", { path }, false) .then((result) => { @@ -470,6 +483,8 @@ function emptyProjection(): SessionProjection { confirmations: new Map(), results: new Map(), freshness: "cached", + transcriptEventArrivalOrdinal: 0, + contextMaintenanceEventArrivalOrdinal: 0, entryIds: new Set(), }; } diff --git a/apps/web/src/features/session-runtime/controller.ts b/apps/web/src/features/session-runtime/controller.ts index 4ac6f2e8..394f2f70 100644 --- a/apps/web/src/features/session-runtime/controller.ts +++ b/apps/web/src/features/session-runtime/controller.ts @@ -6,6 +6,8 @@ import { initialProjection, reduceTranscript, + transcriptIsActive, + type PendingPrompt, type TranscriptFrame, type TranscriptProjection, } from "../transcript/projection.ts"; @@ -37,6 +39,10 @@ export type SessionLink = "live" | "cached" | "offline"; export interface SessionRuntimeSnapshot { readonly projection: TranscriptProjection; readonly link: SessionLink; + /** One activity truth for rendering and all composer behavior. */ + readonly sessionActive: boolean; + /** Accepted user prompts recovered from authoritative session-ref state. */ + readonly pendingPrompts: readonly PendingPrompt[]; /** Commands this connection may send; gates composer affordances. */ readonly canPrompt: boolean; readonly canCancel: boolean; @@ -136,7 +142,7 @@ export function createFixtureSessionRuntime(options: FixtureRuntimeOptions): Ses }; const drainQueuedFollowUps = () => { - if (projection.turnActive || queuedFollowUps.length === 0) return; + if (transcriptIsActive(projection) || queuedFollowUps.length === 0) return; const [head, ...rest] = queuedFollowUps; queuedFollowUps = rest; if (head !== undefined) { @@ -152,7 +158,7 @@ export function createFixtureSessionRuntime(options: FixtureRuntimeOptions): Ses timer = setInterval(() => { const batch = pendingTicks.shift(); if (batch !== undefined) applyFrames(batch); - if (!projection.turnActive) drainQueuedFollowUps(); + if (!transcriptIsActive(projection)) drainQueuedFollowUps(); if (pendingTicks.length === 0 && timer !== null) { clearInterval(timer); timer = null; @@ -170,6 +176,7 @@ export function createFixtureSessionRuntime(options: FixtureRuntimeOptions): Ses transcriptImages, getSnapshot() { if (snapshot === null) { + const sessionActive = transcriptIsActive(projection); const controls: ComposerControlsSnapshot = { modelSupported: link === "live", modelUnsupportedReason: link === "live" ? null : "This session is read-only right now.", @@ -177,7 +184,8 @@ export function createFixtureSessionRuntime(options: FixtureRuntimeOptions): Ses modelSelectedId: model?.id ?? null, modelChoices: script.modelChoices, thinkingSupported: link === "live", - thinkingUnsupportedReason: link === "live" ? null : "This session is read-only right now.", + thinkingUnsupportedReason: + link === "live" ? null : "This session is read-only right now.", thinking, thinkingLevels: THINKING_LEVELS, fastSupported: link === "live", @@ -192,8 +200,10 @@ export function createFixtureSessionRuntime(options: FixtureRuntimeOptions): Ses snapshot = { projection, link, + sessionActive, + pendingPrompts: [], canPrompt: link === "live", - canCancel: link === "live" && projection.turnActive, + canCancel: link === "live" && sessionActive, cancelDisabledReason: null, slashCommands: null, contextUsedTokens: script.contextUsedTokens, @@ -237,7 +247,7 @@ export function createFixtureSessionRuntime(options: FixtureRuntimeOptions): Ses return; } if (intent.kind === "followUp") { - if (projection.turnActive) { + if (transcriptIsActive(projection)) { queuedFollowUps = [...queuedFollowUps, intent.text]; notify(); return; @@ -263,6 +273,7 @@ export function createFixtureSessionRuntime(options: FixtureRuntimeOptions): Ses }, pause() { paused = true; + transcriptImages.pause(); if (timer !== null) { clearInterval(timer); timer = null; @@ -270,6 +281,7 @@ export function createFixtureSessionRuntime(options: FixtureRuntimeOptions): Ses }, resume() { paused = false; + transcriptImages.resume(); ensureTimer(); }, dispose() { diff --git a/apps/web/src/features/session-runtime/fixtures.ts b/apps/web/src/features/session-runtime/fixtures.ts index ccbb0d1d..1208f91b 100644 --- a/apps/web/src/features/session-runtime/fixtures.ts +++ b/apps/web/src/features/session-runtime/fixtures.ts @@ -10,7 +10,7 @@ import { FrameFactory } from "./frame-builders.ts"; import type { SessionIntent } from "./intents.ts"; import type { ModelChoice } from "./session-controls.ts"; -export type TranscriptVariant = "default" | "stress" | "gap"; +export type TranscriptVariant = "default" | "stress" | "gap" | "compaction"; export interface SessionScript { readonly link: "live" | "cached" | "offline"; @@ -401,6 +401,28 @@ function scriptFor(sessionKey: string, variant: TranscriptVariant): Omit turn handoff in the UI. + liveSteps: [factory.event({ type: "turn.start", at: at(12) })], + factory, + }; + } + switch (sessionKey) { case "sess-stream": { const entries = makeEntries(factory, historySeeds()); diff --git a/apps/web/src/features/session-runtime/live-runtime.ts b/apps/web/src/features/session-runtime/live-runtime.ts index 46ecc7c1..8c05a0f9 100644 --- a/apps/web/src/features/session-runtime/live-runtime.ts +++ b/apps/web/src/features/session-runtime/live-runtime.ts @@ -17,6 +17,7 @@ import { type CatalogItem, type ConfirmationChallenge, type Revision, + type SessionEvent, type SessionRef, type SessionSnapshotFrame, } from "@t4-code/protocol"; @@ -25,6 +26,10 @@ import type { RendererServerFrame } from "@t4-code/protocol/desktop-ipc"; import { initialProjection, reduceTranscript, + replayRetainedTranscriptEvents, + retainedTranscriptEventsAreValid, + settleTranscriptTurn, + transcriptIsActive, type ApprovalRequest, type TranscriptFrame, type TranscriptProjection, @@ -39,6 +44,7 @@ import type { import { IMAGE_PROMPTS_UNSUPPORTED_REASON, type SessionIntent } from "./intents.ts"; import { runImagePromptUpload } from "./image-upload.ts"; import { promptRejectionReason } from "./command-errors.ts"; +import { pendingPromptsFromRef } from "./pending-prompts.ts"; import { createTranscriptImageSource, type TranscriptImageAvailability, @@ -51,6 +57,8 @@ import { THINKING_SET_COMMAND, type PendingControl, } from "./session-controls.ts"; +import { sessionIsWorking } from "./session-management.ts"; +import { hostSessionInventoryIsComplete } from "./session-inventory.ts"; export interface LiveRuntimeOptions { readonly controller: DesktopRuntimeController; @@ -70,18 +78,14 @@ const CONTROL_REJECTED: Record = { }; const CONTROL_UNKNOWN = "The connection dropped before the host answered. The control shows the host's last confirmed value."; +const MAX_RETIRED_PENDING_PROMPTS = 128; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Frame types the transcript reducer accepts; mirrors the subscription. */ -const TRANSCRIPT_FRAME_TYPES: ReadonlySet = new Set([ - "snapshot", - "entry", - "event", - "gap", -]); +const TRANSCRIPT_FRAME_TYPES: ReadonlySet = new Set(["snapshot", "entry", "event", "gap"]); function isTranscriptFrame(frame: RendererServerFrame): frame is TranscriptFrame { return TRANSCRIPT_FRAME_TYPES.has(frame.type); @@ -101,12 +105,78 @@ function getQueuedFollowUps(ref: SessionRef | undefined): readonly string[] { return result; } +function retiredPendingPromptId(event: SessionEvent): string | null { + if (event.type !== "message.settled" && event.type !== "message.discarded") return null; + const candidate = + typeof event.transientEntryId === "string" + ? event.transientEntryId + : event.type === "message.discarded" && typeof event.entryId === "string" + ? event.entryId + : null; + return candidate !== null && candidate.length > 0 && candidate.length <= 512 ? candidate : null; +} + +function activePendingPromptId(event: SessionEvent): string | null { + if ( + (event.type !== "message.update" && event.type !== "message.delta") || + event.role !== "user" || + typeof event.entryId !== "string" + ) { + return null; + } + return event.entryId.length > 0 && event.entryId.length <= 512 ? event.entryId : null; +} + +function sessionIsWorkingWithPendingPrompts( + ref: SessionRef | undefined, + pendingPrompts: ReturnType, +): boolean { + if (ref === undefined) return false; + const liveState = isRecord(ref.liveState) ? ref.liveState : {}; + return sessionIsWorking({ + ...ref, + // A present plural value is authoritative over any lagging singular + // fallback, while every independent queue/stream/compaction signal stays. + liveState: { ...liveState, pendingPrompts }, + } as SessionRef); +} + +function sessionRefIsCompacting(ref: SessionRef | undefined): boolean { + if (!isRecord(ref?.liveState)) return false; + return ref.liveState.isCompacting === true || ref.liveState.phase === "compacting"; +} + +/** + * Return working truth only when this connected host has supplied a complete + * live inventory. Cached or truncated rows may be useful to render, but they + * cannot settle a turn after a disconnect. + */ +function authoritativeWorkingState( + runtime: DesktopRuntimeSnapshot, + targetId: string, + hostId: string, + projectionKey: string, + retiredPendingPromptIds: ReadonlySet, +): boolean | null { + if (runtime.connections.get(targetId) !== "connected") return null; + if (runtime.targetHosts.get(targetId) !== hostId) return null; + if (!hostSessionInventoryIsComplete(runtime, hostId)) return null; + const ref = runtime.projection.sessionIndex.get(projectionKey); + if (ref === undefined) return null; + const pendingPrompts = pendingPromptsFromRef(ref).filter( + (prompt) => !retiredPendingPromptIds.has(prompt.entryId), + ); + return sessionIsWorkingWithPendingPrompts(ref, pendingPrompts); +} + /** Commands the runtime recognizes as this session's abort affordance. */ function findCancelCommand(items: readonly CatalogItem[]): CatalogItem | undefined { return items.find( (item) => item.kind === "command" && - (String(item.id) === "session.cancel" || item.name === "session.cancel" || item.name === "cancel"), + (String(item.id) === "session.cancel" || + item.name === "session.cancel" || + item.name === "cancel"), ); } @@ -124,6 +194,22 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu let transcript = initialProjection(); let snapshot: SessionRuntimeSnapshot | null = null; let disposed = false; + const retiredPendingPromptIds = new Set(); + const retirePendingPrompt = (entryId: string) => { + if (retiredPendingPromptIds.has(entryId)) return; + retiredPendingPromptIds.add(entryId); + while (retiredPendingPromptIds.size > MAX_RETIRED_PENDING_PROMPTS) { + const oldest = retiredPendingPromptIds.values().next().value; + if (oldest === undefined) break; + retiredPendingPromptIds.delete(oldest); + } + }; + const applyPendingPromptLifecycle = (event: SessionEvent) => { + const activeId = activePendingPromptId(event); + if (activeId !== null) retiredPendingPromptIds.delete(activeId); + const retiredId = retiredPendingPromptId(event); + if (retiredId !== null) retirePendingPrompt(retiredId); + }; // Composer control command state: which control awaits the host, and the // last failure. Values themselves always come from server state — the // label never swaps optimistically. @@ -190,15 +276,23 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu const warmSession = (runtime: DesktopRuntimeSnapshot): SessionProjection | undefined => runtime.projection.sessions.get(projectionKey); + const withWarmHistoryTruncation = ( + projection: TranscriptProjection, + runtime: DesktopRuntimeSnapshot, + ): TranscriptProjection => + warmSession(runtime)?.historyTruncated === true && !projection.historyTruncated + ? { ...projection, historyTruncated: true } + : projection; + const notify = () => { snapshot = null; for (const listener of listeners) listener(); }; - // Seed from the controller's warm projection: settled entries install as a - // synthetic snapshot at the session cursor, so later live frames apply - // contiguously. In-flight stream state before attach is not reconstructed - // — the next real frame or snapshot brings it. + // Seed from the controller's warm projection. Durable entries install at the + // authoritative warm cursor; the bounded event suffix is then folded in its + // original order to restore requests and other event-derived state. Event + // sequence gaps are expected because durable entry frames live separately. const warm = warmSession(controller.getSnapshot()); if (warm !== undefined && warm.cursor !== undefined) { const seed: SessionSnapshotFrame = { @@ -210,7 +304,22 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu sessionId: wireSessionId, entries: [...warm.entries], }; - transcript = reduceTranscript(transcript, seed); + transcript = withWarmHistoryTruncation( + reduceTranscript(transcript, seed), + controller.getSnapshot(), + ); + const baseline = { + cursor: warm.cursor, + hostId: options.hostId, + sessionId: options.sessionId, + }; + const validWarmEvents = retainedTranscriptEventsAreValid(transcript, warm.events, baseline); + if (validWarmEvents) { + for (const frame of warm.events) applyPendingPromptLifecycle(frame.event); + } + if (warm.gap === undefined && validWarmEvents) { + transcript = replayRetainedTranscriptEvents(transcript, warm.events, baseline); + } } const expectedRevision = (): Revision | undefined => { @@ -230,7 +339,8 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu promptLeaseRevision?: Revision, ): Promise => { const revisionValue = withRevision ? (revisionOverride ?? expectedRevision()) : undefined; - if (withRevision && revisionValue === undefined) return { kind: "unknown", reason: UNKNOWN_REASON }; + if (withRevision && revisionValue === undefined) + return { kind: "unknown", reason: UNKNOWN_REASON }; try { const intentPayload = { hostId: wireHostId, @@ -319,7 +429,11 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu args: Record, ): Promise => { const runtime = controller.getSnapshot(); - const support = commandSupport(runtime.catalogs.get(options.hostId), grantedFor(runtime), command); + const support = commandSupport( + runtime.catalogs.get(options.hostId), + grantedFor(runtime), + command, + ); if (!support.supported) { const reason = support.reason ?? "Not available on this host"; controlError = reason; @@ -407,7 +521,22 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu }; const applyFrame = (frame: TranscriptFrame) => { - const next = reduceTranscript(transcript, frame); + // Renderer frames are sanitized to their global retention budget before + // delivery. Preserve the shared client's smaller/custom retention truth, + // which is otherwise not representable on the app-wire snapshot itself. + const reduced = reduceTranscript(transcript, frame); + // Raw frame subscribers still receive stale/duplicate/gapped events that + // the reducer correctly refuses. Only advance prompt retirement state when + // the transcript actually accepted this event at its advertised cursor. + if ( + frame.type === "event" && + reduced !== transcript && + reduced.cursor?.epoch === frame.cursor.epoch && + reduced.cursor.seq === frame.cursor.seq + ) { + applyPendingPromptLifecycle(frame.event); + } + const next = withWarmHistoryTruncation(reduced, controller.getSnapshot()); if (next !== transcript) { transcript = next; notify(); @@ -419,6 +548,27 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu let retryAfterAttach = false; let connectionGeneration = 0; let previousConnected = controller.getSnapshot().connections.get(targetId) === "connected"; + const initialAuthoritativeWorking = authoritativeWorkingState( + controller.getSnapshot(), + targetId, + options.hostId, + projectionKey, + retiredPendingPromptIds, + ); + const initialProjectionSnapshot = controller.getSnapshot().projection; + const warmTranscriptEventOrdinal = warm?.transcriptEventArrivalOrdinal ?? 0; + const authoritativeRefOrdinal = + initialProjectionSnapshot.sessionRefArrivalOrdinals.get(projectionKey) ?? 0; + if ( + initialAuthoritativeWorking === false && + authoritativeRefOrdinal > warmTranscriptEventOrdinal + ) { + // Transcript and session-index cursors are independent. Settle warm + // volatile UI only when this process observed complete idle ref truth + // after the last accepted transcript event. This preserves a current + // turn/compaction whose active ref delta has not arrived yet. + transcript = settleTranscriptTurn(transcript); + } const attachIfConnected = (runtime: DesktopRuntimeSnapshot) => { if (disposed) return; const connected = runtime.connections.get(targetId) === "connected"; @@ -442,7 +592,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu const generation = connectionGeneration; attached = true; void controller - .attachSession(targetId, options.hostId, options.sessionId) + .attachSession(targetId, options.hostId, options.sessionId, transcript.cursor ?? undefined) .then((result) => { const current = controller.getSnapshot(); transcriptImagesAttached = result.accepted === true && generation === connectionGeneration; @@ -467,9 +617,6 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu } }); }; - syncTranscriptImageAvailability(controller.getSnapshot()); - attachIfConnected(controller.getSnapshot()); - const unsubscribeFrames = controller.subscribeFrames( { targetId, @@ -481,16 +628,52 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu types: ["snapshot", "entry", "event", "gap"], }, (event) => { - if (isTranscriptFrame(event.frame)) applyFrame(event.frame); + if (isTranscriptFrame(event.frame)) { + applyFrame(event.frame); + } }, ); // Connection state, catalog, confirmation, and freshness changes all // surface through the controller snapshot; re-derive on every change. const unsubscribeRuntime = controller.subscribe((runtime) => { + const retainedTranscript = withWarmHistoryTruncation(transcript, runtime); + if (retainedTranscript !== transcript) transcript = retainedTranscript; attachIfConnected(runtime); + const authoritativeWorking = authoritativeWorkingState( + runtime, + targetId, + options.hostId, + projectionKey, + retiredPendingPromptIds, + ); + const warmNow = runtime.projection.sessions.get(projectionKey); + const newestTranscriptEventOrdinal = warmNow?.transcriptEventArrivalOrdinal ?? 0; + const newestRefOrdinal = runtime.projection.sessionRefArrivalOrdinals.get(projectionKey) ?? 0; + if ( + authoritativeWorking === false && + newestRefOrdinal > newestTranscriptEventOrdinal + ) { + // Settle on receive order, not a working true -> false edge. A mounted + // runtime may miss the active ref entirely (null -> idle) or receive two + // idle refs around newer transcript activity (idle -> idle). The newer + // complete ref is the proof; stale metadata/ref generations are fenced + // by authoritativeWorkingState above. + const next = settleTranscriptTurn(transcript, { + // turn.error is diagnostic until terminal lifecycle or ref proof. + // Generation filtering preserves the current turn's explanation + // while allowing this idle proof to retire an older turn's error. + supersedeTransientErrors: transcript.turnActive, + }); + if (next !== transcript) transcript = next; + } syncTranscriptImageAvailability(runtime); notify(); }); + // Subscribe before issuing attach. OMP may synchronously replay frames as + // part of the attach round-trip; no replay frame may land in the gap between + // runtime construction and listener registration. + syncTranscriptImageAvailability(controller.getSnapshot()); + attachIfConnected(controller.getSnapshot()); const pendingChallenge = (runtime: DesktopRuntimeSnapshot): PendingChallenge | null => { const confirmations = warmSession(runtime)?.confirmations; @@ -681,31 +864,58 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu const runtime = controller.getSnapshot(); const warmNow = warmSession(runtime); const connection = runtime.connections.get(targetId); + const indexedRef = runtime.projection.sessionIndex.get(projectionKey); + const inventoryReady = + runtime.targetHosts.get(targetId) === options.hostId && + (indexedRef === undefined || hostSessionInventoryIsComplete(runtime, options.hostId)); const link: SessionLink = connection !== "connected" ? "offline" - : warmNow !== undefined && warmNow.freshness !== "fresh" + : !inventoryReady || (warmNow !== undefined && warmNow.freshness !== "fresh") ? "cached" : "live"; const granted = grantedFor(runtime); const catalog = runtime.catalogs.get(options.hostId); + // Session control truth: warm ref first, session index second. + const ref = warmNow?.ref ?? indexedRef; + const pendingPrompts = pendingPromptsFromRef(ref).filter( + (prompt) => !retiredPendingPromptIds.has(prompt.entryId), + ); + const sessionActive = + link === "live" && + (transcriptIsActive(transcript) || + pendingPrompts.length > 0 || + sessionIsWorkingWithPendingPrompts(ref, pendingPrompts)); const cancelItem = catalog === undefined ? undefined : findCancelCommand(catalog.items); const cancelSupported = cancelItem !== undefined && cancelItem.supported !== false; - const canCancel = link === "live" && transcript.turnActive && cancelSupported; + const canCancel = link === "live" && sessionActive && cancelSupported; const cancelDisabledReason = cancelSupported ? null : catalog === undefined ? "Waiting for this host's command list" : (cancelItem?.reason ?? "This host does not offer a stop command"); const challenge = pendingChallenge(runtime); - const projection: TranscriptProjection = + let projection: TranscriptProjection = transcript.approval === null && challenge !== null ? { ...transcript, approval: challenge.approval } : transcript; + if ( + link === "live" && + projection.contextMaintenance === null && + sessionRefIsCompacting(indexedRef) && + (runtime.projection.sessionRefArrivalOrdinals.get(projectionKey) ?? 0) > + (warmNow?.contextMaintenanceEventArrivalOrdinal ?? 0) + ) { + projection = { + ...projection, + contextMaintenance: { + startedAt: null, + reason: "Restored from current session state", + }, + }; + } const contextUsage = runtime.projection.sessionIndex.get(projectionKey)?.contextUsage; - // Session control truth: warm ref first, session index second. - const ref = warmNow?.ref ?? runtime.projection.sessionIndex.get(projectionKey); const canPrompt = link === "live" && granted.includes("sessions.prompt") && ref?.status !== "closed"; const queuedFollowUps = getQueuedFollowUps(ref); @@ -713,6 +923,8 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu snapshot = { projection, link, + sessionActive, + pendingPrompts, canPrompt, canCancel, cancelDisabledReason, @@ -721,7 +933,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu ? [] : slashCommandsFromCatalog( catalog.items, - { link, turnActive: transcript.turnActive }, + { link, turnActive: sessionActive }, granted, ), contextUsedTokens: contextUsage?.used ?? 0, @@ -750,9 +962,14 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu submitPrompt, pause() { // Live frames keep applying in the background so switch-back is warm. + // Image bytes do not: every inactive runtime releases its object URLs + // and cancels reads so the eight-session projection LRU cannot become + // eight independent 64 MiB renderer caches. + transcriptImages.pause(); }, resume() { if (disposed) return; + transcriptImages.resume(); // Re-activate this session in the shared projection LRU. controller.activateSession(options.hostId, options.sessionId); }, diff --git a/apps/web/src/features/session-runtime/pending-prompts.ts b/apps/web/src/features/session-runtime/pending-prompts.ts new file mode 100644 index 00000000..d2cf8abc --- /dev/null +++ b/apps/web/src/features/session-runtime/pending-prompts.ts @@ -0,0 +1,57 @@ +import { retainedText } from "@t4-code/client"; +import type { SessionRef } from "@t4-code/protocol"; + +import { + boundedAttachmentCount, + MAX_ACCEPTED_PENDING_PROMPTS, + type PendingPrompt, +} from "../transcript/projection.ts"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function pendingPromptFromValue(value: unknown): PendingPrompt | null { + if (!isRecord(value)) return null; + const { entryId, text, at } = value; + if ( + typeof entryId !== "string" || + entryId.length === 0 || + entryId.length > 512 || + typeof text !== "string" || + typeof at !== "string" || + !Number.isFinite(Date.parse(at)) + ) { + return null; + } + return { + entryId, + text: retainedText(text, 8 * 1024), + attachmentCount: boundedAttachmentCount(value.attachmentCount), + at, + }; +} + +/** + * Parse authoritative accepted-prompt state. A present plural field wins even + * when empty or malformed; singular state is only a transition fallback for + * older hosts that do not emit `pendingPrompts` yet. + */ +export function pendingPromptsFromRef(ref: SessionRef | undefined): readonly PendingPrompt[] { + if (!isRecord(ref?.liveState)) return []; + if (Object.hasOwn(ref.liveState, "pendingPrompts")) { + const plural = ref.liveState.pendingPrompts; + if (!Array.isArray(plural)) return []; + const prompts: PendingPrompt[] = []; + const seen = new Set(); + for (const value of plural.slice(0, MAX_ACCEPTED_PENDING_PROMPTS)) { + const prompt = pendingPromptFromValue(value); + if (prompt === null || seen.has(prompt.entryId)) continue; + seen.add(prompt.entryId); + prompts.push(prompt); + } + return prompts; + } + const legacy = pendingPromptFromValue(ref.liveState.pendingPrompt); + return legacy === null ? [] : [legacy]; +} diff --git a/apps/web/src/features/session-runtime/session-event-vocabulary.ts b/apps/web/src/features/session-runtime/session-event-vocabulary.ts index d459f1a6..b26ac6ec 100644 --- a/apps/web/src/features/session-runtime/session-event-vocabulary.ts +++ b/apps/web/src/features/session-runtime/session-event-vocabulary.ts @@ -12,6 +12,7 @@ export type SessionEventProjectionKind = | "message-delta" | "message-update" | "message-settled" + | "message-discarded" | "tool-start" | "tool-progress" | "tool-result" @@ -25,6 +26,8 @@ export type SessionEventProjectionKind = | "turn-error" | "turn-retry" | "compaction" + | "compaction-start" + | "compaction-end" | "inspect-only"; export interface SessionEventSpec { @@ -43,6 +46,7 @@ export const SESSION_EVENT_VOCABULARY = { "message.delta": { activityKind: "system", projection: "message-delta" }, "message.update": { activityKind: "system", projection: "message-update" }, "message.settled": { activityKind: "system", projection: "message-settled" }, + "message.discarded": { activityKind: "system", projection: "message-discarded" }, "tool.start": { activityKind: "tool", projection: "tool-start" }, "tool.progress": { activityKind: "tool", projection: "tool-progress" }, "tool.result": { activityKind: "tool", projection: "tool-result" }, @@ -56,8 +60,8 @@ export const SESSION_EVENT_VOCABULARY = { "turn.retry": { activityKind: "system", projection: "turn-retry" }, "turn.retry.result": { activityKind: "system", projection: "inspect-only" }, compaction: { activityKind: "system", projection: "compaction" }, - "compaction.start": { activityKind: "system", projection: "inspect-only" }, - "compaction.end": { activityKind: "system", projection: "inspect-only" }, + "compaction.start": { activityKind: "system", projection: "compaction-start" }, + "compaction.end": { activityKind: "system", projection: "compaction-end" }, // Complete canonical OMP appserver runtime vocabulary. Most lifecycle and // support events belong in Activity rather than as transcript rows, but @@ -109,6 +113,7 @@ export const OMP_APPSERVER_SESSION_EVENT_TYPES = [ "turn.end", "message.update", "message.settled", + "message.discarded", "tool.start", "tool.progress", "tool.result", diff --git a/apps/web/src/features/session-runtime/session-inventory.ts b/apps/web/src/features/session-runtime/session-inventory.ts new file mode 100644 index 00000000..b461935b --- /dev/null +++ b/apps/web/src/features/session-runtime/session-inventory.ts @@ -0,0 +1,15 @@ +import type { DesktopRuntimeSnapshot } from "@t4-code/client"; + +/** True only when this host has supplied one complete current session inventory. */ +export function hostSessionInventoryIsComplete( + snapshot: DesktopRuntimeSnapshot, + hostId: string, +): boolean { + const metadata = snapshot.projection.sessionIndexMetadata.get(hostId); + if (metadata === undefined || metadata.truncated) return false; + let indexed = 0; + for (const ref of snapshot.projection.sessionIndex.values()) { + if (String(ref.hostId) === hostId) indexed += 1; + } + return indexed === metadata.totalCount; +} diff --git a/apps/web/src/features/session-runtime/session-management.ts b/apps/web/src/features/session-runtime/session-management.ts index e32c500e..a6273f04 100644 --- a/apps/web/src/features/session-runtime/session-management.ts +++ b/apps/web/src/features/session-runtime/session-management.ts @@ -11,6 +11,7 @@ import type { CommandResult } from "@t4-code/protocol/desktop-ipc"; import type { LiveProjectAddress, LiveSessionAddress } from "../../platform/live-workspace.ts"; import { commandSupport } from "./session-controls.ts"; import { sessionActionRejectionReason } from "./command-errors.ts"; +import { pendingPromptsFromRef } from "./pending-prompts.ts"; export type SessionManagementCommand = | "session.rename" @@ -79,6 +80,7 @@ export function sessionIsClosed(ref: SessionRef | undefined): boolean { export function sessionIsWorking(ref: SessionRef | undefined): boolean { if (ref === undefined) return false; + if (pendingPromptsFromRef(ref).length > 0) return true; const rawRef = ref as unknown as Record; if ( ref.status === "active" || diff --git a/apps/web/src/features/session-runtime/transcript-images.ts b/apps/web/src/features/session-runtime/transcript-images.ts index 08a2391c..c2e1b797 100644 --- a/apps/web/src/features/session-runtime/transcript-images.ts +++ b/apps/web/src/features/session-runtime/transcript-images.ts @@ -23,6 +23,8 @@ export const TRANSCRIPT_IMAGE_CACHE_ERROR = "This image cannot fit in the bounded transcript image cache right now."; export const TRANSCRIPT_IMAGE_FIXTURE_REASON = "Transcript images are available only from a connected OMP host."; +export const TRANSCRIPT_IMAGE_PAUSED_REASON = + "Switch back to this session to reload its transcript images."; export type TranscriptImageAvailability = | { readonly available: true } @@ -101,6 +103,15 @@ interface CacheEntry { url: string | null; size: number; reservedSize: number; + accumulator: TranscriptImageAccumulator | null; + /** Full contiguous bytes exist only after the final chunk, during digest/finalization. */ + materializedSize: number; +} + +interface TranscriptImageAccumulator { + readonly expectedSize: number; + readonly chunks: Uint8Array[]; + byteLength: number; } class TranscriptImageFailure extends Error { @@ -154,6 +165,70 @@ class ManagedTranscriptImageReadSignal implements TranscriptImageReadSignal { } } +interface TranscriptImageFinalizationWaiter { + readonly signal: ManagedTranscriptImageReadSignal; + readonly resolve: (release: () => void) => void; + readonly reject: (error: TranscriptImageCancelled) => void; + removeCancel: () => void; +} + +/** + * Cross-session finalization authority. Only one load may materialize its + * contiguous digest input at a time, bounding the renderer-wide peak to one + * image plus any browser-internal digest/Blob snapshot. Queued loads retain + * only received segments, which their session pause can synchronously clear. + */ +class TranscriptImageFinalizationGate { + private readonly waiters: TranscriptImageFinalizationWaiter[] = []; + private held = false; + + acquire(signal: ManagedTranscriptImageReadSignal): Promise<() => void> { + if (signal.cancelled) return Promise.reject(new TranscriptImageCancelled()); + if (!this.held) { + this.held = true; + return Promise.resolve(this.releaseHandle()); + } + return new Promise((resolve, reject) => { + const waiter: TranscriptImageFinalizationWaiter = { + signal, + resolve, + reject, + removeCancel: () => undefined, + }; + this.waiters.push(waiter); + waiter.removeCancel = signal.onCancel(() => { + const index = this.waiters.indexOf(waiter); + if (index < 0) return; + this.waiters.splice(index, 1); + reject(new TranscriptImageCancelled()); + }); + }); + } + + private releaseHandle(): () => void { + let released = false; + return () => { + if (released) return; + released = true; + this.held = false; + while (this.waiters.length > 0) { + const waiter = this.waiters.shift(); + if (waiter === undefined) return; + waiter.removeCancel(); + if (waiter.signal.cancelled) { + waiter.reject(new TranscriptImageCancelled()); + continue; + } + this.held = true; + waiter.resolve(this.releaseHandle()); + return; + } + }; + } +} + +const transcriptImageFinalizationGate = new TranscriptImageFinalizationGate(); + const LOADING_SNAPSHOT: TranscriptImageSnapshot = Object.freeze({ status: "loading" }); function unavailableSnapshot(reason: string): TranscriptImageSnapshot { @@ -471,10 +546,16 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { private readonly pendingLoads: CacheEntry[] = []; private availability: TranscriptImageAvailability; private unavailable: TranscriptImageSnapshot | null; + private readonly pausedSnapshot = unavailableSnapshot(TRANSCRIPT_IMAGE_PAUSED_REASON); + private paused = false; private disposedReason: string | null = null; private disposedSnapshot: TranscriptImageSnapshot | null = null; private residentBytes = 0; private reservedBytes = 0; + /** Decoded chunk segments still held by active reads, excluding ready URLs. */ + private bufferedBytes = 0; + /** Completed contiguous buffers held across the asynchronous digest only. */ + private materializedBytes = 0; private activeLoads = 0; private clock = 0; @@ -533,15 +614,16 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { entry.snapshot = null; } this.notify(entry); - if (availability.available && entry.refCount > 0 && entry.state === "idle") { + if (!this.paused && availability.available && entry.refCount > 0 && entry.state === "idle") { this.start(entry); } } - if (availability.available) this.drainQueue(); + if (!this.paused && availability.available) this.drainQueue(); } getSnapshot(reference: TranscriptImageReference): TranscriptImageSnapshot { if (this.disposedSnapshot !== null) return this.disposedSnapshot; + if (this.paused) return this.pausedSnapshot; const entry = this.entries.get(imageKey(reference)); if (entry?.state === "ready" && entry.snapshot !== null) return entry.snapshot; if (this.unavailable !== null) return this.unavailable; @@ -603,6 +685,58 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { this.cleanupUnused(entry); } + /** + * Drop renderer-owned bytes while this session is inactive without losing + * the source, its subscriptions, or enough metadata to reload on resume. + * + * React effect cleanup can run before child retain/subscription cleanup and + * StrictMode can immediately resume the same runtime, so this boundary is + * deliberately idempotent and reversible rather than a partial dispose. + */ + pause(): void { + if (this.disposedReason !== null || this.paused) return; + this.paused = true; + for (const entry of this.entries.values()) { + if (entry.state === "queued") this.cancelQueued(entry); + if (entry.state === "loading") this.cancelActive(entry); + if (entry.state === "waiting") { + entry.state = "idle"; + entry.retryable = false; + entry.snapshot = null; + } + if (entry.state === "ready") { + this.releaseReadyUrl(entry); + entry.state = "idle"; + entry.retryable = false; + entry.snapshot = null; + } + this.notify(entry); + if ( + entry.promise === null && + entry.refCount === 0 && + entry.listeners.size === 0 && + entry.state !== "loading" + ) { + this.releaseReservation(entry); + this.entries.delete(entry.key); + } + } + this.pendingLoads.length = 0; + } + + /** Reload any still-retained images after a reversible inactive eviction. */ + resume(): void { + if (this.disposedReason !== null || !this.paused) return; + this.paused = false; + for (const entry of this.entries.values()) { + this.notify(entry); + if (this.availability.available && entry.refCount > 0 && entry.state === "idle") { + this.start(entry); + } + } + if (this.availability.available) this.drainQueue(); + } + dispose(reason = "Transcript image cache was closed."): void { if (this.disposedReason !== null) return; this.disposedReason = reason; @@ -615,6 +749,8 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { entry.queued = false; entry.cancelToken?.cancel(); entry.cancelToken = null; + this.clearAccumulator(entry); + this.releaseMaterialized(entry); this.releaseReservation(entry); this.releaseReadyUrl(entry); listeners.push(...entry.listeners); @@ -623,6 +759,8 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { this.entries.clear(); this.residentBytes = 0; this.reservedBytes = 0; + this.bufferedBytes = 0; + this.materializedBytes = 0; for (const listener of listeners) listener(); } @@ -645,6 +783,8 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { url: null, size: 0, reservedSize: 0, + accumulator: null, + materializedSize: 0, }; this.touch(entry); this.entries.set(key, entry); @@ -661,7 +801,7 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { } private retryCapacityWaiters(): void { - if (this.disposedReason !== null || !this.availability.available) return; + if (this.disposedReason !== null || this.paused || !this.availability.available) return; for (const entry of this.entries.values()) { if (entry.refCount > 0 && entry.state === "waiting") { entry.state = "idle"; @@ -675,6 +815,7 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { private start(entry: CacheEntry): void { if ( this.disposedReason !== null || + this.paused || !this.availability.available || entry.refCount <= 0 || entry.state !== "idle" || @@ -693,7 +834,7 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { } private drainQueue(): void { - if (this.disposedReason !== null || !this.availability.available) return; + if (this.disposedReason !== null || this.paused || !this.availability.available) return; while (this.activeLoads < this.maxConcurrentLoads && this.pendingLoads.length > 0) { const entry = this.pendingLoads.shift(); if ( @@ -719,6 +860,7 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { this.cleanupUnused(entry); if ( this.disposedReason === null && + !this.paused && this.availability.available && this.entries.get(entry.key) === entry && entry.refCount > 0 && @@ -745,6 +887,12 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { private cancelActive(entry: CacheEntry): void { if (entry.state !== "loading") return; entry.cancelToken?.cancel(); + this.clearAccumulator(entry); + // A completed contiguous buffer passed to the async digest cannot be + // detached from that Promise. Keep its reservation authoritative until + // the digest unwinds; stalled host-RPC segments, however, are gone now. + const released = entry.materializedSize === 0 && this.releaseReservation(entry); + if (released) this.retryCapacityWaiters(); } private cleanupUnused(entry: CacheEntry): void { @@ -760,17 +908,21 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { return; } this.cancelQueued(entry); - entry.cancelToken?.cancel(); - // A live controller command cannot be aborted. Keep any reservation until - // load() actually unwinds so cancelled buffers remain part of the bound. + this.cancelActive(entry); + // A completed buffer in the digest phase remains reserved until load() + // unwinds. RPC-phase segments were synchronously dropped by cancelActive. const released = entry.promise === null && this.releaseReservation(entry); this.entries.delete(entry.key); if (released) this.retryCapacityWaiters(); } private async load(entry: CacheEntry, signal: ManagedTranscriptImageReadSignal): Promise { + let releaseFinalization: (() => void) | null = null; try { - const bytes = await this.readAll(entry, signal); + await this.readAll(entry, signal); + releaseFinalization = await transcriptImageFinalizationGate.acquire(signal); + this.assertCurrent(entry, signal); + const bytes = this.materializeAccumulator(entry, signal); const digest = await this.digest(bytes); this.assertCurrent(entry, signal); if (digest !== entry.reference.sha256) { @@ -779,7 +931,9 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { if (sniffTranscriptImageMimeType(bytes) !== entry.reference.mimeType) { throw new TranscriptImageFailure(TRANSCRIPT_IMAGE_INTEGRITY_ERROR, false); } - const blob = new Blob([bytes.slice().buffer as ArrayBuffer], { + // `bytes` owns its whole ArrayBuffer; passing it directly avoids the + // former explicit full-size `slice()` copy before Blob snapshots input. + const blob = new Blob([bytes.buffer as ArrayBuffer], { type: entry.reference.mimeType, }); const url = this.createObjectUrl(blob); @@ -789,6 +943,7 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { this.safeRevoke(url); throw error; } + this.releaseMaterialized(entry); this.commitReservation(entry); entry.url = url; entry.size = bytes.byteLength; @@ -804,6 +959,8 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { this.touch(entry); this.notify(entry); } catch (error) { + this.clearAccumulator(entry); + this.releaseMaterialized(entry); const released = this.releaseReservation(entry); if (this.disposedReason !== null) return; if (this.entries.get(entry.key) !== entry) { @@ -834,17 +991,18 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { entry.snapshot = Object.freeze({ status: "error", reason: failure.reason }); this.notify(entry); if (released) this.retryCapacityWaiters(); + } finally { + releaseFinalization?.(); } } private async readAll( entry: CacheEntry, signal: ManagedTranscriptImageReadSignal, - ): Promise { + ): Promise { let offset = 0; let chunks = 0; let expectedSize: number | undefined; - let bytes: Uint8Array | undefined; while (true) { chunks += 1; if (chunks > TRANSCRIPT_IMAGE_MAX_CHUNKS) { @@ -876,24 +1034,86 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { offset, expectedSize, ); - if (bytes === undefined) { + if (entry.accumulator === null) { const reservation = this.reserve(entry, chunk.size); if (reservation === "too-large") { throw new TranscriptImageFailure(TRANSCRIPT_IMAGE_CACHE_ERROR, false); } if (reservation === "wait") throw new TranscriptImageCapacityWait(); expectedSize = chunk.size; - bytes = new Uint8Array(chunk.size); + entry.accumulator = { + expectedSize: chunk.size, + chunks: [], + byteLength: 0, + }; } - bytes.set(chunk.bytes, chunk.offset); + const accumulator = entry.accumulator; + if ( + accumulator === null || + accumulator.expectedSize !== chunk.size || + accumulator.byteLength !== chunk.offset + ) { + throw new TranscriptImageFailure(TRANSCRIPT_IMAGE_PROTOCOL_ERROR, false); + } + accumulator.chunks.push(chunk.bytes); + accumulator.byteLength += chunk.bytes.byteLength; + this.bufferedBytes += chunk.bytes.byteLength; offset = chunk.nextOffset; - if (chunk.complete) return bytes; + if (chunk.complete) return; + } + } + + /** + * Join segments only after the final host response. No full declared-size + * buffer is held across the non-abortable chunk RPC awaits above. + */ + private materializeAccumulator( + entry: CacheEntry, + signal: ManagedTranscriptImageReadSignal, + ): Uint8Array { + this.assertCurrent(entry, signal); + const accumulator = entry.accumulator; + if ( + accumulator === null || + accumulator.byteLength !== accumulator.expectedSize || + accumulator.expectedSize !== entry.reservedSize + ) { + throw new TranscriptImageFailure(TRANSCRIPT_IMAGE_PROTOCOL_ERROR, false); } + const bytes = new Uint8Array(accumulator.expectedSize); + let offset = 0; + for (const chunk of accumulator.chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + this.clearAccumulator(entry); + entry.materializedSize = bytes.byteLength; + this.materializedBytes += bytes.byteLength; + return bytes; + } + + /** Release actual decoded segment references independently of RPC fate. */ + private clearAccumulator(entry: CacheEntry): boolean { + const accumulator = entry.accumulator; + if (accumulator === null) return false; + this.bufferedBytes = Math.max(0, this.bufferedBytes - accumulator.byteLength); + accumulator.chunks.length = 0; + accumulator.byteLength = 0; + entry.accumulator = null; + return true; + } + + private releaseMaterialized(entry: CacheEntry): boolean { + if (entry.materializedSize <= 0) return false; + this.materializedBytes = Math.max(0, this.materializedBytes - entry.materializedSize); + entry.materializedSize = 0; + return true; } private assertCurrent(entry: CacheEntry, signal: ManagedTranscriptImageReadSignal): void { if ( signal.cancelled || + this.paused || this.disposedReason !== null || this.entries.get(entry.key) !== entry || entry.cancelToken !== signal diff --git a/apps/web/src/features/session-runtime/useSessionRuntime.ts b/apps/web/src/features/session-runtime/useSessionRuntime.ts index 070f61ad..8393dcf2 100644 --- a/apps/web/src/features/session-runtime/useSessionRuntime.ts +++ b/apps/web/src/features/session-runtime/useSessionRuntime.ts @@ -19,10 +19,12 @@ import { createLiveSessionRuntime } from "./live-runtime.ts"; const RUNTIME_LRU_LIMIT = 8; const runtimeCache = new Map(); -/** QA/screenshot switch: `?transcript=stress|gap` pins a scripted variant. */ +/** QA/screenshot switch: pin a scripted transcript variant. */ export function parseTranscriptVariant(search: string): TranscriptVariant { const value = new URLSearchParams(search).get("transcript"); - return value === "stress" || value === "gap" ? value : "default"; + return value === "stress" || value === "gap" || value === "compaction" + ? value + : "default"; } function rememberRuntime( diff --git a/apps/web/src/features/settings/LiveSettingsScreen.tsx b/apps/web/src/features/settings/LiveSettingsScreen.tsx index 679adc4a..6f99f474 100644 --- a/apps/web/src/features/settings/LiveSettingsScreen.tsx +++ b/apps/web/src/features/settings/LiveSettingsScreen.tsx @@ -15,18 +15,18 @@ import { DialogHeader, DialogPopup, DialogTitle, - Spinner, } from "@t4-code/ui"; -import { CircleAlert } from "lucide-react"; import { useRef, useState, useSyncExternalStore } from "react"; import { rendererPlatform } from "../../state/store-instance.ts"; +import { useAppUpdateState } from "../updates/update-store.ts"; import type { SaveChallenge } from "./live-controller.ts"; import { createLiveSettingsScreenModel, type LiveSettingsScreenModel, } from "./live-screen-model.ts"; import { SettingsWorkspace, type RestartAction } from "./SettingsWorkspace.tsx"; +import { UnavailableSettingsWorkspace } from "./UnavailableSettingsWorkspace.tsx"; interface PendingChallenge { readonly challenge: SaveChallenge; @@ -81,6 +81,7 @@ export function LiveSettingsScreen({ } const model = modelRef.current; const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + const update = useAppUpdateState(); const shell = rendererPlatform.shell; const restartAction: RestartAction | undefined = @@ -122,25 +123,12 @@ export function LiveSettingsScreen({ ? { title: "Settings can't load", detail: state.message, spin: false } : WAIT_COPY[state.detail]; return ( -
- {copy.spin ? ( - - ) : ( -
+ ); } diff --git a/apps/web/src/features/settings/SettingsWorkspace.tsx b/apps/web/src/features/settings/SettingsWorkspace.tsx index 3569291b..d18abd48 100644 --- a/apps/web/src/features/settings/SettingsWorkspace.tsx +++ b/apps/web/src/features/settings/SettingsWorkspace.tsx @@ -4,9 +4,17 @@ // anywhere with a store api and owns no routing. import { Badge, Button, cn, IconButton, Spinner } from "@t4-code/ui"; import { ArrowLeft, Cable, Download, RotateCcw, Search } from "lucide-react"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { RAIL_OVERLAY_QUERY, useMediaQuery } from "../../hooks/useMediaQuery.ts"; +import { updateIsAvailable } from "../updates/update-model.ts"; +import { + consumeUpdateSettingsRequest, + getUpdateSettingsRequest, + subscribeUpdateSettingsRequest, +} from "../updates/update-navigation.ts"; +import { UpdateSettingsPanel } from "../updates/UpdateSettingsPanel.tsx"; +import { useAppUpdateState } from "../updates/update-store.ts"; import { AccentRow } from "./AccentRow.tsx"; import { FIELD_CLASS } from "./controls.tsx"; import { buildDiagnosticsExport } from "./diagnostics.ts"; @@ -29,6 +37,24 @@ export const SCOPE_TAB_LABEL: Record = { session: "This run", }; +export const UPDATE_SECTION_ID = "t4-updates"; + +interface SettingsRailEntry { + readonly id: string; + readonly label: string; +} + +export function buildSettingsRailSections(sections: readonly SettingsSection[]): readonly SettingsRailEntry[] { + const entries = sections.map(({ id, label }) => ({ id, label })); + const diagnostics = entries.findIndex((section) => section.id === "diagnostics"); + const index = diagnostics < 0 ? entries.length : diagnostics; + return [ + ...entries.slice(0, index), + { id: UPDATE_SECTION_ID, label: "Updates" }, + ...entries.slice(index), + ]; +} + function ScopeTabs({ api, scopes, @@ -67,13 +93,18 @@ function ScopeTabs({ function SectionRail({ api, sections, + activeSectionId, + onSelect, matchedIds, + updateAvailable, }: { readonly api: SettingsStoreApi; - readonly sections: readonly SettingsSection[]; + readonly sections: readonly SettingsRailEntry[]; + readonly activeSectionId: string; + readonly onSelect: (id: string) => void; readonly matchedIds: ReadonlySet | null; + readonly updateAvailable: boolean; }) { - const activeSectionId = useSettings(api, (state) => state.activeSectionId); const drafts = useSettings(api, (state) => state.drafts); const itemRefs = useRef(new Map()); @@ -105,7 +136,7 @@ function SectionRail({ : "text-muted-foreground hover:bg-accent hover:text-foreground", dimmed && "opacity-64", )} - onClick={() => api.getState().setActiveSection(section.id)} + onClick={() => onSelect(section.id)} onKeyDown={(event) => { if ( event.key !== "ArrowDown" && @@ -122,7 +153,7 @@ function SectionRail({ ); if (target === null) return; event.preventDefault(); - api.getState().setActiveSection(target); + onSelect(target); itemRefs.current.get(target)?.focus(); }} ref={(node) => { @@ -136,6 +167,9 @@ function SectionRail({