From a727864cda15f7344b3fd3d33018e6e400bb0a62 Mon Sep 17 00:00:00 2001 From: lycaon Date: Wed, 15 Jul 2026 17:21:32 -0600 Subject: [PATCH 1/3] feat: harden runtime and add verified updates --- .github/workflows/ci.yml | 40 + .github/workflows/deploy-site.yml | 52 +- .github/workflows/release.yml | 86 ++- .../desktop/src/electron-update-controller.ts | 31 + apps/desktop/src/ipc.ts | 56 ++ apps/desktop/src/lifecycle.ts | 42 ++ apps/desktop/src/linux-update-package.ts | 26 + apps/desktop/src/menu.ts | 42 ++ apps/desktop/src/preload.ts | 39 +- apps/desktop/src/strings.ts | 2 +- apps/desktop/src/update-controller.ts | 683 +++++++++++++++++ apps/desktop/test/ipc-lifecycle.test.ts | 51 ++ apps/desktop/test/lifecycle-runtime.test.ts | 53 +- apps/desktop/test/menu.test.ts | 67 ++ apps/desktop/test/update-controller.test.ts | 496 +++++++++++++ apps/mobile/android/app/build.gradle | 3 + .../android/app/src/main/AndroidManifest.xml | 11 +- .../lycaonsolutions/t4code/MainActivity.java | 11 + .../t4code/T4FileProvider.java | 6 + .../t4code/T4UpdateFileStore.java | 228 ++++++ .../t4code/T4UpdatePlugin.java | 696 ++++++++++++++++++ .../t4code/T4UpdateStateMachine.java | 82 +++ .../t4code/T4UpdateVerifier.java | 251 +++++++ .../app/src/main/res/xml/file_paths.xml | 5 +- .../t4code/T4UpdateFileStoreTest.java | 147 ++++ .../t4code/T4UpdateStateMachineTest.java | 130 ++++ .../t4code/T4UpdateVerifierTest.java | 355 +++++++++ apps/mobile/package.json | 1 + apps/mobile/scripts/prepare-web.test.mjs | 74 ++ apps/site/src/release.ts | 1 + apps/site/test/release.test.ts | 2 + apps/web/index.html | 2 + apps/web/src/components/Titlebar.tsx | 28 +- .../features/session-runtime/live-runtime.ts | 101 ++- .../features/settings/LiveSettingsScreen.tsx | 30 +- .../features/settings/SettingsWorkspace.tsx | 113 ++- .../settings/UnavailableSettingsWorkspace.tsx | 114 +++ apps/web/src/features/settings/fixtures.ts | 25 - .../src/features/settings/view-model.test.ts | 2 +- .../features/transcript/TranscriptRows.tsx | 132 ++++ .../transcript/collaboration-messages.ts | 206 ++++++ .../web/src/features/transcript/projection.ts | 292 ++++++-- apps/web/src/features/transcript/rows.ts | 84 ++- .../transcript/tool-render/adapter.ts | 165 ++++- .../features/transcript/tool-render/parts.tsx | 121 ++- .../transcript/tool-render/registry.ts | 6 + .../transcript/tool-render/tools/hub.tsx | 145 ++++ .../transcript/tool-render/tools/propose.tsx | 27 + .../transcript/tool-render/tools/resolve.tsx | 12 +- .../features/updates/UpdateSettingsPanel.tsx | 124 ++++ apps/web/src/features/updates/update-model.ts | 111 +++ .../src/features/updates/update-navigation.ts | 55 ++ apps/web/src/features/updates/update-store.ts | 287 ++++++++ .../platform/browser-connection-lifecycle.ts | 98 +++ apps/web/src/platform/browser-shell-port.ts | 14 + apps/web/src/platform/desktop-runtime.ts | 45 +- apps/web/src/platform/native-mobile.ts | 31 +- apps/web/test/app-updates.test.tsx | 210 ++++++ .../test/browser-connection-lifecycle.test.ts | 72 ++ apps/web/test/browser-platform.test.ts | 51 ++ apps/web/test/collaboration-messages.test.tsx | 189 +++++ apps/web/test/interaction-motion.test.ts | 3 +- apps/web/test/live-composer.test.ts | 193 ++++- apps/web/test/live-session-controls.test.ts | 89 ++- apps/web/test/tool-renderers.test.tsx | 418 ++++++++++- apps/web/test/transcript-projection.test.ts | 232 ++++++ docs/RELEASE_GATE.md | 5 +- electron-builder.config.mjs | 12 + ops/t4-maintainer/README.md | 2 +- ops/t4-maintainer/install.sh | 2 + ops/t4-maintainer/run.sh | 275 ++++++- ops/t4-maintainer/validate.sh | 6 +- package.json | 4 +- .../client/src/desktop-runtime-contracts.ts | 10 + packages/client/src/desktop-runtime.ts | 18 +- packages/client/src/index.ts | 35 + packages/client/src/omp-client-connection.ts | 16 +- packages/client/src/omp-client-contracts.ts | 2 + packages/client/src/omp-client-runtime.ts | 93 ++- packages/client/src/omp-client-state.ts | 4 +- packages/client/src/projection-cache.ts | 54 +- packages/client/src/projection.ts | 102 ++- packages/client/src/transcript-retention.ts | 424 +++++++++++ packages/client/test/client-reconnect.test.ts | 274 +++++++ packages/client/test/desktop-runtime.test.ts | 51 ++ .../client/test/transcript-retention.test.ts | 117 +++ packages/protocol/src/desktop-ipc.ts | 136 ++++ packages/protocol/test/desktop-ipc.test.ts | 75 ++ packages/protocol/test/reexport.test.ts | 41 ++ packages/service-manager/src/rendering.ts | 6 + .../test/service-manager.test.ts | 3 +- .../imports/f2-transcript-20260711.json | 4 +- scripts/check-release-consistency.mjs | 189 ++++- scripts/check-release-consistency.test.mjs | 89 ++- scripts/check-release-publication.mjs | 137 ++++ scripts/check-release-publication.test.mjs | 129 ++++ scripts/deploy-site.mjs | 19 +- scripts/deploy-site.test.mjs | 22 +- scripts/dispatch-site-deployment.mjs | 179 +++++ scripts/dispatch-site-deployment.test.mjs | 113 +++ scripts/generate-release-manifest.mjs | 234 ++++++ scripts/generate-release-manifest.test.mjs | 147 ++++ scripts/inspect-linux-update.mjs | 207 ++++++ scripts/inspect-linux-update.test.mjs | 70 ++ scripts/packaging.test.mjs | 12 + scripts/read-bounded-response.mjs | 61 ++ scripts/reconcile-release-assets.mjs | 279 +++++++ scripts/reconcile-release-assets.test.mjs | 263 +++++++ scripts/t4-maintainer-contract.test.mjs | 76 +- scripts/t4-maintainer-integration.test.mjs | 206 +++++- scripts/tailnet-gateway.mjs | 14 +- scripts/tailnet-gateway.test.mjs | 57 +- scripts/wait-for-release-assets.mjs | 2 +- scripts/wait-for-release-assets.test.mjs | 5 +- 114 files changed, 11550 insertions(+), 327 deletions(-) create mode 100644 apps/desktop/src/electron-update-controller.ts create mode 100644 apps/desktop/src/linux-update-package.ts create mode 100644 apps/desktop/src/menu.ts create mode 100644 apps/desktop/src/update-controller.ts create mode 100644 apps/desktop/test/menu.test.ts create mode 100644 apps/desktop/test/update-controller.test.ts create mode 100644 apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4FileProvider.java create mode 100644 apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateFileStore.java create mode 100644 apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdatePlugin.java create mode 100644 apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateStateMachine.java create mode 100644 apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateVerifier.java create mode 100644 apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateFileStoreTest.java create mode 100644 apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateStateMachineTest.java create mode 100644 apps/mobile/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateVerifierTest.java create mode 100644 apps/web/src/features/settings/UnavailableSettingsWorkspace.tsx create mode 100644 apps/web/src/features/transcript/collaboration-messages.ts create mode 100644 apps/web/src/features/transcript/tool-render/tools/hub.tsx create mode 100644 apps/web/src/features/transcript/tool-render/tools/propose.tsx create mode 100644 apps/web/src/features/updates/UpdateSettingsPanel.tsx create mode 100644 apps/web/src/features/updates/update-model.ts create mode 100644 apps/web/src/features/updates/update-navigation.ts create mode 100644 apps/web/src/features/updates/update-store.ts create mode 100644 apps/web/src/platform/browser-connection-lifecycle.ts create mode 100644 apps/web/test/app-updates.test.tsx create mode 100644 apps/web/test/browser-connection-lifecycle.test.ts create mode 100644 apps/web/test/collaboration-messages.test.tsx create mode 100644 packages/client/src/transcript-retention.ts create mode 100644 packages/client/test/transcript-retention.test.ts create mode 100644 scripts/check-release-publication.mjs create mode 100644 scripts/check-release-publication.test.mjs create mode 100644 scripts/dispatch-site-deployment.mjs create mode 100644 scripts/dispatch-site-deployment.test.mjs create mode 100644 scripts/generate-release-manifest.mjs create mode 100644 scripts/generate-release-manifest.test.mjs create mode 100644 scripts/inspect-linux-update.mjs create mode 100644 scripts/inspect-linux-update.test.mjs create mode 100644 scripts/read-bounded-response.mjs create mode 100644 scripts/reconcile-release-assets.mjs create mode 100644 scripts/reconcile-release-assets.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 193a5a85..b2cdb389 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,43 @@ jobs: - name: Check packaging contract run: pnpm test:packaging + + 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 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..d2f655ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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/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/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/package.json b/apps/mobile/package.json index 365dedad..793ae9ff 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -7,6 +7,7 @@ "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/src/release.ts b/apps/site/src/release.ts index 0f6d544e..5e1a052f 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -16,6 +16,7 @@ export const APP_WIRE_VERSION = "0.5.5"; export const RELEASE_TAG = "v0.1.18"; export const RELEASE_VERSION = "0.1.18"; 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; diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index c7be4ad1..12e34f4e 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -13,6 +13,7 @@ import { OMP_UPSTREAM_URL, primaryAsset, RELEASE_ASSETS, + RELEASE_MANIFEST_URL, RELEASE_TAG, RELEASE_VERSION, REPO_URL, @@ -39,6 +40,7 @@ describe("release assets", () => { 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_MANIFEST_URL).toBe("https://t4code.net/releases/latest.json"); }); it("splits assets by platform with correct architectures", () => { 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/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/session-runtime/live-runtime.ts b/apps/web/src/features/session-runtime/live-runtime.ts index 46ecc7c1..ec365c7f 100644 --- a/apps/web/src/features/session-runtime/live-runtime.ts +++ b/apps/web/src/features/session-runtime/live-runtime.ts @@ -25,6 +25,7 @@ import type { RendererServerFrame } from "@t4-code/protocol/desktop-ipc"; import { initialProjection, reduceTranscript, + settleTranscriptTurn, type ApprovalRequest, type TranscriptFrame, type TranscriptProjection, @@ -51,6 +52,7 @@ import { THINKING_SET_COMMAND, type PendingControl, } from "./session-controls.ts"; +import { sessionIsWorking } from "./session-management.ts"; export interface LiveRuntimeOptions { readonly controller: DesktopRuntimeController; @@ -76,12 +78,7 @@ function isRecord(value: unknown): value is Record { } /** 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 +98,37 @@ function getQueuedFollowUps(ref: SessionRef | undefined): readonly string[] { return result; } +/** + * 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, +): boolean | null { + if (runtime.connections.get(targetId) !== "connected") return null; + const metadata = runtime.projection.sessionIndexMetadata.get(hostId); + if (metadata === undefined || metadata.truncated) return null; + let indexed = 0; + for (const ref of runtime.projection.sessionIndex.values()) { + if (String(ref.hostId) === hostId) indexed += 1; + } + if (indexed < metadata.totalCount) return null; + const ref = runtime.projection.sessionIndex.get(projectionKey); + return ref === undefined ? null : sessionIsWorking(ref); +} + /** 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"), ); } @@ -190,6 +212,14 @@ 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(); @@ -210,7 +240,10 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu sessionId: wireSessionId, entries: [...warm.entries], }; - transcript = reduceTranscript(transcript, seed); + transcript = withWarmHistoryTruncation( + reduceTranscript(transcript, seed), + controller.getSnapshot(), + ); } const expectedRevision = (): Revision | undefined => { @@ -230,7 +263,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 +353,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 +445,13 @@ 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 next = withWarmHistoryTruncation( + reduceTranscript(transcript, frame), + controller.getSnapshot(), + ); if (next !== transcript) { transcript = next; notify(); @@ -419,6 +463,12 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu let retryAfterAttach = false; let connectionGeneration = 0; let previousConnected = controller.getSnapshot().connections.get(targetId) === "connected"; + let previousAuthoritativeWorking: boolean | null = authoritativeWorkingState( + controller.getSnapshot(), + targetId, + options.hostId, + projectionKey, + ); const attachIfConnected = (runtime: DesktopRuntimeSnapshot) => { if (disposed) return; const connected = runtime.connections.get(targetId) === "connected"; @@ -487,7 +537,36 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu // 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; + const wasConnected = previousConnected; attachIfConnected(runtime); + const connected = runtime.connections.get(targetId) === "connected"; + const authoritativeWorking = authoritativeWorkingState( + runtime, + targetId, + options.hostId, + projectionKey, + ); + const reconnected = !wasConnected && connected; + if (authoritativeWorking !== null) { + if ( + connected && + authoritativeWorking === false && + (reconnected || previousAuthoritativeWorking === true) + ) { + const next = settleTranscriptTurn(transcript, { + // turn.error itself makes turnActive false. Seeing it true here is + // direct evidence that a later turn started and this authoritative + // idle observation supersedes that older transient error. + supersedeTransientErrors: transcript.turnActive, + }); + if (next !== transcript) transcript = next; + } + // Unknown (offline/truncated) evidence must not erase the last complete + // working observation; the next complete idle inventory settles it. + previousAuthoritativeWorking = authoritativeWorking; + } syncTranscriptImageAvailability(runtime); notify(); }); 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({