diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 708bf9d3..6281715e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,7 +10,7 @@ body: id: version attributes: label: T4 Code version - placeholder: "0.1.30" + placeholder: "0.1.31" validations: required: true - type: dropdown diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6da34f12..b377de6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,6 @@ jobs: official_omp_gate0: ${{ steps.classify.outputs.official_omp_gate0 }} tooling: ${{ steps.classify.outputs.tooling }} android_debug: ${{ steps.classify.outputs.android_debug }} - flutter: ${{ steps.classify.outputs.flutter }} - flutter_android: ${{ steps.classify.outputs.flutter_android }} - flutter_apple: ${{ steps.classify.outputs.flutter_apple }} steps: - name: Check out source and base history uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -87,13 +84,13 @@ jobs: run: pnpm exec playwright install --with-deps chromium - name: Check source and types - run: pnpm check:release && pnpm check:provenance && pnpm lint && pnpm --filter '!@t4-code/flutter' -r typecheck + run: pnpm check - name: Run tests - run: pnpm --filter '!@t4-code/flutter' -r test + run: pnpm test - name: Build all workspaces - run: pnpm --filter '!@t4-code/flutter' -r build + run: pnpm build - name: Run built-app end-to-end tests run: pnpm test:e2e @@ -373,161 +370,10 @@ jobs: - name: Verify unsigned Android debug application run: pnpm --filter @t4-code/mobile check:android:debug - flutter: - needs: changes - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.flutter == 'true' }} - runs-on: ubuntu-24.04 - timeout-minutes: 30 - 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 Flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - flutter-version: 3.44.6 - channel: stable - cache: true - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Check Flutter source - run: pnpm check:flutter - - - name: Run Flutter tests with coverage gate - run: pnpm --filter @t4-code/flutter test:coverage - - - name: Build Flutter web application - run: pnpm build:flutter:web - - flutter-android: - needs: changes - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.flutter_android == 'true' }} - runs-on: ubuntu-24.04 - timeout-minutes: 35 - steps: - - name: Check out source - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Java - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 - with: - distribution: temurin - java-version: "21" - cache: gradle - - - name: Install Flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - flutter-version: 3.44.6 - channel: stable - cache: true - - - name: Build Flutter Android application - working-directory: apps/flutter - run: flutter build apk --debug - - - name: Run Flutter Android native tests - working-directory: apps/flutter/android - run: ./gradlew app:testDebugUnitTest - - - name: Enable Android emulator acceleration - run: sudo chmod 666 /dev/kvm - - - name: Run Flutter Android device smoke test - uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 - with: - api-level: 35 - arch: x86_64 - profile: pixel_6 - script: cd apps/flutter && flutter test integration_test/app_smoke_test.dart -d emulator-5554 - - flutter-apple: - needs: changes - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.flutter_apple == 'true' }} - runs-on: macos-15 - timeout-minutes: 40 - steps: - - name: Check out source - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - flutter-version: 3.44.6 - channel: stable - cache: true - - - name: Build Flutter iOS simulator application - working-directory: apps/flutter - run: flutter build ios --simulator --debug - - - name: Run Flutter iOS launch smoke test - working-directory: apps/flutter - shell: bash - run: | - set -euo pipefail - DEVICE_ID="$(xcrun simctl list devices available -j | python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(device["udid"] for devices in data["devices"].values() for device in devices if device["name"].startswith("iPhone")))')" - BUNDLE_ID="com.lycaonsolutions.t4code" - xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true - xcrun simctl bootstatus "$DEVICE_ID" -b - xcrun simctl install "$DEVICE_ID" build/ios/iphonesimulator/Runner.app - trap 'xcrun simctl terminate "$DEVICE_ID" "$BUNDLE_ID" 2>/dev/null || true' EXIT - launch_output="$(xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID")" - app_pid="$(sed -nE 's/^.*: ([0-9]+)$/\1/p' <<<"$launch_output" | tail -n 1)" - [[ "$app_pid" =~ ^[0-9]+$ ]] - sleep 5 - kill -0 "$app_pid" - - - 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 Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.3.14 - - - name: Install T4 dependencies - run: pnpm install --frozen-lockfile - - - name: Build standalone T4 host for Flutter macOS - run: pnpm build:host - - - name: Build Flutter macOS application - working-directory: apps/flutter - run: flutter build macos --debug - - - name: Verify bundled Flutter macOS host - run: test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host - - - name: Run Flutter macOS native tests - working-directory: apps/flutter/macos - run: xcodebuild test -workspace Runner.xcworkspace -scheme Runner -configuration Debug -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO - verify: name: verify if: ${{ always() }} - needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple] + needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -541,9 +387,6 @@ jobs: CLUSTER_RESULT: ${{ needs.cluster.result }} TOOLING_RESULT: ${{ needs.tooling.result }} ANDROID_RESULT: ${{ needs.android-debug.result }} - FLUTTER_RESULT: ${{ needs.flutter.result }} - FLUTTER_ANDROID_RESULT: ${{ needs.flutter-android.result }} - FLUTTER_APPLE_RESULT: ${{ needs.flutter-apple.result }} run: | set -euo pipefail test "$CHANGES_RESULT" = success @@ -553,10 +396,7 @@ jobs: "$OFFICIAL_OMP_GATE0_RESULT" \ "$CLUSTER_RESULT" \ "$TOOLING_RESULT" \ - "$ANDROID_RESULT" \ - "$FLUTTER_RESULT" \ - "$FLUTTER_ANDROID_RESULT" \ - "$FLUTTER_APPLE_RESULT" + "$ANDROID_RESULT" do case "$result" in success|skipped) ;; diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 61184d2b..a73708cd 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -6,7 +6,7 @@ on: branches: [main, master] paths: - "apps/site/**" - - "apps/flutter/**" + - "apps/web/**" - "packages/**" - "scripts/check-release-publication.mjs" - "scripts/build-demo.mjs" @@ -65,13 +65,6 @@ jobs: with: node-version: 24.13.1 - - name: Install Flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - flutter-version: 3.44.6 - channel: stable - cache: true - - name: Install dependencies run: pnpm install --frozen-lockfile @@ -81,28 +74,7 @@ jobs: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: us-east-1 - - name: Check whether the live demo permits the Flutter renderer - id: demo_csp - shell: bash - run: | - set -euo pipefail - headers=$(curl --fail --silent --show-error --head https://t4code.net/demo) - if printf '%s\n' "$headers" \ - | tr -d '\r' \ - | grep -i '^content-security-policy:' \ - | grep -Fq "'wasm-unsafe-eval'"; then - echo "ready=true" >> "$GITHUB_OUTPUT" - else - echo "ready=false" >> "$GITHUB_OUTPUT" - echo "::warning::Flutter demo deployment is deferred until the demo CloudFront CSP permits wasm-unsafe-eval." - fi - - - name: Defer Flutter demo until its response policy is active - if: ${{ steps.demo_csp.outputs.ready != 'true' }} - run: echo "Apply infra/site/cloudformation.yml, then rerun this workflow from a main push." - - - name: Build and deploy current Flutter demo - if: ${{ steps.demo_csp.outputs.ready == 'true' }} + - name: Build and deploy current React demo env: T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }} T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }} diff --git a/.woodpecker.yml b/.woodpecker.yml index a96101ac..8642ac13 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -55,9 +55,9 @@ steps: commands: - export PATH="$PWD/.ci:$PATH" - corepack enable - - pnpm check:release && pnpm check:provenance && pnpm lint && pnpm --filter '!@t4-code/flutter' -r typecheck - - VP_RUN_CONCURRENCY_LIMIT=1 pnpm --filter '!@t4-code/flutter' -r test - - pnpm --filter '!@t4-code/flutter' -r build + - pnpm check + - VP_RUN_CONCURRENCY_LIMIT=1 pnpm test + - pnpm build - pnpm exec playwright install --with-deps chromium - pnpm test:e2e - pnpm test:packaging diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index e445c24d..70ad8669 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -42,7 +42,7 @@ OMP authority: `packages/coding-agent/src/session/agent-session.ts`, `session-ma | List/recent/search/filter | session store and metadata | Codex-parity left rail: By project or In one list; Priority, Last updated, or Manual order; real project/session dragging with keyboard fallbacks; title/project/host search; attention/running/unread/error filters; pinned shortcuts; five-row Show more; project aliases; reversible hidden projects; bulk read/archive; local-only Finder reveal; direct pin/archive controls | `Rail.tsx`, `session-tree.ts`, workspace store, management helpers, browser tests | Launch | | New session | `/new` | Create in selected project/host; model/profile defaults visible before first prompt | draft routes and composer draft store | Launch | | Fast switch and tabs | session IDs and snapshots | One-click/keyboard switch; preserve draft, scroll anchor, panel widths/tabs, terminal focus; no white flash | T3 routes, `composerDraftStore`, `rightPanelStore`, terminal store | Launch | -| Tail-first transcript history | Bounded `transcript.page` range reads plus the existing live attach cursor | Paint a small newest page on cold open; prepend older pages without moving the reading anchor or live cursor | T4-owned host, web client, and thin OMP bridge implemented; Flutter local cache planned | Launch | +| Tail-first transcript history | Bounded `transcript.page` range reads plus the existing live attach cursor | Paint a small newest page on cold open; prepend older pages without moving the reading anchor or live cursor | T4-owned host, React client, and thin OMP bridge implemented; offline display cache remains planned | Launch | | Resume | `/resume` | Open existing session by stable ID/path; recover moved/missing files with explicit error | thread routing and reconnect supervisor | Launch | | Rename | `/rename` | Inline rename with optimistic state and rollback | sidebar row actions | Launch | | Move working directory/session | `/move` | Native/remote path picker, validation, explicit impact message | environment picker patterns | Parity | diff --git a/PRODUCT_BRIEF.md b/PRODUCT_BRIEF.md index 78db5438..cea936fc 100644 --- a/PRODUCT_BRIEF.md +++ b/PRODUCT_BRIEF.md @@ -2,7 +2,8 @@ ## Product -T4 Code is a Flutter desktop, mobile, and web workspace for official Oh My Pi (OMP). It makes +T4 Code is an Electron desktop workspace with a shared React compatibility client for official Oh +My Pi (OMP). It makes projects, concurrent sessions, live streaming, tools, terminal activity, task agents, reviews, files, settings, and local or remote execution easier to operate without reimplementing OMP behavior. @@ -17,8 +18,8 @@ settings, and local or remote execution easier to operate without reimplementing - Browser and app preview remains a focused workspace rather than a permanent sixth pane. - Keyboard shortcuts, Quick Open, workspace menus, and transcript links use one action registry so availability and behavior do not diverge. -- Quick Open searches through bounded authorized operations. Flutter never receives or chooses an - absolute path it does not already own. +- Quick Open searches through bounded authorized operations. The renderer never receives or + chooses an absolute path it does not already own. - The Universal Working Set lets a user deliberately stage exact material from a file preview, transcript message, review diff, selected terminal text, or browser accessibility snapshot. The user can inspect and remove each item before it joins one ordinary OMP prompt; it does not become @@ -28,7 +29,9 @@ settings, and local or remote execution easier to operate without reimplementing ## Product modes -T4 Code presents one client experience across four execution profiles: +T4 Code presents one desktop-first client experience across four execution profiles. The +responsive browser/PWA and React/Capacitor Android builds are compatibility clients for paired +hosts; they do not promise native desktop parity. - **T4 Local:** native execution on this macOS or Linux computer. - **Personal Hub:** a managed installation on one Linux machine. diff --git a/README.md b/README.md index 28f27aa1..97ef08ca 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c ![T4 Code main window](docs/assets/t4-code-main.png) -[**Download v0.1.30**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.30) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) +[**Download v0.1.31**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.31) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) ## Requirements -T4 Code v0.1.30 packages its own standalone `t4-host` and needs the matching OMP build with the smaller authority bridge. +T4 Code v0.1.31 packages its own standalone `t4-host` and needs the matching OMP build with the smaller authority bridge. -T4 Code v0.1.30 was verified with OMP 17.0.5 built from [`8476f445`](https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4), tagged [`t4code-17.0.5-appserver-10`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-10). That integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It exposes the bounded `t4-omp-authority/1` bridge used by T4's standalone host and removes the old public OMP appserver launchers. It also includes bounded newest-first transcript paging, stale-owner recovery, privacy-safe project reveal, fast lazy session indexing, cross-session attention and transcript search, the negotiated browser-preview command surface, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, lock-aware session observation, complete transcript reconciliation, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. It also does not include the authority bridge needed by T4's standalone host. T4 Code vendors `@oh-my-pi/app-wire` 0.7.0 from integration commit [`796bb7dc`](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. +T4 Code v0.1.31 was verified with OMP 17.0.5 built from [`8476f445`](https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4), tagged [`t4code-17.0.5-appserver-10`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-10). That integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It exposes the bounded `t4-omp-authority/1` bridge used by T4's standalone host and removes the old public OMP appserver launchers. It also includes bounded newest-first transcript paging, stale-owner recovery, privacy-safe project reveal, fast lazy session indexing, cross-session attention and transcript search, the negotiated browser-preview command surface, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, lock-aware session observation, complete transcript reconciliation, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. It also does not include the authority bridge needed by T4's standalone host. T4 Code vendors `@oh-my-pi/app-wire` 0.7.0 from integration commit [`796bb7dc`](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. T4 owns the client wire, generic host service, and standalone daemon in `@t4-code/host-wire`, `@t4-code/host-service`, and `@t4-code/host-daemon`. The frozen `@oh-my-pi/app-wire` 0.7.0 tarball remains only as compatibility evidence. The released package still launches `t4-host` against the strict `t4-omp-authority/1` bridge, while OMP owns session files, locks, agent execution, and takeover decisions. A separately pinned unmodified official OMP 17.0.6 now passes T4's direct RPC behavior gate on macOS ARM64; native Linux evidence and packaged cutover proof remain before that path replaces the released fallback. @@ -20,9 +20,10 @@ T4 owns the client wire, generic host service, and standalone daemon in `@t4-cod | Linux | x86_64 | `.deb`, AppImage | | macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**signed and notarized**) | -No Windows build and no Intel Mac build in v0.1.30. The iOS TestFlight build is coming soon. +No Windows build, Intel Mac build, or native iOS application is currently shipped. iPhone and iPad +access use the responsive Tailnet browser/PWA compatibility client. -## What changed in v0.1.30 +## What changed in v0.1.31 - The session rail now matches the Codex desktop organization model: search, activity filters, sort controls, collapsible projects, flat and grouped views, and persistent preferences. - Project menus can create sessions, open folders in the system file manager, collapse a group, or hide it from the rail. Hidden projects remain recoverable through the filter menu. @@ -52,7 +53,7 @@ No Windows build and no Intel Mac build in v0.1.30. The iOS TestFlight build is ### Android 1. On the Android phone, sign in to Tailscale with an account that can reach the T4 Code host. -2. Download [`T4-Code-0.1.30-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-android.apk). +2. Download [`T4-Code-0.1.31-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-android.apk). 3. If Android asks, allow your browser or file manager to install unknown apps, then install the APK. 4. Open T4 Code and enter the host's HTTPS Tailscale address, including its port. The app saves the address; you can add more hosts later and switch between them. @@ -61,8 +62,8 @@ The APK does not contain a host daemon or expose one to the public internet. It ### Linux (Debian/Ubuntu) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-linux-amd64.deb -sudo apt install ./T4-Code-0.1.30-linux-amd64.deb +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-linux-amd64.deb +sudo apt install ./T4-Code-0.1.31-linux-amd64.deb ``` Use `apt install` rather than `dpkg -i` so system dependencies resolve automatically. @@ -70,14 +71,14 @@ Use `apt install` rather than `dpkg -i` so system dependencies resolve automatic ### Linux (AppImage) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-linux-x86_64.AppImage -chmod +x T4-Code-0.1.30-linux-x86_64.AppImage -./T4-Code-0.1.30-linux-x86_64.AppImage +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-linux-x86_64.AppImage +chmod +x T4-Code-0.1.31-linux-x86_64.AppImage +./T4-Code-0.1.31-linux-x86_64.AppImage ``` ### macOS (Apple Silicon) -1. Download [`T4-Code-0.1.30-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-mac-arm64.dmg) (or [`T4-Code-0.1.30-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-mac-arm64.zip)). +1. Download [`T4-Code-0.1.31-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-mac-arm64.dmg) (or [`T4-Code-0.1.31-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-mac-arm64.zip)). 2. Drag `T4 Code.app` into `/Applications`. 3. Open T4 Code normally. The release workflow verifies the pinned publisher, hardened runtime, secure timestamp, Apple notarization, stapled ticket, and Gatekeeper acceptance before publication. @@ -124,11 +125,7 @@ pnpm test:soak # headless 10k-history and 20-reconnect stress checks pnpm package:linux # .deb + AppImage into release/ pnpm package:mac:unsigned # unsigned macOS build (on a Mac) pnpm package:mac # maintainer-only signed and notarized macOS build -cd apps/flutter -flutter test # shared client, protocol, settings, and UI contracts -flutter build apk --debug -flutter build ios --simulator --debug -pnpm --dir ../.. build:flutter:macos +pnpm --filter @t4-code/mobile check:android:debug # React/Capacitor compatibility APK ``` Prefer Task as a Make alternative? Install [Task](https://taskfile.dev/), then run `task setup`, @@ -143,11 +140,11 @@ native release checks. ## Architecture ``` -apps/desktop Electron main process: window, local OMP discovery, - host lifecycle, pairing, credential storage -apps/flutter Canonical Android/iOS/macOS/web UI: responsive workspace, - secure credentials, lifecycle, updates, OMP service controls -apps/web Legacy React compatibility UI for the Electron client +apps/desktop Primary Electron shell: windows, local OMP discovery, + host lifecycle, pairing, credential storage, native surfaces +apps/web Canonical React renderer shared by Electron, Tailnet browser/PWA, + and the React/Capacitor Android compatibility client +apps/mobile Android compatibility wrapper and native secure-storage/update bridges packages/ client, protocol, host-wire, host-service, remote, service-manager, ui ``` diff --git a/SECURITY.md b/SECURITY.md index 09378b65..b5286065 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,5 +26,5 @@ We read every report and will reply to tell you what happens next. This is a sma - T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at . - Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome. -- The macOS v0.1.30 build is signed with Apple Developer ID and notarized by Apple. Reports of certificate, Team ID, hardened-runtime, timestamp, Gatekeeper, or stapled-ticket drift are security-relevant. +- Published macOS builds are signed with Apple Developer ID and notarized by Apple. Reports of certificate, Team ID, hardened-runtime, timestamp, Gatekeeper, or stapled-ticket drift are security-relevant. - Starting with v0.1.24, the release workflow requires the pinned Developer ID identity, hardened runtime, Apple notarization, a stapled ticket, and a successful Gatekeeper assessment before publishing macOS artifacts. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d9a4e9c6..7a9b3fb4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/desktop", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/target-manager.ts b/apps/desktop/src/target-manager.ts index ceb644dc..9dcd2583 100644 --- a/apps/desktop/src/target-manager.ts +++ b/apps/desktop/src/target-manager.ts @@ -406,7 +406,7 @@ export class DesktopTargetManager { capabilities: requestedCapabilities, requestedFeatures: this.requestedFeatures, compatibilityRequestedFeatures: this.compatibilityRequestedFeatures, - client: { name: "T4 Code", version: "0.1.30", build: "desktop", platform: process.platform }, + client: { name: "T4 Code", version: "0.1.31", build: "desktop", platform: process.platform }, reconnect: { baseMs: 250, maxMs: 10_000 }, }; const client = createOmpClient(clientOptions); diff --git a/apps/flutter/.gitignore b/apps/flutter/.gitignore deleted file mode 100644 index 3820a95c..00000000 --- a/apps/flutter/.gitignore +++ /dev/null @@ -1,45 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/apps/flutter/.metadata b/apps/flutter/.metadata deleted file mode 100644 index 224ef085..00000000 --- a/apps/flutter/.metadata +++ /dev/null @@ -1,45 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "ee80f08bbf97172ec030b8751ceab557177a34a6" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: android - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: ios - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: linux - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: macos - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: web - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: windows - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/flutter/README.md b/apps/flutter/README.md deleted file mode 100644 index eb5a72d0..00000000 --- a/apps/flutter/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# T4 Code Flutter client - -Native Android, iOS, and macOS client for an OMP `omp-app/1` host. The Flutter -shell owns the responsive UI and typed wire projection; platform channels are -limited to credentials, lifecycle, runtime-service management, and signed -application updates. - -## Run and verify - -From this directory: - -```sh -flutter run -d android -flutter run -d ios -pnpm --dir ../.. dev:flutter -- -d macos -flutter test -flutter analyze -flutter test integration_test/app_smoke_test.dart -d -``` - -`--dart-define=T4_DEVELOPMENT_ENDPOINT=wss://…` connects an unsigned development -build directly to a host. Development credentials are intentionally volatile. -Release builds use platform secure storage. - -The public `https://t4code.net/demo/` preview is a read-only Flutter build with -local display data. From the repository root, `pnpm build:demo` creates that -subpath build in `apps/site/dist/demo`; it does not connect to a host or store -credentials. Demo publication follows current `main` independently of desktop -release tags. - -The device smoke harness launches the real native shell, waits for persistent -storage and platform initialization, and opens host management. CI runs it on -an Android emulator and an iOS simulator; local device IDs come from -`flutter devices`. - -## Platform lifecycle - -- Android reports foreground/resume events and performs user-driven updates - only after validating the canonical T4 manifest, APK package, version, and - signer. -- iOS uses the Flutter lifecycle and App Store-managed updates. -- macOS bundles the standalone `t4-host`, manages its per-user LaunchAgent, - connects it to OMP's authority bridge, and validates canonical signed DMG - updates before opening the installer. - -The Settings surface is generated from host `catalog.get` and `settings.read` -frames. Non-secret values may be staged and written only with negotiated -capabilities and explicit host confirmation; secret values are never projected -to the client. Diagnostics exports contain a fixed redacted allowlist. - -Transcript search uses the bounded `transcript.search` and -`transcript.context` commands. Results expose display-safe snippets and inline -historical context without projecting transcript paths or full session files. - -Opening a session fetches the newest bounded `transcript.page` before the live -stream attaches. The app keeps a small encrypted, display-only copy of recent -messages, so a previously opened conversation can paint immediately while the -host refreshes it. Each session is stored separately, earlier pages load on -demand, and paging cursors are never stored. Hosts without `transcript.page` -keep the original snapshot-and-stream behavior. - -Usage and account status use the bounded `usage.read` and `broker.status` -commands. The client renders provider limits and sanitized broker endpoints; -credentials and unrecognized metadata are rejected at the wire boundary. - -The Inbox projects host-reported parent/child agent relationships and live -progress. Active agents can be cancelled only with `agents.control`, the latest -session revision, and the host's correlated confirmation challenge. - -Files are edited against host-provided authority revisions. Writes and review -applications use the wire protocol's correlated confirmation challenge. -Preview controls cover navigation, capture, click, fill, type, select, key -press, scroll, upload, and handoff without executing page code in the client. diff --git a/apps/flutter/analysis_options.yaml b/apps/flutter/analysis_options.yaml deleted file mode 100644 index f9b30346..00000000 --- a/apps/flutter/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: package:flutter_lints/flutter.yaml diff --git a/apps/flutter/android/.gitignore b/apps/flutter/android/.gitignore deleted file mode 100644 index be3943c9..00000000 --- a/apps/flutter/android/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks diff --git a/apps/flutter/android/app/build.gradle.kts b/apps/flutter/android/app/build.gradle.kts deleted file mode 100644 index 199cdaf2..00000000 --- a/apps/flutter/android/app/build.gradle.kts +++ /dev/null @@ -1,50 +0,0 @@ -plugins { - id("com.android.application") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.lycaonsolutions.t4code" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.lycaonsolutions.t4code" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } -} - -dependencies { - implementation("androidx.core:core:1.17.0") - testImplementation("junit:junit:4.13.2") -} - -flutter { - source = "../.." -} diff --git a/apps/flutter/android/app/src/debug/AndroidManifest.xml b/apps/flutter/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index d16cdf39..00000000 --- a/apps/flutter/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - diff --git a/apps/flutter/android/app/src/main/AndroidManifest.xml b/apps/flutter/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index e47f44a9..00000000 --- a/apps/flutter/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/LegacyCredentialMigration.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/LegacyCredentialMigration.java deleted file mode 100644 index aadabdeb..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/LegacyCredentialMigration.java +++ /dev/null @@ -1,221 +0,0 @@ -package com.lycaonsolutions.t4code; - -import android.content.Context; -import android.content.SharedPreferences; -import android.util.Base64; - -import org.json.JSONObject; - -import java.nio.charset.StandardCharsets; -import java.security.KeyStore; -import java.security.MessageDigest; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import javax.crypto.Cipher; -import javax.crypto.SecretKey; -import javax.crypto.spec.GCMParameterSpec; - -final class LegacyCredentialMigration { - private static final String KEYSTORE_PROVIDER = "AndroidKeyStore"; - private static final String KEY_ALIAS = "t4_code_device_credentials_v1"; - private static final String CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"; - private static final String PREFERENCES_NAME = "t4_code_secure_storage"; - private static final String PREFERENCE_IV = "credentials_iv"; - private static final String PREFERENCE_PAYLOAD = "credentials_payload"; - private static final String PREFERENCE_IV_PREFIX = "credentials_iv_"; - private static final String PREFERENCE_PAYLOAD_PREFIX = "credentials_payload_"; - private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray(); - private static final int GCM_TAG_BITS = 128; - private static final int MAX_HOST_KEY_LENGTH = 2048; - private static final int MAX_DEVICE_ID_LENGTH = 256; - private static final int MAX_DEVICE_TOKEN_LENGTH = 512; - - private final Context context; - - LegacyCredentialMigration(Context context) { - this.context = context.getApplicationContext(); - } - - synchronized Map discover(List hostKeys, boolean includeUnkeyed) - throws Exception { - SharedPreferences preferences = preferences(); - Set visited = new HashSet<>(); - for (Object value : hostKeys) { - if (!(value instanceof String) || !isBoundedText((String) value, MAX_HOST_KEY_LENGTH)) { - throw new IllegalArgumentException("Invalid host key."); - } - String hostKey = (String) value; - if (!visited.add(hostKey)) continue; - - String suffix = preferenceSuffix(hostKey); - String ivKey = PREFERENCE_IV_PREFIX + suffix; - String payloadKey = PREFERENCE_PAYLOAD_PREFIX + suffix; - String encodedIv = preferences.getString(ivKey, null); - String encodedPayload = preferences.getString(payloadKey, null); - if (encodedIv != null || encodedPayload != null) { - requireComplete(encodedIv, encodedPayload); - return decrypt( - encodedIv, - encodedPayload, - hostKey, - sourceSelector("keyed", suffix, encodedIv, encodedPayload) - ); - } - } - - if (!includeUnkeyed) return null; - String encodedIv = preferences.getString(PREFERENCE_IV, null); - String encodedPayload = preferences.getString(PREFERENCE_PAYLOAD, null); - if (encodedIv == null && encodedPayload == null) return null; - requireComplete(encodedIv, encodedPayload); - return decrypt( - encodedIv, - encodedPayload, - null, - sourceSelector("unkeyed", "", encodedIv, encodedPayload) - ); - } - - synchronized void clear(String source) throws Exception { - if (source == null) throw new IllegalArgumentException("Invalid legacy source."); - String[] parts = source.split("\\.", -1); - final String ivKey; - final String payloadKey; - final String expectedFingerprint; - if (parts.length == 3 && "keyed".equals(parts[0]) && isHexDigest(parts[1])) { - ivKey = PREFERENCE_IV_PREFIX + parts[1]; - payloadKey = PREFERENCE_PAYLOAD_PREFIX + parts[1]; - expectedFingerprint = parts[2]; - } else if (parts.length == 2 && "unkeyed".equals(parts[0])) { - ivKey = PREFERENCE_IV; - payloadKey = PREFERENCE_PAYLOAD; - expectedFingerprint = parts[1]; - } else { - throw new IllegalArgumentException("Invalid legacy source."); - } - if (!isHexDigest(expectedFingerprint)) { - throw new IllegalArgumentException("Invalid legacy source."); - } - - SharedPreferences preferences = preferences(); - String encodedIv = preferences.getString(ivKey, null); - String encodedPayload = preferences.getString(payloadKey, null); - requireComplete(encodedIv, encodedPayload); - String actualFingerprint = payloadFingerprint(encodedIv, encodedPayload); - if (!MessageDigest.isEqual( - expectedFingerprint.getBytes(StandardCharsets.US_ASCII), - actualFingerprint.getBytes(StandardCharsets.US_ASCII) - )) { - throw new IllegalStateException("Legacy source changed."); - } - if (!preferences.edit().remove(ivKey).remove(payloadKey).commit()) { - throw new IllegalStateException("Legacy source could not be cleared."); - } - } - - private SharedPreferences preferences() { - return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); - } - - private Map decrypt( - String encodedIv, - String encodedPayload, - String hostKey, - String source - ) throws Exception { - byte[] iv = Base64.decode(encodedIv, Base64.NO_WRAP); - byte[] ciphertext = Base64.decode(encodedPayload, Base64.NO_WRAP); - Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); - cipher.init(Cipher.DECRYPT_MODE, existingKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); - if (hostKey != null) cipher.updateAAD(hostKey.getBytes(StandardCharsets.UTF_8)); - String plaintext = new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); - JSONObject credentials = new JSONObject(plaintext); - String deviceId = credentials.getString("deviceId"); - String deviceToken = credentials.getString("deviceToken"); - if (!isBoundedText(deviceId, MAX_DEVICE_ID_LENGTH) - || !isBoundedText(deviceToken, MAX_DEVICE_TOKEN_LENGTH)) { - throw new IllegalStateException("Invalid legacy credential payload."); - } - - Map result = new HashMap<>(); - result.put("deviceId", deviceId); - result.put("deviceToken", deviceToken); - result.put("source", source); - return result; - } - - private SecretKey existingKey() throws Exception { - KeyStore keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER); - keyStore.load(null); - if (!keyStore.containsAlias(KEY_ALIAS)) { - throw new IllegalStateException("Legacy key is unavailable."); - } - return (SecretKey) keyStore.getKey(KEY_ALIAS, null); - } - - private static void requireComplete(String encodedIv, String encodedPayload) { - if (encodedIv == null || encodedPayload == null) { - throw new IllegalStateException("Incomplete legacy credential state."); - } - } - - private static String preferenceSuffix(String hostKey) throws Exception { - return hex( - MessageDigest.getInstance("SHA-256") - .digest(hostKey.getBytes(StandardCharsets.UTF_8)) - ); - } - - private static String sourceSelector( - String kind, - String suffix, - String encodedIv, - String encodedPayload - ) throws Exception { - String fingerprint = payloadFingerprint(encodedIv, encodedPayload); - return "keyed".equals(kind) - ? kind + "." + suffix + "." + fingerprint - : kind + "." + fingerprint; - } - - private static String payloadFingerprint(String encodedIv, String encodedPayload) - throws Exception { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - digest.update(encodedIv.getBytes(StandardCharsets.UTF_8)); - digest.update((byte) 0); - digest.update(encodedPayload.getBytes(StandardCharsets.UTF_8)); - return hex(digest.digest()); - } - - private static String hex(byte[] bytes) { - StringBuilder result = new StringBuilder(bytes.length * 2); - for (byte value : bytes) { - result.append(HEX_DIGITS[(value >>> 4) & 0x0f]); - result.append(HEX_DIGITS[value & 0x0f]); - } - return result.toString(); - } - - private static boolean isHexDigest(String value) { - if (value.length() != 64) return false; - for (int index = 0; index < value.length(); index += 1) { - char character = value.charAt(index); - if ((character < '0' || character > '9') - && (character < 'a' || character > 'f')) return false; - } - return true; - } - - private static boolean isBoundedText(String value, int maxLength) { - if (value == null || value.isEmpty() || value.length() > maxLength) return false; - for (int index = 0; index < value.length(); index += 1) { - char character = value.charAt(index); - if (character <= 0x1f || character == 0x7f) return false; - } - return true; - } -} diff --git a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java deleted file mode 100644 index 6b91a951..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.lycaonsolutions.t4code; - -import androidx.annotation.NonNull; - -import java.util.List; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -import io.flutter.embedding.android.FlutterActivity; -import io.flutter.embedding.engine.FlutterEngine; -import io.flutter.plugin.common.MethodChannel; - -public class MainActivity extends FlutterActivity { - private static final String LEGACY_CREDENTIAL_CHANNEL = - "com.lycaonsolutions.t4code/legacy_credentials"; - private T4UpdatePlugin updatePlugin; - - private final ExecutorService legacyCredentialExecutor = new ThreadPoolExecutor( - 1, - 1, - 0L, - TimeUnit.MILLISECONDS, - new ArrayBlockingQueue<>(8) - ); - - @Override - public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { - super.configureFlutterEngine(flutterEngine); - if (updatePlugin != null) updatePlugin.destroy(); - updatePlugin = new T4UpdatePlugin(this); - new MethodChannel( - flutterEngine.getDartExecutor().getBinaryMessenger(), - T4UpdatePlugin.CHANNEL_NAME - ).setMethodCallHandler(updatePlugin::handle); - - LegacyCredentialMigration migration = new LegacyCredentialMigration(this); - new MethodChannel( - flutterEngine.getDartExecutor().getBinaryMessenger(), - LEGACY_CREDENTIAL_CHANNEL - ).setMethodCallHandler((call, result) -> { - try { - switch (call.method) { - case "discoverCredentials": - List hostKeys = call.argument("hostKeys"); - Boolean includeUnkeyed = call.argument("includeUnkeyed"); - if (hostKeys == null || hostKeys.isEmpty() || includeUnkeyed == null) { - throw new IllegalArgumentException("Invalid discovery request."); - } - executeLegacyOperation( - result, - () -> migration.discover(hostKeys, includeUnkeyed) - ); - break; - case "clearCredentials": - String source = call.argument("source"); - executeLegacyOperation(result, () -> { - migration.clear(source); - return null; - }); - break; - default: - result.notImplemented(); - break; - } - } catch (Exception error) { - sendLegacyError(result); - } - }); - } - - @Override - protected void onPause() { - if (updatePlugin != null) updatePlugin.onPause(); - super.onPause(); - } - - @Override - protected void onResume() { - super.onResume(); - if (updatePlugin != null) updatePlugin.onResume(); - } - - @Override - protected void onDestroy() { - if (updatePlugin != null) { - updatePlugin.destroy(); - updatePlugin = null; - } - legacyCredentialExecutor.shutdownNow(); - super.onDestroy(); - } - - private void executeLegacyOperation( - MethodChannel.Result result, - LegacyOperation operation - ) { - try { - legacyCredentialExecutor.execute(() -> { - try { - Object value = operation.run(); - runOnUiThread(() -> result.success(value)); - } catch (Exception error) { - runOnUiThread(() -> sendLegacyError(result)); - } - }); - } catch (RejectedExecutionException error) { - sendLegacyError(result); - } - } - - private void sendLegacyError(MethodChannel.Result result) { - result.error( - "legacy_credentials_unavailable", - "Legacy credentials could not be migrated.", - null - ); - } - - private interface LegacyOperation { - Object run() throws Exception; - } -} diff --git a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4FileProvider.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4FileProvider.java deleted file mode 100644 index 612c1081..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4FileProvider.java +++ /dev/null @@ -1,6 +0,0 @@ -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/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateFileStore.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateFileStore.java deleted file mode 100644 index 2c720a98..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateFileStore.java +++ /dev/null @@ -1,228 +0,0 @@ -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/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdatePlugin.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdatePlugin.java deleted file mode 100644 index 1e2060d3..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdatePlugin.java +++ /dev/null @@ -1,767 +0,0 @@ -package com.lycaonsolutions.t4code; - -import android.app.Activity; -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 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.IOException; -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.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -import javax.net.ssl.HttpsURLConnection; - -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; - -/** - * User-driven Android release updates for Flutter's platform lifecycle channel. - * The Dart layer supplies no URLs or package data: native code verifies T4's - * exact first-party manifest, APK bytes, package identity, version, and signer - * before a separate user action may open Android's installer. - */ -final class T4UpdatePlugin { - static final String CHANNEL_NAME = "com.lycaonsolutions.t4code/platform_lifecycle"; - - 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 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_TEXT_LENGTH = 512; - - private final Activity activity; - private final T4UpdateStateMachine updateState = new T4UpdateStateMachine(); - private final T4UpdateFileStore updateFiles; - private final ExecutorService executor = new ThreadPoolExecutor( - 1, - 1, - 0L, - TimeUnit.MILLISECONDS, - new ArrayBlockingQueue<>(8), - runnable -> { - Thread thread = new Thread(runnable, "T4VerifiedUpdate"); - thread.setDaemon(true); - return thread; - }, - new ThreadPoolExecutor.AbortPolicy() - ); - - private String latestVersion; - private Long checkedAt; - private String errorMessage; - private String statusMessage; - private ManifestRelease validatedRelease; - private File verifiedPackage; - private File installerHandoff; - private boolean installerWasPaused; - private boolean recoveredHandoff; - private volatile boolean destroyed; - - T4UpdatePlugin(Activity activity) { - this.activity = activity; - updateFiles = new T4UpdateFileStore( - new File(activity.getCacheDir(), UPDATE_CACHE_DIRECTORY) - ); - submitInternal(() -> { - try { - File recovered = updateFiles.prepareOnStartup(); - synchronized (this) { - if (destroyed) return; - installerHandoff = recovered; - recoveredHandoff = recovered != null; - } - } catch (IOException ignored) { - // The foreground download path retries the private cache sweep. - } - }); - } - - void handle(MethodCall call, MethodChannel.Result result) { - if (call.arguments != null) { - sendError(result, "invalid_state", "Android update methods do not accept arguments."); - return; - } - switch (call.method) { - case "update.getState": - submit(result, () -> completeSuccess(result, statePayload())); - break; - case "update.check": - submit(result, () -> checkForUpdate(result)); - break; - case "update.download": - submit(result, () -> downloadUpdate(result)); - break; - case "update.install": - submit(result, () -> installUpdate(result)); - break; - default: - result.notImplemented(); - break; - } - } - - void onPause() { - synchronized (this) { - if (installerHandoff != null && "installer".equals(updateState.phase())) { - installerWasPaused = true; - } - } - } - - void onResume() { - submitInternal(() -> { - final File completedHandoff; - synchronized (this) { - if (installerHandoff == null || (!recoveredHandoff && !installerWasPaused)) return; - completedHandoff = installerHandoff; - installerHandoff = null; - recoveredHandoff = false; - installerWasPaused = false; - verifiedPackage = null; - if ("installer".equals(updateState.phase())) { - boolean canRetry = validatedRelease != null; - updateState.installerReturned(canRetry); - errorMessage = null; - statusMessage = canRetry - ? "Android's installer closed. Download the release again to retry." - : null; - } - } - finishInstallerHandoff(completedHandoff); - }); - } - - void destroy() { - destroyed = true; - executor.shutdownNow(); - File unhandedPackage; - synchronized (this) { - unhandedPackage = verifiedPackage; - verifiedPackage = null; - validatedRelease = null; - statusMessage = null; - errorMessage = null; - updateState.reset(); - } - updateFiles.discard(unhandedPackage); - updateFiles.cleanupForDestroy(); - } - - private void checkForUpdate(MethodChannel.Result result) { - File stalePackage; - synchronized (this) { - if (!updateState.beginCheck()) { - completeSuccess(result, statePayload()); - return; - } - stalePackage = verifiedPackage; - verifiedPackage = null; - statusMessage = "Checking the published Android release."; - errorMessage = null; - } - updateFiles.discard(stalePackage); - - try { - ManifestRelease release = fetchRelease(); - String currentVersion = currentVersion(); - int comparison = T4UpdateVerifier.compareVersions(release.version, currentVersion); - Map state; - synchronized (this) { - if (!updateState.finishCheck(comparison > 0 ? "available" : "current")) { - completeSuccess(result, statePayload()); - return; - } - latestVersion = release.version; - checkedAt = System.currentTimeMillis(); - errorMessage = null; - statusMessage = null; - validatedRelease = comparison > 0 ? release : null; - state = statePayload(); - } - completeSuccess(result, state); - } catch (IOException error) { - finishCheckFailure( - result, - "T4 Code could not reach the published Android release. Check your connection and try again." - ); - } catch (Exception error) { - finishCheckFailure( - result, - "T4 Code could not validate the published Android release manifest." - ); - } - } - - private void finishCheckFailure(MethodChannel.Result result, String message) { - Map state; - synchronized (this) { - if (!updateState.finishCheck("error")) { - completeSuccess(result, statePayload()); - return; - } - latestVersion = null; - checkedAt = System.currentTimeMillis(); - validatedRelease = null; - statusMessage = null; - errorMessage = boundedText(message); - state = statePayload(); - } - completeSuccess(result, state); - } - - private void downloadUpdate(MethodChannel.Result result) { - final ManifestRelease release; - synchronized (this) { - release = validatedRelease; - if (updateState.beginDownload(release != null) != T4UpdateStateMachine.DownloadStart.STARTED) { - completeSuccess(result, statePayload()); - return; - } - errorMessage = null; - statusMessage = "Downloading and verifying the published Android APK."; - } - - File packageFile = null; - try { - if (destroyed) throw new IllegalStateException("activity was destroyed"); - packageFile = downloadVerifiedPackage(release); - verifyAndroidPackage(packageFile, release.version); - if (destroyed) throw new IllegalStateException("activity was destroyed"); - Map state; - synchronized (this) { - verifiedPackage = packageFile; - updateState.downloadSucceeded(); - errorMessage = null; - statusMessage = "The Android update is verified and ready for installation."; - state = statePayload(); - } - completeSuccess(result, state); - } catch (Exception error) { - updateFiles.discard(packageFile); - Map state; - synchronized (this) { - if ("downloading".equals(updateState.phase())) updateState.downloadFailed(); - verifiedPackage = null; - validatedRelease = null; - statusMessage = null; - errorMessage = boundedText( - "T4 Code could not verify the Android update. Your current installation is unchanged." - ); - state = statePayload(); - } - completeSuccess(result, state); - } - } - - private void installUpdate(MethodChannel.Result result) { - final File packageFile; - synchronized (this) { - packageFile = verifiedPackage; - if (packageFile == null || !packageFile.isFile() || !"available".equals(updateState.phase())) { - sendError(result, "invalid_state", "Download and verify an available update first."); - return; - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - !activity.getPackageManager().canRequestPackageInstalls()) { - sendError( - result, - "installer_unavailable", - "Android has not allowed T4 Code to request package installation." - ); - return; - } - - final File handoff; - final Uri contentUri; - try { - handoff = updateFiles.beginInstallerHandoff(packageFile); - contentUri = FileProvider.getUriForFile( - activity, - activity.getPackageName() + ".fileprovider", - handoff - ); - synchronized (this) { - installerHandoff = handoff; - recoveredHandoff = false; - installerWasPaused = false; - verifiedPackage = null; - } - } catch (Exception error) { - updateFiles.discard(packageFile); - synchronized (this) { - verifiedPackage = null; - } - sendError(result, "installer_unavailable", "Android's package installer is unavailable."); - return; - } - - CountDownLatch installerCompleted = new CountDownLatch(1); - activity.runOnUiThread(() -> { - try { - if (destroyed) { - failInstallerHandoff(result, handoff); - return; - } - 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); - activity.startActivity(intent); - Map state; - synchronized (this) { - updateState.installerOpened(); - errorMessage = null; - statusMessage = "The verified APK is open in Android's installer."; - state = statePayload(); - } - result.success(state); - } catch (Exception error) { - failInstallerHandoff(result, handoff); - } finally { - installerCompleted.countDown(); - } - }); - try { - installerCompleted.await(); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - } - - private void failInstallerHandoff(MethodChannel.Result result, File handoff) { - synchronized (this) { - installerHandoff = null; - recoveredHandoff = false; - installerWasPaused = false; - } - submitInternal(() -> finishInstallerHandoff(handoff)); - result.error( - "installer_unavailable", - boundedText("Android's package installer is unavailable."), - null - ); - } - - private File downloadVerifiedPackage(ManifestRelease release) throws Exception { - updateFiles.prepareForDownload(); - File partial = updateFiles.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 updateFiles.finalizeVerified(partial); - } catch (Exception error) { - updateFiles.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 = activity.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; - } - - @SuppressWarnings("deprecation") - private String currentVersion() { - String packageName = activity.getPackageName(); - if (!EXPECTED_PACKAGE_ID.equals(packageName)) { - throw new IllegalStateException("Android application identity is invalid"); - } - try { - PackageInfo packageInfo; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageInfo = activity.getPackageManager().getPackageInfo( - packageName, - PackageManager.PackageInfoFlags.of(0) - ); - } else { - packageInfo = activity.getPackageManager().getPackageInfo(packageName, 0); - } - String version = packageInfo.versionName; - if (!T4UpdateVerifier.isValidVersion(version)) { - throw new IllegalStateException("Android application version is invalid"); - } - return version; - } catch (PackageManager.NameNotFoundException error) { - throw new IllegalStateException("Android application identity is unavailable", error); - } - } - - 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 IOException("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"); - String string = (String) value; - if (string.length() > 8192) throw new IllegalStateException(key + " is too long"); - return string; - } - - 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 synchronized Map statePayload() { - Map result = new LinkedHashMap<>(7); - 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", boundedText(errorMessage)); - if (statusMessage != null) result.put("message", boundedText(statusMessage)); - return result; - } - - private String boundedText(String message) { - if (message == null) return ""; - StringBuilder output = new StringBuilder(Math.min(message.length(), MAX_TEXT_LENGTH)); - for (int index = 0; index < message.length() && output.length() < MAX_TEXT_LENGTH; index += 1) { - char character = message.charAt(index); - output.append(character <= 0x1f || character == 0x7f ? ' ' : character); - } - return output.toString(); - } - - private void submit(MethodChannel.Result result, Runnable operation) { - try { - executor.execute(() -> { - try { - operation.run(); - } catch (Exception error) { - sendError(result, "invalid_state", "Android update state is unavailable."); - } - }); - } catch (RejectedExecutionException error) { - sendError(result, "invalid_state", "Android update operations are unavailable."); - } - } - - private void submitInternal(Runnable operation) { - try { - executor.execute(operation); - } catch (RejectedExecutionException ignored) { - // Activity teardown owns final private-cache cleanup. - } - } - - private void completeSuccess(MethodChannel.Result result, Map value) { - activity.runOnUiThread(() -> result.success(value)); - } - - private void sendError(MethodChannel.Result result, String code, String message) { - activity.runOnUiThread(() -> result.error(code, boundedText(message), null)); - } - - private void finishInstallerHandoff(File handoff) { - if (handoff == null) return; - try { - updateFiles.finishInstallerHandoff(handoff); - } catch (IOException ignored) { - // Startup and pre-download sweeps retry this bounded one-file cleanup. - } - } - - 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/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateStateMachine.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateStateMachine.java deleted file mode 100644 index 5fd814c5..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateStateMachine.java +++ /dev/null @@ -1,89 +0,0 @@ -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 downloadSucceeded() { - requireActiveDownload(); - downloadInProgress = false; - transition("available"); - } - - synchronized void installerOpened() { - if (downloadInProgress || !"available".equals(phase)) { - throw new IllegalStateException("no verified update is ready for installation"); - } - 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/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateVerifier.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateVerifier.java deleted file mode 100644 index f3c8d3da..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateVerifier.java +++ /dev/null @@ -1,251 +0,0 @@ -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/flutter/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/flutter/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3..00000000 --- a/apps/flutter/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/apps/flutter/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/apps/flutter/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index c7bd21db..00000000 --- a/apps/flutter/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - diff --git a/apps/flutter/android/app/src/main/res/drawable/launch_background.xml b/apps/flutter/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f8..00000000 --- a/apps/flutter/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 036d09bc..00000000 --- a/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 036d09bc..00000000 --- a/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 04e56297..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index ba8eafc3..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 04e56297..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 2e8a85cd..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 04344b6f..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 2e8a85cd..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 8e1579f7..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 8f46c7ff..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 8e1579f7..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 4e4e05be..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 8262c741..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 0d1adac8..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 7854e90f..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 54aca261..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 7854e90f..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/values-night/styles.xml b/apps/flutter/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be7..00000000 --- a/apps/flutter/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/apps/flutter/android/app/src/main/res/values/ic_launcher_background.xml b/apps/flutter/android/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index 06781cec..00000000 --- a/apps/flutter/android/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #0D0D0D - diff --git a/apps/flutter/android/app/src/main/res/values/styles.xml b/apps/flutter/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef880..00000000 --- a/apps/flutter/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/apps/flutter/android/app/src/main/res/xml/update_file_paths.xml b/apps/flutter/android/app/src/main/res/xml/update_file_paths.xml deleted file mode 100644 index 44e6b48d..00000000 --- a/apps/flutter/android/app/src/main/res/xml/update_file_paths.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/apps/flutter/android/app/src/profile/AndroidManifest.xml b/apps/flutter/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/apps/flutter/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateFileStoreTest.java b/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateFileStoreTest.java deleted file mode 100644 index 8e2ffa0b..00000000 --- a/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateFileStoreTest.java +++ /dev/null @@ -1,147 +0,0 @@ -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/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateStateMachineTest.java b/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateStateMachineTest.java deleted file mode 100644 index af524870..00000000 --- a/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateStateMachineTest.java +++ /dev/null @@ -1,132 +0,0 @@ -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.downloadSucceeded(); - 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.downloadSucceeded(); - 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/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateVerifierTest.java b/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateVerifierTest.java deleted file mode 100644 index 82404196..00000000 --- a/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateVerifierTest.java +++ /dev/null @@ -1,355 +0,0 @@ -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/flutter/android/build.gradle.kts b/apps/flutter/android/build.gradle.kts deleted file mode 100644 index dbee657b..00000000 --- a/apps/flutter/android/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/apps/flutter/android/gradle.properties b/apps/flutter/android/gradle.properties deleted file mode 100644 index e96108cf..00000000 --- a/apps/flutter/android/gradle.properties +++ /dev/null @@ -1,6 +0,0 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -# This newDsl flag was added by the Flutter template -android.newDsl=false -# This builtInKotlin flag was added by the Flutter template -android.builtInKotlin=false diff --git a/apps/flutter/android/gradle/wrapper/gradle-wrapper.properties b/apps/flutter/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2d428bfb..00000000 --- a/apps/flutter/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/apps/flutter/android/settings.gradle.kts b/apps/flutter/android/settings.gradle.kts deleted file mode 100644 index c21f0c5b..00000000 --- a/apps/flutter/android/settings.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "9.0.1" apply false - id("org.jetbrains.kotlin.android") version "2.3.20" apply false -} - -include(":app") diff --git a/apps/flutter/assets/fonts/DMSans-OFL.txt b/apps/flutter/assets/fonts/DMSans-OFL.txt deleted file mode 100644 index 4430b85a..00000000 --- a/apps/flutter/assets/fonts/DMSans-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/flutter/assets/fonts/DMSans-Variable.ttf b/apps/flutter/assets/fonts/DMSans-Variable.ttf deleted file mode 100644 index c672f980..00000000 Binary files a/apps/flutter/assets/fonts/DMSans-Variable.ttf and /dev/null differ diff --git a/apps/flutter/assets/fonts/JetBrainsMono-OFL.txt b/apps/flutter/assets/fonts/JetBrainsMono-OFL.txt deleted file mode 100644 index 821a3dac..00000000 --- a/apps/flutter/assets/fonts/JetBrainsMono-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. - -This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/flutter/assets/fonts/JetBrainsMono-Variable.ttf b/apps/flutter/assets/fonts/JetBrainsMono-Variable.ttf deleted file mode 100644 index aa310be8..00000000 Binary files a/apps/flutter/assets/fonts/JetBrainsMono-Variable.ttf and /dev/null differ diff --git a/apps/flutter/integration_test/app_smoke_test.dart b/apps/flutter/integration_test/app_smoke_test.dart deleted file mode 100644 index 54afbb60..00000000 --- a/apps/flutter/integration_test/app_smoke_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:t4code/main.dart' as app; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets('native shell launches and opens host management', ( - tester, - ) async { - await tester.pumpWidget(const app.T4Bootstrap()); - await _pumpUntil( - tester, - () => - find.byTooltip('Open navigation').evaluate().isNotEmpty || - find.byTooltip('Manage hosts').evaluate().isNotEmpty || - find.text('Manage hosts').evaluate().isNotEmpty || - find.text('Add host').evaluate().isNotEmpty, - ); - - if (find.text('Add host').evaluate().isEmpty) { - final navigationButton = find.byTooltip('Open navigation'); - if (navigationButton.evaluate().isNotEmpty) { - await tester.tap(navigationButton); - await tester.pumpAndSettle(); - } - final manageHosts = find.text('Manage hosts'); - final manageHostsButton = find.byTooltip('Manage hosts'); - expect( - manageHosts.evaluate().isNotEmpty || - manageHostsButton.evaluate().isNotEmpty, - isTrue, - ); - await tester.tap( - manageHosts.evaluate().isNotEmpty - ? manageHosts.last - : manageHostsButton, - ); - await tester.pumpAndSettle(); - } - expect(find.text('Add host'), findsOneWidget); - }); -} - -Future _pumpUntil(WidgetTester tester, bool Function() predicate) async { - for (var attempt = 0; attempt < 100; attempt += 1) { - if (predicate()) return; - await tester.pump(const Duration(milliseconds: 100)); - } - throw TestFailure('Native shell did not reach host navigation.'); -} diff --git a/apps/flutter/ios/.gitignore b/apps/flutter/ios/.gitignore deleted file mode 100644 index 7a7f9873..00000000 --- a/apps/flutter/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/apps/flutter/ios/Flutter/AppFrameworkInfo.plist b/apps/flutter/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 391a902b..00000000 --- a/apps/flutter/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - - diff --git a/apps/flutter/ios/Flutter/Debug.xcconfig b/apps/flutter/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/apps/flutter/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/apps/flutter/ios/Flutter/Release.xcconfig b/apps/flutter/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/apps/flutter/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/apps/flutter/ios/Runner.xcodeproj/project.pbxproj b/apps/flutter/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 19be5f1e..00000000 --- a/apps/flutter/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,655 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 8DBA452E253DC32896127870 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - 8DBA452E253DC32896127870 /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, - ); - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/apps/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index c3fedb29..00000000 --- a/apps/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16..00000000 --- a/apps/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/apps/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/apps/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/apps/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/apps/flutter/ios/Runner/AppDelegate.swift b/apps/flutter/ios/Runner/AppDelegate.swift deleted file mode 100644 index c30b367e..00000000 --- a/apps/flutter/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - } -} diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fab..00000000 --- a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index 9d3a1a38..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index a56bace0..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 69dee203..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 937761a5..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 2c93f3c5..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index 181d9846..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 29861d50..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 69dee203..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index ba014889..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index bb09fb4e..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index bb09fb4e..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index a3dc9702..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 10496d3a..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index f7e37df8..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 42cd43a1..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2f..00000000 --- a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b..00000000 --- a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/apps/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7..00000000 --- a/apps/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/ios/Runner/Base.lproj/Main.storyboard b/apps/flutter/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516..00000000 --- a/apps/flutter/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/ios/Runner/DebugProfile.entitlements b/apps/flutter/ios/Runner/DebugProfile.entitlements deleted file mode 100644 index fbad0237..00000000 --- a/apps/flutter/ios/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - keychain-access-groups - - - diff --git a/apps/flutter/ios/Runner/Info.plist b/apps/flutter/ios/Runner/Info.plist deleted file mode 100644 index 1b8e3097..00000000 --- a/apps/flutter/ios/Runner/Info.plist +++ /dev/null @@ -1,70 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - T4 Code - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - T4 Code - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/apps/flutter/ios/Runner/Release.entitlements b/apps/flutter/ios/Runner/Release.entitlements deleted file mode 100644 index fbad0237..00000000 --- a/apps/flutter/ios/Runner/Release.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - keychain-access-groups - - - diff --git a/apps/flutter/ios/Runner/Runner-Bridging-Header.h b/apps/flutter/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a56..00000000 --- a/apps/flutter/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/apps/flutter/ios/Runner/SceneDelegate.swift b/apps/flutter/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b9ce8ea2..00000000 --- a/apps/flutter/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/apps/flutter/ios/RunnerTests/RunnerTests.swift b/apps/flutter/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b1..00000000 --- a/apps/flutter/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart deleted file mode 100644 index 62c77b46..00000000 --- a/apps/flutter/lib/main.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter/foundation.dart'; - -import 'src/client/app_state.dart'; -import 'src/client/t4_client_controller.dart'; -import 'src/client/transcript_tail_store.dart'; -import 'src/demo/demo_app.dart'; -import 'src/host/app_preferences.dart'; -import 'src/host/persistent_host_stores.dart'; -import 'src/platform/platform_lifecycle_controller.dart'; -import 'src/ui/t4_app.dart'; - -void main() { - const demoMode = bool.fromEnvironment('T4_DEMO_MODE'); - if (demoMode) { - runApp(T4DemoApp()); - return; - } - runApp(T4Bootstrap(developmentEndpoint: _developmentEndpoint())); -} - -Uri? _developmentEndpoint() { - const configured = String.fromEnvironment('T4_DEVELOPMENT_ENDPOINT'); - if (configured.isEmpty) return null; - final endpoint = Uri.tryParse(configured); - if (endpoint == null || - (endpoint.scheme != 'ws' && endpoint.scheme != 'wss')) { - return null; - } - return endpoint; -} - -final class T4Bootstrap extends StatefulWidget { - const T4Bootstrap({this.developmentEndpoint, super.key}); - - final Uri? developmentEndpoint; - - @override - State createState() => _T4BootstrapState(); -} - -final class _T4BootstrapState extends State - with WidgetsBindingObserver { - late final T4ClientController _controller; - late final PlatformLifecycleController _platformController; - late final bool _credentialsAreVolatile; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _credentialsAreVolatile = - kDebugMode && defaultTargetPlatform == TargetPlatform.macOS; - _controller = T4ClientController( - hostDirectoryStore: PersistentHostDirectoryStore(), - hostCredentialStore: _credentialsAreVolatile - ? VolatileHostCredentialStore() - : SecureHostCredentialStore(), - appPreferenceStore: PersistentAppPreferenceStore(), - transcriptTailStore: PersistentTranscriptTailStore(), - developmentEndpoint: widget.developmentEndpoint, - ); - _platformController = PlatformLifecycleController(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - unawaited(_controller.initialize()); - unawaited(_platformController.initialize()); - }); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.resumed: - unawaited(_controller.handleLifecyclePhase(T4LifecyclePhase.resumed)); - unawaited(_platformController.refreshPlatformState()); - case AppLifecycleState.hidden: - case AppLifecycleState.paused: - case AppLifecycleState.detached: - unawaited( - _controller.handleLifecyclePhase(T4LifecyclePhase.background), - ); - case AppLifecycleState.inactive: - break; - } - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: Listenable.merge([ - _controller, - _platformController, - ]), - builder: (context, _) => T4App( - state: _controller.state, - actions: _controller, - credentialsAreVolatile: _credentialsAreVolatile, - platformState: _platformController.state, - platformActions: _platformController, - ), - ); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _controller.dispose(); - _platformController.dispose(); - super.dispose(); - } -} diff --git a/apps/flutter/lib/src/client/app_state.dart b/apps/flutter/lib/src/client/app_state.dart deleted file mode 100644 index 4da9e6e3..00000000 --- a/apps/flutter/lib/src/client/app_state.dart +++ /dev/null @@ -1,780 +0,0 @@ -import 'dart:typed_data'; - -import '../host/host_profile.dart'; -import '../protocol/models.dart'; -import 'model_labels.dart'; - -enum ConnectionPhase { - disconnected, - connecting, - synchronizing, - ready, - retrying, - failed, -} - -enum AuthenticationPhase { unknown, local, pairingRequired, pairing, paired } - -const List t4RequestedFeatures = [ - 'resume', - 'host.watch', - 'session.watch', - 'session.state', - 'session.delta', - 'session.observer', - 'controller.lease', - 'prompt.lease', - 'prompt.images', - 'transcript.images', - 'transcript.search', - 'transcript.page', - 'agent.lifecycle', - 'agent.progress', - 'agent.event', - 'agent.transcript', - 'terminal.io', - 'files.list', - 'files.search', - 'files.diff', - 'audit.tail', - 'catalog.metadata', - 'settings.metadata', - 'preview.control', -]; - -const List t4RequestedCapabilities = [ - 'sessions.read', - 'sessions.prompt', - 'sessions.control', - 'sessions.manage', - 'term.open', - 'term.input', - 'term.resize', - 'files.read', - 'files.write', - 'files.list', - 'files.diff', - 'agents.control', - 'audit.read', - 'config.read', - 'catalog.read', - 'config.write', - 'broker.read', - 'usage.read', - 'preview.read', - 'preview.control', - 'preview.input', -]; - -final String t4PairCommand = [ - 'omp appserver pair', - for (final capability in t4RequestedCapabilities) '--capability $capability', -].join(' '); - -enum MessageRole { user, assistant, system, tool } - -enum TranscriptKind { message, tool, compaction, notice } - -final class TranscriptImageMetadata { - const TranscriptImageMetadata({required this.sha256, required this.mimeType}); - - final String sha256; - final String mimeType; -} - -final class SessionSummary { - const SessionSummary({ - required this.hostId, - required this.sessionId, - required this.title, - required this.revision, - required this.status, - this.projectId = 'unknown-project', - this.projectName = 'Project', - this.updatedAt = '', - this.archivedAt, - this.working = false, - this.modelSelector, - this.modelDisplayName, - this.thinking, - this.thinkingSupported, - this.thinkingLevels = const [], - this.fast = false, - this.fastAvailable = false, - this.turnActive = false, - this.isPaused = false, - this.queuedFollowUpCount = 0, - }); - - final String hostId; - final String sessionId; - final String title; - final String revision; - final String status; - final String projectId; - final String projectName; - final String updatedAt; - final String? archivedAt; - final bool working; - final String? modelSelector; - final String? modelDisplayName; - final String? thinking; - final bool? thinkingSupported; - final List thinkingLevels; - final bool fast; - final bool fastAvailable; - final bool turnActive; - final bool isPaused; - final int queuedFollowUpCount; - - bool get archived => archivedAt != null; -} - -final class TranscriptMessage { - const TranscriptMessage({ - required this.id, - required this.role, - required this.text, - this.kind = TranscriptKind.message, - this.reasoning = '', - this.streaming = false, - this.toolName, - this.toolTitle, - this.toolArguments, - this.toolOutput, - this.toolSucceeded, - this.toolRunning = false, - this.toolProgress, - this.images = const [], - }); - - final String id; - final MessageRole role; - final String text; - final TranscriptKind kind; - final String reasoning; - final bool streaming; - final String? toolName; - final String? toolTitle; - final String? toolArguments; - final String? toolOutput; - final bool? toolSucceeded; - final bool toolRunning; - final double? toolProgress; - final List images; -} - -final class ComposerModelChoice { - const ComposerModelChoice({ - required this.label, - required this.selector, - this.provider = '', - this.providerLabel = '', - this.supported = true, - this.reason, - }); - - final String label; - final String selector; - - /// Raw provider id (empty when the model has no provider). - final String provider; - - /// Friendly provider name (empty when none). - final String providerLabel; - final bool supported; - final String? reason; -} - -final class ComposerSlashCommand { - const ComposerSlashCommand({ - required this.name, - required this.description, - required this.insert, - this.aliases = const [], - this.disabledReason, - }); - - final String name; - final String description; - final String insert; - final List aliases; - final String? disabledReason; -} - -final class SessionComposerState { - const SessionComposerState({ - this.modelLabel, - this.modelSelector, - this.modelChoices = const [], - this.modelGroups = const [], - this.slashCommands = const [], - this.thinking, - this.thinkingLevels = const [], - this.fastEnabled = false, - this.fastAvailable = false, - this.turnActive = false, - this.isPaused = false, - this.queuedFollowUpCount = 0, - }); - - final String? modelLabel; - final String? modelSelector; - final List modelChoices; - - /// Provider-grouped choices for the navigable model selector. - final List modelGroups; - final List slashCommands; - final String? thinking; - final List thinkingLevels; - final bool fastEnabled; - final bool fastAvailable; - final bool turnActive; - final bool isPaused; - final int queuedFollowUpCount; -} - -final class PromptImageAttachment { - const PromptImageAttachment({ - required this.id, - required this.name, - required this.mimeType, - required this.bytes, - }); - - final String id; - final String name; - final String mimeType; - final Uint8List bytes; -} - -enum AttentionKind { - approval, - question, - plan, - confirmation, - completed, - failed, - cancelled, -} - -enum AttentionDecision { approve, deny, revise, reject } - -final class AttentionChoice { - const AttentionChoice({required this.id, required this.label}); - - final String id; - final String label; -} - -final class AttentionItem { - const AttentionItem({ - required this.key, - required this.kind, - required this.sessionId, - required this.sessionTitle, - required this.revision, - required this.title, - required this.summary, - required this.at, - this.requestId, - this.confirmationId, - this.commandId, - this.expiresAt, - this.choices = const [], - this.allowText = false, - this.actionable = false, - }); - - final String key; - final AttentionKind kind; - final String sessionId; - final String sessionTitle; - final String revision; - final String title; - final String summary; - final DateTime at; - final String? requestId; - final String? confirmationId; - final String? commandId; - final DateTime? expiresAt; - final List choices; - final bool allowText; - final bool actionable; - - bool get needsResponse => - kind == AttentionKind.approval || - kind == AttentionKind.question || - kind == AttentionKind.plan || - kind == AttentionKind.confirmation; - - bool get isProblem => - kind == AttentionKind.failed || kind == AttentionKind.cancelled; -} - -final class AgentActivity { - const AgentActivity({ - required this.agentId, - required this.sessionId, - required this.label, - required this.status, - required this.updatedAt, - this.progress, - this.parentAgentId, - this.description, - this.model, - this.currentTool, - this.evidence, - }); - - final String agentId; - final String sessionId; - final String label; - final String status; - final DateTime updatedAt; - final double? progress; - final String? parentAgentId; - final String? description; - final String? model; - final String? currentTool; - final String? evidence; -} - -final class AttentionResponse { - const AttentionResponse({ - required this.decision, - this.optionIds = const [], - this.text = '', - }); - - final AttentionDecision decision; - final List optionIds; - final String text; -} - -enum DeveloperSurface { activity, files, review, terminal, preview } - -final class DeveloperActivity { - const DeveloperActivity({ - required this.id, - required this.category, - required this.title, - required this.detail, - required this.at, - required this.raw, - }); - - final String id; - final String category; - final String title; - final String detail; - final DateTime at; - final String raw; -} - -final class TerminalSession { - const TerminalSession({ - required this.terminalId, - required this.sessionId, - required this.title, - this.output = '', - this.running = true, - this.exitCode, - this.signal, - }); - - final String terminalId; - final String sessionId; - final String title; - final String output; - final bool running; - final int? exitCode; - final String? signal; -} - -final class DeveloperFileEntry { - const DeveloperFileEntry({ - required this.path, - required this.kind, - this.size, - this.revision, - }); - - final String path; - final String kind; - final int? size; - final String? revision; -} - -final class FileWorkspaceState { - const FileWorkspaceState({ - this.path = '', - this.entries = const [], - this.content, - this.diff, - this.revision, - this.loading = false, - this.error, - }); - - final String path; - final List entries; - final String? content; - final String? diff; - final String? revision; - final bool loading; - final String? error; -} - -final class ProjectFileSearchResult { - const ProjectFileSearchResult({required this.paths, required this.truncated}); - - final List paths; - final bool truncated; -} - -final class ReviewWorkspaceItem { - const ReviewWorkspaceItem({ - required this.reviewId, - required this.sessionId, - required this.status, - required this.findings, - this.path, - }); - - final String reviewId; - final String sessionId; - final String status; - final String? path; - final List> findings; -} - -final class PreviewWorkspaceState { - const PreviewWorkspaceState({ - required this.previewId, - required this.sessionId, - required this.state, - required this.url, - required this.revision, - this.title, - this.canGoBack = false, - this.canGoForward = false, - this.capture, - this.captureMimeType, - this.error, - }); - - final String previewId; - final String sessionId; - final String state; - final String url; - final String revision; - final String? title; - final bool canGoBack; - final bool canGoForward; - final Uint8List? capture; - final String? captureMimeType; - final String? error; -} - -enum T4ThemePreference { system, light, dark } - -enum T4LifecyclePhase { resumed, background } - -enum HostSettingControlKind { - boolean, - number, - text, - enumeration, - list, - map, - secret, - unsupported, -} - -final class HostSettingOption { - const HostSettingOption({ - required this.value, - required this.label, - this.help, - }); - - final String value; - final String label; - final String? help; -} - -final class HostSettingEntry { - const HostSettingEntry({ - required this.path, - required this.section, - required this.label, - required this.help, - required this.control, - required this.configured, - required this.options, - required this.writableScopes, - required this.restartRequired, - required this.available, - required this.sensitive, - this.effectiveValue, - this.effectiveSource, - this.min, - this.max, - this.unit, - }); - - final String path; - final String section; - final String label; - final String help; - final HostSettingControlKind control; - final Object? effectiveValue; - final bool configured; - final String? effectiveSource; - final List options; - final num? min; - final num? max; - final String? unit; - final List writableScopes; - final bool restartRequired; - final bool available; - final bool sensitive; -} - -final class HostSettingsState { - const HostSettingsState({ - this.revision, - this.entries = const [], - this.loading = false, - this.error, - this.issues = const [], - }); - - final String? revision; - final List entries; - final bool loading; - final String? error; - final List issues; -} - -final class T4ViewState { - const T4ViewState({ - required this.connectionPhase, - this.sessions = const [], - this.selectedSessionId, - this.messages = const [], - this.transcriptHistoryLoading = false, - this.transcriptHistoryHasMore = false, - this.transcriptHistoryError, - this.transcriptTailFromCache = false, - this.errorMessage, - this.hostDirectory = const HostDirectory.empty(), - this.authenticationPhase = AuthenticationPhase.unknown, - this.grantedCapabilities = const {}, - this.grantedFeatures = const {}, - this.targetConfigured = false, - this.hostOperationPending = false, - this.submitting = false, - this.sessionOperationPending = false, - this.composer = const SessionComposerState(), - this.attentionItems = const [], - this.agentActivities = const [], - this.attentionPartial = false, - this.omittedAttentionCount = 0, - this.activities = const [], - this.terminals = const [], - this.activeTerminalId, - this.fileWorkspace = const FileWorkspaceState(), - this.previews = const [], - this.reviews = const [], - this.activePreviewId, - this.developerOperationPending = false, - this.themePreference = T4ThemePreference.system, - this.settings = const HostSettingsState(), - this.lifecyclePhase = T4LifecyclePhase.resumed, - this.settingsOperationPending = false, - }); - - const T4ViewState.disconnected() - : this(connectionPhase: ConnectionPhase.disconnected); - - final ConnectionPhase connectionPhase; - final List sessions; - final String? selectedSessionId; - final List messages; - final bool transcriptHistoryLoading; - final bool transcriptHistoryHasMore; - final String? transcriptHistoryError; - final bool transcriptTailFromCache; - final String? errorMessage; - final HostDirectory hostDirectory; - final AuthenticationPhase authenticationPhase; - final Set grantedCapabilities; - final Set grantedFeatures; - final bool targetConfigured; - final bool hostOperationPending; - final bool submitting; - final bool sessionOperationPending; - final SessionComposerState composer; - final List attentionItems; - final List agentActivities; - final bool attentionPartial; - final int omittedAttentionCount; - final List activities; - final List terminals; - final String? activeTerminalId; - final FileWorkspaceState fileWorkspace; - final List previews; - final List reviews; - final String? activePreviewId; - final bool developerOperationPending; - final T4ThemePreference themePreference; - final HostSettingsState settings; - final T4LifecyclePhase lifecyclePhase; - final bool settingsOperationPending; - - TerminalSession? get activeTerminal => terminals - .where((terminal) => terminal.terminalId == activeTerminalId) - .firstOrNull; - - PreviewWorkspaceState? get activePreview => previews - .where((preview) => preview.previewId == activePreviewId) - .firstOrNull; - - int get urgentAttentionCount => - attentionItems.where((item) => item.needsResponse).length + - omittedAttentionCount; - - SessionSummary? get selectedSession { - for (final session in sessions) { - if (session.sessionId == selectedSessionId) return session; - } - return null; - } -} - -abstract interface class T4Actions { - Future connect(); - Future disconnect(); - Future setThemePreference(T4ThemePreference preference); - Future refreshSettings(); - Future writeSetting( - String path, - String scope, { - Object? value, - bool reset = false, - }); - Future handleLifecyclePhase(T4LifecyclePhase phase); - - void cancelHostProbe(); - - Future addHost( - String address, { - String profileId = defaultHostProfileId, - }); - - Future activateHost(String endpointKey); - - Future removeHost(String endpointKey); - - Future pairHost(String code); - - Future selectSession(String sessionId); - Future createSession(String projectId, {String? title}); - - Future renameSession(String sessionId, String title); - - Future terminateSession(String sessionId); - - Future archiveSession(String sessionId); - - Future restoreSession(String sessionId); - - Future deleteSession(String sessionId); - Future searchTranscripts({ - required String query, - String? cursor, - String? projectId, - List? roles, - String archived = 'include', - DateTime? from, - DateTime? to, - }); - - Future loadTranscriptContext({ - required String sessionId, - required String anchorId, - int before = 8, - int after = 8, - }); - - Future loadEarlierTranscript(); - - Future readUsage(); - Future readBrokerStatus(); - - Future submitPrompt( - String message, { - List images = const [], - }); - - Future queuePrompt(String message); - - Future cancelTurn(); - - Future pauseSession(); - - Future resumeSession(); - - Future compactSession({String? instructions}); - - Future setSessionModel(String selector); - - Future setSessionThinking(String level); - - Future setSessionFast(bool enabled); - - Future respondToAttention( - AttentionItem item, - AttentionResponse response, - ); - - Future retrySession(String sessionId); - - Future refreshActivity(); - - Future openTerminal({String? cwd}); - void sendTerminalInput(String terminalId, String data); - void resizeTerminal(String terminalId, int cols, int rows); - - Future cancelAgent(String agentId); - void closeTerminal(String terminalId); - - Future listFiles([String path = '']); - Future searchProjectFiles( - String query, { - int limit = 12, - }); - Future readFile(String path); - Future loadSessionDiff(); - Future writeFile(String path, String content); - Future refreshReview(String reviewId); - Future applyReview(String reviewId); - - Future launchPreview(String url); - Future selectPreview(String previewId); - Future navigatePreview(String previewId, String url); - Future runPreviewAction(String previewId, String action); - Future runPreviewInteraction( - String previewId, - String action, - Map args, - ); - Future capturePreview(String previewId); - - Future readTranscriptImage( - String entryId, - TranscriptImageMetadata image, - ); -} diff --git a/apps/flutter/lib/src/client/model_labels.dart b/apps/flutter/lib/src/client/model_labels.dart deleted file mode 100644 index 570a4bd0..00000000 --- a/apps/flutter/lib/src/client/model_labels.dart +++ /dev/null @@ -1,324 +0,0 @@ -// Presentation-only humanization for the in-chat model selector, mirroring the -// Electron/web T4 model picker (apps/web/src/features/settings/settings-presentation.ts). -// -// The host catalog's `name` is the label authority when it names a model; every -// fallback is deterministic so the same input always renders the same words. -// Canonical `provider/modelId` selectors remain the submitted protocol values — -// these helpers NEVER rewrite what is sent to `session.model.set`. - -import '../protocol/models.dart'; - -/// Acronyms kept uppercase when a raw id is humanized for display. -const Map _idAcronyms = { - 'ai': true, - 'api': true, - 'aws': true, - 'cli': true, - 'glm': true, - 'gpt': true, - 'io': true, - 'llm': true, - 'mcp': true, - 'omp': true, - 'qa': true, - 'sdk': true, - 'tts': true, - 'tui': true, - 'ui': true, - 'ux': true, -}; - -/// `gpt-engineer` → "GPT Engineer", `claude-fable-5` → "Claude Fable 5", -/// `quickTask` → "Quick Task". Split on case changes, hyphens, underscores, -/// and spaces; title-case words; known acronyms go uppercase. Never invents -/// words — a token it can't improve passes through capitalized. -String humanizeIdentifier(String id) { - // Insert a space before each uppercase letter that follows a lowercase letter - // or digit (`quickTask` → `quick Task`, `gpt5` → `gpt 5`). - final camelSplit = id.replaceAllMapped( - RegExp(r'([a-z0-9])([A-Z])'), - (Match match) => '${match.group(1)} ${match.group(2)}', - ); - final words = camelSplit - .split(RegExp(r'[-_\s]+')) - .where((word) => word.isNotEmpty) - .toList(growable: false); - if (words.isEmpty) return id; - return words - .map((String word) { - final lower = word.toLowerCase(); - if (_idAcronyms[lower] == true) { - return lower.toUpperCase(); - } - return word[0].toUpperCase() + word.substring(1); - }) - .join(' '); -} - -/// Friendly names for provider ids OMP routes through. Lowercase keys. -const Map _providerNames = { - 'anthropic': 'Anthropic', - 'openai': 'OpenAI', - 'google': 'Google', - 'gemini': 'Google', - 'xai': 'xAI', - 'openrouter': 'OpenRouter', - 'github-copilot': 'GitHub Copilot', - 'copilot': 'GitHub Copilot', - 'amazon-bedrock': 'Amazon Bedrock', - 'bedrock': 'Amazon Bedrock', - 'azure-openai': 'Azure OpenAI', - 'azure': 'Azure OpenAI', -}; - -/// Friendly provider name. Case-insensitive; the longest hyphen-segment prefix -/// wins, so `openai-codex` → "OpenAI", `xai-oauth` → "xAI", -/// `google-vertex` → "Google". Unknown ids humanize deterministically -/// (`ollama` → "Ollama") instead of echoing raw casing. -String providerDisplayName(String id) { - final segments = id.trim().toLowerCase().split('-'); - for (var end = segments.length; end > 0; end -= 1) { - final hit = _providerNames[segments.sublist(0, end).join('-')]; - if (hit != null) return hit; - } - return humanizeIdentifier(id); -} - -/// `provider/modelId` with any trailing `:level` thinking suffix removed. -String baseSelector(String selector) { - final colon = selector.lastIndexOf(':'); - final slash = selector.indexOf('/'); - return (colon > slash && colon != -1) - ? selector.substring(0, colon) - : selector; -} - -/// The provider segment of a `provider/modelId` selector, or '' when the -/// selector has no provider. -String providerOf(String selector) { - final base = baseSelector(selector); - final slash = base.indexOf('/'); - return slash > 0 ? base.substring(0, slash) : ''; -} - -/// The model-id segment of a `provider/modelId` selector, or the whole -/// selector when it has no provider. -String modelIdOf(String selector) { - final base = baseSelector(selector); - final slash = base.indexOf('/'); - return slash > 0 ? base.substring(slash + 1) : base; -} - -/// The last path segment of a model id, humanized for unknown-model fallback. -String _lastSegment(String modelId) { - final lastSlash = modelId.lastIndexOf('/'); - return lastSlash >= 0 ? modelId.substring(lastSlash + 1) : modelId; -} - -/// A resolved display label for one catalog model. -/// -/// [label] is the human-facing name (catalog name wins; a miss humanizes the -/// model id's last path segment). [provider] is the raw provider id carried -/// through for grouping; [providerLabel] is its friendly name. The [selector] -/// is the exact `provider/modelId` string submitted to `session.model.set`. -final class ModelLabel { - const ModelLabel({ - required this.selector, - required this.label, - required this.provider, - required this.providerLabel, - required this.inCatalog, - }); - - final String selector; - final String label; - final String provider; - final String providerLabel; - final bool inCatalog; -} - -/// Resolve a display label for a catalog model item. -/// -/// The catalog `name` wins when it is a human name (not the raw selector); -/// otherwise the model id's last path segment is humanized. The selector is -/// always the exact `provider/modelId` from metadata (or `item.name` when it -/// already contains a slash), carried through untouched as the submitted value. -ModelLabel modelLabelFor(CatalogItem item) { - final selector = modelItemSelector(item); - if (selector == null) { - // No provider/modelId metadata and name has no slash: treat the name as - // both the label and the selector so the model stays selectable. - return ModelLabel( - selector: item.name, - label: item.name, - provider: '', - providerLabel: '', - inCatalog: true, - ); - } - final provider = providerOf(selector); - final modelId = modelIdOf(selector); - final catalogName = item.name.trim(); - final labelIsRawSelector = - catalogName.isEmpty || catalogName == selector || catalogName == modelId; - final label = labelIsRawSelector - ? humanizeIdentifier(_lastSegment(modelId)) - : catalogName; - return ModelLabel( - selector: selector, - label: label, - provider: provider, - providerLabel: provider.isEmpty ? '' : providerDisplayName(provider), - inCatalog: true, - ); -} - -/// `provider/modelId` from a catalog model item's metadata, guarded. -/// -/// Mirrors the web `modelItemSelector`: metadata's `provider`/`modelId` win; -/// a name that already contains a slash is accepted as a selector; otherwise -/// the item does not resolve to a switchable selector. -String? modelItemSelector(CatalogItem item) { - final metadata = item.metadata; - if (metadata != null) { - final provider = metadata['provider']; - final modelId = metadata['modelId']; - if (provider is String && - provider.isNotEmpty && - modelId is String && - modelId.isNotEmpty) { - return '$provider/$modelId'; - } - } - return item.name.contains('/') ? item.name : null; -} - -/// A provider group for the navigable model menu. -final class ModelProviderGroup { - const ModelProviderGroup({ - required this.provider, - required this.label, - required this.choices, - }); - - /// Raw provider id (empty string for models with no provider). - final String provider; - - /// Friendly provider name, or 'Other models' when the provider is unknown. - final String label; - - /// Choices within this group, ordered by label. - final List choices; -} - -/// A model choice with its resolved display label and raw selector. -final class ResolvedModelChoice { - const ResolvedModelChoice({ - required this.selector, - required this.label, - required this.provider, - required this.providerLabel, - required this.supported, - required this.reason, - }); - - /// Exact `provider/modelId` submitted to `session.model.set`. - final String selector; - - /// Human-facing model name. - final String label; - - /// Raw provider id (empty when none). - final String provider; - - /// Friendly provider name (empty when none). - final String providerLabel; - - final bool supported; - final String? reason; -} - -/// Group catalog model choices by provider for a navigable selector. -/// -/// Known providers are ordered by friendly name; an unknown/empty provider -/// bucket ('Other models') sorts last so it never hides known providers. -/// Within a group, choices sort by label. Duplicate selectors collapse to the -/// first occurrence. Unsupported models are retained but marked, so they stay -/// selectable (and the host can explain why) rather than silently disappearing. -List groupModelChoices(Iterable catalog) { - final seen = {}; - final byProvider = >{}; - for (final item in catalog) { - if (item.kind != 'model') continue; - final selector = modelItemSelector(item); - if (selector == null || !seen.add(selector)) continue; - final label = modelLabelFor(item); - byProvider - .putIfAbsent(label.provider, () => []) - .add( - ResolvedModelChoice( - selector: label.selector, - label: label.label, - provider: label.provider, - providerLabel: label.providerLabel, - supported: item.supported != false, - reason: item.reason, - ), - ); - } - final groups = []; - for (final entry in byProvider.entries) { - entry.value.sort((a, b) => a.label.compareTo(b.label)); - groups.add( - ModelProviderGroup( - provider: entry.key, - label: entry.key.isEmpty - ? 'Other models' - : (entry.value.first.providerLabel.isEmpty - ? 'Other models' - : entry.value.first.providerLabel), - choices: List.unmodifiable(entry.value), - ), - ); - } - // Known providers alphabetical by friendly label; 'Other models' last. - groups.sort((a, b) { - final aOther = a.provider.isEmpty; - final bOther = b.provider.isEmpty; - if (aOther != bOther) return aOther ? 1 : -1; - return a.label.compareTo(b.label); - }); - return List.unmodifiable(groups); -} - -/// Resolve a human-facing label for a session's current model selector. -/// -/// Catalog labels win; a selector that matches no catalog entry humanizes its -/// model id's last path segment so unknown models still read as a name rather -/// than a raw `provider/modelId` id. The raw selector is returned unchanged as -/// the submitted value. Returns null when the session has no model selector. -String? sessionModelLabel( - String? selector, - String? displayName, - Iterable catalog, -) { - if (selector == null || selector.isEmpty) { - return displayName; - } - if (displayName != null && displayName.trim().isNotEmpty) { - return displayName; - } - final base = baseSelector(selector); - for (final item in catalog) { - if (item.kind != 'model') continue; - final itemSelector = modelItemSelector(item); - if (itemSelector == null) continue; - if (baseSelector(itemSelector) == base) { - final label = modelLabelFor(item); - return label.label; - } - } - // Unknown selector: humanize the model id's last path segment. - final modelId = modelIdOf(selector); - return humanizeIdentifier(_lastSegment(modelId)); -} diff --git a/apps/flutter/lib/src/client/t4_client_controller.dart b/apps/flutter/lib/src/client/t4_client_controller.dart deleted file mode 100644 index 400f5005..00000000 --- a/apps/flutter/lib/src/client/t4_client_controller.dart +++ /dev/null @@ -1,5157 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:convert'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:crypto/crypto.dart'; -import 'package:flutter/foundation.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -import '../host/app_preferences.dart'; -import '../host/host_profile.dart'; -import '../protocol/protocol.dart'; -import 'app_state.dart'; -import 'model_labels.dart'; -import 'transcript_tail_store.dart'; -import 'web_socket_connector.dart'; - -String _secureToken(int byteLength) { - final random = Random.secure(); - final bytes = List.generate(byteLength, (_) => random.nextInt(256)); - return base64Url.encode(bytes).replaceAll('=', ''); -} - -const int _maxPagedTranscriptEntries = 4096; - -final class T4ClientController extends ChangeNotifier implements T4Actions { - T4ClientController({ - required this.hostDirectoryStore, - required this.hostCredentialStore, - AppPreferenceStore? appPreferenceStore, - TranscriptTailStore? transcriptTailStore, - WebSocketConnector? webSocketConnector, - this.developmentEndpoint, - }) : appPreferenceStore = appPreferenceStore ?? InMemoryAppPreferenceStore(), - transcriptTailStore = - transcriptTailStore ?? InMemoryTranscriptTailStore(), - _webSocketConnector = webSocketConnector ?? connectPlatformWebSocket; - - final HostDirectoryStore hostDirectoryStore; - final HostCredentialStore hostCredentialStore; - final AppPreferenceStore appPreferenceStore; - final TranscriptTailStore transcriptTailStore; - final WebSocketConnector _webSocketConnector; - final Uri? developmentEndpoint; - - final LinkedHashMap _messages = LinkedHashMap(); - final Map _savedCursors = - {}; - List _pagedTranscriptEntries = const []; - String? _transcriptPageGeneration; - String? _transcriptPageCursor; - bool _transcriptPageLoading = false; - bool _transcriptPageHasMore = false; - String? _transcriptPageError; - String? _transcriptRecoverySessionId; - bool _transcriptTailFromCache = false; - int _transcriptOpenGeneration = 0; - int? _transcriptPrimeGeneration; - final Map _pendingCommands = - {}; - final Map _pendingSessionOperations = - {}; - final Map _attentionBySession = - {}; - final Map _attentionConfirmations = - {}; - final Map _agentActivities = {}; - final LinkedHashMap _activities = LinkedHashMap(); - final Map _terminals = {}; - final Map _terminalCursors = - {}; - final Map _previews = - {}; - final Map _previewCaptures = {}; - final Map _reviews = - {}; - - HostDirectory _hostDirectory = const HostDirectory.empty(); - WebSocketChannel? _channel; - StreamSubscription? _subscription; - WebSocketChannel? _hostProbe; - Timer? _reconnectTimer; - List _sessions = const []; - ConnectionPhase _phase = ConnectionPhase.disconnected; - AuthenticationPhase _authenticationPhase = AuthenticationPhase.unknown; - String? _selectedSessionId; - String? _errorMessage; - String? _hostId; - _PendingPair? _pendingPair; - Set _grantedCapabilities = const {}; - Set _grantedFeatures = const {}; - List _catalogItems = const []; - CatalogFrame? _catalogFrame; - SettingsFrame? _settingsFrame; - HostSettingsState _settingsState = const HostSettingsState(); - T4ThemePreference _themePreference = T4ThemePreference.system; - T4LifecyclePhase _lifecyclePhase = T4LifecyclePhase.resumed; - bool _settingsOperationPending = false; - bool _resumeConnectionOwed = false; - Completer? _settingsRefreshCompleter; - bool _settingsRefreshSawCatalog = false; - bool _settingsRefreshSawSettings = false; - Timer? _settingsRefreshTimer; - int _settingsBootstrapGeneration = -1; - FileWorkspaceState _fileWorkspace = const FileWorkspaceState(); - String? _activeTerminalId; - String? _activePreviewId; - Future? _initialization; - bool _initialized = false; - bool _hostOperationPending = false; - bool _submitting = false; - bool _sessionOperationPending = false; - bool _developerOperationPending = false; - bool _directoryLoaded = false; - bool _disposed = false; - int _connectionGeneration = 0; - int _hostOperationGeneration = 0; - int? _hostProbeOperation; - bool _reconnectEnabled = true; - int _bootstrapGeneration = -1; - int _commandOrdinal = 0; - final String _commandNamespace = _secureToken(12); - int _localPromptOrdinal = 0; - String? _sessionIndexEpoch; - int? _sessionIndexSeq; - int _reconnectAttempt = 0; - - T4ViewState get state => T4ViewState( - connectionPhase: _phase, - sessions: List.unmodifiable(_sessions), - selectedSessionId: _selectedSessionId, - messages: List.unmodifiable(_messages.values), - transcriptHistoryLoading: _transcriptPageLoading, - transcriptHistoryHasMore: _transcriptPageHasMore, - transcriptHistoryError: _transcriptPageError, - transcriptTailFromCache: _transcriptTailFromCache, - errorMessage: _errorMessage, - hostDirectory: _hostDirectory, - authenticationPhase: _authenticationPhase, - grantedCapabilities: Set.unmodifiable(_grantedCapabilities), - grantedFeatures: Set.unmodifiable(_grantedFeatures), - targetConfigured: developmentEndpoint != null || _activeProfile != null, - hostOperationPending: _hostOperationPending, - submitting: _submitting, - sessionOperationPending: _sessionOperationPending, - composer: _composerState, - attentionItems: _allAttentionItems, - agentActivities: _allAgentActivities, - attentionPartial: _attentionBySession.values.any( - (attention) => attention.malformed || attention.truncated, - ), - omittedAttentionCount: _attentionBySession.values.fold( - 0, - (total, attention) => total + attention.omittedCount, - ), - activities: List.unmodifiable( - _activities.values.toList(growable: false).reversed, - ), - terminals: List.unmodifiable( - _terminals.values.where( - (terminal) => terminal.sessionId == _selectedSessionId, - ), - ), - activeTerminalId: _activeTerminalId, - fileWorkspace: _fileWorkspace, - previews: List.unmodifiable( - _previews.values.where( - (preview) => preview.sessionId == _selectedSessionId, - ), - ), - reviews: List.unmodifiable( - _reviews.values.where((review) => review.sessionId == _selectedSessionId), - ), - activePreviewId: _activePreviewId, - developerOperationPending: _developerOperationPending, - themePreference: _themePreference, - settings: _settingsState, - lifecyclePhase: _lifecyclePhase, - settingsOperationPending: _settingsOperationPending, - ); - - SessionComposerState get _composerState { - final session = _sessions - .where((candidate) => candidate.sessionId == _selectedSessionId) - .firstOrNull; - if (session == null) return const SessionComposerState(); - final choices = []; - final seen = {}; - final slashCommands = {}; - final operationCapabilities = _catalogFrame?.operations; - for (final item in _catalogItems) { - if (item.kind == 'model') { - final selector = modelItemSelector(item); - if (selector == null || !seen.add(selector)) continue; - final label = modelLabelFor(item); - choices.add( - ComposerModelChoice( - label: label.label, - selector: label.selector, - provider: label.provider, - providerLabel: label.providerLabel, - supported: item.supported != false, - reason: item.reason, - ), - ); - continue; - } - if (item.kind != 'command' || operationCapabilities != null) continue; - final metadata = item.metadata ?? const {}; - if (!item.name.startsWith('/') && metadata['slashCommand'] != true) { - continue; - } - final bareName = item.name.replaceFirst(RegExp(r'^/+'), ''); - final missingCapability = item.capabilities - ?.where((capability) => !_grantedCapabilities.contains(capability)) - .firstOrNull; - final disabledReason = item.supported == false - ? item.reason ?? 'Not available on this host' - : missingCapability == null - ? null - : 'Not granted on this host'; - final name = '/$bareName'; - slashCommands[name] = ComposerSlashCommand( - name: name, - description: item.description ?? '', - insert: '$name ', - disabledReason: disabledReason, - ); - } - for (final operation - in operationCapabilities ?? const []) { - if (!operation.operationId.startsWith('slash.')) continue; - final bareName = operation.operationId.substring('slash.'.length); - if (bareName.isEmpty) continue; - final name = '/$bareName'; - final metadata = operation.raw['metadata']; - final rawAliases = metadata is Map - ? metadata['aliases'] - : null; - final aliases = rawAliases is List - ? rawAliases - .whereType() - .where((alias) => alias.isNotEmpty) - .map((alias) => '/${alias.replaceFirst(RegExp(r'^/+'), '')}') - .toList(growable: false) - : const []; - final requiredCapabilities = { - 'sessions.prompt', - ...?operation.capabilities, - }; - final missingCapability = requiredCapabilities - .where((capability) => !_grantedCapabilities.contains(capability)) - .firstOrNull; - String? disabledReason; - if (!operation.supported) { - disabledReason = - operation.disabledReason?.message ?? 'Not available on this host'; - } else if (missingCapability != null) { - disabledReason = missingCapability == 'terminal.io' - ? 'Needs terminal access on this host' - : 'Not granted on this host'; - } else if ((session.turnActive || _submitting) && bareName == 'compact') { - disabledReason = 'Wait for the turn to finish'; - } else if ((session.turnActive || _submitting) && bareName == 'retry') { - disabledReason = 'A turn is already running'; - } - slashCommands[name] = ComposerSlashCommand( - name: name, - aliases: List.unmodifiable(aliases), - description: operation.description ?? '', - insert: '$name ', - disabledReason: disabledReason, - ); - } - final groups = groupModelChoices(_catalogItems); - // The host reports concrete effort levels for the selected model and a - // thinkingSupported flag. Off and Auto are client-side bookends the host - // never lists; the menu is Off, Auto, then the model's concrete efforts in - // host-reported order. When the model cannot reason the menu is empty. - final levels = []; - if (session.thinkingSupported != false) { - levels.addAll(['off', 'auto']); - for (final level in session.thinkingLevels) { - if (level != 'off' && level != 'auto' && !levels.contains(level)) { - levels.add(level); - } - } - // Keep a still-valid configured value selectable even before the host - // reports concrete efforts (e.g. a resumed session set to "high"). The - // host already accepted this level, so it is advertised for the model. - final configured = session.thinking; - if (configured != null && - configured != 'off' && - configured != 'auto' && - configured != 'inherit' && - !levels.contains(configured)) { - levels.add(configured); - } - } - return SessionComposerState( - modelLabel: sessionModelLabel( - session.modelSelector, - session.modelDisplayName, - _catalogItems, - ), - modelSelector: session.modelSelector, - modelChoices: List.unmodifiable(choices), - modelGroups: List.unmodifiable(groups), - slashCommands: List.unmodifiable( - slashCommands.values, - ), - thinking: session.thinking, - thinkingLevels: List.unmodifiable(levels), - fastEnabled: session.fast, - fastAvailable: session.fastAvailable, - turnActive: session.turnActive || _submitting, - isPaused: session.isPaused, - queuedFollowUpCount: session.queuedFollowUpCount, - ); - } - - List get _allAttentionItems { - final items = [ - for (final attention in _attentionBySession.values) ...attention.items, - for (final frame in _attentionConfirmations.values) - if (DateTime.tryParse( - frame.expiresAt, - )?.isAfter(DateTime.now().toUtc()) == - true) - AttentionItem( - key: - 'confirmation:${frame.sessionId ?? 'host'}:${frame.confirmationId}', - kind: AttentionKind.confirmation, - sessionId: frame.sessionId ?? '', - sessionTitle: frame.sessionId == null - ? (_activeProfile?.label ?? 'Host') - : _sessions - .where( - (session) => session.sessionId == frame.sessionId, - ) - .firstOrNull - ?.title ?? - 'Session', - revision: frame.revision, - title: 'Security confirmation', - summary: frame.preview ?? frame.summary, - at: DateTime.now().toUtc(), - requestId: frame.confirmationId, - confirmationId: frame.confirmationId, - commandId: frame.commandId, - expiresAt: DateTime.tryParse(frame.expiresAt)?.toUtc(), - actionable: true, - ), - ]; - items.sort((left, right) { - if (left.needsResponse != right.needsResponse) { - return left.needsResponse ? -1 : 1; - } - return right.at.compareTo(left.at); - }); - return List.unmodifiable(items); - } - - List get _allAgentActivities { - final activities = _agentActivities.values.toList(growable: false) - ..sort((left, right) => right.updatedAt.compareTo(left.updatedAt)); - return List.unmodifiable(activities); - } - - Future initialize() => _initialization ??= _initialize(); - - Future _initialize() async { - try { - final storedTheme = await appPreferenceStore.loadThemePreference(); - if (_disposed) return; - _themePreference = - T4ThemePreference.values - .where((preference) => preference.name == storedTheme) - .firstOrNull ?? - T4ThemePreference.system; - } on Object { - if (_disposed) return; - _themePreference = T4ThemePreference.system; - } - _publish(); - - try { - final directory = await hostDirectoryStore.load(); - if (_disposed) return; - _hostDirectory = directory; - _initialized = true; - _directoryLoaded = true; - _errorMessage = null; - _publish(); - if (_lifecyclePhase == T4LifecyclePhase.resumed && _reconnectEnabled) { - await _connectCurrent(); - } - } on Object catch (error) { - if (_disposed) return; - _initialized = true; - _fail('Could not load saved hosts: $error'); - } - } - - @override - Future connect() async { - _reconnectEnabled = true; - if (_lifecyclePhase == T4LifecyclePhase.background) { - _resumeConnectionOwed = true; - _publish(); - return; - } - if (!_initialized) { - await initialize(); - return; - } - await _connectCurrent(); - } - - @override - Future setThemePreference(T4ThemePreference preference) async { - if (_themePreference == preference) return; - _themePreference = preference; - _publish(); - try { - await appPreferenceStore.saveThemePreference(preference.name); - } on Object catch (error) { - _errorMessage = 'Could not save appearance preference: $error'; - _publish(); - rethrow; - } - } - - @override - Future refreshSettings() { - _requireSettingsReadAccess(); - final completer = Completer(); - _beginSettingsRefresh(completer: completer); - return completer.future; - } - - @override - Future writeSetting( - String path, - String scope, { - Object? value, - bool reset = false, - }) async { - if (_settingsOperationPending) { - throw StateError('another settings change is already running'); - } - if (_phase != ConnectionPhase.ready || _hostId == null) { - throw StateError('connect before changing settings'); - } - if (!_grantedCapabilities.contains('config.write')) { - throw StateError('this device was not granted config.write'); - } - final revision = _settingsFrame?.revision; - if (revision == null) { - throw StateError('refresh settings before changing them'); - } - final entry = _settingsState.entries - .where((candidate) => candidate.path == path) - .firstOrNull; - if (entry == null) { - throw ArgumentError.value(path, 'path', 'is not a published setting'); - } - if (!entry.available || - entry.sensitive || - entry.control == HostSettingControlKind.secret || - entry.control == HostSettingControlKind.unsupported) { - throw StateError('this setting is not writable'); - } - if ((scope != 'global' && scope != 'session') || - !entry.writableScopes.contains(scope)) { - throw ArgumentError.value(scope, 'scope', 'is not writable for $path'); - } - if (!reset && !_settingValueMatches(entry, value)) { - throw ArgumentError.value(value, 'value', 'does not match $path'); - } - if (reset && value != null) { - throw ArgumentError.value(value, 'value', 'must be omitted when reset'); - } - - final edit = { - 'path': path, - 'scope': scope, - if (reset) 'reset': true else 'value': _copySettingValue(value), - }; - _settingsOperationPending = true; - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - loading: _settingsState.loading, - issues: _settingsState.issues, - ); - _publish(); - try { - final frame = await _runHostCommand( - prefix: 'settings-write', - command: 'settings.write', - expectedRevision: revision, - args: { - 'edits': >[edit], - 'expectedRevision': revision, - }, - confirmationExpected: true, - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? 'settings.write failed'); - } - await refreshSettings(); - } on Object catch (error) { - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - error: 'Settings change failed: $error', - issues: _settingsState.issues, - ); - _publish(); - rethrow; - } finally { - _settingsOperationPending = false; - _publish(); - } - } - - void _requireSettingsReadAccess() { - if (_hostId == null || _phase == ConnectionPhase.disconnected) { - throw StateError('connect before refreshing settings'); - } - if (!_grantedCapabilities.contains('catalog.read') || - !_grantedFeatures.contains('catalog.metadata') || - !_grantedCapabilities.contains('config.read') || - !_grantedFeatures.contains('settings.metadata')) { - throw StateError('this host did not grant live settings metadata'); - } - } - - void _beginSettingsRefresh({Completer? completer}) { - _settingsRefreshCompleter?.completeError( - StateError('settings refresh was replaced'), - ); - _settingsRefreshCompleter = completer; - _settingsRefreshSawCatalog = false; - _settingsRefreshSawSettings = false; - _settingsRefreshTimer?.cancel(); - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - loading: true, - issues: _settingsState.issues, - ); - _publish(); - _sendHostProduct('catalog.get'); - _sendHostProduct('settings.read'); - _settingsRefreshTimer = Timer(const Duration(seconds: 10), () { - if (!_settingsState.loading) return; - final error = TimeoutException('settings refresh timed out'); - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - error: error.message, - issues: _settingsState.issues, - ); - final pending = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (pending != null && !pending.isCompleted) pending.completeError(error); - _publish(); - }); - } - - void _sendHostProduct(String command) { - final hostId = _hostId; - if (hostId == null) throw StateError('host identity is unavailable'); - final ids = _nextCommandIds(command.replaceAll('.', '-')); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: command, - ); - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - command: command, - args: const {}, - ), - ); - } - - Future _runHostCommand({ - required String prefix, - required String command, - required Map args, - String? expectedRevision, - bool confirmationExpected = false, - }) async { - final hostId = _hostId; - if (hostId == null) throw StateError('host identity is unavailable'); - final ids = _nextCommandIds(prefix); - final completer = Completer(); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: command, - completer: completer, - expectedRevision: expectedRevision, - confirmationExpected: confirmationExpected, - ); - try { - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - command: command, - expectedRevision: expectedRevision, - args: args, - ), - ); - return await completer.future.timeout( - const Duration(seconds: 30), - onTimeout: () => throw TimeoutException('$command timed out'), - ); - } finally { - _pendingCommands.remove(ids.requestId); - } - } - - void _projectHostSettings() { - final catalog = _catalogFrame; - final settings = _settingsFrame; - if (catalog != null && - settings != null && - catalog.hostId == _hostId && - settings.hostId == _hostId) { - final projection = _buildHostSettings(catalog, settings); - final complete = - _settingsRefreshSawCatalog && _settingsRefreshSawSettings; - _settingsState = HostSettingsState( - revision: settings.revision, - entries: projection.entries, - loading: !complete, - issues: projection.issues, - ); - if (complete) { - _settingsRefreshTimer?.cancel(); - _settingsRefreshTimer = null; - final pending = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (pending != null && !pending.isCompleted) pending.complete(); - } - } - _publish(); - } - - void _clearSettingsProjection() { - _catalogFrame = null; - _settingsFrame = null; - _settingsState = const HostSettingsState(); - _settingsOperationPending = false; - _settingsRefreshTimer?.cancel(); - _settingsRefreshTimer = null; - final pending = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (pending != null && !pending.isCompleted) { - pending.completeError(StateError('settings projection was cleared')); - } - _settingsRefreshSawCatalog = false; - _settingsRefreshSawSettings = false; - _settingsBootstrapGeneration = -1; - } - - @override - Future handleLifecyclePhase(T4LifecyclePhase phase) async { - if (_lifecyclePhase == phase) return; - _lifecyclePhase = phase; - if (phase == T4LifecyclePhase.background) { - final active = - _channel != null || - _subscription != null || - _reconnectTimer != null || - _phase == ConnectionPhase.connecting || - _phase == ConnectionPhase.synchronizing || - _phase == ConnectionPhase.ready || - _phase == ConnectionPhase.retrying; - if (!active || !_reconnectEnabled) { - _publish(); - return; - } - _resumeConnectionOwed = true; - _reconnectEnabled = false; - _connectionGeneration += 1; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - final previousSubscription = _subscription; - final previousChannel = _channel; - _subscription = null; - _channel = null; - _cancelPendingCommands(StateError('app moved to the background')); - _settingsOperationPending = false; - _settingsRefreshTimer?.cancel(); - _settingsRefreshTimer = null; - final refresh = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (refresh != null && !refresh.isCompleted) { - refresh.completeError(StateError('app moved to the background')); - } - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - issues: _settingsState.issues, - ); - _phase = ConnectionPhase.disconnected; - _authenticationPhase = AuthenticationPhase.unknown; - _errorMessage = null; - _publish(); - await previousSubscription?.cancel(); - await previousChannel?.sink.close(); - return; - } - - final reconnect = _resumeConnectionOwed; - _resumeConnectionOwed = false; - if (!reconnect) { - _publish(); - return; - } - _reconnectEnabled = true; - _publish(); - if (_initialized) await _connectCurrent(); - } - - @override - Future disconnect() async { - _reconnectEnabled = false; - _resumeConnectionOwed = false; - _connectionGeneration += 1; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - final previousSubscription = _subscription; - final previousChannel = _channel; - _subscription = null; - _channel = null; - _cancelPendingCommands( - StateError('connection closed before the command completed'), - ); - _pendingPair = null; - _submitting = false; - _sessionOperationPending = false; - _hostId = null; - _grantedCapabilities = const {}; - _grantedFeatures = const {}; - _catalogItems = const []; - _clearSettingsProjection(); - _bootstrapGeneration = -1; - _reconnectAttempt = 0; - _phase = ConnectionPhase.disconnected; - _authenticationPhase = AuthenticationPhase.unknown; - _errorMessage = null; - _publish(); - await previousSubscription?.cancel(); - await previousChannel?.sink.close(); - } - - @override - void cancelHostProbe() { - final operation = _hostProbeOperation; - if (operation == null) return; - _hostOperationGeneration += 1; - _hostProbeOperation = null; - final probe = _hostProbe; - _hostProbe = null; - _hostOperationPending = false; - _errorMessage = null; - _publish(); - if (probe != null) unawaited(probe.sink.close()); - } - - Future _connectCurrent() async { - final profile = developmentEndpoint == null ? _activeProfile : null; - final target = developmentEndpoint ?? profile?.webSocketUrl; - final generation = ++_connectionGeneration; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - final previousSubscription = _subscription; - final previousChannel = _channel; - _subscription = null; - _channel = null; - await previousSubscription?.cancel(); - await previousChannel?.sink.close(); - if (_disposed || generation != _connectionGeneration) return; - - if (target == null) { - _phase = ConnectionPhase.disconnected; - _authenticationPhase = AuthenticationPhase.unknown; - _errorMessage = 'Add a host to connect.'; - _publish(); - return; - } - - _phase = _reconnectAttempt == 0 - ? ConnectionPhase.connecting - : ConnectionPhase.retrying; - _authenticationPhase = AuthenticationPhase.unknown; - _grantedCapabilities = const {}; - _grantedFeatures = const {}; - _errorMessage = null; - _publish(); - - WebSocketChannel? connectingChannel; - try { - final credentials = profile == null - ? null - : await hostCredentialStore.read(profile); - if (_disposed || generation != _connectionGeneration) return; - - connectingChannel = await _webSocketConnector(target); - await connectingChannel.ready; - if (_disposed || generation != _connectionGeneration) { - await connectingChannel.sink.close(); - return; - } - _channel = connectingChannel; - _subscription = connectingChannel.stream.listen( - (message) => unawaited(_handlePayload(generation, message)), - onError: (Object error, StackTrace stackTrace) => - _handleTransportLoss(generation, error), - onDone: () => _handleTransportLoss(generation), - cancelOnError: true, - ); - _phase = ConnectionPhase.synchronizing; - _publish(); - connectingChannel.sink.add(_hello(credentials)); - } on Object catch (error) { - if (connectingChannel != null && connectingChannel != _channel) { - unawaited(_closeChannelQuietly(connectingChannel)); - } - _handleTransportLoss(generation, error); - } - } - - Future _closeChannelQuietly(WebSocketChannel channel) async { - try { - await channel.sink.close().timeout(const Duration(seconds: 1)); - } on Object { - // Failed handshakes can leave close futures unresolved. - } - } - - String _hello(DeviceCredentials? credentials) => WireEncoder.hello( - client: ClientIdentity( - name: 'T4 Code', - version: '0.1.30', - build: 'flutter', - platform: defaultTargetPlatform.name, - ), - requestedFeatures: t4RequestedFeatures, - capabilities: t4RequestedCapabilities, - authentication: credentials == null - ? null - : DeviceAuthentication( - deviceId: credentials.deviceId, - deviceToken: credentials.deviceToken, - ), - savedCursors: _savedCursors.entries - .map((entry) { - final session = _sessions - .where((candidate) => candidate.sessionId == entry.key) - .firstOrNull; - return SavedCursor( - hostId: session?.hostId ?? _hostId ?? '', - sessionId: entry.key, - cursor: entry.value, - ); - }) - .where((cursor) => cursor.hostId.isNotEmpty), - ); - - HostProfile? get _activeProfile => _hostDirectory.activeProfile; - - @override - Future addHost( - String address, { - String profileId = defaultHostProfileId, - }) async { - if (!_initialized) await initialize(); - if (_disposed || !_directoryLoaded) { - throw StateError('Saved hosts are unavailable.'); - } - if (_hostOperationPending) return; - final operation = ++_hostOperationGeneration; - _hostProbeOperation = operation; - _hostOperationPending = true; - _errorMessage = null; - _publish(); - WebSocketChannel? probe; - try { - final profile = HostProfile.parseTailnetAddress( - address, - profileId: profileId, - ); - probe = await _webSocketConnector(profile.webSocketUrl); - if (!_acceptHostOperation(operation)) { - await probe.sink.close(); - return; - } - _hostProbe = probe; - await probe.ready; - await probe.sink.close(); - probe = null; - _hostProbe = null; - if (!_acceptHostOperation(operation)) return; - - final next = _hostDirectory.upsert(profile); - await hostDirectoryStore.save(next); - if (!_acceptHostOperation(operation)) return; - final switched = _hostDirectory.activeEndpointKey != profile.endpointKey; - _hostDirectory = next; - if (switched) _clearTargetProjection(); - _hostOperationPending = false; - _reconnectEnabled = true; - _publish(); - await _connectCurrent(); - } on Object catch (error) { - if (probe != null) await probe.sink.close(); - if (!_acceptHostOperation(operation)) return; - _hostOperationPending = false; - _errorMessage = 'Could not add host: $error'; - _publish(); - rethrow; - } finally { - if (_hostProbeOperation == operation) _hostProbeOperation = null; - if (identical(_hostProbe, probe)) _hostProbe = null; - } - } - - @override - Future activateHost(String endpointKey) async { - if (!_initialized) await initialize(); - if (_disposed || !_directoryLoaded) { - throw StateError('Saved hosts are unavailable.'); - } - if (_hostOperationPending || - endpointKey == _hostDirectory.activeEndpointKey) { - return; - } - final operation = ++_hostOperationGeneration; - _hostOperationPending = true; - _errorMessage = null; - _publish(); - try { - final next = _hostDirectory.activate(endpointKey); - await hostDirectoryStore.save(next); - if (!_acceptHostOperation(operation)) return; - _hostDirectory = next; - _clearTargetProjection(); - _hostOperationPending = false; - _publish(); - _reconnectEnabled = true; - await _connectCurrent(); - } on Object catch (error) { - if (!_acceptHostOperation(operation)) return; - _hostOperationPending = false; - _errorMessage = 'Could not switch hosts: $error'; - _publish(); - rethrow; - } - } - - @override - Future removeHost(String endpointKey) async { - if (!_initialized) await initialize(); - if (_disposed || !_directoryLoaded) { - throw StateError('Saved hosts are unavailable.'); - } - if (_hostOperationPending) return; - HostProfile? profile; - for (final candidate in _hostDirectory.profiles) { - if (candidate.endpointKey == endpointKey) profile = candidate; - } - if (profile == null) return; - - final operation = ++_hostOperationGeneration; - final previous = _hostDirectory; - final next = previous.remove(endpointKey); - final removedActive = previous.activeEndpointKey == endpointKey; - _hostOperationPending = true; - _errorMessage = null; - _publish(); - try { - await hostDirectoryStore.save(next); - if (!_acceptHostOperation(operation)) return; - try { - await hostCredentialStore.delete(profile); - } on Object { - await hostDirectoryStore.save(previous); - if (!_acceptHostOperation(operation)) return; - throw StateError( - 'Could not remove host credentials; the host was restored.', - ); - } - if (!_acceptHostOperation(operation)) return; - _hostDirectory = next; - if (removedActive) _clearTargetProjection(); - _hostOperationPending = false; - _publish(); - if (removedActive) await _connectCurrent(); - } on Object catch (error) { - if (!_acceptHostOperation(operation)) return; - _hostOperationPending = false; - _errorMessage = 'Could not remove host: $error'; - _publish(); - rethrow; - } - } - - bool _acceptHostOperation(int operation) => - !_disposed && operation == _hostOperationGeneration; - - @override - Future pairHost(String code) async { - if (!RegExp(r'^\d{6}$').hasMatch(code)) { - _errorMessage = 'Enter the six-digit pairing code.'; - _publish(); - return; - } - final profile = developmentEndpoint == null ? _activeProfile : null; - if (profile == null || - _authenticationPhase != AuthenticationPhase.pairingRequired || - _pendingPair != null) { - return; - } - final ids = _nextCommandIds('pair'); - final deviceId = _newDeviceId(); - final pending = _PendingPair( - requestId: ids.requestId, - endpointKey: profile.endpointKey, - deviceId: deviceId, - deviceName: 'T4 Code', - platform: defaultTargetPlatform.name, - requestedCapabilities: t4RequestedCapabilities, - ); - _pendingPair = pending; - _authenticationPhase = AuthenticationPhase.pairing; - _errorMessage = null; - _publish(); - _send( - WireEncoder.pairStart( - requestId: pending.requestId, - code: code, - deviceId: pending.deviceId, - deviceName: pending.deviceName, - platform: pending.platform, - requestedCapabilities: pending.requestedCapabilities, - ), - ); - } - - String _newDeviceId() => _secureToken(24); - - @override - Future selectSession(String sessionId) async { - if (_selectedSessionId == sessionId && _phase == ConnectionPhase.ready) { - return; - } - final known = _sessions.any((session) => session.sessionId == sessionId); - if (!known) return; - _selectedSessionId = sessionId; - _selectDeveloperSessionProjection(sessionId); - _resetTranscriptPage(); - _messages.clear(); - _phase = ConnectionPhase.synchronizing; - _errorMessage = null; - _publish(); - _primeTranscriptTailThenAttach(sessionId); - } - - @override - Future createSession(String projectId, {String? title}) async { - final normalizedProjectId = projectId.trim(); - if (normalizedProjectId.isEmpty) { - throw ArgumentError.value(projectId, 'projectId', 'must not be empty'); - } - final normalizedTitle = title?.trim(); - final frame = await _runSessionOperation( - prefix: 'create-session', - command: 'session.create', - capability: 'sessions.manage', - args: { - 'projectId': normalizedProjectId, - if (normalizedTitle != null && normalizedTitle.isNotEmpty) - 'title': normalizedTitle, - }, - ); - final result = frame.result; - if (result is! Map || !result.containsKey('session')) { - throw const FormatException('session.create result is missing'); - } - final created = WireDecoder.decodeSessionRef(result['session']); - if (created.hostId != _hostId || - created.project['projectId'] != normalizedProjectId) { - throw const FormatException('session.create returned another project'); - } - _upsertSession(created); - await selectSession(created.sessionId); - } - - @override - Future renameSession(String sessionId, String title) async { - final normalized = title.trim(); - if (normalized.isEmpty) { - throw ArgumentError.value(title, 'title', 'must not be empty'); - } - await _runLifecycleCommand( - sessionId, - command: 'session.rename', - args: {'name': normalized}, - ); - } - - @override - Future terminateSession(String sessionId) => _runLifecycleCommand( - sessionId, - command: 'session.close', - confirmationExpected: true, - ); - - @override - Future archiveSession(String sessionId) async { - await _runLifecycleCommand(sessionId, command: 'session.archive'); - if (_selectedSessionId == sessionId) { - final replacement = _sessions - .where((session) => !session.archived) - .firstOrNull; - if (replacement == null) { - _selectedSessionId = null; - _selectDeveloperSessionProjection(null); - _messages.clear(); - _publish(); - } else { - await selectSession(replacement.sessionId); - } - } - } - - @override - Future restoreSession(String sessionId) => - _runLifecycleCommand(sessionId, command: 'session.restore'); - - @override - Future deleteSession(String sessionId) => _runLifecycleCommand( - sessionId, - command: 'session.delete', - confirmationExpected: true, - ); - - @override - Future cancelAgent(String agentId) async { - final activity = _agentActivities[agentId]; - if (activity == null) { - throw ArgumentError.value(agentId, 'agentId', 'is not projected'); - } - if (const {'completed', 'failed', 'cancelled'}.contains(activity.status)) { - throw StateError('this agent has already stopped'); - } - final session = _sessions - .where((candidate) => candidate.sessionId == activity.sessionId) - .firstOrNull; - if (session == null) { - throw StateError('the agent session is no longer indexed'); - } - await _runSessionOperation( - prefix: 'agent-cancel', - command: 'agent.cancel', - capability: 'agents.control', - session: session, - args: {'agentId': agentId}, - confirmationExpected: true, - ); - } - - @override - Future searchTranscripts({ - required String query, - String? cursor, - String? projectId, - List? roles, - String archived = 'include', - DateTime? from, - DateTime? to, - }) async { - final normalizedQuery = query.trim(); - if (normalizedQuery.isEmpty) { - throw ArgumentError.value(query, 'query', 'must not be blank'); - } - if (_phase != ConnectionPhase.ready || _hostId == null) { - throw StateError('connect before searching transcripts'); - } - if (!_grantedCapabilities.contains('sessions.read')) { - throw StateError('this device was not granted sessions.read'); - } - if (!const {'include', 'only', 'exclude'}.contains(archived)) { - throw ArgumentError.value(archived, 'archived', 'is not supported'); - } - if (roles != null && - (roles.isEmpty || roles.toSet().length != roles.length)) { - throw ArgumentError.value(roles, 'roles', 'must be non-empty and unique'); - } - final fromUtc = from?.toUtc(); - final toUtc = to?.toUtc(); - if (fromUtc != null && toUtc != null && fromUtc.isAfter(toUtc)) { - throw ArgumentError.value(from, 'from', 'must not be later than to'); - } - final frame = await _runHostCommand( - prefix: 'transcript-search', - command: 'transcript.search', - args: { - 'query': normalizedQuery, - 'limit': 25, - 'archived': archived, - 'cursor': ?cursor, - 'projectId': ?projectId, - if (roles != null) - 'roles': roles.map((role) => role.name).toList(growable: false), - if (fromUtc != null) 'from': fromUtc.toIso8601String(), - if (toUtc != null) 'to': toUtc.toIso8601String(), - }, - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? 'transcript search failed'); - } - final result = frame.result; - if (result is! TranscriptSearchResult) { - throw const WireFormatException( - 'transcript.search returned an invalid result', - 'result', - ); - } - return result; - } - - @override - Future loadTranscriptContext({ - required String sessionId, - required String anchorId, - int before = 8, - int after = 8, - }) async { - if (sessionId.isEmpty) { - throw ArgumentError.value(sessionId, 'sessionId', 'must not be empty'); - } - if (anchorId.isEmpty) { - throw ArgumentError.value(anchorId, 'anchorId', 'must not be empty'); - } - if (before < 0 || before > 20 || after < 0 || after > 20) { - throw ArgumentError('context bounds must be between 0 and 20'); - } - final frame = await _runSessionReadCommand( - prefix: 'transcript-context', - command: 'transcript.context', - sessionId: sessionId, - args: { - 'anchorId': anchorId, - 'before': before, - 'after': after, - }, - ); - final result = frame.result; - if (result is! TranscriptContextResult) { - throw const WireFormatException( - 'transcript.context returned an invalid result', - 'result', - ); - } - return result; - } - - @override - Future loadEarlierTranscript() async { - final sessionId = _selectedSessionId; - final before = _transcriptPageCursor; - if (_transcriptPageLoading || - !_transcriptPageSupported || - sessionId == null || - before == null) { - return; - } - final remaining = - _maxPagedTranscriptEntries - _pagedTranscriptEntries.length; - if (remaining <= 0) { - _transcriptPageCursor = null; - _transcriptPageHasMore = false; - _transcriptPageError = 'This view reached its history limit.'; - _publish(); - return; - } - final connectionGeneration = _connectionGeneration; - final openGeneration = _transcriptOpenGeneration; - final pageGeneration = _transcriptPageGeneration; - _transcriptPageLoading = true; - _transcriptPageError = null; - _publish(); - try { - final frame = await _runSessionReadCommand( - prefix: 'transcript-page-older', - command: 'transcript.page', - sessionId: sessionId, - args: { - 'before': before, - 'limit': min(128, remaining), - 'maxBytes': 512 * 1024, - }, - ); - if (connectionGeneration != _connectionGeneration || - openGeneration != _transcriptOpenGeneration || - sessionId != _selectedSessionId) { - return; - } - final page = frame.transcriptPageResult; - if (page == null) { - throw const WireFormatException( - 'transcript.page returned an invalid result', - 'result', - ); - } - _validateTranscriptPage(page, sessionId); - if (pageGeneration != null && page.generation != pageGeneration) { - throw StateError( - 'The transcript changed while older history was loading.', - ); - } - final existingIds = _pagedTranscriptEntries - .map((entry) => entry.id) - .toSet(); - final added = page.entries - .where((entry) => existingIds.add(entry.id)) - .take(remaining) - .toList(growable: false); - _pagedTranscriptEntries = [ - ...added, - ..._pagedTranscriptEntries, - ]; - _transcriptPageGeneration = page.generation; - _transcriptPageCursor = page.nextCursor; - _transcriptPageHasMore = page.hasMore && page.nextCursor != null; - if (_pagedTranscriptEntries.length >= _maxPagedTranscriptEntries) { - _transcriptPageCursor = null; - _transcriptPageHasMore = false; - if (page.hasMore) { - _transcriptPageError = 'This view reached its history limit.'; - } - } - final visible = _messages.values.toList(growable: false); - _messages.clear(); - for (final entry in added) { - _upsertEntry(entry); - } - for (final message in visible) { - _messages[message.id] = message; - } - } on Object catch (error) { - if (connectionGeneration == _connectionGeneration && - openGeneration == _transcriptOpenGeneration && - sessionId == _selectedSessionId) { - _transcriptPageError = error is StateError - ? error.message - : 'Older transcript history could not be loaded.'; - } - } finally { - if (connectionGeneration == _connectionGeneration && - openGeneration == _transcriptOpenGeneration && - sessionId == _selectedSessionId) { - _transcriptPageLoading = false; - _publish(); - } - } - } - - @override - Future readUsage() async { - final frame = await _runHostReadCommand( - prefix: 'usage-read', - command: 'usage.read', - capability: 'usage.read', - ); - final result = frame.result; - if (result is! UsageReadResult) { - throw const WireFormatException( - 'usage.read returned an invalid result', - 'result', - ); - } - return result; - } - - @override - Future readBrokerStatus() async { - final frame = await _runHostReadCommand( - prefix: 'broker-status', - command: 'broker.status', - capability: 'broker.read', - ); - final result = frame.result; - if (result is! BrokerStatusResult) { - throw const WireFormatException( - 'broker.status returned an invalid result', - 'result', - ); - } - return result; - } - - Future _runHostReadCommand({ - required String prefix, - required String command, - required String capability, - }) async { - if (_phase != ConnectionPhase.ready || _hostId == null) { - throw StateError('connect before reading host status'); - } - if (!_grantedCapabilities.contains(capability)) { - throw StateError('this device was not granted $capability'); - } - final frame = await _runHostCommand( - prefix: prefix, - command: command, - args: const {}, - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? '$command failed'); - } - return frame; - } - - Future _runSessionReadCommand({ - required String prefix, - required String command, - required String sessionId, - required Map args, - }) async { - final hostId = _hostId; - if (_channel == null || - hostId == null || - _phase == ConnectionPhase.disconnected || - _phase == ConnectionPhase.retrying || - _phase == ConnectionPhase.failed) { - throw StateError('connect before reading a session'); - } - if (!_grantedCapabilities.contains('sessions.read')) { - throw StateError('this device was not granted sessions.read'); - } - final ids = _nextCommandIds(prefix); - final completer = Completer(); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: command, - sessionId: sessionId, - completer: completer, - ); - try { - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - sessionId: sessionId, - command: command, - args: args, - ), - ); - final frame = await completer.future.timeout( - const Duration(seconds: 30), - onTimeout: () => throw TimeoutException('$command timed out'), - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? '$command failed'); - } - return frame; - } finally { - _pendingCommands.remove(ids.requestId); - } - } - - Future _runLifecycleCommand( - String sessionId, { - required String command, - Map args = const {}, - bool confirmationExpected = false, - }) { - final session = _sessions - .where((candidate) => candidate.sessionId == sessionId) - .firstOrNull; - if (session == null) { - throw ArgumentError.value(sessionId, 'sessionId', 'is not indexed'); - } - return _runSessionOperation( - prefix: command.replaceAll('.', '-'), - command: command, - capability: 'sessions.manage', - session: session, - args: args, - confirmationExpected: confirmationExpected, - ); - } - - Future _runSessionOperation({ - required String prefix, - required String command, - required String capability, - SessionSummary? session, - Map args = const {}, - bool confirmationExpected = false, - String? expectedRevision, - }) async { - if (_sessionOperationPending) { - throw StateError('another session action is already running'); - } - final hostId = _hostId; - if (_phase != ConnectionPhase.ready || hostId == null) { - throw StateError('connect before managing sessions'); - } - if (!_grantedCapabilities.contains(capability)) { - throw StateError('this device was not granted $capability'); - } - final operationRevision = expectedRevision ?? session?.revision; - final ids = _nextCommandIds(prefix); - final completer = Completer(); - final pending = _PendingCommand( - commandId: ids.commandId, - command: command, - sessionId: session?.sessionId, - completer: completer, - expectedRevision: operationRevision, - confirmationExpected: confirmationExpected, - ); - _pendingSessionOperations[ids.requestId] = pending; - _sessionOperationPending = true; - _errorMessage = null; - _publish(); - try { - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - sessionId: session?.sessionId, - command: command, - expectedRevision: operationRevision, - args: args, - ), - ); - final frame = await completer.future.timeout( - const Duration(seconds: 10), - onTimeout: () => throw TimeoutException('$command timed out'), - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? '$command failed'); - } - return frame; - } on Object catch (error) { - _errorMessage = 'Session action failed: $error'; - _publish(); - rethrow; - } finally { - _pendingSessionOperations.remove(ids.requestId); - _sessionOperationPending = false; - _publish(); - } - } - - @override - Future submitPrompt( - String message, { - List images = const [], - }) async { - final text = message.trim(); - final session = state.selectedSession; - if (session == null || _phase != ConnectionPhase.ready) return false; - if (text.isEmpty && images.isEmpty) return false; - if (state.composer.turnActive) { - if (images.isNotEmpty) { - throw StateError('Images cannot be added while a turn is active.'); - } - return _sendPromptText('session.steer', text); - } - if (images.length > 8) { - throw ArgumentError.value(images.length, 'images', 'maximum is 8'); - } - - final optimisticId = - 'local-prompt:${session.sessionId}:${_localPromptOrdinal++}'; - _messages[optimisticId] = TranscriptMessage( - id: optimisticId, - role: MessageRole.user, - text: text, - ); - _submitting = true; - _publish(); - final uploaded = []; - try { - for (final image in images) { - uploaded.add(await _uploadImage(session, image)); - } - final frame = await _runPromptLeasedCommand( - prefix: 'prompt', - command: 'session.prompt', - session: session, - expectedRevision: session.revision, - args: { - 'message': text, - if (uploaded.isNotEmpty) - 'images': >[ - for (final imageId in uploaded) - {'imageId': imageId}, - ], - }, - ); - final result = frame.result; - return result is Map && result['accepted'] == true; - } on Object { - await _discardUploadedImages(session, uploaded); - _messages.remove(optimisticId); - _submitting = false; - _publish(); - rethrow; - } - } - - @override - Future queuePrompt(String message) => - _sendPromptText('session.followUp', message.trim()); - - Future _sendPromptText(String command, String text) async { - final session = state.selectedSession; - if (text.isEmpty || session == null || _phase != ConnectionPhase.ready) { - return false; - } - final frame = await _runPromptLeasedCommand( - prefix: command.replaceAll('.', '-'), - command: command, - session: session, - expectedRevision: session.revision, - args: {'message': text}, - ); - final result = frame.result; - return result is Map && result['accepted'] == true; - } - - @override - Future cancelTurn() async { - final session = state.selectedSession; - if (session == null || !state.composer.turnActive) return; - await _runSessionOperation( - prefix: 'session-cancel', - command: 'session.cancel', - capability: 'sessions.control', - session: session, - confirmationExpected: true, - ); - } - - @override - Future pauseSession() async { - final session = state.selectedSession; - if (session == null || session.isPaused) return; - final frame = await _runSessionOperation( - prefix: 'session-pause', - command: 'session.pause', - capability: 'sessions.control', - session: session, - ); - final result = frame.result; - if (result is Map && result['paused'] is bool) { - _setSessionPaused(session.sessionId, result['paused']! as bool); - } - } - - @override - Future resumeSession() async { - final session = state.selectedSession; - if (session == null || !session.isPaused) return; - final frame = await _runSessionOperation( - prefix: 'session-resume', - command: 'session.resume', - capability: 'sessions.control', - session: session, - ); - final result = frame.result; - final paused = result is Map && result['paused'] is bool - ? result['paused']! as bool - : false; - _setSessionPaused(session.sessionId, paused); - } - - @override - Future compactSession({String? instructions}) async { - final session = state.selectedSession; - if (session == null || session.turnActive || session.isPaused) return; - final normalized = instructions?.trim(); - await _runSessionOperation( - prefix: 'session-compact', - command: 'session.compact', - capability: 'sessions.control', - session: session, - args: { - if (normalized != null && normalized.isNotEmpty) - 'instructions': normalized, - }, - ); - } - - void _setSessionPaused(String sessionId, bool paused) { - _sessions = _sessions - .map( - (session) => session.sessionId == sessionId - ? SessionSummary( - hostId: session.hostId, - sessionId: session.sessionId, - title: session.title, - revision: session.revision, - status: session.status, - projectId: session.projectId, - projectName: session.projectName, - updatedAt: session.updatedAt, - archivedAt: session.archivedAt, - working: session.working, - modelSelector: session.modelSelector, - modelDisplayName: session.modelDisplayName, - thinking: session.thinking, - thinkingSupported: session.thinkingSupported, - thinkingLevels: session.thinkingLevels, - fast: session.fast, - fastAvailable: session.fastAvailable, - turnActive: session.turnActive, - isPaused: paused, - queuedFollowUpCount: session.queuedFollowUpCount, - ) - : session, - ) - .toList(growable: false); - _publish(); - } - - @override - Future setSessionModel(String selector) async { - final session = state.selectedSession; - if (session == null) return; - await _runSessionOperation( - prefix: 'session-model', - command: 'session.model.set', - capability: 'sessions.manage', - session: session, - args: {'selector': selector, 'persistence': 'session'}, - ); - } - - @override - Future setSessionThinking(String level) async { - final session = state.selectedSession; - if (session == null) return; - await _runSessionOperation( - prefix: 'session-thinking', - command: 'session.thinking.set', - capability: 'sessions.manage', - session: session, - args: {'level': level}, - ); - } - - @override - Future setSessionFast(bool enabled) async { - final session = state.selectedSession; - if (session == null) return; - await _runSessionOperation( - prefix: 'session-fast', - command: 'session.fast.set', - capability: 'sessions.manage', - session: session, - args: {'enabled': enabled}, - ); - } - - @override - Future respondToAttention( - AttentionItem item, - AttentionResponse response, - ) async { - final current = _allAttentionItems - .where( - (candidate) => - candidate.key == item.key && - candidate.revision == item.revision && - candidate.needsResponse, - ) - .firstOrNull; - if (current == null) { - throw StateError('This attention item was already resolved or replaced.'); - } - if (_phase != ConnectionPhase.ready) { - throw StateError('Connect before responding to attention items.'); - } - if (current.kind == AttentionKind.confirmation) { - final confirmationId = current.confirmationId; - final commandId = current.commandId; - final hostId = _hostId; - if (confirmationId == null || commandId == null || hostId == null) { - throw StateError('The confirmation is incomplete.'); - } - final expiresAt = current.expiresAt; - if (expiresAt == null || !expiresAt.isAfter(DateTime.now().toUtc())) { - _attentionConfirmations.remove(confirmationId); - _publish(); - throw StateError('The confirmation expired.'); - } - final ids = _nextCommandIds('attention-confirm'); - _send( - WireEncoder.confirm( - requestId: ids.requestId, - confirmationId: confirmationId, - commandId: commandId, - hostId: hostId, - sessionId: current.sessionId.isEmpty ? null : current.sessionId, - decision: response.decision == AttentionDecision.approve - ? 'approve' - : 'deny', - ), - ); - _attentionConfirmations.remove(confirmationId); - _publish(); - return true; - } - if (!_grantedCapabilities.contains('sessions.prompt')) { - throw StateError('This device cannot answer session requests.'); - } - final requestId = current.requestId; - final session = _sessions - .where((candidate) => candidate.sessionId == current.sessionId) - .firstOrNull; - if (requestId == null || session == null) { - throw StateError('The attention item no longer belongs to this host.'); - } - final args = {'requestId': requestId}; - switch (current.kind) { - case AttentionKind.approval: - args['confirmed'] = response.decision == AttentionDecision.approve; - case AttentionKind.question: - final value = response.text.trim().isNotEmpty - ? response.text.trim() - : response.optionIds.join(', '); - if (value.isEmpty) throw ArgumentError('Choose or enter an answer.'); - args['value'] = value; - case AttentionKind.plan: - switch (response.decision) { - case AttentionDecision.approve: - args['confirmed'] = true; - case AttentionDecision.reject: - case AttentionDecision.deny: - args['confirmed'] = false; - case AttentionDecision.revise: - args['value'] = response.text.trim(); - } - case AttentionKind.confirmation: - case AttentionKind.completed: - case AttentionKind.failed: - case AttentionKind.cancelled: - throw StateError('This attention item cannot be answered.'); - } - final frame = await _runPromptLeasedCommand( - prefix: 'attention-response', - command: 'session.ui.respond', - session: session, - expectedRevision: current.revision, - args: args, - stillCurrent: () => _allAttentionItems.any( - (candidate) => - candidate.key == current.key && - candidate.revision == current.revision, - ), - ); - final result = frame.result; - return result is Map && result['accepted'] == true; - } - - @override - Future retrySession(String sessionId) async { - final session = _sessions - .where((candidate) => candidate.sessionId == sessionId) - .firstOrNull; - if (session == null) throw StateError('The session no longer exists.'); - await _runSessionOperation( - prefix: 'session-retry', - command: 'session.retry', - capability: 'sessions.control', - session: session, - ); - } - - void _selectDeveloperSessionProjection(String? sessionId) { - _fileWorkspace = const FileWorkspaceState(); - _activeTerminalId = sessionId == null - ? null - : _terminals.values - .where((terminal) => terminal.sessionId == sessionId) - .firstOrNull - ?.terminalId; - _activePreviewId = sessionId == null - ? null - : _previews.values - .where((preview) => preview.sessionId == sessionId) - .firstOrNull - ?.previewId; - } - - SessionSummary _developerSession() => - state.selectedSession ?? - (throw StateError('Choose a session before opening developer tools.')); - - Future _runDeveloperOperation(Future Function() operation) async { - if (_developerOperationPending) { - throw StateError('Another developer action is already running.'); - } - _developerOperationPending = true; - _publish(); - try { - return await operation(); - } finally { - _developerOperationPending = false; - _publish(); - } - } - - @override - Future refreshActivity() => _runDeveloperOperation(() async { - final frame = await _runSessionOperation( - prefix: 'audit-read', - command: 'audit.read', - capability: 'audit.read', - ); - final result = frame.result; - if (result is! Map || result['events'] is! List) { - throw const FormatException('audit.read result is invalid'); - } - for (final raw in result['events']! as List) { - if (raw is Map) _recordAuditMap(raw); - } - _publish(); - }); - - @override - Future openTerminal({String? cwd}) => _runDeveloperOperation( - () async { - final session = _developerSession(); - final frame = await _runSessionOperation( - prefix: 'terminal-open', - command: 'term.open', - capability: 'term.open', - session: session, - args: { - if (cwd != null && cwd.trim().isNotEmpty) 'cwd': cwd.trim(), - 'cols': 80, - 'rows': 24, - }, - confirmationExpected: true, - ); - final result = frame.result; - if (result is! Map || result['terminalId'] is! String) { - throw const FormatException('term.open result is invalid'); - } - final terminalId = result['terminalId']! as String; - _terminals[terminalId] = TerminalSession( - terminalId: terminalId, - sessionId: session.sessionId, - title: cwd?.trim().isNotEmpty == true ? cwd!.trim() : 'Terminal', - ); - _activeTerminalId = terminalId; - _recordActivity( - category: 'shell', - title: 'Terminal opened', - detail: cwd ?? session.projectName, - raw: result, - ); - return terminalId; - }, - ); - - @override - void sendTerminalInput(String terminalId, String data) { - final terminal = _terminals[terminalId]; - final hostId = _hostId; - if (terminal == null || - !terminal.running || - hostId == null || - _phase != ConnectionPhase.ready || - !_grantedCapabilities.contains('term.input')) { - return; - } - _send( - WireEncoder.terminalInput( - hostId: hostId, - sessionId: terminal.sessionId, - terminalId: terminalId, - data: data, - ), - ); - } - - @override - void resizeTerminal(String terminalId, int cols, int rows) { - final terminal = _terminals[terminalId]; - final hostId = _hostId; - if (terminal == null || - !terminal.running || - hostId == null || - _phase != ConnectionPhase.ready || - !_grantedCapabilities.contains('term.resize')) { - return; - } - _send( - WireEncoder.terminalResize( - hostId: hostId, - sessionId: terminal.sessionId, - terminalId: terminalId, - cols: cols, - rows: rows, - ), - ); - } - - @override - void closeTerminal(String terminalId) { - final terminal = _terminals[terminalId]; - final hostId = _hostId; - if (terminal == null || hostId == null) return; - if (_phase == ConnectionPhase.ready) { - _send( - WireEncoder.terminalClose( - hostId: hostId, - sessionId: terminal.sessionId, - terminalId: terminalId, - reason: 'user', - ), - ); - } - _terminals.remove(terminalId); - _terminalCursors.remove(terminalId); - _activeTerminalId = _terminals.keys.lastOrNull; - _publish(); - } - - @override - Future listFiles([String path = '']) => - _runDeveloperOperation(() async { - final session = _developerSession(); - _fileWorkspace = FileWorkspaceState( - path: path, - loading: true, - content: _fileWorkspace.content, - diff: _fileWorkspace.diff, - ); - _publish(); - final frame = await _runComposerCommand( - prefix: 'files-list', - command: 'files.list', - session: session, - capability: 'files.list', - args: {if (path.isNotEmpty) 'path': path}, - ); - final result = frame.result; - if (result is! Map || - result['entries'] is! List) { - throw const FormatException('files.list result is invalid'); - } - final entries = []; - for (final raw in result['entries']! as List) { - if (raw is! Map || - raw['path'] is! String || - raw['kind'] is! String) { - throw const FormatException('files.list entry is invalid'); - } - entries.add( - DeveloperFileEntry( - path: raw['path']! as String, - kind: raw['kind']! as String, - size: raw['size'] is int ? raw['size']! as int : null, - revision: raw['revision'] is String - ? raw['revision']! as String - : null, - ), - ); - } - _fileWorkspace = FileWorkspaceState( - path: path, - entries: List.unmodifiable(entries), - revision: result['revision'] is String - ? result['revision']! as String - : null, - ); - }); - - @override - Future searchProjectFiles( - String query, { - int limit = 12, - }) async { - final normalized = query.trim(); - if (normalized.isEmpty) { - throw ArgumentError.value(query, 'query', 'must not be blank'); - } - if (utf8.encode(normalized).length > 256) { - throw ArgumentError.value(query, 'query', 'must be at most 256 bytes'); - } - if (limit < 1 || limit > 50) { - throw RangeError.range(limit, 1, 50, 'limit'); - } - if (!_grantedFeatures.contains('files.search')) { - throw StateError('This host does not support project file search.'); - } - final session = _developerSession(); - final frame = await _runComposerCommand( - prefix: 'files-search', - command: 'files.search', - session: session, - capability: 'files.list', - args: {'query': normalized, 'limit': limit}, - ); - final result = frame.result; - if (result is! Map || - result['matches'] is! List || - result['truncated'] is! bool) { - throw const FormatException('files.search result is invalid'); - } - if (result.keys.toSet().difference(const { - 'matches', - 'truncated', - }).isNotEmpty || - (result['matches']! as List).length > 50) { - throw const FormatException('files.search result is invalid'); - } - final paths = []; - final seen = {}; - for (final raw in result['matches']! as List) { - if (raw is! Map || - raw.keys.toSet().difference(const {'path'}).isNotEmpty || - raw['path'] is! String) { - throw const FormatException('files.search match is invalid'); - } - final path = _safeProjectSearchPath(raw['path']! as String); - if (!seen.add(path)) { - throw const FormatException('files.search match is duplicated'); - } - paths.add(path); - } - return ProjectFileSearchResult( - paths: List.unmodifiable(paths), - truncated: result['truncated']! as bool, - ); - } - - @override - Future readFile(String path) => _runDeveloperOperation(() async { - final session = _developerSession(); - final frame = await _runComposerCommand( - prefix: 'files-read', - command: 'files.read', - session: session, - capability: 'files.read', - args: {'path': path}, - ); - final result = frame.result; - if (result is! Map || result['content'] is! String) { - throw const FormatException('files.read result is invalid'); - } - _fileWorkspace = FileWorkspaceState( - path: path, - entries: _fileWorkspace.entries, - content: result['content']! as String, - revision: result['revision'] is String - ? result['revision']! as String - : null, - ); - }); - - @override - Future loadSessionDiff() => _runDeveloperOperation(() async { - final session = _developerSession(); - final path = _fileWorkspace.path; - if (path.isEmpty) { - throw StateError('Choose a file before loading its diff.'); - } - final frame = await _runComposerCommand( - prefix: 'files-diff', - command: 'files.diff', - session: session, - capability: 'files.diff', - args: {'path': path}, - ); - final result = frame.result; - if (result is! Map || result['diff'] is! String) { - throw const FormatException('files.diff result is invalid'); - } - _fileWorkspace = FileWorkspaceState( - path: path, - entries: _fileWorkspace.entries, - content: _fileWorkspace.content, - diff: result['diff']! as String, - revision: result['toRevision'] is String - ? result['toRevision']! as String - : _fileWorkspace.revision, - ); - }); - - @override - Future writeFile(String path, String content) => - _runDeveloperOperation(() async { - final session = _developerSession(); - if (path != _fileWorkspace.path || _fileWorkspace.content == null) { - throw StateError('Read this file again before saving it.'); - } - final revision = _fileWorkspace.revision; - if (revision == null) { - throw StateError('Waiting for the file revision before saving.'); - } - final frame = await _runSessionOperation( - prefix: 'files-write', - command: 'files.write', - capability: 'files.write', - session: session, - expectedRevision: revision, - args: {'path': path, 'content': content}, - confirmationExpected: true, - ); - final result = frame.result; - final nextRevision = - result is Map && result['revision'] is String - ? result['revision']! as String - : _fileWorkspace.revision; - _fileWorkspace = FileWorkspaceState( - path: path, - entries: _fileWorkspace.entries, - content: content, - revision: nextRevision, - ); - }); - - @override - Future refreshReview(String reviewId) => - _runDeveloperOperation(() async { - final review = _reviews[reviewId]; - if (review == null) { - throw ArgumentError.value(reviewId, 'reviewId', 'is not projected'); - } - final session = _developerSession(); - if (session.sessionId != review.sessionId) { - throw StateError('Open the review session before refreshing it.'); - } - await _runComposerCommand( - prefix: 'review-read', - command: 'review.read', - session: session, - capability: 'files.read', - expectedRevision: session.revision, - args: {'reviewId': reviewId}, - ); - }); - - @override - Future applyReview(String reviewId) => _runDeveloperOperation(() async { - final review = _reviews[reviewId]; - if (review == null) { - throw ArgumentError.value(reviewId, 'reviewId', 'is not projected'); - } - if (review.status == 'applied' || review.status == 'discarded') { - throw StateError('This review is no longer pending.'); - } - final session = _developerSession(); - if (session.sessionId != review.sessionId) { - throw StateError('Open the review session before applying it.'); - } - await _runSessionOperation( - prefix: 'review-apply', - command: 'review.apply', - capability: 'files.write', - session: session, - args: {'reviewId': reviewId}, - confirmationExpected: true, - ); - }); - - @override - Future launchPreview(String url) => _runDeveloperOperation(() async { - final session = _developerSession(); - final frame = await _runSessionOperation( - prefix: 'preview-launch', - command: 'preview.launch', - capability: 'preview.control', - session: session, - args: {'url': url.trim(), 'authorityId': 'omp-session'}, - confirmationExpected: true, - ); - final preview = _previewResult(frame, session.sessionId); - _activePreviewId = preview.previewId; - return preview.previewId; - }); - - @override - Future selectPreview(String previewId) => - _runPreviewMutation(previewId, 'preview.activate'); - - @override - Future navigatePreview(String previewId, String url) => - _runPreviewMutation( - previewId, - 'preview.navigate', - extraArgs: {'url': url.trim()}, - confirmationExpected: true, - ); - - @override - Future runPreviewAction(String previewId, String action) { - if (!const { - 'back', - 'forward', - 'reload', - 'close', - }.contains(action)) { - throw ArgumentError.value( - action, - 'action', - 'is not a safe preview action', - ); - } - return _runPreviewMutation(previewId, 'preview.$action'); - } - - @override - Future runPreviewInteraction( - String previewId, - String action, - Map args, - ) { - const inputActions = { - 'click', - 'fill', - 'scroll', - 'type', - 'select', - 'press', - 'upload', - }; - if (!inputActions.contains(action) && action != 'handoff') { - throw ArgumentError.value( - action, - 'action', - 'is not a supported preview interaction', - ); - } - if (args.containsKey('previewId')) { - throw ArgumentError.value(args, 'args', 'must not override previewId'); - } - return _runPreviewMutation( - previewId, - 'preview.$action', - capability: action == 'handoff' ? 'preview.control' : 'preview.input', - extraArgs: args, - confirmationExpected: action == 'upload', - ); - } - - Future _runPreviewMutation( - String previewId, - String command, { - Map extraArgs = const {}, - String capability = 'preview.control', - bool confirmationExpected = false, - }) => _runDeveloperOperation(() async { - final session = _developerSession(); - final frame = confirmationExpected - ? await _runSessionOperation( - prefix: command.replaceAll('.', '-'), - command: command, - capability: capability, - session: session, - args: {...extraArgs, 'previewId': previewId}, - confirmationExpected: true, - ) - : await _runComposerCommand( - prefix: command.replaceAll('.', '-'), - command: command, - session: session, - capability: capability, - args: {...extraArgs, 'previewId': previewId}, - ); - final preview = _previewResult(frame, session.sessionId); - if (command == 'preview.close' || preview.state == 'closed') { - _previews.remove(previewId); - _previewCaptures.remove(previewId); - _activePreviewId = _previews.keys.lastOrNull; - } else { - _activePreviewId = previewId; - } - }); - - @override - Future capturePreview(String previewId) => - _runDeveloperOperation(() async { - final session = _developerSession(); - final captureFrame = await _runComposerCommand( - prefix: 'preview-capture', - command: 'preview.capture', - session: session, - capability: 'preview.read', - args: {'previewId': previewId}, - ); - final preview = _previewResult(captureFrame, session.sessionId); - final rawPreview = - (captureFrame.result! as Map)['preview']; - final capture = rawPreview is Map - ? rawPreview['capture'] - : null; - if (capture is! Map || - capture['captureId'] is! String || - capture['size'] is! int || - capture['sha256'] is! String || - capture['mimeType'] is! String) { - throw const FormatException('preview capture metadata is invalid'); - } - final bytes = BytesBuilder(copy: false); - var offset = 0; - final size = capture['size']! as int; - while (offset < size) { - final chunkFrame = await _runComposerCommand( - prefix: 'preview-capture-read', - command: 'preview.capture.read', - session: session, - capability: 'preview.read', - args: { - 'previewId': previewId, - 'captureId': capture['captureId']! as String, - 'offset': offset, - }, - ); - final chunk = chunkFrame.result; - if (chunk is! Map || - chunk['offset'] != offset || - chunk['nextOffset'] is! int || - chunk['content'] is! String) { - throw const FormatException('preview capture chunk is invalid'); - } - final decoded = base64Decode(chunk['content']! as String); - final nextOffset = chunk['nextOffset']! as int; - if (nextOffset - offset != decoded.length || nextOffset <= offset) { - throw const FormatException('preview capture offsets are invalid'); - } - bytes.add(decoded); - offset = nextOffset; - } - final content = bytes.takeBytes(); - if (content.length != size || - sha256.convert(content).toString() != capture['sha256']) { - throw const FormatException('preview capture integrity check failed'); - } - _previewCaptures[previewId] = content; - _previews[previewId] = PreviewWorkspaceState( - previewId: preview.previewId, - sessionId: preview.sessionId, - state: preview.state, - url: preview.url, - revision: preview.revision, - title: preview.title, - canGoBack: preview.canGoBack, - canGoForward: preview.canGoForward, - capture: content, - captureMimeType: capture['mimeType']! as String, - ); - }); - - @override - Future readTranscriptImage( - String entryId, - TranscriptImageMetadata image, - ) async { - final session = state.selectedSession; - if (session == null) { - throw StateError('choose a session before loading transcript images'); - } - final bytes = BytesBuilder(copy: false); - var offset = 0; - int? expectedSize; - while (true) { - final frame = await _runComposerCommand( - prefix: 'image-read', - command: 'session.image.read', - session: session, - capability: 'sessions.read', - args: { - 'entryId': entryId, - 'sha256': image.sha256, - 'offset': offset, - }, - ); - final result = frame.result; - if (result is! Map || - result['sha256'] != image.sha256 || - result['mimeType'] != image.mimeType || - result['size'] is! int || - result['offset'] != offset || - result['nextOffset'] is! int || - result['complete'] is! bool || - result['content'] is! String) { - throw const FormatException('session.image.read result is invalid'); - } - final size = result['size']! as int; - expectedSize ??= size; - if (size != expectedSize || size <= 0 || size > 20 * 1024 * 1024) { - throw const FormatException('transcript image size changed'); - } - final nextOffset = result['nextOffset']! as int; - final chunk = base64Decode(result['content']! as String); - if (nextOffset <= offset || nextOffset - offset != chunk.length) { - throw const FormatException('transcript image offsets are invalid'); - } - bytes.add(chunk); - offset = nextOffset; - final complete = result['complete']! as bool; - if (complete != (offset == size)) { - throw const FormatException('transcript image completion is invalid'); - } - if (complete) break; - } - final value = bytes.takeBytes(); - if (value.length != expectedSize || - sha256.convert(value).toString() != image.sha256) { - throw const FormatException('transcript image integrity check failed'); - } - return value; - } - - Future _uploadImage( - SessionSummary session, - PromptImageAttachment attachment, - ) async { - if (!const { - 'image/png', - 'image/jpeg', - 'image/gif', - 'image/webp', - }.contains(attachment.mimeType)) { - throw ArgumentError.value( - attachment.mimeType, - 'mimeType', - 'is not supported', - ); - } - if (attachment.bytes.isEmpty || - attachment.bytes.length > 20 * 1024 * 1024) { - throw ArgumentError.value( - attachment.bytes.length, - 'bytes', - 'image must be between 1 byte and 20 MiB', - ); - } - final begin = await _runComposerCommand( - prefix: 'image-begin', - command: 'session.image.begin', - session: session, - args: { - 'mimeType': attachment.mimeType, - 'size': attachment.bytes.length, - 'sha256': sha256.convert(attachment.bytes).toString(), - }, - ); - final beginResult = begin.result; - if (beginResult is! Map || - beginResult['imageId'] is! String || - beginResult['chunkBytes'] is! int) { - throw const FormatException('session.image.begin result is invalid'); - } - final imageId = beginResult['imageId']! as String; - final chunkBytes = beginResult['chunkBytes']! as int; - if (chunkBytes <= 0) { - throw const FormatException('session.image.begin chunk size is invalid'); - } - for ( - var offset = 0; - offset < attachment.bytes.length; - offset += chunkBytes - ) { - final end = min(offset + chunkBytes, attachment.bytes.length); - final chunk = Uint8List.sublistView(attachment.bytes, offset, end); - final response = await _runComposerCommand( - prefix: 'image-chunk', - command: 'session.image.chunk', - session: session, - args: { - 'imageId': imageId, - 'offset': offset, - 'content': base64Encode(chunk), - }, - ); - final result = response.result; - if (result is! Map || - result['imageId'] != imageId || - result['received'] != end || - result['complete'] != (end == attachment.bytes.length)) { - throw const FormatException('session.image.chunk result is invalid'); - } - } - return imageId; - } - - Future _discardUploadedImages( - SessionSummary session, - List imageIds, - ) async { - for (final imageId in imageIds) { - try { - await _runComposerCommand( - prefix: 'image-discard', - command: 'session.image.discard', - session: session, - args: {'imageId': imageId}, - ); - } on Object { - // The original upload failure remains the actionable error. - } - } - } - - Future _runPromptLeasedCommand({ - required String prefix, - required String command, - required SessionSummary session, - required String expectedRevision, - required Map args, - bool Function()? stillCurrent, - }) async { - if (!_grantedFeatures.contains('prompt.lease')) { - return _runComposerCommand( - prefix: prefix, - command: command, - session: session, - expectedRevision: expectedRevision, - args: args, - ); - } - final acquisition = await _runComposerCommand( - prefix: 'prompt-lease-acquire', - command: 'prompt.lease.acquire', - session: session, - expectedRevision: expectedRevision, - args: const {'ownerId': 't4-code-flutter'}, - ); - final result = acquisition.result; - if (result is! Map || - result['accepted'] == false || - result['leaseId'] is! String || - (result['leaseId']! as String).isEmpty) { - throw const FormatException('prompt lease acquisition was rejected'); - } - final currentSession = _sessions - .where((candidate) => candidate.sessionId == session.sessionId) - .firstOrNull; - if (currentSession?.revision != expectedRevision || - stillCurrent?.call() == false) { - throw StateError('The session request changed before it could be sent.'); - } - return _runComposerCommand( - prefix: prefix, - command: command, - session: currentSession!, - expectedRevision: expectedRevision, - args: {...args, 'leaseId': result['leaseId']! as String}, - ); - } - - Future _runComposerCommand({ - required String prefix, - required String command, - required SessionSummary session, - required Map args, - String? expectedRevision, - String capability = 'sessions.prompt', - }) async { - if (_phase != ConnectionPhase.ready || _hostId == null) { - throw StateError('connect before sending a prompt'); - } - if (!_grantedCapabilities.contains(capability)) { - throw StateError('this device was not granted $capability'); - } - final ids = _nextCommandIds(prefix); - final completer = Completer(); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: command, - sessionId: session.sessionId, - completer: completer, - ); - _errorMessage = null; - _publish(); - try { - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: session.hostId, - sessionId: session.sessionId, - command: command, - expectedRevision: expectedRevision, - args: args, - ), - ); - final frame = await completer.future.timeout( - const Duration(seconds: 30), - onTimeout: () => throw TimeoutException('$command timed out'), - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? '$command failed'); - } - return frame; - } finally { - _pendingCommands.remove(ids.requestId); - } - } - - Future _handlePayload(int generation, Object? payload) async { - if (_disposed || generation != _connectionGeneration) return; - try { - final encoded = switch (payload) { - String value => value, - List value => utf8.decode(value, allowMalformed: false), - _ => throw const FormatException('unsupported websocket payload'), - }; - final frame = WireDecoder.decode(encoded); - if (frame case PairOkFrame()) { - await _applyPairOk(generation, frame); - } else { - _applyFrame(frame); - } - } on Object catch (error) { - if (_disposed || generation != _connectionGeneration) return; - _connectionGeneration += 1; - _reconnectTimer?.cancel(); - _fail('Protocol error: $error'); - unawaited(_subscription?.cancel()); - unawaited(_channel?.sink.close()); - } - } - - void _applyFrame(WireFrame frame) { - switch (frame) { - case WelcomeFrame(): - _hostId = frame.hostId; - _reconnectAttempt = 0; - _authenticationPhase = switch (frame.authentication) { - 'local' => AuthenticationPhase.local, - 'pairing-required' => AuthenticationPhase.pairingRequired, - 'paired' => AuthenticationPhase.paired, - _ => throw const FormatException('unknown authentication state'), - }; - _grantedCapabilities = frame.grantedCapabilities.toSet(); - _grantedFeatures = frame.grantedFeatures.toSet(); - if (_authenticationPhase != AuthenticationPhase.pairingRequired && - _settingsBootstrapGeneration != _connectionGeneration) { - final canReadCatalog = - _grantedCapabilities.contains('catalog.read') && - _grantedFeatures.contains('catalog.metadata'); - final canReadSettings = - _grantedCapabilities.contains('config.read') && - _grantedFeatures.contains('settings.metadata'); - if (canReadCatalog || canReadSettings) { - _settingsBootstrapGeneration = _connectionGeneration; - final needsCatalog = canReadCatalog && _catalogFrame == null; - final needsSettings = canReadSettings && _settingsFrame == null; - if (needsCatalog && needsSettings) { - _beginSettingsRefresh(); - } else { - if (needsCatalog) { - _sendHostProduct('catalog.get'); - } - if (needsSettings) { - _sendHostProduct('settings.read'); - } - } - } - } - if (_authenticationPhase != AuthenticationPhase.pairingRequired && - !_grantedCapabilities.contains('sessions.read')) { - _phase = ConnectionPhase.ready; - _errorMessage = - 'This device cannot read sessions. Pair again with ' - 'sessions.read permission.'; - _publish(); - return; - } - _publish(); - if (_grantedCapabilities.contains('sessions.read') && - _authenticationPhase != AuthenticationPhase.pairingRequired && - _bootstrapGeneration != _connectionGeneration) { - _bootstrapGeneration = _connectionGeneration; - _sendSessionList(); - } - case SessionsFrame(): - _applySessions(frame); - case SnapshotFrame(): - if (frame.hostId == _hostId && frame.sessionId == _selectedSessionId) { - _applySnapshot(frame); - } - case EntryFrame(): - if (frame.hostId == _hostId && - frame.sessionId == _selectedSessionId && - _acceptTranscriptCursor(frame.sessionId, frame.cursor)) { - _upsertEntry(frame.entry); - _publish(); - } - case EventFrame(): - if (frame.hostId == _hostId && - frame.sessionId == _selectedSessionId && - _acceptTranscriptCursor(frame.sessionId, frame.cursor)) { - _applyEvent(frame.event, frame.cursor); - _publish(); - } - case TerminalOutputFrame(): - _applyTerminalOutput(frame); - case TerminalExitFrame(): - _applyTerminalExit(frame); - case FilesAdditiveFrame(): - _applyFilesFrame(frame); - case ReviewFrame(): - _applyReviewFrame(frame); - case AuditTailFrame(): - _applyAuditEvents(frame.events); - case AuditEventFrame(): - _applyAuditEvents([frame.event]); - case PreviewFrame(): - _applyPreviewFrame(frame); - case ResponseFrame(): - _applyResponse(frame); - case SessionDeltaFrame(): - _applySessionDelta(frame); - case ConfirmationFrame(): - _applyConfirmation(frame); - case AgentFrame(): - _applyLegacyAgentFrame(frame); - case AgentAdditiveFrame(): - _applyAgentFrame(frame); - case PairErrorFrame(): - _applyPairError(frame); - case CatalogFrame(): - if (frame.hostId == _hostId) { - _catalogFrame = frame; - _catalogItems = List.unmodifiable(frame.items); - _settingsRefreshSawCatalog = true; - _projectHostSettings(); - } - case SettingsFrame(): - if (frame.hostId == _hostId) { - _settingsFrame = frame; - _settingsRefreshSawSettings = true; - _projectHostSettings(); - } - case ErrorFrame(): - _errorMessage = frame.message; - _submitting = false; - _publish(); - case GapFrame(): - if (frame.hostId == _hostId && frame.sessionId == _selectedSessionId) { - _transcriptRecoverySessionId = frame.sessionId; - _savedCursors.remove(frame.sessionId); - _phase = ConnectionPhase.synchronizing; - _errorMessage = 'Recovering transcript continuity…'; - _publish(); - _sendAttach(frame.sessionId); - } - default: - break; - } - } - - Future _applyPairOk(int generation, PairOkFrame frame) async { - final pending = _pendingPair; - if (pending == null || frame.requestId != pending.requestId) { - throw const FormatException('pairing response correlation mismatch'); - } - final requested = pending.requestedCapabilities.toSet(); - final expiration = DateTime.tryParse(frame.expiresAt); - if (frame.deviceId != pending.deviceId || - frame.deviceName != pending.deviceName || - frame.platform != pending.platform || - frame.requestedCapabilities.any( - (value) => !requested.contains(value), - ) || - frame.grantedCapabilities.any((value) => !requested.contains(value)) || - expiration == null || - !expiration.isAfter(DateTime.now().toUtc())) { - throw const FormatException('pairing response identity mismatch'); - } - final profile = _activeProfile; - if (profile == null || profile.endpointKey != pending.endpointKey) return; - _pendingPair = null; - await hostCredentialStore.write( - profile, - DeviceCredentials( - deviceId: frame.deviceId, - deviceToken: frame.deviceToken, - ), - ); - if (_disposed || - generation != _connectionGeneration || - _activeProfile?.endpointKey != pending.endpointKey) { - return; - } - _authenticationPhase = AuthenticationPhase.paired; - _publish(); - await _connectCurrent(); - } - - void _applyPairError(PairErrorFrame frame) { - final pending = _pendingPair; - if (pending == null || - (frame.requestId != null && frame.requestId != pending.requestId)) { - throw const FormatException('pairing error correlation mismatch'); - } - _pendingPair = null; - _authenticationPhase = AuthenticationPhase.pairingRequired; - _errorMessage = - 'Pairing failed (${frame.code}). Check the code and try again.'; - _publish(); - } - - void _applySessions(SessionsFrame frame) { - _applySessionListCursor(frame.cursor); - _applySessionRefs(frame.sessions); - } - - void _applySessionListCursor(SessionIndexCursor cursor) { - _sessionIndexEpoch = cursor.epoch; - _sessionIndexSeq = cursor.seq; - } - - void _applySessionRefs(List sessions) { - final previousSelectedSessionId = _selectedSessionId; - _sessions = sessions.map(_summaryFromRef).toList(growable: false) - ..sort(_compareSessions); - _attentionBySession - ..clear() - ..addEntries( - sessions.map( - (session) => - MapEntry(session.sessionId, _decodeSessionAttention(session)), - ), - ); - final selectedStillExists = _sessions.any( - (session) => session.sessionId == _selectedSessionId, - ); - if (!selectedStillExists) { - _selectedSessionId = - _sessions - .where((session) => !session.archived) - .firstOrNull - ?.sessionId ?? - _sessions.firstOrNull?.sessionId; - _selectDeveloperSessionProjection(_selectedSessionId); - } - final selected = _selectedSessionId; - if (selected == null) { - _phase = ConnectionPhase.ready; - _publish(); - return; - } - final selectionChanged = selected != previousSelectedSessionId; - if (selectionChanged) { - _resetTranscriptPage(); - _messages.clear(); - } - _publish(); - final cursor = selectionChanged ? null : _savedCursors[selected]; - if (cursor == null) { - _primeTranscriptTailThenAttach(selected); - } else { - _sendAttach(selected, cursor: cursor); - } - } - - SessionSummary _summaryFromRef(SessionRef session) { - final projectId = session.project['projectId']; - final projectName = session.project['name']; - final archivedAt = session.raw['archivedAt']; - final pendingApproval = session.raw['pendingApproval']; - final pendingUserInput = session.raw['pendingUserInput']; - final rawLiveState = session.raw['liveState']; - final liveState = rawLiveState is Map - ? rawLiveState - : const {}; - final rawModel = liveState['model']; - String? modelSelector; - String? modelDisplayName; - if (rawModel is String && rawModel.isNotEmpty) { - modelSelector = rawModel; - } else if (rawModel is Map) { - final selector = rawModel['selector']; - final provider = rawModel['provider']; - final id = rawModel['id']; - if (selector is String && selector.isNotEmpty) { - modelSelector = selector; - } else if (provider is String && - provider.isNotEmpty && - id is String && - id.isNotEmpty) { - modelSelector = '$provider/$id'; - } - final displayName = rawModel['displayName']; - if (displayName is String && displayName.isNotEmpty) { - modelDisplayName = displayName; - } - } - final refModel = session.raw['model']; - if (modelSelector == null && refModel is String && refModel.isNotEmpty) { - modelSelector = refModel; - } - final rawThinking = liveState['thinking'] ?? session.raw['thinking']; - final thinking = rawThinking is String ? rawThinking : null; - final thinkingSupported = liveState['thinkingSupported'] == true; - final thinkingLevels = switch (liveState['thinkingLevels']) { - final List values => values.whereType().toList( - growable: false, - ), - _ => const [], - }; - final queuedCount = switch (liveState['queuedMessageCount']) { - final int count when count >= 0 => count, - _ => _queuedMessageCount(liveState['queuedMessages']), - }; - final streaming = liveState['isStreaming'] == true; - return SessionSummary( - hostId: session.hostId, - sessionId: session.sessionId, - title: session.title, - revision: session.revision, - status: session.status, - projectId: projectId is String ? projectId : 'unknown-project', - projectName: projectName is String && projectName.trim().isNotEmpty - ? projectName - : 'Project', - updatedAt: session.updatedAt, - archivedAt: archivedAt is String ? archivedAt : null, - working: - session.status == 'active' || - pendingApproval == true || - pendingUserInput == true || - streaming, - modelSelector: modelSelector, - modelDisplayName: modelDisplayName, - thinking: thinking, - thinkingSupported: thinkingSupported, - thinkingLevels: thinkingLevels, - fast: liveState['fast'] == true, - fastAvailable: liveState['fastAvailable'] == true, - turnActive: - streaming || pendingApproval == true || pendingUserInput == true, - isPaused: liveState['isPaused'] == true, - queuedFollowUpCount: queuedCount, - ); - } - - _SessionAttention _decodeSessionAttention(SessionRef session) { - final raw = session.raw['attention']; - if (raw == null) return const _SessionAttention(); - if (raw is! Map) { - return const _SessionAttention(malformed: true); - } - final pending = raw['pending']; - final pendingCount = raw['pendingCount']; - final truncated = raw['truncated']; - if (pending is! List || - pending.length > 8 || - pendingCount is! int || - pendingCount < pending.length || - truncated is! bool || - truncated != (pendingCount > pending.length)) { - return const _SessionAttention(malformed: true); - } - final items = []; - for (final value in pending) { - if (value is! Map || - value['kind'] is! String || - value['id'] is! String || - value['requestedAt'] is! String) { - return const _SessionAttention(malformed: true); - } - final kind = switch (value['kind']) { - 'approval' => AttentionKind.approval, - 'question' => AttentionKind.question, - 'plan' => AttentionKind.plan, - _ => null, - }; - final at = DateTime.tryParse(value['requestedAt']! as String); - final requestId = value['id']! as String; - if (kind == null || requestId.isEmpty || at == null) { - return const _SessionAttention(malformed: true); - } - var title = ''; - var summary = ''; - var allowText = false; - var choices = const []; - if (kind == AttentionKind.question) { - final question = value['question']; - final options = value['options']; - if (question is! String || - options is! List || - options.length > 32 || - value['allowText'] is! bool) { - return const _SessionAttention(malformed: true); - } - final parsed = []; - for (final option in options) { - if (option is! Map || - option['id'] is! String || - option['label'] is! String) { - return const _SessionAttention(malformed: true); - } - parsed.add( - AttentionChoice( - id: option['id']! as String, - label: option['label']! as String, - ), - ); - } - title = 'Question'; - summary = question; - allowText = value['allowText']! as bool; - choices = List.unmodifiable(parsed); - } else { - if (value['title'] is! String || value['summary'] is! String) { - return const _SessionAttention(malformed: true); - } - title = value['title']! as String; - summary = value['summary']! as String; - } - items.add( - AttentionItem( - key: '${session.sessionId}:${kind.name}:$requestId', - kind: kind, - sessionId: session.sessionId, - sessionTitle: session.title, - revision: session.revision, - title: title, - summary: summary, - at: at.toUtc(), - requestId: requestId, - choices: choices, - allowText: allowText, - actionable: true, - ), - ); - } - final latest = raw['latestOutcome']; - if (latest != null) { - if (latest is! Map || - latest['id'] is! String || - latest['kind'] is! String || - latest['at'] is! String || - latest['summary'] is! String) { - return const _SessionAttention(malformed: true); - } - final kind = switch (latest['kind']) { - 'completed' => AttentionKind.completed, - 'failed' => AttentionKind.failed, - 'cancelled' => AttentionKind.cancelled, - _ => null, - }; - final at = DateTime.tryParse(latest['at']! as String); - if (kind == null || at == null) { - return const _SessionAttention(malformed: true); - } - final outcomeId = latest['id']! as String; - items.add( - AttentionItem( - key: '${session.sessionId}:${kind.name}:$outcomeId', - kind: kind, - sessionId: session.sessionId, - sessionTitle: session.title, - revision: session.revision, - title: switch (kind) { - AttentionKind.completed => 'Completed', - AttentionKind.failed => 'Failed', - AttentionKind.cancelled => 'Cancelled', - _ => 'Update', - }, - summary: latest['summary']! as String, - at: at.toUtc(), - ), - ); - } - return _SessionAttention( - items: List.unmodifiable(items), - omittedCount: pendingCount - pending.length, - truncated: truncated, - ); - } - - int _queuedMessageCount(Object? value) { - if (value is! Map) return 0; - var count = 0; - for (final messages in value.values) { - if (messages is List) count += messages.length; - } - return count; - } - - int _compareSessions(SessionSummary left, SessionSummary right) { - final updated = right.updatedAt.compareTo(left.updatedAt); - return updated != 0 ? updated : left.sessionId.compareTo(right.sessionId); - } - - void _upsertSession(SessionRef ref) { - final next = _summaryFromRef(ref); - final index = _sessions.indexWhere( - (session) => session.sessionId == next.sessionId, - ); - final sessions = _sessions.toList(growable: true); - if (index < 0) { - sessions.add(next); - } else { - sessions[index] = next; - } - sessions.sort(_compareSessions); - _sessions = List.unmodifiable(sessions); - _attentionBySession[ref.sessionId] = _decodeSessionAttention(ref); - _publish(); - } - - void _applySessionDelta(SessionDeltaFrame frame) { - final cursor = frame.cursor; - final currentEpoch = _sessionIndexEpoch; - final currentSeq = _sessionIndexSeq; - if (currentEpoch != null && currentSeq != null) { - if (cursor.epoch == currentEpoch && cursor.seq <= currentSeq) return; - if (cursor.epoch != currentEpoch || cursor.seq != currentSeq + 1) { - _sendSessionList(); - return; - } - } - _sessionIndexEpoch = cursor.epoch; - _sessionIndexSeq = cursor.seq; - final upsert = frame.upsert; - if (upsert != null) { - _upsertSession(upsert); - return; - } - final removed = frame.remove; - if (removed == null) return; - final wasSelected = _selectedSessionId == removed; - _sessions = _sessions - .where((session) => session.sessionId != removed) - .toList(growable: false); - _attentionBySession.remove(removed); - _attentionConfirmations.removeWhere( - (_, frame) => frame.sessionId == removed, - ); - _agentActivities.removeWhere( - (_, activity) => activity.sessionId == removed, - ); - _reviews.removeWhere((_, review) => review.sessionId == removed); - _savedCursors.remove(removed); - if (!wasSelected) { - _publish(); - return; - } - _messages.clear(); - final replacement = - _sessions.where((session) => !session.archived).firstOrNull ?? - _sessions.firstOrNull; - _selectedSessionId = replacement?.sessionId; - _selectDeveloperSessionProjection(_selectedSessionId); - if (replacement == null) { - _phase = ConnectionPhase.ready; - _publish(); - return; - } - _phase = ConnectionPhase.synchronizing; - _publish(); - _sendAttach(replacement.sessionId); - } - - void _applyConfirmation(ConfirmationFrame frame) { - if (frame.hostId != _hostId || - (frame.sessionId != null && - !_sessions.any( - (session) => session.sessionId == frame.sessionId, - ))) { - return; - } - final expiresAt = DateTime.tryParse(frame.expiresAt); - if (expiresAt == null || !expiresAt.isAfter(DateTime.now().toUtc())) { - throw const FormatException('confirmation is expired'); - } - final sessionPending = _pendingSessionOperations.values - .where((candidate) => candidate.commandId == frame.commandId) - .firstOrNull; - if (sessionPending != null) { - if (!sessionPending.confirmationExpected || - sessionPending.confirmationSent || - frame.sessionId != sessionPending.sessionId || - frame.summary != sessionPending.command || - frame.revision != sessionPending.expectedRevision) { - throw const FormatException('confirmation correlation mismatch'); - } - sessionPending.confirmationSent = true; - final ids = _nextCommandIds('confirm'); - _send( - WireEncoder.confirm( - requestId: ids.requestId, - confirmationId: frame.confirmationId, - commandId: frame.commandId, - hostId: frame.hostId, - sessionId: frame.sessionId, - decision: 'approve', - ), - ); - _attentionConfirmations.remove(frame.confirmationId); - return; - } - - final hostPending = _pendingCommands.values - .where((candidate) => candidate.commandId == frame.commandId) - .firstOrNull; - if (hostPending != null && - (!hostPending.confirmationExpected || - hostPending.confirmationSent || - frame.sessionId != hostPending.sessionId || - frame.revision != hostPending.expectedRevision)) { - throw const FormatException('confirmation correlation mismatch'); - } - if (hostPending != null) hostPending.confirmationSent = true; - _attentionConfirmations[frame.confirmationId] = frame; - _publish(); - } - - void _applyLegacyAgentFrame(AgentFrame frame) { - if (frame.hostId != _hostId) return; - final detail = frame.detail; - String? text(String key) => switch (detail?[key]) { - final String value when value.trim().isNotEmpty => value.trim(), - _ => null, - }; - final parentAgentId = text('parentId'); - _agentActivities[frame.agentId] = AgentActivity( - agentId: frame.agentId, - sessionId: frame.sessionId, - label: text('title') ?? text('name') ?? frame.agentId, - status: frame.state, - progress: frame.progress, - updatedAt: DateTime.now().toUtc(), - parentAgentId: parentAgentId == frame.agentId ? null : parentAgentId, - description: text('description'), - model: text('model'), - currentTool: text('currentTool') ?? text('tool'), - evidence: text('evidence'), - ); - _recordActivity( - category: 'agent', - title: _agentActivities[frame.agentId]!.label, - detail: frame.state, - raw: frame.raw, - ); - _publish(); - } - - void _applyAgentFrame(AgentAdditiveFrame frame) { - if (frame.hostId != _hostId || frame.frameType == 'agent.transcript') { - return; - } - final previous = _agentActivities[frame.agentId]; - final status = - frame.lifecycle ?? - frame.state ?? - frame.event ?? - previous?.status ?? - 'active'; - final detail = frame.detail; - final label = switch (detail?['title'] ?? - detail?['label'] ?? - detail?['message']) { - final String value when value.trim().isNotEmpty => value.trim(), - _ => previous?.label ?? 'Background agent ${frame.agentId}', - }; - _agentActivities[frame.agentId] = AgentActivity( - agentId: frame.agentId, - sessionId: frame.sessionId, - label: label, - status: status, - progress: frame.progress ?? previous?.progress, - updatedAt: DateTime.now().toUtc(), - parentAgentId: previous?.parentAgentId, - description: previous?.description, - model: previous?.model, - currentTool: previous?.currentTool, - evidence: previous?.evidence, - ); - _recordActivity( - category: 'agent', - title: label, - detail: status, - raw: frame.raw, - ); - _publish(); - } - - void _applyTerminalOutput(TerminalOutputFrame frame) { - if (frame.hostId != _hostId) return; - final terminal = _terminals[frame.terminalId]; - if (terminal == null || terminal.sessionId != frame.sessionId) return; - final current = _terminalCursors[frame.terminalId]; - if (current != null && - current.epoch == frame.cursor.epoch && - frame.cursor.seq <= current.seq) { - return; - } - _terminalCursors[frame.terminalId] = frame.cursor; - final chunk = frame.encoding == 'base64' - ? utf8.decode(base64Decode(frame.data), allowMalformed: true) - : frame.data; - final combined = - '${current == null || current.epoch == frame.cursor.epoch ? terminal.output : ''}$chunk'; - final output = combined.length > 1000000 - ? combined.substring(combined.length - 1000000) - : combined; - _terminals[frame.terminalId] = TerminalSession( - terminalId: terminal.terminalId, - sessionId: terminal.sessionId, - title: terminal.title, - output: output, - running: terminal.running, - exitCode: terminal.exitCode, - signal: terminal.signal, - ); - _publish(); - } - - void _applyTerminalExit(TerminalExitFrame frame) { - if (frame.hostId != _hostId) return; - final terminal = _terminals[frame.terminalId]; - if (terminal == null || terminal.sessionId != frame.sessionId) return; - _terminalCursors[frame.terminalId] = frame.cursor; - _terminals[frame.terminalId] = TerminalSession( - terminalId: terminal.terminalId, - sessionId: terminal.sessionId, - title: terminal.title, - output: terminal.output, - running: false, - exitCode: frame.exitCode, - signal: frame.signal, - ); - _recordActivity( - category: 'shell', - title: 'Terminal exited', - detail: frame.signal ?? 'Exit code ${frame.exitCode}', - raw: frame.raw, - ); - _publish(); - } - - void _applyFilesFrame(FilesAdditiveFrame frame) { - if (frame.hostId != _hostId || frame.sessionId != _selectedSessionId) { - return; - } - _fileWorkspace = FileWorkspaceState( - path: frame.path, - entries: frame.entries == null - ? _fileWorkspace.entries - : [ - for (final entry in frame.entries!) - DeveloperFileEntry( - path: entry.path, - kind: entry.kind, - size: entry.size, - revision: entry.revision, - ), - ], - content: frame.content ?? _fileWorkspace.content, - diff: frame.diff ?? frame.patch ?? _fileWorkspace.diff, - revision: frame.revision ?? frame.toRevision ?? _fileWorkspace.revision, - ); - _recordActivity( - category: 'files', - title: frame.frameType, - detail: frame.path, - raw: frame.raw, - ); - _publish(); - } - - void _applyReviewFrame(ReviewFrame frame) { - if (frame.hostId != _hostId) return; - _reviews[frame.reviewId] = ReviewWorkspaceItem( - reviewId: frame.reviewId, - sessionId: frame.sessionId, - status: frame.status, - path: frame.path, - findings: frame.findings, - ); - _recordActivity( - category: 'review', - title: 'Review ${frame.status}', - detail: frame.path ?? frame.reviewId, - raw: frame.raw, - ); - _publish(); - } - - void _applyAuditEvents(List events) { - for (final event in events) { - _recordActivity( - id: event.eventId, - category: 'audit', - title: event.action, - detail: event.actor, - at: DateTime.tryParse(event.timestamp)?.toUtc(), - raw: event.raw, - ); - } - _publish(); - } - - void _recordAuditMap(Map raw) { - final eventId = raw['eventId']; - final action = raw['action']; - final actor = raw['actor']; - final timestamp = raw['timestamp']; - if (eventId is! String || action is! String || actor is! String) return; - _recordActivity( - id: eventId, - category: 'audit', - title: action, - detail: actor, - at: timestamp is String ? DateTime.tryParse(timestamp)?.toUtc() : null, - raw: raw, - ); - } - - void _recordActivity({ - String? id, - required String category, - required String title, - required String detail, - required Map raw, - DateTime? at, - }) { - final timestamp = at ?? DateTime.now().toUtc(); - final key = id ?? '$category:${timestamp.microsecondsSinceEpoch}'; - _activities[key] = DeveloperActivity( - id: key, - category: category, - title: title, - detail: detail, - at: timestamp, - raw: const JsonEncoder.withIndent(' ').convert(_redact(raw)), - ); - while (_activities.length > 1000) { - _activities.remove(_activities.keys.first); - } - } - - Object? _redact(Object? value) { - if (value is List) return value.map(_redact).toList(); - if (value is! Map) return value; - return { - for (final entry in value.entries) - entry.key: - RegExp( - r'token|password|secret|authorization|cookie', - caseSensitive: false, - ).hasMatch(entry.key) - ? '' - : _redact(entry.value), - }; - } - - PreviewWorkspaceState _previewResult(ResponseFrame frame, String sessionId) { - final result = frame.result; - if (result is! Map || - result['preview'] is! Map) { - throw const FormatException('preview command result is invalid'); - } - return _applyPreviewMap( - result['preview']! as Map, - sessionId, - ); - } - - PreviewWorkspaceState _applyPreviewMap( - Map raw, - String sessionId, - ) { - if (raw['previewId'] is! String || - raw['state'] is! String || - raw['url'] is! String || - raw['revision'] is! String) { - throw const FormatException('preview snapshot is invalid'); - } - final previewId = raw['previewId']! as String; - final preview = PreviewWorkspaceState( - previewId: previewId, - sessionId: sessionId, - state: raw['state']! as String, - url: raw['url']! as String, - revision: raw['revision']! as String, - title: raw['title'] is String ? raw['title']! as String : null, - canGoBack: raw['canGoBack'] == true, - canGoForward: raw['canGoForward'] == true, - capture: _previewCaptures[previewId], - captureMimeType: raw['capture'] is Map - ? (raw['capture']! as Map)['mimeType'] as String? - : null, - ); - _previews[previewId] = preview; - _activePreviewId ??= previewId; - return preview; - } - - void _applyPreviewFrame(PreviewFrame frame) { - if (frame.hostId != _hostId) return; - final snapshot = frame.snapshot; - if (snapshot != null) { - final preview = PreviewWorkspaceState( - previewId: snapshot.previewId, - sessionId: frame.sessionId, - state: snapshot.state, - url: snapshot.url, - revision: snapshot.revision, - title: snapshot.title, - canGoBack: snapshot.canGoBack ?? false, - canGoForward: snapshot.canGoForward ?? false, - capture: _previewCaptures[snapshot.previewId], - captureMimeType: snapshot.capture?['mimeType'] as String?, - ); - _previews[preview.previewId] = preview; - _activePreviewId ??= preview.previewId; - } else if (_previews[frame.previewId] case final previous?) { - _previews[frame.previewId] = PreviewWorkspaceState( - previewId: previous.previewId, - sessionId: previous.sessionId, - state: previous.state, - url: previous.url, - revision: frame.revision, - title: previous.title, - canGoBack: previous.canGoBack, - canGoForward: previous.canGoForward, - capture: previous.capture, - captureMimeType: previous.captureMimeType, - error: frame.message ?? frame.error ?? frame.code, - ); - } - _recordActivity( - category: 'preview', - title: frame.frameType, - detail: frame.message ?? frame.error ?? frame.previewId, - raw: frame.raw, - ); - _publish(); - } - - void _applySnapshot(SnapshotFrame frame) { - if (_selectedSessionId != frame.sessionId) return; - _transcriptRecoverySessionId = null; - final liveIds = frame.entries.map((entry) => entry.id).toSet(); - final firstOverlap = _pagedTranscriptEntries.indexWhere( - (entry) => liveIds.contains(entry.id), - ); - final pagedPrefix = firstOverlap < 0 - ? _pagedTranscriptEntries - : _pagedTranscriptEntries.take(firstOverlap); - final cachedEntries = [...pagedPrefix, ...frame.entries]; - _pagedTranscriptEntries = cachedEntries; - _transcriptTailFromCache = false; - _messages.clear(); - for (final entry in cachedEntries) { - _upsertEntry(entry); - } - final hostId = _hostId; - final pageGeneration = _transcriptPageGeneration; - if (hostId != null && pageGeneration != null) { - unawaited( - transcriptTailStore - .save( - hostId: hostId, - sessionId: frame.sessionId, - generation: pageGeneration, - entries: cachedEntries, - ) - .then((_) {}, onError: (_, _) {}), - ); - } - _savedCursors[frame.sessionId] = frame.cursor; - _sessions = _sessions - .map( - (session) => session.sessionId == frame.sessionId - ? SessionSummary( - hostId: session.hostId, - sessionId: session.sessionId, - title: session.title, - revision: frame.revision, - status: session.status, - projectId: session.projectId, - projectName: session.projectName, - updatedAt: session.updatedAt, - archivedAt: session.archivedAt, - working: session.working, - modelSelector: session.modelSelector, - modelDisplayName: session.modelDisplayName, - thinking: session.thinking, - thinkingSupported: session.thinkingSupported, - thinkingLevels: session.thinkingLevels, - fast: session.fast, - fastAvailable: session.fastAvailable, - turnActive: session.turnActive, - isPaused: session.isPaused, - queuedFollowUpCount: session.queuedFollowUpCount, - ) - : session, - ) - .toList(growable: false); - _phase = ConnectionPhase.ready; - _errorMessage = null; - _publish(); - } - - bool _acceptTranscriptCursor(String sessionId, TranscriptCursor next) { - final current = _savedCursors[sessionId]; - if (current == null) { - _savedCursors[sessionId] = next; - return true; - } - if (next.epoch == current.epoch && next.seq <= current.seq) return false; - if (next.epoch != current.epoch || next.seq != current.seq + 1) { - _phase = ConnectionPhase.synchronizing; - _errorMessage = 'Transcript continuity changed; waiting for a snapshot.'; - _publish(); - return false; - } - _savedCursors[sessionId] = next; - return true; - } - - void _upsertEntry(DurableEntry entry) { - final data = entry.data; - if (entry.kind == 'message') { - final text = data['text']; - if (text is! String) return; - _messages[entry.id] = TranscriptMessage( - id: entry.id, - role: _messageRole(data['role']), - text: text, - reasoning: data['reasoning'] is String - ? data['reasoning']! as String - : '', - images: _entryImages(data['images']), - ); - return; - } - if (entry.kind == 'tool-use') { - final result = data['result']; - final resultMap = result is Map ? result : null; - final output = resultMap?['output']; - final isError = resultMap?['isError']; - _messages[entry.id] = TranscriptMessage( - id: entry.id, - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: data['tool'] is String ? data['tool']! as String : 'tool', - toolTitle: data['title'] is String ? data['title']! as String : null, - toolArguments: _jsonDisplay(data['args']), - toolOutput: output is String ? output : _jsonDisplay(result), - toolSucceeded: isError is bool ? !isError : data['ok'] as bool?, - images: _entryImages(data['images']), - ); - return; - } - if (entry.kind == 'compaction') { - final summary = data['summary']; - if (summary is! String) return; - _messages[entry.id] = TranscriptMessage( - id: entry.id, - role: MessageRole.system, - kind: TranscriptKind.compaction, - text: summary, - ); - } - } - - List _entryImages(Object? value) { - if (value is! List) return const []; - final images = []; - for (final item in value) { - if (item is! Map) continue; - final sha256 = item['sha256']; - final mimeType = item['mimeType']; - if (sha256 is String && mimeType is String) { - images.add(TranscriptImageMetadata(sha256: sha256, mimeType: mimeType)); - } - } - return List.unmodifiable(images); - } - - String? _jsonDisplay(Object? value) { - if (value == null) return null; - if (value is String) return value; - try { - return const JsonEncoder.withIndent(' ').convert(value); - } on Object { - return value.toString(); - } - } - - String _eventItemId(TranscriptCursor cursor, String suffix) => - 'event-${cursor.epoch}-${cursor.seq}-$suffix'; - - void _applyEvent(Map event, TranscriptCursor cursor) { - final eventType = event['type']; - _recordActivity( - id: 'runtime:${cursor.epoch}:${cursor.seq}', - category: eventType is String && eventType.startsWith('tool.') - ? 'tool' - : 'runtime', - title: eventType is String ? eventType : 'Unknown event', - detail: - _jsonDisplay(event['message'] ?? event['title'] ?? event['note']) ?? - '', - raw: event, - ); - switch (event['type']) { - case 'message.update': - final entryId = event['entryId']; - final text = event['text']; - if (entryId is String && text is String) { - if (event['role'] == 'user') { - _messages.removeWhere( - (id, message) => id.startsWith('local-prompt:'), - ); - } - _messages[entryId] = TranscriptMessage( - id: entryId, - role: _messageRole(event['role']), - text: text, - reasoning: event['reasoning'] is String - ? event['reasoning']! as String - : '', - streaming: true, - ); - } - case 'message.settled': - final transientEntryId = event['transientEntryId']; - if (transientEntryId is String) _messages.remove(transientEntryId); - case 'message.discarded': - final transientEntryId = event['transientEntryId']; - if (transientEntryId is String) _messages.remove(transientEntryId); - case 'tool.start': - final callId = event['callId']; - if (callId is String) { - _messages['tool:$callId'] = TranscriptMessage( - id: 'tool:$callId', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: event['tool'] is String - ? event['tool']! as String - : 'tool', - toolTitle: event['title'] is String - ? event['title']! as String - : null, - toolArguments: _jsonDisplay(event['args']), - toolRunning: true, - ); - } - case 'tool.progress': - final callId = event['callId']; - final current = callId is String ? _messages['tool:$callId'] : null; - if (callId is String && current != null) { - final note = event['note']; - final chunk = event['chunk']; - final appended = [ - if (current.toolOutput case final output? when output.isNotEmpty) - output, - if (chunk is String && chunk.isNotEmpty) chunk, - ].join(); - _messages['tool:$callId'] = TranscriptMessage( - id: current.id, - role: current.role, - kind: current.kind, - text: note is String && note.isNotEmpty ? note : current.text, - toolName: current.toolName, - toolTitle: current.toolTitle, - toolArguments: current.toolArguments, - toolOutput: appended.isEmpty ? null : appended, - toolRunning: true, - toolProgress: switch (event['progress']) { - final num progress => progress.toDouble().clamp(0, 1), - _ => current.toolProgress, - }, - ); - } - case 'tool.result': - final callId = event['callId']; - final current = callId is String ? _messages['tool:$callId'] : null; - if (callId is String) { - _messages['tool:$callId'] = TranscriptMessage( - id: 'tool:$callId', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: current?.text ?? '', - toolName: current?.toolName ?? 'tool', - toolTitle: current?.toolTitle, - toolArguments: current?.toolArguments, - toolOutput: _jsonDisplay(event['result']), - toolSucceeded: event['ok'] is bool ? event['ok']! as bool : null, - ); - } - case 'turn.start': - case 'agent.start': - _submitting = true; - case 'turn.end': - case 'agent.end': - _submitting = false; - case 'turn.error': - _submitting = false; - final message = event['message']; - final text = message is String ? message : 'The agent turn failed.'; - _errorMessage = text; - _messages[_eventItemId(cursor, 'error')] = TranscriptMessage( - id: _eventItemId(cursor, 'error'), - role: MessageRole.system, - kind: TranscriptKind.notice, - text: text, - ); - case 'notice': - final message = event['message']; - if (message is String) { - _messages[_eventItemId(cursor, 'notice')] = TranscriptMessage( - id: _eventItemId(cursor, 'notice'), - role: MessageRole.system, - kind: TranscriptKind.notice, - text: message, - ); - } - } - } - - void _applyResponse(ResponseFrame frame) { - final operation = _pendingSessionOperations.remove(frame.requestId); - if (operation != null) { - if (operation.commandId != frame.commandId || - operation.command != frame.command || - operation.sessionId != frame.sessionId) { - throw const FormatException('session operation correlation mismatch'); - } - operation.completer!.complete(frame); - return; - } - final pending = _pendingCommands.remove(frame.requestId); - if (pending == null) { - throw FormatException( - 'unexpected response for ${frame.command ?? 'unknown command'}', - ); - } - if (pending.commandId != frame.commandId) { - throw const FormatException('response command ID mismatch'); - } - if (pending.command != frame.command) { - throw const FormatException('response command mismatch'); - } - if (pending.sessionId != frame.sessionId) { - throw const FormatException('response session mismatch'); - } - final completer = pending.completer; - if (completer != null) { - completer.complete(frame); - return; - } - if (pending.command == 'session.attach' && - pending.sessionId != _selectedSessionId) { - return; - } - if (!frame.ok) { - if (pending.command == 'session.state.get' && - frame.error?.code == 'session_locked') { - _publish(); - return; - } - if (frame.command == 'catalog.get' || frame.command == 'settings.read') { - final error = StateError( - frame.error?.message ?? '${frame.command} failed', - ); - _settingsRefreshTimer?.cancel(); - _settingsRefreshTimer = null; - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - error: error.message, - issues: _settingsState.issues, - ); - final refresh = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (refresh != null && !refresh.isCompleted) { - refresh.completeError(error); - } - } - _submitting = false; - _errorMessage = frame.error?.message ?? 'Command failed.'; - _publish(); - return; - } - if (frame.command == 'catalog.get') { - final result = frame.catalogResult; - if (result == null) { - throw const FormatException('catalog.get result is missing'); - } - _catalogFrame = CatalogFrame( - hostId: frame.hostId, - revision: result.revision, - items: result.items, - operations: result.operations, - raw: frame.raw, - ); - _catalogItems = List.unmodifiable(result.items); - _settingsRefreshSawCatalog = true; - _projectHostSettings(); - return; - } - if (frame.command == 'settings.read') { - final result = frame.settingsResult; - if (result == null) { - throw const FormatException('settings.read result is missing'); - } - _settingsFrame = SettingsFrame( - hostId: frame.hostId, - revision: result.revision, - settings: result.settings, - raw: frame.raw, - ); - _settingsRefreshSawSettings = true; - _projectHostSettings(); - return; - } - if (frame.command == 'session.list') { - final sessions = frame.sessionListResult; - if (sessions == null) { - throw const FormatException('session.list result is missing'); - } - _applySessionListCursor(sessions.cursor); - _applySessionRefs(sessions.sessions); - if (_grantedFeatures.contains('host.watch')) { - _sendHostWatch(sessions.cursor); - } - return; - } - if (frame.command == 'session.state.get') { - final result = frame.sessionStateResult; - if (result == null || pending.sessionId == null) { - throw const FormatException('session.state.get result is missing'); - } - _applySessionStateResult(pending.sessionId!, result); - return; - } - if (frame.command == 'session.attach' && - pending.sessionId == _selectedSessionId && - _transcriptRecoverySessionId != pending.sessionId && - (_messages.isNotEmpty || - _savedCursors.containsKey(_selectedSessionId))) { - _phase = ConnectionPhase.ready; - } - if (frame.command == 'session.attach' && - pending.sessionId == _selectedSessionId) { - _sendSessionStateGet(pending.sessionId!); - } - _publish(); - } - - void _applySessionStateResult(String sessionId, SessionStateResult result) { - _sessions = _sessions - .map((session) { - if (session.sessionId != sessionId) return session; - final model = result.model; - final modelSelector = - model?.selector ?? - (model == null ? null : '${model.provider}/${model.id}'); - return SessionSummary( - hostId: session.hostId, - sessionId: session.sessionId, - title: session.title, - revision: session.revision, - status: session.status, - projectId: session.projectId, - projectName: session.projectName, - updatedAt: session.updatedAt, - archivedAt: session.archivedAt, - working: - session.status == 'active' || - session.working || - result.isStreaming, - modelSelector: modelSelector ?? session.modelSelector, - modelDisplayName: model?.displayName ?? session.modelDisplayName, - thinking: result.thinking ?? session.thinking, - thinkingSupported: - result.thinkingSupported ?? session.thinkingSupported, - thinkingLevels: result.thinkingLevels ?? session.thinkingLevels, - fast: result.fastActive ?? result.fast ?? session.fast, - fastAvailable: result.fastAvailable ?? session.fastAvailable, - turnActive: result.isStreaming || session.turnActive, - isPaused: result.isPaused, - queuedFollowUpCount: result.queuedMessageCount, - ); - }) - .toList(growable: false); - _publish(); - } - - void _sendSessionList() { - final hostId = _hostId; - if (hostId == null || - _pendingCommands.values.any( - (pending) => pending.command == 'session.list', - )) { - return; - } - final ids = _nextCommandIds('session-list'); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: 'session.list', - ); - _send( - WireEncoder.sessionList( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - ), - ); - } - - void _sendHostWatch(SessionIndexCursor cursor) { - final hostId = _hostId; - if (hostId == null) return; - final ids = _nextCommandIds('host-watch'); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: 'host.watch', - ); - _send( - WireEncoder.hostWatch( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - cursor: cursor, - ), - ); - } - - void _resetTranscriptPage() { - _transcriptOpenGeneration += 1; - _pagedTranscriptEntries = const []; - _transcriptPageGeneration = null; - _transcriptPageCursor = null; - _transcriptPageLoading = false; - _transcriptPageHasMore = false; - _transcriptPageError = null; - _transcriptRecoverySessionId = null; - _transcriptTailFromCache = false; - _transcriptPrimeGeneration = null; - } - - bool get _transcriptPageSupported => - _grantedCapabilities.contains('sessions.read') && - _grantedFeatures.contains('transcript.page'); - - void _primeTranscriptTailThenAttach(String sessionId) { - if (_pendingCommands.values.any( - (pending) => - pending.command == 'transcript.page' && - pending.sessionId == sessionId, - )) { - return; - } - if (!_transcriptPageSupported) { - _sendAttach(sessionId); - return; - } - final connectionGeneration = _connectionGeneration; - final openGeneration = _transcriptOpenGeneration; - if (_transcriptPrimeGeneration == openGeneration) return; - final hostId = _hostId; - if (hostId == null) { - _sendAttach(sessionId); - return; - } - _transcriptPrimeGeneration = openGeneration; - unawaited( - _loadCachedThenInitialTranscriptPage( - hostId: hostId, - sessionId: sessionId, - openGeneration: openGeneration, - ).whenComplete(() { - if (_transcriptPrimeGeneration == openGeneration) { - _transcriptPrimeGeneration = null; - } - if (_disposed || - connectionGeneration != _connectionGeneration || - openGeneration != _transcriptOpenGeneration || - sessionId != _selectedSessionId || - _channel == null) { - return; - } - _sendAttach(sessionId); - }), - ); - } - - Future _loadCachedThenInitialTranscriptPage({ - required String hostId, - required String sessionId, - required int openGeneration, - }) async { - try { - final cached = await transcriptTailStore.load( - hostId: hostId, - sessionId: sessionId, - ); - if (!_transcriptOpenIsCurrent(sessionId, openGeneration)) return; - if (cached != null) { - _pagedTranscriptEntries = cached.entries; - _transcriptPageGeneration = cached.generation; - _transcriptTailFromCache = true; - _messages.clear(); - for (final entry in cached.entries) { - _upsertEntry(entry); - } - _publish(); - } - } on Object { - // A damaged or unavailable display cache never blocks the live session. - } - if (_transcriptOpenIsCurrent(sessionId, openGeneration)) { - await _loadInitialTranscriptPage(sessionId, openGeneration); - } - } - - bool _transcriptOpenIsCurrent(String sessionId, int openGeneration) => - !_disposed && - _selectedSessionId == sessionId && - _transcriptOpenGeneration == openGeneration; - - Future _loadInitialTranscriptPage( - String sessionId, - int openGeneration, - ) async { - try { - final frame = await _runSessionReadCommand( - prefix: 'transcript-page', - command: 'transcript.page', - sessionId: sessionId, - args: const {'limit': 64, 'maxBytes': 256 * 1024}, - ); - if (!_transcriptOpenIsCurrent(sessionId, openGeneration)) return; - final page = frame.transcriptPageResult; - if (page == null) { - throw const WireFormatException( - 'transcript.page returned an invalid result', - 'result', - ); - } - _validateTranscriptPage(page, sessionId); - _pagedTranscriptEntries = page.entries; - _transcriptPageGeneration = page.generation; - _transcriptPageCursor = page.nextCursor; - _transcriptPageHasMore = page.hasMore && page.nextCursor != null; - _transcriptPageError = null; - _transcriptTailFromCache = false; - _messages.clear(); - for (final entry in page.entries) { - _upsertEntry(entry); - } - _publish(); - final hostId = _hostId; - if (hostId != null) { - unawaited( - transcriptTailStore - .save( - hostId: hostId, - sessionId: sessionId, - generation: page.generation, - entries: page.entries, - ) - .then((_) {}, onError: (_, _) {}), - ); - } - } on Object { - // Bounded history is an optional read lane. Live attach remains the - // authority and must proceed when paging is unavailable or stale. - } - } - - void _validateTranscriptPage(TranscriptPageResult page, String sessionId) { - final hostId = _hostId; - if (hostId == null || - page.entries.any( - (entry) => entry.hostId != hostId || entry.sessionId != sessionId, - )) { - throw const WireFormatException( - 'transcript.page returned entries for another session', - 'result.entries', - ); - } - } - - void _sendSessionStateGet(String sessionId) { - final hostId = _hostId; - if (hostId == null || - !_grantedCapabilities.contains('sessions.read') || - _pendingCommands.values.any( - (pending) => - pending.command == 'session.state.get' && - pending.sessionId == sessionId, - )) { - return; - } - final ids = _nextCommandIds('session-state'); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: 'session.state.get', - sessionId: sessionId, - ); - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - sessionId: sessionId, - command: 'session.state.get', - args: const {}, - ), - ); - } - - void _sendAttach(String sessionId, {TranscriptCursor? cursor}) { - final session = _sessions - .where((item) => item.sessionId == sessionId) - .firstOrNull; - if (session == null || - _pendingCommands.values.any( - (pending) => - pending.command == 'session.attach' && - pending.sessionId == sessionId, - )) { - return; - } - final ids = _nextCommandIds('attach'); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: 'session.attach', - sessionId: sessionId, - ); - _send( - WireEncoder.sessionAttach( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: session.hostId, - sessionId: session.sessionId, - cursor: cursor, - ), - ); - } - - ({String requestId, String commandId}) _nextCommandIds(String prefix) { - final ordinal = ++_commandOrdinal; - return ( - requestId: '$prefix-$_commandNamespace-request-$ordinal', - commandId: '$prefix-$_commandNamespace-command-$ordinal', - ); - } - - void _send(String encoded) { - final channel = _channel; - if (channel == null) throw StateError('websocket is not connected'); - channel.sink.add(encoded); - } - - void _clearTargetProjection() { - _connectionGeneration += 1; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - unawaited(_subscription?.cancel()); - unawaited(_channel?.sink.close()); - _subscription = null; - _channel = null; - _sessions = const []; - _messages.clear(); - _resetTranscriptPage(); - _savedCursors.clear(); - _attentionBySession.clear(); - _attentionConfirmations.clear(); - _agentActivities.clear(); - _activities.clear(); - _terminals.clear(); - _terminalCursors.clear(); - _previews.clear(); - _previewCaptures.clear(); - _reviews.clear(); - _fileWorkspace = const FileWorkspaceState(); - _activeTerminalId = null; - _activePreviewId = null; - _developerOperationPending = false; - _cancelPendingCommands(StateError('active host changed')); - _pendingPair = null; - _selectedSessionId = null; - _hostId = null; - _submitting = false; - _sessionOperationPending = false; - _grantedCapabilities = const {}; - _sessionIndexEpoch = null; - _sessionIndexSeq = null; - _grantedFeatures = const {}; - _catalogItems = const []; - _clearSettingsProjection(); - _bootstrapGeneration = -1; - _reconnectAttempt = 0; - _phase = ConnectionPhase.disconnected; - _authenticationPhase = AuthenticationPhase.unknown; - } - - void _handleTransportLoss(int generation, [Object? error]) { - if (_disposed || generation != _connectionGeneration) return; - _subscription = null; - _channel = null; - _cancelPendingCommands(StateError('connection lost')); - _pendingPair = null; - _submitting = false; - _sessionOperationPending = false; - if (!_reconnectEnabled) { - _phase = ConnectionPhase.disconnected; - _authenticationPhase = AuthenticationPhase.unknown; - _errorMessage = null; - _publish(); - return; - } - _reconnectAttempt += 1; - final exponent = (_reconnectAttempt - 1).clamp(0, 4); - final delay = Duration(milliseconds: 500 * (1 << exponent)); - _phase = ConnectionPhase.retrying; - _authenticationPhase = AuthenticationPhase.unknown; - _errorMessage = error == null - ? 'Connection closed. Retrying…' - : 'Connection lost: $error'; - _publish(); - _reconnectTimer?.cancel(); - _reconnectTimer = Timer(delay, () { - if (!_disposed && - _reconnectEnabled && - generation == _connectionGeneration) { - unawaited(_connectCurrent()); - } - }); - } - - MessageRole _messageRole(Object? role) => switch (role) { - 'user' => MessageRole.user, - 'system' => MessageRole.system, - 'tool' => MessageRole.tool, - _ => MessageRole.assistant, - }; - - void _cancelPendingCommands(Object error) { - for (final pending in _pendingSessionOperations.values) { - final completer = pending.completer; - if (completer != null && !completer.isCompleted) { - completer.completeError(error); - } - } - for (final pending in _pendingCommands.values) { - final completer = pending.completer; - if (completer != null && !completer.isCompleted) { - completer.completeError(error); - } - } - _pendingSessionOperations.clear(); - _pendingCommands.clear(); - } - - void _fail(String message) { - _cancelPendingCommands(StateError(message)); - _phase = ConnectionPhase.failed; - _errorMessage = message; - _submitting = false; - _sessionOperationPending = false; - _publish(); - } - - void _publish() { - if (!_disposed) notifyListeners(); - } - - @override - void dispose() { - final settingsRefresh = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (settingsRefresh != null && !settingsRefresh.isCompleted) { - settingsRefresh.completeError(StateError('controller disposed')); - } - _disposed = true; - _reconnectEnabled = false; - _hostProbeOperation = null; - final hostProbe = _hostProbe; - _hostProbe = null; - if (hostProbe != null) unawaited(hostProbe.sink.close()); - _connectionGeneration += 1; - _hostOperationGeneration += 1; - _reconnectTimer?.cancel(); - _settingsRefreshTimer?.cancel(); - _cancelPendingCommands(StateError('controller disposed')); - unawaited(_subscription?.cancel()); - unawaited(_channel?.sink.close()); - super.dispose(); - } -} - -const Set _knownSettingItemKeys = { - 'path', - 'label', - 'description', - 'controlType', - 'options', - 'min', - 'max', - 'step', - 'unit', - 'scopes', - 'restartRequired', - 'platform', - 'availability', - 'maxItems', - 'maxEntries', - 'default', - 'effective', - 'effectiveSource', - 'configured', - 'sensitive', - 'tab', - 'group', -}; - -final Set _knownSettingValueKeys = _knownSettingItemKeys - .where( - (key) => - key != 'path' && - key != 'label' && - key != 'description' && - key != 'tab' && - key != 'group', - ) - .toSet(); - -final RegExp _settingControlCharacters = RegExp( - r'[\u0000-\u001f\u007f-\u009f]', -); - -({List entries, List issues}) _buildHostSettings( - CatalogFrame catalog, - SettingsFrame settings, -) { - final issues = []; - final rows = <({HostSettingEntry entry, String group})>[]; - final seen = {}; - for (final item in catalog.items.where((item) => item.kind == 'setting')) { - final metadata = item.metadata; - final fallbackPath = _safeSettingText(item.name, 128) ?? item.id; - if (metadata == null) { - issues.add('${item.id}: setting item carries no metadata'); - if (seen.add(fallbackPath)) { - rows.add(( - entry: _unsupportedSetting( - path: fallbackPath, - section: 'advanced', - label: _humanizeSettingPath(fallbackPath), - help: '', - ), - group: '', - )); - } - continue; - } - final path = - _safeSettingText(metadata['path'], 128) ?? - _safeSettingText(item.name, 128); - if (path == null || path.startsWith('/') || path.startsWith('~')) { - issues.add('${item.id}: setting item has no usable path'); - continue; - } - if (!seen.add(path)) { - issues.add('$path: duplicate setting path ignored'); - continue; - } - final section = _safeSettingText(metadata['tab'], 64) ?? 'advanced'; - final group = _safeSettingText(metadata['group'], 64) ?? ''; - final hostLabel = _safeSettingText(metadata['label'], 200); - final label = hostLabel != null && hostLabel != path - ? hostLabel - : _humanizeSettingPath(path); - final help = _safeSettingText(metadata['description'], 2000) ?? ''; - final unknown = metadata.keys - .where((key) => !_knownSettingItemKeys.contains(key)) - .toList(growable: false); - if (unknown.isNotEmpty) { - issues.add('$path: unrecognized metadata (${unknown.join(', ')})'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - ), - group: group, - )); - continue; - } - - final rawValues = settings.settings[path]; - final values = rawValues is Map ? rawValues : null; - if (rawValues != null && values == null) { - issues.add('$path: value metadata is not a record'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - ), - group: group, - )); - continue; - } - final unknownValues = values?.keys - .where((key) => !_knownSettingValueKeys.contains(key)) - .toList(growable: false); - if (unknownValues != null && unknownValues.isNotEmpty) { - issues.add( - '$path: unrecognized value metadata (${unknownValues.join(', ')})', - ); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - ), - group: group, - )); - continue; - } - - final source = values ?? metadata; - final sensitive = - metadata['sensitive'] == true || values?['sensitive'] == true; - final configured = - metadata['configured'] == true || values?['configured'] == true; - final restartRequired = metadata['restartRequired'] == true; - final available = metadata['availability'] != false; - if (sensitive) { - final delivered = - metadata.containsKey('default') || - metadata.containsKey('effective') || - (values?.containsKey('default') ?? false) || - (values?.containsKey('effective') ?? false); - if (delivered) { - issues.add('$path: sensitive setting arrived with a value'); - } - rows.add(( - entry: HostSettingEntry( - path: path, - section: section, - label: label, - help: help, - control: delivered - ? HostSettingControlKind.unsupported - : HostSettingControlKind.secret, - configured: configured, - options: const [], - writableScopes: const [], - restartRequired: restartRequired, - available: available, - sensitive: true, - ), - group: group, - )); - continue; - } - - final combined = {...metadata, ...?values}; - final controlProjection = _settingControl(combined); - if (controlProjection.issue case final issue?) { - issues.add('$path: $issue'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - configured: configured, - restartRequired: restartRequired, - available: available, - ), - group: group, - )); - continue; - } - if (source.containsKey('default') && - _copySafeSettingValue(source['default']) == null) { - issues.add('$path: default value has a shape this app cannot edit'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - configured: configured, - restartRequired: restartRequired, - available: available, - ), - group: group, - )); - continue; - } - final hasEffective = source.containsKey('effective'); - final effective = hasEffective - ? _copySafeSettingValue(source['effective']) - : null; - if (hasEffective && effective == null) { - issues.add('$path: effective value has a shape this app cannot edit'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - configured: configured, - restartRequired: restartRequired, - available: available, - ), - group: group, - )); - continue; - } - final effectiveSource = source['effectiveSource']; - if (effective != null && - (effectiveSource is! String || - !const { - 'override', - 'configOverlay', - 'project', - 'global', - 'default', - }.contains(effectiveSource))) { - issues.add('$path: unrecognized effective source $effectiveSource'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - configured: configured, - restartRequired: restartRequired, - available: available, - ), - group: group, - )); - continue; - } - final scopeProjection = _settingScopes(metadata['scopes']); - if (scopeProjection.issue case final issue?) { - issues.add('$path: $issue'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - configured: configured, - restartRequired: restartRequired, - available: available, - ), - group: group, - )); - continue; - } - rows.add(( - entry: HostSettingEntry( - path: path, - section: section, - label: label, - help: help, - control: controlProjection.kind, - effectiveValue: effective, - configured: configured, - effectiveSource: effective == null ? null : effectiveSource as String, - options: controlProjection.options, - min: controlProjection.min, - max: controlProjection.max, - unit: controlProjection.unit, - writableScopes: available ? scopeProjection.scopes : const [], - restartRequired: restartRequired, - available: available, - sensitive: false, - ), - group: group, - )); - } - rows.sort((left, right) { - final section = left.entry.section.compareTo(right.entry.section); - if (section != 0) return section; - final group = left.group.compareTo(right.group); - if (group != 0) return group; - return left.entry.path.compareTo(right.entry.path); - }); - return ( - entries: List.unmodifiable(rows.map((row) => row.entry)), - issues: List.unmodifiable(issues), - ); -} - -HostSettingEntry _unsupportedSetting({ - required String path, - required String section, - required String label, - required String help, - bool configured = false, - bool restartRequired = false, - bool available = true, -}) => HostSettingEntry( - path: path, - section: section, - label: label, - help: help, - control: HostSettingControlKind.unsupported, - configured: configured, - options: const [], - writableScopes: const [], - restartRequired: restartRequired, - available: available, - sensitive: false, -); - -({ - HostSettingControlKind kind, - List options, - num? min, - num? max, - String? unit, - String? issue, -}) -_settingControl(Map metadata) { - final declared = metadata['controlType']; - switch (declared) { - case 'boolean': - return ( - kind: HostSettingControlKind.boolean, - options: const [], - min: null, - max: null, - unit: null, - issue: null, - ); - case 'number': - final min = _finiteSettingNumber(metadata['min']); - final max = _finiteSettingNumber(metadata['max']); - if ((metadata['min'] != null && min == null) || - (metadata['max'] != null && max == null)) { - return _unsupportedControl('number bounds are malformed'); - } - return ( - kind: HostSettingControlKind.number, - options: const [], - min: min, - max: max, - unit: _safeSettingText(metadata['unit'], 16), - issue: null, - ); - case 'string': - return ( - kind: HostSettingControlKind.text, - options: const [], - min: null, - max: null, - unit: null, - issue: null, - ); - case 'enum': - final options = _settingOptions(metadata['options']); - if (options == null) { - return _unsupportedControl('enum options are malformed'); - } - return ( - kind: HostSettingControlKind.enumeration, - options: options, - min: null, - max: null, - unit: null, - issue: null, - ); - case 'array': - return ( - kind: HostSettingControlKind.list, - options: const [], - min: null, - max: null, - unit: null, - issue: null, - ); - case 'record': - return ( - kind: HostSettingControlKind.map, - options: const [], - min: null, - max: null, - unit: null, - issue: null, - ); - default: - return _unsupportedControl( - declared == null - ? 'setting control is missing' - : 'unrecognized setting control $declared', - ); - } -} - -({ - HostSettingControlKind kind, - List options, - num? min, - num? max, - String? unit, - String? issue, -}) -_unsupportedControl(String issue) => ( - kind: HostSettingControlKind.unsupported, - options: const [], - min: null, - max: null, - unit: null, - issue: issue, -); - -List? _settingOptions(Object? raw) { - if (raw is! List || raw.isEmpty) return null; - final options = []; - for (final option in raw) { - if (option is String) { - final value = _safeSettingText(option, 200); - if (value == null) return null; - options.add(HostSettingOption(value: value, label: value)); - continue; - } - if (option is! Map) return null; - final rawValue = option['value']; - final value = - _safeSettingText(rawValue, 200) ?? - (rawValue is num && _finiteSettingNumber(rawValue) != null - ? rawValue.toString() - : null); - if (value == null) return null; - options.add( - HostSettingOption( - value: value, - label: _safeSettingText(option['label'], 200) ?? value, - help: _safeSettingText(option['description'], 2000), - ), - ); - } - return List.unmodifiable(options); -} - -({List scopes, String? issue}) _settingScopes(Object? raw) { - if (raw == null) { - return (scopes: const ['global'], issue: null); - } - if (raw is! List || raw.any((scope) => scope is! String)) { - return (scopes: const [], issue: 'writable scopes are malformed'); - } - final scopes = raw - .cast() - .where((scope) => scope == 'global' || scope == 'session') - .toSet() - .toList(growable: false); - return ( - scopes: scopes.isEmpty - ? const ['global'] - : List.unmodifiable(scopes), - issue: null, - ); -} - -String? _safeSettingText(Object? value, int maximumLength) { - if (value is! String || - value.isEmpty || - value.length > maximumLength || - _settingControlCharacters.hasMatch(value)) { - return null; - } - return value; -} - -num? _finiteSettingNumber(Object? value) { - if (value is int) return value; - if (value is double && value.isFinite) return value; - return null; -} - -Object? _copySafeSettingValue(Object? value) { - if (value is bool) return value; - if (value is num) return _finiteSettingNumber(value); - if (value is String) { - if (value.isEmpty) return value; - return _safeSettingText(value, 4096); - } - if (value is List) { - final result = []; - for (final item in value) { - if (item is! String || _settingControlCharacters.hasMatch(item)) { - return null; - } - result.add(item); - } - return List.unmodifiable(result); - } - if (value is Map) { - final result = {}; - for (final entry in value.entries) { - if (entry.value is! String || - _settingControlCharacters.hasMatch(entry.key) || - _settingControlCharacters.hasMatch(entry.value! as String)) { - return null; - } - result[entry.key] = entry.value! as String; - } - return Map.unmodifiable(result); - } - return null; -} - -Object? _copySettingValue(Object? value) => _copySafeSettingValue(value); - -bool _settingValueMatches(HostSettingEntry entry, Object? value) { - final safe = _copySafeSettingValue(value); - if (safe == null) return false; - return switch (entry.control) { - HostSettingControlKind.boolean => safe is bool, - HostSettingControlKind.number => - safe is num && - (entry.min == null || safe >= entry.min!) && - (entry.max == null || safe <= entry.max!), - HostSettingControlKind.text => safe is String, - HostSettingControlKind.enumeration => - safe is String && entry.options.any((option) => option.value == safe), - HostSettingControlKind.list => safe is List, - HostSettingControlKind.map => safe is Map, - HostSettingControlKind.secret || - HostSettingControlKind.unsupported => false, - }; -} - -String _humanizeSettingPath(String path) => path - .split('.') - .map( - (segment) => segment - .replaceAllMapped( - RegExp(r'([a-z0-9])([A-Z])'), - (match) => '${match[1]} ${match[2]}', - ) - .split(RegExp(r'[-_\s]+')) - .where((word) => word.isNotEmpty) - .map( - (word) => - '${word.substring(0, 1).toUpperCase()}${word.substring(1)}', - ) - .join(' '), - ) - .join(' · '); - -String _safeProjectSearchPath(String value) { - final hasControl = value.runes.any((rune) => rune <= 0x1f || rune == 0x7f); - if (value.isEmpty || - utf8.encode(value).length > 4096 || - hasControl || - value.contains(r'\') || - value.startsWith('/') || - RegExp(r'^[A-Za-z]:').hasMatch(value) || - value.startsWith('~')) { - throw const FormatException( - 'files.search path must be a safe relative POSIX path', - ); - } - final parts = value.split('/'); - if (parts.any((part) => part.isEmpty || part == '.' || part == '..')) { - throw const FormatException('files.search path contains an unsafe segment'); - } - return value; -} - -final class _PendingCommand { - _PendingCommand({ - required this.commandId, - required this.command, - this.sessionId, - this.completer, - this.expectedRevision, - this.confirmationExpected = false, - }); - - final String commandId; - final String command; - final String? sessionId; - final Completer? completer; - final String? expectedRevision; - final bool confirmationExpected; - bool confirmationSent = false; -} - -final class _PendingPair { - const _PendingPair({ - required this.requestId, - required this.endpointKey, - required this.deviceId, - required this.deviceName, - required this.platform, - required this.requestedCapabilities, - }); - - final String requestId; - final String endpointKey; - final String deviceId; - final String deviceName; - final String platform; - final List requestedCapabilities; -} - -final class _SessionAttention { - const _SessionAttention({ - this.items = const [], - this.omittedCount = 0, - this.truncated = false, - this.malformed = false, - }); - - final List items; - final int omittedCount; - final bool truncated; - final bool malformed; -} diff --git a/apps/flutter/lib/src/client/transcript_tail_store.dart b/apps/flutter/lib/src/client/transcript_tail_store.dart deleted file mode 100644 index f6f42b5b..00000000 --- a/apps/flutter/lib/src/client/transcript_tail_store.dart +++ /dev/null @@ -1,239 +0,0 @@ -import 'dart:convert'; - -import 'package:crypto/crypto.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../protocol/protocol.dart'; - -const String transcriptTailStorageKey = - 't4-code:secure-transcript-tail:index:v1'; -const String _recordKeyPrefix = 't4-code:secure-transcript-tail:record:v1:'; -const int _maxCachedSessions = 8; -const int _maxCachedEntries = 64; -const int _maxCachedTailBytes = 128 * 1024; - -final class CachedTranscriptTail { - const CachedTranscriptTail({required this.entries, required this.generation}); - - final List entries; - final String generation; -} - -abstract interface class TranscriptTailStore { - Future load({ - required String hostId, - required String sessionId, - }); - - Future save({ - required String hostId, - required String sessionId, - required String generation, - required List entries, - }); -} - -abstract interface class TranscriptTailStorage { - Future getString(String key); - - Future setString(String key, String value); - - Future delete(String key); -} - -final class InMemoryTranscriptTailStore implements TranscriptTailStore { - final Map _tails = - {}; - - @override - Future load({ - required String hostId, - required String sessionId, - }) async => _tails[_cacheId(hostId, sessionId)]; - - @override - Future save({ - required String hostId, - required String sessionId, - required String generation, - required List entries, - }) async { - _tails[_cacheId(hostId, sessionId)] = CachedTranscriptTail( - entries: List.unmodifiable(entries), - generation: generation, - ); - } -} - -/// A bounded, encrypted display cache for recently opened transcripts. -/// -/// Each session is stored separately so opening one conversation does not read -/// every cached transcript. Live and backward-page cursors are never stored; -/// the host remains authoritative after every reconnect. -final class PersistentTranscriptTailStore implements TranscriptTailStore { - PersistentTranscriptTailStore({TranscriptTailStorage? storage}) - : _storage = storage ?? _SecureTranscriptTailStorage(); - - final TranscriptTailStorage _storage; - Future _writes = Future.value(); - - @override - Future load({ - required String hostId, - required String sessionId, - }) async { - await _writes; - final encoded = await _storage.getString(_recordKey(hostId, sessionId)); - if (encoded == null) return null; - try { - final decoded = jsonDecode(encoded); - if (decoded is! Map || - decoded['version'] != 1 || - decoded['hostId'] != hostId || - decoded['sessionId'] != sessionId) { - return null; - } - final result = WireDecoder.decodeTranscriptPageResult({ - 'entries': decoded['entries'], - 'hasMore': false, - 'generation': decoded['generation'], - }); - if (result.entries.any( - (entry) => entry.hostId != hostId || entry.sessionId != sessionId, - )) { - return null; - } - return CachedTranscriptTail( - entries: result.entries, - generation: result.generation, - ); - } on Object { - return null; - } - } - - @override - Future save({ - required String hostId, - required String sessionId, - required String generation, - required List entries, - }) { - final operation = _writes.then( - (_) => _saveNow( - hostId: hostId, - sessionId: sessionId, - generation: generation, - entries: entries, - ), - ); - _writes = operation.then((_) {}, onError: (_, _) {}); - return operation; - } - - Future _saveNow({ - required String hostId, - required String sessionId, - required String generation, - required List entries, - }) async { - final bounded = entries.length <= _maxCachedEntries - ? entries.toList(growable: true) - : entries.sublist(entries.length - _maxCachedEntries); - while (bounded.isNotEmpty && - utf8 - .encode(jsonEncode(bounded.map((entry) => entry.raw).toList())) - .length > - _maxCachedTailBytes) { - bounded.removeAt(0); - } - - final key = _recordKey(hostId, sessionId); - final index = await _readIndex(); - index.removeWhere((record) => record.key == key); - if (bounded.isEmpty) { - await _storage.delete(key); - } else { - final updatedAt = DateTime.now().toUtc().toIso8601String(); - await _storage.setString( - key, - jsonEncode({ - 'version': 1, - 'hostId': hostId, - 'sessionId': sessionId, - 'generation': generation, - 'entries': bounded.map((entry) => entry.raw).toList(growable: false), - }), - ); - index.add((key: key, updatedAt: updatedAt)); - } - - index.sort((left, right) => right.updatedAt.compareTo(left.updatedAt)); - if (index.length > _maxCachedSessions) { - final removed = index.sublist(_maxCachedSessions); - index.removeRange(_maxCachedSessions, index.length); - for (final record in removed) { - await _storage.delete(record.key); - } - } - await _storage.setString( - transcriptTailStorageKey, - jsonEncode({ - 'version': 1, - 'records': [ - for (final record in index) - {'key': record.key, 'updatedAt': record.updatedAt}, - ], - }), - ); - } - - Future> _readIndex() async { - final encoded = await _storage.getString(transcriptTailStorageKey); - if (encoded == null) return <({String key, String updatedAt})>[]; - try { - final decoded = jsonDecode(encoded); - if (decoded is! Map || decoded['version'] != 1) { - return <({String key, String updatedAt})>[]; - } - final values = decoded['records']; - if (values is! List) { - return <({String key, String updatedAt})>[]; - } - return <({String key, String updatedAt})>[ - for (final value in values.take(_maxCachedSessions)) - if (value is Map && - value['key'] is String && - (value['key']! as String).startsWith(_recordKeyPrefix) && - value['updatedAt'] is String) - ( - key: value['key']! as String, - updatedAt: value['updatedAt']! as String, - ), - ]; - } on Object { - return <({String key, String updatedAt})>[]; - } - } -} - -String _cacheId(String hostId, String sessionId) => '$hostId\u0000$sessionId'; - -String _recordKey(String hostId, String sessionId) => - '$_recordKeyPrefix${sha256.convert(utf8.encode(_cacheId(hostId, sessionId)))}'; - -final class _SecureTranscriptTailStorage implements TranscriptTailStorage { - _SecureTranscriptTailStorage() : _storage = const FlutterSecureStorage(); - - final FlutterSecureStorage _storage; - - @override - Future getString(String key) => _storage.read(key: key); - - @override - Future setString(String key, String value) => - _storage.write(key: key, value: value); - - @override - Future delete(String key) => _storage.delete(key: key); -} diff --git a/apps/flutter/lib/src/client/web_socket_connector.dart b/apps/flutter/lib/src/client/web_socket_connector.dart deleted file mode 100644 index 9424879c..00000000 --- a/apps/flutter/lib/src/client/web_socket_connector.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:web_socket_channel/web_socket_channel.dart'; - -import 'web_socket_connector_stub.dart' - if (dart.library.io) 'web_socket_connector_io.dart' - if (dart.library.html) 'web_socket_connector_web.dart' - as platform; - -typedef WebSocketConnector = Future Function(Uri endpoint); - -Future connectPlatformWebSocket(Uri endpoint) => - platform.connectPlatformWebSocket(endpoint); diff --git a/apps/flutter/lib/src/client/web_socket_connector_io.dart b/apps/flutter/lib/src/client/web_socket_connector_io.dart deleted file mode 100644 index 95e071f0..00000000 --- a/apps/flutter/lib/src/client/web_socket_connector_io.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:web_socket_channel/io.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -Future connectPlatformWebSocket(Uri endpoint) async => - IOWebSocketChannel.connect( - endpoint, - headers: const {'Origin': 'https://localhost'}, - ); diff --git a/apps/flutter/lib/src/client/web_socket_connector_stub.dart b/apps/flutter/lib/src/client/web_socket_connector_stub.dart deleted file mode 100644 index 91c5a922..00000000 --- a/apps/flutter/lib/src/client/web_socket_connector_stub.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:web_socket_channel/web_socket_channel.dart'; - -Future connectPlatformWebSocket(Uri endpoint) => - Future.error( - UnsupportedError('WebSocket transport is unavailable on this platform.'), - ); diff --git a/apps/flutter/lib/src/client/web_socket_connector_web.dart b/apps/flutter/lib/src/client/web_socket_connector_web.dart deleted file mode 100644 index d938204b..00000000 --- a/apps/flutter/lib/src/client/web_socket_connector_web.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:web_socket_channel/html.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -Future connectPlatformWebSocket(Uri endpoint) async => - HtmlWebSocketChannel.connect(endpoint); diff --git a/apps/flutter/lib/src/demo/demo_app.dart b/apps/flutter/lib/src/demo/demo_app.dart deleted file mode 100644 index 20055f70..00000000 --- a/apps/flutter/lib/src/demo/demo_app.dart +++ /dev/null @@ -1,561 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter/widgets.dart'; - -import '../client/app_state.dart'; -import '../host/host_profile.dart'; -import '../protocol/models.dart'; -import '../ui/t4_app.dart'; - -/// Read-only public preview of the canonical Flutter client. -/// -/// The demo deliberately uses local display data and never opens a network -/// connection or stores credentials. -final class T4DemoApp extends StatelessWidget { - const T4DemoApp({super.key}); - - static const T4Actions _actions = _DemoActions(); - - @override - Widget build(BuildContext context) => T4App( - state: demoViewState, - actions: _actions, - credentialsAreVolatile: false, - demoMode: true, - ); -} - -final HostProfile _demoProfile = HostProfile.parseTailnetAddress( - 'https://demo.t4code.ts.net', -); - -/// Fixed timestamp for all demo usage data: 2026-07-21T08:00:00Z. -const int _demoGeneratedAtMs = 1784620800000; - -final T4ViewState demoViewState = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(_demoProfile), - authenticationPhase: AuthenticationPhase.paired, - targetConfigured: true, - grantedCapabilities: t4RequestedCapabilities.toSet(), - grantedFeatures: t4RequestedFeatures.toSet(), - selectedSessionId: 'sess-settings', - sessions: const [ - SessionSummary( - hostId: 'demo-host', - sessionId: 'sess-settings', - projectId: 'project-t4', - projectName: 'T4 Code', - title: 'Fix quick-open stale results', - revision: 'demo-revision-3', - status: 'idle', - updatedAt: '2026-07-21T08:00:00Z', - modelSelector: 'openai-codex/gpt-5.6-sol', - modelDisplayName: 'GPT-5.6 Sol', - thinking: 'high', - thinkingSupported: true, - thinkingLevels: ['off', 'medium', 'high'], - fastAvailable: true, - ), - SessionSummary( - hostId: 'demo-host', - sessionId: 'sess-runtime', - projectId: 'project-t4', - projectName: 'T4 Code', - title: 'Flutter runtime integration', - revision: 'demo-revision-2', - status: 'idle', - updatedAt: '2026-07-21T07:40:00Z', - ), - SessionSummary( - hostId: 'demo-host', - sessionId: 'sess-release', - projectId: 'project-t4', - projectName: 'T4 Code', - title: 'Release readiness', - revision: 'demo-revision-1', - status: 'idle', - updatedAt: '2026-07-21T07:10:00Z', - ), - SessionSummary( - hostId: 'demo-host', - sessionId: 'sess-omp-advisor', - projectId: 'project-omp', - projectName: 'omp', - title: 'Advisor routing selectors', - revision: 'demo-revision-8', - status: 'running widget tests', - updatedAt: '2026-07-21T07:58:00Z', - working: true, - modelSelector: 'openai-codex/gpt-5.6-sol', - modelDisplayName: 'GPT-5.6 Sol', - ), - SessionSummary( - hostId: 'demo-host', - sessionId: 'sess-omp-usage', - projectId: 'project-omp', - projectName: 'omp', - title: 'Usage meter polish', - revision: 'demo-revision-5', - status: 'closed', - updatedAt: '2026-07-18T16:45:00Z', - archivedAt: '2026-07-18T16:45:00Z', - ), - ], - messages: const [ - TranscriptMessage( - id: 'demo-message-1', - role: MessageRole.user, - text: - 'Quick open keeps showing results from the previous session after I ' - 'switch projects. Can you fix it?', - ), - TranscriptMessage( - id: 'demo-message-2', - role: MessageRole.assistant, - reasoning: - 'Reproduced the report: open quick open in session A, switch to ' - 'session B, reopen the palette — the first frame still lists paths ' - 'from A. The result cache in quick_open.dart is keyed by query text ' - 'only, so it survives session switches. Either the cache needs the ' - 'session id in its key, or it should be dropped when the selected ' - 'session changes. Scoping the key is smaller and keeps warm results ' - 'per session.', - text: - 'Reproduced it. The palette caches results in `_resultCache`, keyed ' - 'by the query string alone, so switching sessions replays stale ' - 'paths from the previous workspace. Let me confirm the shape of the ' - 'cache before proposing a fix.', - ), - TranscriptMessage( - id: 'demo-message-3', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: 'files.read', - toolTitle: 'Read lib/src/quick_open.dart', - toolArguments: '{"path": "lib/src/quick_open.dart"}', - toolOutput: - 'final Map> _resultCache =\n' - ' >{};\n' - '\n' - 'Future> search(String query) async {\n' - ' final cached = _resultCache[query];\n' - ' if (cached != null) return cached;\n' - ' ...\n' - '}', - toolSucceeded: true, - ), - TranscriptMessage( - id: 'demo-message-4', - role: MessageRole.assistant, - text: - 'Confirmed — the cache never sees the session change. Two viable ' - 'fixes:\n' - '\n' - '| Approach | Blast radius | Warm-cache behavior |\n' - '| --- | --- | --- |\n' - '| Scope keys by `(sessionId, query)` | One file | Keeps per-session warm results |\n' - '| Clear cache on `selectSession` | Two files | Cold palette after every switch |\n' - '\n' - '> Scoping the key is the smaller change and preserves warm results ' - 'when you switch back to a session.\n' - '\n' - 'Checking for other readers of `_resultCache` before touching it.', - ), - TranscriptMessage( - id: 'demo-message-5', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: 'terminal.run', - toolTitle: 'grep -rn _resultCache lib/', - toolArguments: '{"command": "grep -rn _resultCache lib/"}', - toolOutput: - 'lib/src/quick_open.dart:18:final Map> _resultCache =\n' - 'lib/src/quick_open.dart:22: final cached = _resultCache[query];\n' - 'lib/src/quick_open.dart:31: _resultCache[query] = paths;', - toolSucceeded: true, - ), - TranscriptMessage( - id: 'demo-message-6', - role: MessageRole.assistant, - text: - 'Only quick_open.dart touches the cache, so the scoped key is safe. ' - 'Proposed change:\n' - '\n' - '```dart\n' - 'final Map<(String, String), List> _resultCache =\n' - ' <(String, String), List>{};\n' - '\n' - 'Future> search(String sessionId, String query) async {\n' - ' final key = (sessionId, query);\n' - ' final cached = _resultCache[key];\n' - ' if (cached != null) return cached;\n' - ' final paths = await _actions.searchProjectFiles(query);\n' - ' _resultCache[key] = paths.paths;\n' - ' return paths.paths;\n' - '}\n' - '```\n' - '\n' - 'The palette already knows the selected session, so the callsite ' - 'change is one line.', - ), - TranscriptMessage( - id: 'demo-message-7', - role: MessageRole.user, - text: - 'Go with the session-scoped cache. Run the tests before you wrap up.', - ), - TranscriptMessage( - id: 'demo-message-8', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: 'terminal.run', - toolTitle: 'flutter test test/quick_open_test.dart', - toolArguments: '{"command": "flutter test test/quick_open_test.dart"}', - toolOutput: - '00:03 +11: quick open scopes cached results per session\n' - '00:04 +12: All tests passed!', - toolSucceeded: true, - ), - TranscriptMessage( - id: 'demo-message-9', - role: MessageRole.assistant, - text: - 'Applied and verified. To reproduce the check locally:\n' - '\n' - '```bash\n' - 'cd apps/flutter\n' - 'flutter analyze\n' - 'flutter test test/quick_open_test.dart\n' - '```\n' - '\n' - 'Quick open now keys its cache by `(sessionId, query)`, so switching ' - 'projects can no longer surface another workspace\u2019s paths.', - ), - TranscriptMessage( - id: 'demo-message-10', - role: MessageRole.system, - kind: TranscriptKind.notice, - text: - 'Turn complete \u00b7 2 files changed \u00b7 12 tests passed \u00b7 ' - 'quick-open results are now scoped per session.', - ), - ], - attentionItems: [ - AttentionItem( - key: 'demo-attention-approval', - kind: AttentionKind.approval, - sessionId: 'sess-omp-advisor', - sessionTitle: 'Advisor routing selectors', - revision: 'demo-revision-8', - title: 'Run pnpm test?', - summary: - 'The agent wants to run `pnpm test` in packages/advisor before ' - 'committing the selector change.', - at: DateTime.utc(2026, 7, 21, 7, 58), - choices: const [ - AttentionChoice(id: 'allow', label: 'Allow'), - AttentionChoice(id: 'deny', label: 'Deny'), - ], - actionable: true, - ), - AttentionItem( - key: 'demo-attention-completed', - kind: AttentionKind.completed, - sessionId: 'sess-settings', - sessionTitle: 'Fix quick-open stale results', - revision: 'demo-revision-3', - title: 'Quick-open fix landed', - summary: - 'Session-scoped result cache applied; analyzer clean and 12 tests ' - 'passing.', - at: DateTime.utc(2026, 7, 21, 8), - ), - ], - agentActivities: [ - AgentActivity( - agentId: 'demo-agent-tests', - sessionId: 'sess-omp-advisor', - label: 'Widget test sweep', - status: 'running', - updatedAt: DateTime.utc(2026, 7, 21, 7, 59), - progress: 0.62, - description: 'Running the advisor selector widget tests before commit.', - model: 'GPT-5.6 Sol', - currentTool: 'terminal.run', - ), - ], - fileWorkspace: FileWorkspaceState( - path: 'lib/src/quick_open.dart', - entries: const [ - DeveloperFileEntry(path: 'lib', kind: 'dir'), - DeveloperFileEntry(path: 'test', kind: 'dir'), - DeveloperFileEntry(path: 'README.md', kind: 'file', size: 412), - DeveloperFileEntry(path: 'CHANGELOG.md', kind: 'file', size: 268), - DeveloperFileEntry(path: 'pubspec.yaml', kind: 'file', size: 301), - DeveloperFileEntry(path: 'analysis_options.yaml', kind: 'file', size: 88), - ], - content: _demoWorkspaceFiles['lib/src/quick_open.dart'], - diff: _demoQuickOpenDiff, - revision: 'demo-revision-3', - ), - reviews: const [ - ReviewWorkspaceItem( - reviewId: 'demo-review-1', - sessionId: 'sess-settings', - status: 'completed', - path: 'lib/src/quick_open.dart', - findings: >[ - { - 'path': 'lib/src/quick_open.dart', - 'severity': 'info', - 'message': - 'Result cache is now keyed by (sessionId, query); no cross-' - 'session reuse remains.', - }, - ], - ), - ], - composer: const SessionComposerState( - modelLabel: 'GPT-5.6 Sol', - modelSelector: 'openai-codex/gpt-5.6-sol', - modelChoices: [ - ComposerModelChoice( - label: 'GPT-5.6 Sol', - selector: 'openai-codex/gpt-5.6-sol', - provider: 'openai-codex', - providerLabel: 'OpenAI Codex', - ), - ], - thinking: 'high', - thinkingLevels: ['off', 'medium', 'high'], - fastAvailable: true, - ), - themePreference: T4ThemePreference.system, -); - -/// Small static workspace used by quick open, file search, and file reads. -const Map _demoWorkspaceFiles = { - 'README.md': - '# demo workspace\n' - '\n' - 'Sample project served by the T4 Code public preview. Everything here ' - 'is static display data; no command leaves the browser.\n' - '\n' - '- `lib/main.dart` — app entrypoint\n' - '- `lib/src/quick_open.dart` — palette + session-scoped result cache\n' - '- `test/quick_open_test.dart` — regression coverage for the cache\n', - 'CHANGELOG.md': - '## Unreleased\n' - '\n' - '- Quick open: scope cached results per session.\n' - '- Usage pane: show provider limit windows with reset times.\n', - 'pubspec.yaml': - 'name: demo_workspace\n' - 'description: Sample workspace for the T4 Code public preview.\n' - 'environment:\n' - " sdk: '>=3.8.0 <4.0.0'\n" - 'dependencies:\n' - ' flutter:\n' - ' sdk: flutter\n', - 'analysis_options.yaml': - 'include: package:flutter_lints/flutter.yaml\n' - 'linter:\n' - ' rules:\n' - ' - prefer_const_constructors\n', - 'lib/main.dart': - "import 'package:flutter/widgets.dart';\n" - "import 'src/quick_open.dart';\n" - '\n' - 'void main() => runApp(const DemoWorkspaceApp());\n', - 'lib/src/quick_open.dart': - 'final Map<(String, String), List> _resultCache =\n' - ' <(String, String), List>{};\n' - '\n' - 'Future> search(String sessionId, String query) async {\n' - ' final key = (sessionId, query);\n' - ' return _resultCache[key] ??= await _lookup(query);\n' - '}\n', - 'lib/src/session_scope.dart': - '/// Identifies the session a palette query belongs to.\n' - 'final class SessionScope {\n' - ' const SessionScope(this.sessionId);\n' - ' final String sessionId;\n' - '}\n', - 'test/quick_open_test.dart': - "import 'package:flutter_test/flutter_test.dart';\n" - '\n' - 'void main() {\n' - " test('quick open scopes cached results per session', () {\n" - ' // Regression: switching sessions must not replay stale paths.\n' - ' });\n' - '}\n', -}; - -const String _demoQuickOpenDiff = - '--- a/lib/src/quick_open.dart\n' - '+++ b/lib/src/quick_open.dart\n' - '@@ -15,10 +15,11 @@\n' - '-final Map> _resultCache =\n' - '- >{};\n' - '+final Map<(String, String), List> _resultCache =\n' - '+ <(String, String), List>{};\n' - ' \n' - '-Future> search(String query) async {\n' - '- final cached = _resultCache[query];\n' - '+Future> search(String sessionId, String query) async {\n' - '+ final key = (sessionId, query);\n' - '+ final cached = _resultCache[key];\n' - ' if (cached != null) return cached;\n'; - -/// Safe action sink for the public preview. Interactive controls render as the -/// real client does, but no command leaves the browser. -final class _DemoActions implements T4Actions { - const _DemoActions(); - - @override - Future refreshSettings() async {} - - @override - Future setThemePreference(T4ThemePreference preference) async {} - - @override - Future selectSession(String sessionId) async {} - - @override - Future submitPrompt( - String message, { - List images = const [], - }) async => false; - - @override - Future queuePrompt(String message) async => false; - - @override - Future respondToAttention( - AttentionItem item, - AttentionResponse response, - ) async => false; - - @override - Future readTranscriptImage( - String entryId, - TranscriptImageMetadata image, - ) async => Uint8List(0); - - @override - Future readUsage() async => const UsageReadResult( - generatedAt: _demoGeneratedAtMs, - reports: [ - UsageReport( - provider: 'openai-codex', - fetchedAt: _demoGeneratedAtMs, - limits: [ - UsageLimit( - id: 'session-5h', - label: '5-hour window', - scope: UsageScope(provider: 'openai-codex'), - window: UsageWindow( - id: 'session-5h', - label: '5h', - durationMs: 5 * 60 * 60 * 1000, - resetsAt: _demoGeneratedAtMs + 2 * 60 * 60 * 1000, - ), - amount: UsageAmount( - unit: UsageUnit.percent, - used: 34, - limit: 100, - remaining: 66, - usedFraction: 0.34, - remainingFraction: 0.66, - ), - status: UsageStatus.ok, - notes: [], - ), - UsageLimit( - id: 'weekly', - label: 'Weekly window', - scope: UsageScope(provider: 'openai-codex'), - window: UsageWindow( - id: 'weekly', - label: '7d', - durationMs: 7 * 24 * 60 * 60 * 1000, - resetsAt: _demoGeneratedAtMs + 3 * 24 * 60 * 60 * 1000, - ), - amount: UsageAmount( - unit: UsageUnit.percent, - used: 61, - limit: 100, - remaining: 39, - usedFraction: 0.61, - remainingFraction: 0.39, - ), - status: UsageStatus.warning, - notes: [], - ), - ], - notes: [], - metadata: {}, - ), - ], - accountsWithoutUsage: [], - capacity: >{}, - ); - - @override - Future readBrokerStatus() async => - const BrokerStatusResult( - state: BrokerState.connected, - generation: 7, - endpoint: 'https://demo.t4code.ts.net', - ); - - @override - Future searchProjectFiles( - String query, { - int limit = 12, - }) async { - final needle = query.trim().toLowerCase(); - final matches = _demoWorkspaceFiles.keys - .where((path) => needle.isEmpty || path.toLowerCase().contains(needle)) - .take(limit) - .toList(growable: false); - return ProjectFileSearchResult(paths: matches, truncated: false); - } - - @override - dynamic noSuchMethod(Invocation invocation) { - return switch (invocation.memberName) { - #searchTranscripts => Future.value( - const TranscriptSearchResult( - items: [], - incomplete: false, - index: TranscriptSearchIndexStatus( - state: TranscriptSearchIndexState.ready, - indexedSessions: 5, - knownSessions: 5, - generation: 'demo', - ), - ), - ), - #loadTranscriptContext => Future.value( - const TranscriptContextResult( - anchorId: '', - rows: [], - anchorIndex: 0, - hasBefore: false, - hasAfter: false, - generation: 'demo', - ), - ), - #submitPrompt || - #queuePrompt || - #respondToAttention => Future.value(false), - #openTerminal || #launchPreview => Future.value(''), - _ => Future.value(), - }; - } -} diff --git a/apps/flutter/lib/src/host/app_preferences.dart b/apps/flutter/lib/src/host/app_preferences.dart deleted file mode 100644 index 3014ee60..00000000 --- a/apps/flutter/lib/src/host/app_preferences.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:shared_preferences/shared_preferences.dart'; - -const String appThemePreferenceKey = 't4-code:theme-preference:v1'; - -abstract interface class AppPreferenceStore { - Future loadThemePreference(); - - Future saveThemePreference(String value); -} - -final class InMemoryAppPreferenceStore implements AppPreferenceStore { - InMemoryAppPreferenceStore({this.themePreference}); - - String? themePreference; - - @override - Future loadThemePreference() async => themePreference; - - @override - Future saveThemePreference(String value) async { - themePreference = value; - } -} - -final class PersistentAppPreferenceStore implements AppPreferenceStore { - PersistentAppPreferenceStore({SharedPreferencesAsync? preferences}) - : _preferences = preferences ?? SharedPreferencesAsync(); - - final SharedPreferencesAsync _preferences; - - @override - Future loadThemePreference() => - _preferences.getString(appThemePreferenceKey); - - @override - Future saveThemePreference(String value) => - _preferences.setString(appThemePreferenceKey, value); -} diff --git a/apps/flutter/lib/src/host/host_profile.dart b/apps/flutter/lib/src/host/host_profile.dart deleted file mode 100644 index f2e8059a..00000000 --- a/apps/flutter/lib/src/host/host_profile.dart +++ /dev/null @@ -1,282 +0,0 @@ -const int hostProfileSchemaVersion = 3; -const int maximumSavedHosts = 16; -const int maximumHostUrlLength = 2048; -const int maximumHostLabelLength = 128; -const int maximumProfileIdLength = 64; -const int maximumDeviceIdLength = 256; -const int maximumDeviceTokenLength = 512; -const String defaultHostProfileId = 'default'; - -final RegExp _profileIdPattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'); - -final class HostProfile { - const HostProfile._({ - required this.endpointKey, - required this.origin, - required this.profileId, - required this.webSocketUrl, - required this.label, - }); - - factory HostProfile.parseTailnetAddress( - String value, { - String profileId = defaultHostProfileId, - }) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw const FormatException( - 'Enter the HTTPS address shown by T4 on your computer.', - ); - } - if (trimmed.length > maximumHostUrlLength) { - throw const FormatException('That address is too long.'); - } - - final normalizedProfileId = normalizeHostProfileId(profileId); - final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed'; - final parsed = Uri.tryParse(candidate); - if (parsed == null || !parsed.hasAuthority || parsed.scheme != 'https') { - throw const FormatException('Enter a valid HTTPS Tailnet address.'); - } - if (parsed.userInfo.isNotEmpty) { - throw const FormatException('The address cannot contain credentials.'); - } - if ((parsed.path.isNotEmpty && parsed.path != '/') || - parsed.hasQuery || - parsed.hasFragment) { - throw const FormatException( - 'Enter the host address only, without a path, query, or fragment.', - ); - } - - final hostname = parsed.host.toLowerCase(); - if (hostname == 'ts.net' || !hostname.endsWith('.ts.net')) { - throw const FormatException( - 'Use the full Tailscale hostname ending in .ts.net.', - ); - } - - final explicitPort = parsed.hasPort && parsed.port != 443 - ? ':${parsed.port}' - : ''; - final origin = 'https://$hostname$explicitPort'; - final encodedProfileId = Uri.encodeComponent(normalizedProfileId); - final path = normalizedProfileId == defaultHostProfileId - ? '/v1/ws' - : '/v1/profiles/$encodedProfileId/ws'; - final endpointKey = '$origin#profile=$normalizedProfileId'; - final label = 'T4 on ${hostname.substring(0, hostname.indexOf('.'))}'; - - return HostProfile._( - endpointKey: endpointKey, - origin: origin, - profileId: normalizedProfileId, - webSocketUrl: Uri.parse('wss://$hostname$explicitPort$path'), - label: _boundedText(label, 'host label', maximumHostLabelLength), - ); - } - - factory HostProfile.fromJson(Object? value) { - final data = _jsonObject(value, 'saved host'); - if (data['version'] != hostProfileSchemaVersion) { - throw const FormatException( - 'The saved host is from an unsupported app version.', - ); - } - - final origin = _boundedText( - data['origin'], - 'host origin', - maximumHostUrlLength, - ); - final profileId = normalizeHostProfileId( - _boundedText(data['profileId'], 'profile ID', maximumProfileIdLength), - ); - final canonical = HostProfile.parseTailnetAddress( - origin, - profileId: profileId, - ); - if (data['endpointKey'] != canonical.endpointKey || - data['wsUrl'] != canonical.webSocketUrl.toString() || - data['label'] != canonical.label) { - throw const FormatException( - 'The saved host is inconsistent. Add the host again.', - ); - } - return canonical; - } - - final String endpointKey; - final String origin; - final String profileId; - final Uri webSocketUrl; - final String label; - - Map toJson() => { - 'version': hostProfileSchemaVersion, - 'endpointKey': endpointKey, - 'origin': origin, - 'profileId': profileId, - 'wsUrl': webSocketUrl.toString(), - 'label': label, - }; -} - -final class HostDirectory { - HostDirectory._({ - required List profiles, - required this.activeEndpointKey, - }) : profiles = List.unmodifiable(profiles); - - const HostDirectory.empty() - : profiles = const [], - activeEndpointKey = null; - - factory HostDirectory.fromJson(Object? value) { - final data = _jsonObject(value, 'saved host list'); - if (data['version'] != hostProfileSchemaVersion || - data['activeEndpointKey'] is! String || - data['backends'] is! List) { - throw const FormatException( - 'The saved host list is from an unsupported app version.', - ); - } - - final rawProfiles = data['backends']! as List; - if (rawProfiles.isEmpty || rawProfiles.length > maximumSavedHosts) { - throw const FormatException( - 'The saved host list is from an unsupported app version.', - ); - } - final profiles = rawProfiles.map(HostProfile.fromJson).toList(); - final keys = profiles.map((profile) => profile.endpointKey).toSet(); - final activeEndpointKey = data['activeEndpointKey']! as String; - if (keys.length != profiles.length || !keys.contains(activeEndpointKey)) { - throw const FormatException( - 'The saved host list is inconsistent. Add the host again.', - ); - } - return HostDirectory._( - profiles: profiles, - activeEndpointKey: activeEndpointKey, - ); - } - - final List profiles; - final String? activeEndpointKey; - - HostProfile? get activeProfile { - final activeKey = activeEndpointKey; - if (activeKey == null) return null; - for (final profile in profiles) { - if (profile.endpointKey == activeKey) return profile; - } - return null; - } - - HostDirectory upsert(HostProfile profile) { - final existing = profiles - .where((candidate) => candidate.endpointKey != profile.endpointKey) - .toList(); - if (existing.length >= maximumSavedHosts) { - throw StateError('This device can save up to 16 T4 hosts.'); - } - return HostDirectory._( - profiles: [...existing, profile], - activeEndpointKey: profile.endpointKey, - ); - } - - HostDirectory activate(String endpointKey) { - if (!profiles.any((profile) => profile.endpointKey == endpointKey)) { - throw ArgumentError.value(endpointKey, 'endpointKey', 'Unknown host.'); - } - return HostDirectory._(profiles: profiles, activeEndpointKey: endpointKey); - } - - HostDirectory remove(String endpointKey) { - final remaining = profiles - .where((profile) => profile.endpointKey != endpointKey) - .toList(); - if (remaining.length == profiles.length) return this; - final nextActive = activeEndpointKey == endpointKey - ? (remaining.isEmpty ? null : remaining.first.endpointKey) - : activeEndpointKey; - return HostDirectory._(profiles: remaining, activeEndpointKey: nextActive); - } - - Map toJson() { - final activeKey = activeEndpointKey; - if (profiles.isEmpty || activeKey == null) { - throw StateError('An empty host directory is not persisted.'); - } - return { - 'version': hostProfileSchemaVersion, - 'activeEndpointKey': activeKey, - 'backends': profiles.map((profile) => profile.toJson()).toList(), - }; - } -} - -final class DeviceCredentials { - DeviceCredentials({required String deviceId, required String deviceToken}) - : deviceId = _boundedText(deviceId, 'device ID', maximumDeviceIdLength), - deviceToken = _boundedText( - deviceToken, - 'device token', - maximumDeviceTokenLength, - ); - - final String deviceId; - final String deviceToken; -} - -abstract interface class HostDirectoryStore { - Future load(); - - Future save(HostDirectory directory); -} - -abstract interface class HostCredentialStore { - Future read(HostProfile profile); - - Future write(HostProfile profile, DeviceCredentials credentials); - - Future delete(HostProfile profile); -} - -String normalizeHostProfileId(String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) return defaultHostProfileId; - if (trimmed.length > maximumProfileIdLength || - !_profileIdPattern.hasMatch(trimmed)) { - throw const FormatException( - 'Use a profile ID made of ASCII letters, numbers, dot, dash, or underscore.', - ); - } - return trimmed; -} - -Map _jsonObject(Object? value, String name) { - if (value is! Map) { - throw FormatException('The $name is damaged.'); - } - final result = {}; - for (final entry in value.entries) { - if (entry.key is! String) { - throw FormatException('The $name is damaged.'); - } - result[entry.key! as String] = entry.value; - } - return result; -} - -String _boundedText(Object? value, String name, int maximumLength) { - if (value is! String || - value.isEmpty || - value.length > maximumLength || - value.codeUnits.any((code) => code <= 0x1f || code == 0x7f)) { - throw FormatException('Invalid $name.'); - } - return value; -} diff --git a/apps/flutter/lib/src/host/persistent_host_stores.dart b/apps/flutter/lib/src/host/persistent_host_stores.dart deleted file mode 100644 index 90ef89b3..00000000 --- a/apps/flutter/lib/src/host/persistent_host_stores.dart +++ /dev/null @@ -1,310 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter/services.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'host_profile.dart'; - -const String hostDirectoryStorageKey = 't4-code:mobile-backends:v3'; -const String _credentialStoragePrefix = 't4-code:device-credentials:v1:'; - -abstract interface class HostDirectoryPreferences { - Future getString(String key); - - Future setString(String key, String value); - - Future remove(String key); -} - -abstract interface class HostCredentialStorage { - Future read(String key); - - Future write(String key, String value); - - Future delete(String key); -} - -abstract interface class LegacyHostCredentialSource { - Future discover({ - required List hostKeys, - required bool includeUnkeyed, - }); - - Future clear(String source); -} - -final class LegacyHostCredentials { - const LegacyHostCredentials({ - required this.deviceId, - required this.deviceToken, - required this.source, - }); - - final String deviceId; - final String deviceToken; - final String source; -} - -final class PersistentHostDirectoryStore implements HostDirectoryStore { - PersistentHostDirectoryStore({HostDirectoryPreferences? preferences}) - : _preferences = preferences ?? _SharedPreferencesAdapter(); - - final HostDirectoryPreferences _preferences; - - @override - Future load() async { - final encoded = await _preferences.getString(hostDirectoryStorageKey); - if (encoded == null) return const HostDirectory.empty(); - return HostDirectory.fromJson(jsonDecode(encoded)); - } - - @override - Future save(HostDirectory directory) { - if (directory.profiles.isEmpty) { - return _preferences.remove(hostDirectoryStorageKey); - } - return _preferences.setString( - hostDirectoryStorageKey, - jsonEncode(directory.toJson()), - ); - } -} - -/// Process-local credentials for unsigned macOS development builds. -/// -/// Values are intentionally lost when the app exits and never cross into a -/// platform channel or persistent store. -final class VolatileHostCredentialStore implements HostCredentialStore { - final Map _credentials = - {}; - - @override - Future read(HostProfile profile) async => - _credentials[profile.endpointKey]; - - @override - Future write(HostProfile profile, DeviceCredentials credentials) async { - _credentials[profile.endpointKey] = credentials; - } - - @override - Future delete(HostProfile profile) async { - _credentials.remove(profile.endpointKey); - } -} - -final class SecureHostCredentialStore implements HostCredentialStore { - SecureHostCredentialStore({ - HostCredentialStorage? storage, - LegacyHostCredentialSource? legacySource, - }) : _storage = storage ?? _FlutterSecureStorageAdapter(), - _legacySource = - legacySource ?? - (storage == null - ? const _MethodChannelLegacyCredentialSource() - : const _AbsentLegacyCredentialSource()); - - final HostCredentialStorage _storage; - final LegacyHostCredentialSource _legacySource; - - @override - Future read(HostProfile profile) async { - final currentKey = _credentialKey(profile.endpointKey); - final current = await _storage.read(currentKey); - if (current != null) return _decodeCredentials(current); - - if (profile.profileId == defaultHostProfileId) { - final originKey = _credentialKey(profile.origin); - final origin = await _storage.read(originKey); - if (origin != null) { - final credentials = _decodeCredentials(origin); - await _migrate( - currentKey, - credentials, - () => _storage.delete(originKey), - ); - return credentials; - } - } - - final isDefault = profile.profileId == defaultHostProfileId; - final legacy = await _legacySource.discover( - hostKeys: [profile.endpointKey, if (isDefault) profile.origin], - includeUnkeyed: isDefault, - ); - if (legacy == null) return null; - final credentials = DeviceCredentials( - deviceId: legacy.deviceId, - deviceToken: legacy.deviceToken, - ); - await _migrate( - currentKey, - credentials, - () => _legacySource.clear(legacy.source), - ); - return credentials; - } - - Future _migrate( - String currentKey, - DeviceCredentials credentials, - Future Function() clearLegacy, - ) async { - await _storage.write(currentKey, _encodeCredentials(credentials)); - try { - await clearLegacy(); - } catch (error, stackTrace) { - try { - await _storage.delete(currentKey); - } catch (_) { - // Preserve the clear failure that explains why migration did not commit. - } - Error.throwWithStackTrace(error, stackTrace); - } - } - - @override - Future write(HostProfile profile, DeviceCredentials credentials) async { - await _storage.write( - _credentialKey(profile.endpointKey), - _encodeCredentials(credentials), - ); - if (profile.profileId == defaultHostProfileId) { - await _storage.delete(_credentialKey(profile.origin)); - } - } - - @override - Future delete(HostProfile profile) async { - await _storage.delete(_credentialKey(profile.endpointKey)); - if (profile.profileId == defaultHostProfileId) { - await _storage.delete(_credentialKey(profile.origin)); - } - } -} - -final class _SharedPreferencesAdapter implements HostDirectoryPreferences { - _SharedPreferencesAdapter() : _preferences = SharedPreferencesAsync(); - - final SharedPreferencesAsync _preferences; - - @override - Future getString(String key) => _preferences.getString(key); - - @override - Future setString(String key, String value) => - _preferences.setString(key, value); - - @override - Future remove(String key) => _preferences.remove(key); -} - -final class _FlutterSecureStorageAdapter implements HostCredentialStorage { - _FlutterSecureStorageAdapter() : _storage = const FlutterSecureStorage(); - - final FlutterSecureStorage _storage; - - @override - Future read(String key) => _storage.read(key: key); - - @override - Future write(String key, String value) => - _storage.write(key: key, value: value); - - @override - Future delete(String key) => _storage.delete(key: key); -} - -final class _AbsentLegacyCredentialSource - implements LegacyHostCredentialSource { - const _AbsentLegacyCredentialSource(); - - @override - Future discover({ - required List hostKeys, - required bool includeUnkeyed, - }) async => null; - - @override - Future clear(String source) async {} -} - -final class _MethodChannelLegacyCredentialSource - implements LegacyHostCredentialSource { - const _MethodChannelLegacyCredentialSource(); - - static const MethodChannel _channel = MethodChannel( - 'com.lycaonsolutions.t4code/legacy_credentials', - ); - - @override - Future discover({ - required List hostKeys, - required bool includeUnkeyed, - }) async { - Map? value; - try { - value = await _channel.invokeMapMethod( - 'discoverCredentials', - { - 'hostKeys': hostKeys, - 'includeUnkeyed': includeUnkeyed, - }, - ); - } on MissingPluginException { - return null; - } - if (value == null) return null; - if (value.length != 3 || - value['deviceId'] is! String || - value['deviceToken'] is! String || - value['source'] is! String) { - throw const FormatException('The legacy device credentials are damaged.'); - } - final source = value['source']! as String; - if (source.isEmpty || - source.length > 256 || - source.codeUnits.any((code) => code <= 0x1f || code == 0x7f)) { - throw const FormatException('The legacy device credentials are damaged.'); - } - return LegacyHostCredentials( - deviceId: value['deviceId']! as String, - deviceToken: value['deviceToken']! as String, - source: source, - ); - } - - @override - Future clear(String source) => _channel.invokeMethod( - 'clearCredentials', - {'source': source}, - ); -} - -String _credentialKey(String hostKey) { - final encodedHostKey = base64Url - .encode(utf8.encode(hostKey)) - .replaceAll('=', ''); - return '$_credentialStoragePrefix$encodedHostKey'; -} - -String _encodeCredentials(DeviceCredentials credentials) => - jsonEncode({ - 'deviceId': credentials.deviceId, - 'deviceToken': credentials.deviceToken, - }); - -DeviceCredentials _decodeCredentials(String encoded) { - final value = jsonDecode(encoded); - if (value is! Map || - value.length != 2 || - value['deviceId'] is! String || - value['deviceToken'] is! String) { - throw const FormatException('The saved device credentials are damaged.'); - } - return DeviceCredentials( - deviceId: value['deviceId']! as String, - deviceToken: value['deviceToken']! as String, - ); -} diff --git a/apps/flutter/lib/src/platform/platform_lifecycle.dart b/apps/flutter/lib/src/platform/platform_lifecycle.dart deleted file mode 100644 index 9d4275cf..00000000 --- a/apps/flutter/lib/src/platform/platform_lifecycle.dart +++ /dev/null @@ -1,270 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -const String t4PlatformLifecycleChannel = - 'com.lycaonsolutions.t4code/platform_lifecycle'; - -enum RuntimeDefinitionState { missing, current, drifted } - -enum RuntimeServicePhase { stopped, starting, running, failed, unknown } - -enum PlatformUpdatePhase { - unsupported, - idle, - checking, - current, - available, - manual, - downloading, - installer, - ready, - error, -} - -final class RuntimeServiceStatus { - const RuntimeServiceStatus({ - required this.supported, - required this.available, - required this.definition, - required this.service, - required this.diagnostics, - this.executable, - this.issueCode, - this.message, - }); - - const RuntimeServiceStatus.unsupported() - : supported = false, - available = false, - definition = RuntimeDefinitionState.missing, - service = RuntimeServicePhase.unknown, - diagnostics = 'Local OMP service management is available on macOS only.', - executable = null, - issueCode = 'unsupported_platform', - message = null; - - final bool supported; - final bool available; - final RuntimeDefinitionState definition; - final RuntimeServicePhase service; - final String diagnostics; - final String? executable; - final String? issueCode; - final String? message; - - factory RuntimeServiceStatus.fromMap(Map value) { - final definition = _enumByName( - RuntimeDefinitionState.values, - value['definition'], - 'definition', - ); - final service = _enumByName( - RuntimeServicePhase.values, - value['service'], - 'service', - ); - return RuntimeServiceStatus( - supported: true, - available: _requiredBool(value, 'available'), - definition: definition, - service: service, - diagnostics: _boundedText(value['diagnostics'], 'diagnostics', 4096), - executable: _optionalText(value['executable'], 'executable', 1024), - issueCode: _optionalText(value['issueCode'], 'issueCode', 128), - message: _optionalText(value['message'], 'message', 512), - ); - } -} - -final class PlatformUpdateStatus { - const PlatformUpdateStatus({ - required this.supported, - required this.currentVersion, - required this.phase, - this.latestVersion, - this.checkedAt, - this.revision, - this.progressPercent, - this.error, - this.message, - }); - - const PlatformUpdateStatus.unsupported() - : supported = false, - currentVersion = '', - phase = PlatformUpdatePhase.unsupported, - latestVersion = null, - checkedAt = null, - revision = null, - progressPercent = null, - error = null, - message = 'Updates are managed outside this app on this platform.'; - - final bool supported; - final String currentVersion; - final PlatformUpdatePhase phase; - final String? latestVersion; - final int? checkedAt; - final int? revision; - final double? progressPercent; - final String? error; - final String? message; - - factory PlatformUpdateStatus.fromMap(Map value) { - final phaseName = _boundedText(value['phase'], 'phase', 32); - final phase = PlatformUpdatePhase.values - .where((candidate) => candidate.name == phaseName) - .firstOrNull; - if (phase == null || phase == PlatformUpdatePhase.unsupported) { - throw FormatException('unsupported update phase: $phaseName'); - } - final checkedAt = value['checkedAt']; - if (checkedAt != null && (checkedAt is! int || checkedAt < 0)) { - throw const FormatException('checkedAt must be a positive integer'); - } - final revision = value['revision']; - if (revision != null && - (revision is! int || revision < 0 || revision > 9007199254740991)) { - throw const FormatException('revision must be a positive safe integer'); - } - final progress = value['progressPercent']; - if (progress != null && - (progress is! num || - !progress.isFinite || - progress < 0 || - progress > 100)) { - throw const FormatException('progressPercent must be between 0 and 100'); - } - return PlatformUpdateStatus( - supported: true, - currentVersion: _boundedText( - value['currentVersion'], - 'currentVersion', - 64, - ), - phase: phase, - latestVersion: _optionalText(value['latestVersion'], 'latestVersion', 64), - checkedAt: checkedAt as int?, - revision: revision as int?, - progressPercent: (progress as num?)?.toDouble(), - error: _optionalText(value['error'], 'error', 128), - message: _optionalText(value['message'], 'message', 512), - ); - } -} - -abstract interface class PlatformLifecycleBridge { - bool get supportsRuntimeService; - bool get supportsUpdates; - - Future inspectRuntime(); - Future installRuntime(); - Future startRuntime(); - Future stopRuntime(); - Future restartRuntime(); - Future uninstallRuntime(); - - Future getUpdateState(); - Future checkForUpdates(); - Future downloadUpdate(); - Future installUpdate(); -} - -final class MethodChannelPlatformLifecycleBridge - implements PlatformLifecycleBridge { - const MethodChannelPlatformLifecycleBridge({ - this._channel = const MethodChannel(t4PlatformLifecycleChannel), - }); - - final MethodChannel _channel; - - @override - bool get supportsRuntimeService => - !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; - - @override - bool get supportsUpdates => - !kIsWeb && - (defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.macOS); - - @override - Future inspectRuntime() => _runtime('runtime.inspect'); - - @override - Future installRuntime() => _runtime('runtime.install'); - - @override - Future startRuntime() => _runtime('runtime.start'); - - @override - Future stopRuntime() => _runtime('runtime.stop'); - - @override - Future restartRuntime() => _runtime('runtime.restart'); - - @override - Future uninstallRuntime() => - _runtime('runtime.uninstall'); - - @override - Future getUpdateState() => _update('update.getState'); - - @override - Future checkForUpdates() => _update('update.check'); - - @override - Future downloadUpdate() => _update('update.download'); - - @override - Future installUpdate() => _update('update.install'); - - Future _runtime(String method) async { - if (!supportsRuntimeService) { - return const RuntimeServiceStatus.unsupported(); - } - final value = await _channel.invokeMethod(method); - return RuntimeServiceStatus.fromMap(_map(value, method)); - } - - Future _update(String method) async { - if (!supportsUpdates) return const PlatformUpdateStatus.unsupported(); - final value = await _channel.invokeMethod(method); - return PlatformUpdateStatus.fromMap(_map(value, method)); - } -} - -Map _map(Object? value, String field) { - if (value is! Map) { - throw FormatException('$field result must be an object'); - } - return value; -} - -T _enumByName(List values, Object? value, String field) { - final name = _boundedText(value, field, 32); - final match = values.where((candidate) => candidate.name == name).firstOrNull; - if (match == null) throw FormatException('unsupported $field: $name'); - return match; -} - -bool _requiredBool(Map value, String field) { - final result = value[field]; - if (result is! bool) throw FormatException('$field must be a boolean'); - return result; -} - -String _boundedText(Object? value, String field, int maxLength) { - if (value is! String || value.isEmpty || value.length > maxLength) { - throw FormatException('$field must be non-empty bounded text'); - } - if (value.runes.any((rune) => rune < 0x20 && rune != 0x09 && rune != 0x0a)) { - throw FormatException('$field contains control characters'); - } - return value; -} - -String? _optionalText(Object? value, String field, int maxLength) { - if (value == null) return null; - return _boundedText(value, field, maxLength); -} diff --git a/apps/flutter/lib/src/platform/platform_lifecycle_controller.dart b/apps/flutter/lib/src/platform/platform_lifecycle_controller.dart deleted file mode 100644 index 5ce79f4a..00000000 --- a/apps/flutter/lib/src/platform/platform_lifecycle_controller.dart +++ /dev/null @@ -1,280 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -import 'platform_lifecycle.dart'; - -final class PlatformLifecycleViewState { - const PlatformLifecycleViewState({ - required this.runtime, - required this.update, - this.initializing = false, - this.runtimeOperationPending = false, - this.updateOperationPending = false, - this.errorMessage, - }); - - const PlatformLifecycleViewState.initial() - : runtime = const RuntimeServiceStatus.unsupported(), - update = const PlatformUpdateStatus.unsupported(), - initializing = true, - runtimeOperationPending = false, - updateOperationPending = false, - errorMessage = null; - - final RuntimeServiceStatus runtime; - final PlatformUpdateStatus update; - final bool initializing; - final bool runtimeOperationPending; - final bool updateOperationPending; - final String? errorMessage; - - PlatformLifecycleViewState copyWith({ - RuntimeServiceStatus? runtime, - PlatformUpdateStatus? update, - bool? initializing, - bool? runtimeOperationPending, - bool? updateOperationPending, - String? errorMessage, - bool clearError = false, - }) => PlatformLifecycleViewState( - runtime: runtime ?? this.runtime, - update: update ?? this.update, - initializing: initializing ?? this.initializing, - runtimeOperationPending: - runtimeOperationPending ?? this.runtimeOperationPending, - updateOperationPending: - updateOperationPending ?? this.updateOperationPending, - errorMessage: clearError ? null : errorMessage ?? this.errorMessage, - ); -} - -abstract interface class PlatformLifecycleActions { - Future refreshPlatformState(); - Future installRuntime(); - Future startRuntime(); - Future stopRuntime(); - Future restartRuntime(); - Future uninstallRuntime(); - Future checkForUpdates(); - Future downloadUpdate(); - Future installUpdate(); -} - -final class PlatformLifecycleController extends ChangeNotifier - implements PlatformLifecycleActions { - PlatformLifecycleController() - : _bridge = const MethodChannelPlatformLifecycleBridge(); - - @visibleForTesting - PlatformLifecycleController.withBridge(this._bridge); - - final PlatformLifecycleBridge _bridge; - PlatformLifecycleViewState _state = - const PlatformLifecycleViewState.initial(); - bool _disposed = false; - - PlatformLifecycleViewState get state => _state; - - Future initialize() async { - final results = await Future.wait( - [ - _bridge.supportsRuntimeService - ? _bridge.inspectRuntime() - : Future.value( - const RuntimeServiceStatus.unsupported(), - ), - _bridge.supportsUpdates - ? _bridge.getUpdateState() - : Future.value( - const PlatformUpdateStatus.unsupported(), - ), - ].map((future) async { - try { - return await future; - } on Object catch (error) { - return error; - } - }), - ); - if (_disposed) return; - final runtime = results[0]; - final update = results[1]; - final errors = [ - if (runtime is! RuntimeServiceStatus) _safeError(runtime), - if (update is! PlatformUpdateStatus) _safeError(update), - ]; - _state = PlatformLifecycleViewState( - runtime: runtime is RuntimeServiceStatus - ? runtime - : _bridge.supportsRuntimeService - ? const RuntimeServiceStatus( - supported: true, - available: false, - definition: RuntimeDefinitionState.missing, - service: RuntimeServicePhase.unknown, - diagnostics: '', - issueCode: 'runtime_status_unavailable', - message: 'The desktop OMP runtime status could not be loaded.', - ) - : const RuntimeServiceStatus.unsupported(), - update: update is PlatformUpdateStatus - ? update - : _bridge.supportsUpdates - ? const PlatformUpdateStatus( - supported: true, - currentVersion: 'unknown', - phase: PlatformUpdatePhase.error, - error: 'update_status_unavailable', - message: 'The app update status could not be loaded.', - ) - : const PlatformUpdateStatus.unsupported(), - errorMessage: errors.isEmpty ? null : errors.join(' '), - ); - _notify(); - } - - @override - Future refreshPlatformState() => _runBoth( - runtime: _bridge.supportsRuntimeService ? _bridge.inspectRuntime : null, - update: _bridge.supportsUpdates ? _bridge.getUpdateState : null, - ); - - @override - Future installRuntime() => _runRuntime(_bridge.installRuntime); - - @override - Future startRuntime() => _runRuntime(_bridge.startRuntime); - - @override - Future stopRuntime() => _runRuntime(_bridge.stopRuntime); - - @override - Future restartRuntime() => _runRuntime(_bridge.restartRuntime); - - @override - Future uninstallRuntime() => _runRuntime(_bridge.uninstallRuntime); - - @override - Future checkForUpdates() => _runUpdate(_bridge.checkForUpdates); - - @override - Future downloadUpdate() => _runUpdate(_bridge.downloadUpdate); - - @override - Future installUpdate() => _runUpdate(_bridge.installUpdate); - - Future _runBoth({ - Future Function()? runtime, - Future Function()? update, - }) async { - if (_state.runtimeOperationPending || _state.updateOperationPending) return; - _state = _state.copyWith( - runtimeOperationPending: runtime != null, - updateOperationPending: update != null, - clearError: true, - ); - _notify(); - try { - final runtimeFuture = runtime?.call(); - final updateFuture = update?.call(); - final runtimeResult = runtimeFuture == null ? null : await runtimeFuture; - final updateResult = updateFuture == null ? null : await updateFuture; - if (_disposed) return; - _state = _state.copyWith( - runtime: runtimeResult, - update: updateResult, - runtimeOperationPending: false, - updateOperationPending: false, - clearError: true, - ); - } on Object catch (error) { - if (_disposed) return; - _state = _state.copyWith( - runtimeOperationPending: false, - updateOperationPending: false, - errorMessage: _safeError(error), - ); - } - _notify(); - } - - Future _runRuntime( - Future Function() operation, - ) async { - if (!_bridge.supportsRuntimeService || _state.runtimeOperationPending) { - return; - } - _state = _state.copyWith(runtimeOperationPending: true, clearError: true); - _notify(); - try { - final runtime = await operation(); - if (_disposed) return; - _state = _state.copyWith( - runtime: runtime, - runtimeOperationPending: false, - clearError: true, - ); - } on Object catch (error) { - if (_disposed) return; - _state = _state.copyWith( - runtimeOperationPending: false, - errorMessage: _safeError(error), - ); - } - _notify(); - } - - Future _runUpdate( - Future Function() operation, - ) async { - if (!_bridge.supportsUpdates || _state.updateOperationPending) return; - _state = _state.copyWith(updateOperationPending: true, clearError: true); - _notify(); - try { - final update = await operation(); - if (_disposed) return; - _state = _state.copyWith( - update: update, - updateOperationPending: false, - clearError: true, - ); - } on Object catch (error) { - if (_disposed) return; - _state = _state.copyWith( - updateOperationPending: false, - errorMessage: _safeError(error), - ); - } - _notify(); - } - - void _notify() { - if (!_disposed) notifyListeners(); - } - - @override - void dispose() { - _disposed = true; - super.dispose(); - } -} - -String _safeError(Object? error) { - var message = error.toString().replaceAll( - RegExp(r'[\u0000-\u0008\u000b\u000c\u000e-\u001f]'), - '', - ); - message = message.replaceAllMapped( - RegExp( - r'(authorization|cookie|password|passphrase|secret|token|api[_-]?key|credential)(\s*[:=]\s*)(\S+)', - caseSensitive: false, - ), - (match) => '${match.group(1)}${match.group(2)}[REDACTED]', - ); - message = message.replaceAll( - RegExp(r'Bearer\s+\S+', caseSensitive: false), - 'Bearer [REDACTED]', - ); - return message.length <= 512 ? message : message.substring(0, 512); -} diff --git a/apps/flutter/lib/src/protocol/models.dart b/apps/flutter/lib/src/protocol/models.dart deleted file mode 100644 index 50dcc101..00000000 --- a/apps/flutter/lib/src/protocol/models.dart +++ /dev/null @@ -1,1317 +0,0 @@ -/// The only protocol version accepted by the pinned app-wire 0.6.1 client. -const String ompAppProtocolVersion = 'omp-app/1'; - -/// A decoding failure at the application wire boundary. -final class WireFormatException implements FormatException { - const WireFormatException(this.message, [this.path]); - - @override - final String message; - - /// Dot/bracket path of the invalid value, when one is available. - final String? path; - - @override - int? get offset => null; - - @override - Object? get source => null; - - @override - String toString() => path == null - ? 'WireFormatException: $message' - : 'WireFormatException at $path: $message'; -} - -/// A cursor in a session's transcript stream. -/// -/// This is deliberately not assignable to [SessionIndexCursor]. -final class TranscriptCursor { - const TranscriptCursor({required this.epoch, required this.seq}); - - final String epoch; - final int seq; - - @override - bool operator ==(Object other) => - other is TranscriptCursor && other.epoch == epoch && other.seq == seq; - - @override - int get hashCode => Object.hash(epoch, seq); -} - -/// A cursor in the host-wide session-index stream. -/// -/// Session-index sequence numbers must never be compared with transcript -/// sequence numbers. -final class SessionIndexCursor { - const SessionIndexCursor({required this.epoch, required this.seq}); - - final String epoch; - final int seq; - - @override - bool operator ==(Object other) => - other is SessionIndexCursor && other.epoch == epoch && other.seq == seq; - - @override - int get hashCode => Object.hash(epoch, seq); -} - -final class ClientIdentity { - const ClientIdentity({ - required this.name, - required this.version, - required this.build, - required this.platform, - }); - - final String name; - final String version; - final String build; - final String platform; -} - -final class DeviceAuthentication { - const DeviceAuthentication({ - required this.deviceId, - required this.deviceToken, - }); - - final String deviceId; - final String deviceToken; -} - -final class SavedCursor { - const SavedCursor({ - required this.hostId, - required this.sessionId, - required this.cursor, - }); - - final String hostId; - final String sessionId; - final TranscriptCursor cursor; -} - -/// Immutable projection of a session-index item. -final class SessionRef { - const SessionRef({ - required this.hostId, - required this.sessionId, - required this.title, - required this.revision, - required this.status, - required this.updatedAt, - required this.project, - required this.raw, - }); - - final String hostId; - final String sessionId; - final String title; - final String revision; - final String status; - final String updatedAt; - final Map project; - final Map raw; -} - -/// Immutable durable transcript entry. -final class DurableEntry { - const DurableEntry({ - required this.id, - required this.parentId, - required this.hostId, - required this.sessionId, - required this.kind, - required this.timestamp, - required this.data, - required this.raw, - }); - - final String id; - final String? parentId; - final String hostId; - final String sessionId; - final String kind; - final String timestamp; - final Map data; - final Map raw; -} - -/// Base type for the modeled omp-app/1 application-frame union. -sealed class WireFrame { - const WireFrame({required this.raw}); - - /// The complete, recursively immutable decoded frame, including additive - /// fields not understood by this client version. - final Map raw; -} - -final class WelcomeFrame extends WireFrame { - const WelcomeFrame({ - required this.hostId, - required this.resumed, - required this.selectedProtocol, - required this.epoch, - required this.authentication, - required this.grantedCapabilities, - required this.grantedFeatures, - required this.negotiatedLimits, - required super.raw, - }); - - final String hostId; - final bool resumed; - final String selectedProtocol; - final String epoch; - final String authentication; - final List grantedCapabilities; - final List grantedFeatures; - final Map negotiatedLimits; -} - -final class SessionsFrame extends WireFrame { - const SessionsFrame({ - required this.hostId, - required this.cursor, - required this.sessions, - required this.totalCount, - required this.truncated, - required super.raw, - }); - - final String? hostId; - final SessionIndexCursor cursor; - final List sessions; - final int totalCount; - final bool truncated; -} - -/// Typed result payload of host.list and session.list command responses. -final class SessionListResult { - const SessionListResult({ - required this.cursor, - required this.sessions, - required this.totalCount, - required this.truncated, - required this.raw, - }); - - final SessionIndexCursor cursor; - final List sessions; - final int totalCount; - final bool truncated; - final Map raw; -} - -enum TranscriptSearchRole { user, assistant, summary } - -enum TranscriptSearchIndexState { building, ready, stale } - -final class TranscriptSearchHighlight { - const TranscriptSearchHighlight({required this.start, required this.end}); - - final int start; - final int end; -} - -final class TranscriptSearchItem { - const TranscriptSearchItem({ - required this.sessionId, - required this.projectId, - required this.sessionTitle, - required this.anchorId, - required this.role, - required this.timestamp, - required this.snippet, - required this.highlights, - this.archivedAt, - }); - - final String sessionId; - final String projectId; - final String sessionTitle; - final String? archivedAt; - final String anchorId; - final TranscriptSearchRole role; - final String timestamp; - final String snippet; - final List highlights; -} - -final class TranscriptSearchIndexStatus { - const TranscriptSearchIndexStatus({ - required this.state, - required this.indexedSessions, - required this.knownSessions, - required this.generation, - }); - - final TranscriptSearchIndexState state; - final int indexedSessions; - final int knownSessions; - final String generation; -} - -final class TranscriptSearchResult { - const TranscriptSearchResult({ - required this.items, - required this.incomplete, - required this.index, - this.nextCursor, - }); - - final List items; - final String? nextCursor; - final bool incomplete; - final TranscriptSearchIndexStatus index; -} - -final class TranscriptPageResult { - const TranscriptPageResult({ - required this.entries, - required this.hasMore, - required this.generation, - this.nextCursor, - }); - - final List entries; - final String? nextCursor; - final bool hasMore; - final String generation; -} - -final class TranscriptContextRow { - const TranscriptContextRow({ - required this.anchorId, - required this.role, - required this.timestamp, - required this.text, - }); - - final String anchorId; - final TranscriptSearchRole role; - final String timestamp; - final String text; -} - -final class TranscriptContextResult { - const TranscriptContextResult({ - required this.anchorId, - required this.rows, - required this.anchorIndex, - required this.hasBefore, - required this.hasAfter, - required this.generation, - }); - - final String anchorId; - final List rows; - final int anchorIndex; - final bool hasBefore; - final bool hasAfter; - final String generation; -} - -enum UsageUnit { percent, tokens, requests, usd, minutes, bytes, unknown } - -enum UsageStatus { ok, warning, exhausted, unknown } - -enum UsageAccountType { apiKey, oauth } - -final class UsageWindow { - const UsageWindow({ - required this.id, - required this.label, - this.durationMs, - this.resetsAt, - }); - - final String id; - final String label; - final int? durationMs; - final int? resetsAt; -} - -final class UsageAmount { - const UsageAmount({ - required this.unit, - this.used, - this.limit, - this.remaining, - this.usedFraction, - this.remainingFraction, - }); - - final UsageUnit unit; - final double? used; - final double? limit; - final double? remaining; - final double? usedFraction; - final double? remainingFraction; -} - -final class UsageScope { - const UsageScope({ - required this.provider, - this.accountId, - this.projectId, - this.orgId, - this.modelId, - this.tier, - this.windowId, - this.shared, - }); - - final String provider; - final String? accountId; - final String? projectId; - final String? orgId; - final String? modelId; - final String? tier; - final String? windowId; - final bool? shared; -} - -final class UsageLimit { - const UsageLimit({ - required this.id, - required this.label, - required this.scope, - required this.amount, - required this.notes, - this.window, - this.status, - }); - - final String id; - final String label; - final UsageScope scope; - final UsageWindow? window; - final UsageAmount amount; - final UsageStatus? status; - final List notes; -} - -final class UsageReport { - const UsageReport({ - required this.provider, - required this.fetchedAt, - required this.limits, - required this.notes, - required this.metadata, - this.availableResetCredits, - }); - - final String provider; - final int fetchedAt; - final List limits; - final int? availableResetCredits; - final List notes; - final Map metadata; -} - -final class UsageAccountWithoutReport { - const UsageAccountWithoutReport({ - required this.provider, - required this.type, - this.email, - this.accountId, - this.projectId, - this.enterpriseUrl, - this.orgId, - this.orgName, - }); - - final String provider; - final UsageAccountType type; - final String? email; - final String? accountId; - final String? projectId; - final String? enterpriseUrl; - final String? orgId; - final String? orgName; -} - -final class UsageCapacityWindow { - const UsageCapacityWindow({ - required this.window, - required this.accounts, - required this.usedAccounts, - required this.remainingAccounts, - this.durationMs, - }); - - final String window; - final int? durationMs; - final int accounts; - final double usedAccounts; - final double remainingAccounts; -} - -final class UsageReadResult { - const UsageReadResult({ - required this.generatedAt, - required this.reports, - required this.accountsWithoutUsage, - required this.capacity, - }); - - final int generatedAt; - final List reports; - final List accountsWithoutUsage; - final Map> capacity; -} - -enum BrokerState { local, connected, missingToken, unreachable } - -final class BrokerStatusResult { - const BrokerStatusResult({ - required this.state, - required this.generation, - this.endpoint, - }); - - final BrokerState state; - final int generation; - final String? endpoint; -} - -final class SnapshotFrame extends WireFrame { - const SnapshotFrame({ - required this.hostId, - required this.sessionId, - required this.cursor, - required this.revision, - required this.entries, - required super.raw, - }); - - final String hostId; - final String sessionId; - final TranscriptCursor cursor; - final String revision; - final List entries; -} - -final class EntryFrame extends WireFrame { - const EntryFrame({ - required this.hostId, - required this.sessionId, - required this.cursor, - required this.revision, - required this.entry, - required super.raw, - }); - - final String hostId; - final String sessionId; - final TranscriptCursor cursor; - final String revision; - final DurableEntry entry; -} - -final class EventFrame extends WireFrame { - const EventFrame({ - required this.hostId, - required this.sessionId, - required this.cursor, - required this.event, - required super.raw, - }); - - final String hostId; - final String sessionId; - final TranscriptCursor cursor; - - /// Raw immutable event payload. Unknown event subtypes are intentionally - /// accepted as long as their `type` is a valid string. - final Map event; -} - -final class WireResponseError { - const WireResponseError({ - required this.code, - required this.message, - required this.details, - required this.raw, - }); - - final String code; - final String message; - final Map? details; - final Map raw; -} - -enum OperationExecution { typed, headless, terminalOnly, unavailable } - -final class OperationDisabledReason { - const OperationDisabledReason({ - required this.code, - required this.message, - required this.raw, - }); - - final String code; - final String message; - final Map raw; -} - -final class OperationCapability { - const OperationCapability({ - required this.operationId, - required this.label, - required this.description, - required this.execution, - required this.supported, - required this.disabledReason, - required this.capabilities, - required this.raw, - }); - - final String operationId; - final String label; - final String? description; - final OperationExecution execution; - final bool supported; - final OperationDisabledReason? disabledReason; - final List? capabilities; - final Map raw; -} - -final class CatalogResult { - const CatalogResult({ - required this.revision, - required this.items, - this.operations, - }); - - final String revision; - final List items; - final List? operations; -} - -final class SettingsResult { - const SettingsResult({required this.revision, required this.settings}); - - final String revision; - final Map settings; -} - -final class SessionStateModel { - const SessionStateModel({ - required this.id, - required this.provider, - required this.displayName, - required this.selector, - required this.role, - }); - - final String id; - final String provider; - final String? displayName; - final String? selector; - final String? role; -} - -final class SessionStateResult { - const SessionStateResult({ - required this.isStreaming, - required this.isCompacting, - required this.isPaused, - required this.messageCount, - required this.queuedMessageCount, - required this.model, - required this.thinking, - required this.thinkingLevels, - required this.thinkingSupported, - required this.fast, - required this.fastAvailable, - required this.fastActive, - }); - - final bool isStreaming; - final bool isCompacting; - final bool isPaused; - final int messageCount; - final int queuedMessageCount; - final SessionStateModel? model; - final String? thinking; - final List? thinkingLevels; - final bool? thinkingSupported; - final bool? fast; - final bool? fastAvailable; - final bool? fastActive; -} - -final class ResponseFrame extends WireFrame { - const ResponseFrame({ - required this.requestId, - required this.commandId, - required this.hostId, - required this.sessionId, - required this.command, - required this.ok, - required this.result, - required this.error, - required super.raw, - }); - - final String requestId; - final String? commandId; - final String hostId; - final String? sessionId; - final String? command; - final bool ok; - final Object? result; - final WireResponseError? error; - - /// Typed command products decoded at the wire boundary. - SessionListResult? get sessionListResult => - result is SessionListResult ? result as SessionListResult : null; - - CatalogResult? get catalogResult => - result is CatalogResult ? result as CatalogResult : null; - - SettingsResult? get settingsResult => - result is SettingsResult ? result as SettingsResult : null; - - TranscriptSearchResult? get transcriptSearchResult => - result is TranscriptSearchResult - ? result as TranscriptSearchResult - : null; - - TranscriptContextResult? get transcriptContextResult => - result is TranscriptContextResult - ? result as TranscriptContextResult - : null; - - TranscriptPageResult? get transcriptPageResult => - result is TranscriptPageResult ? result as TranscriptPageResult : null; - - SessionStateResult? get sessionStateResult => - result is SessionStateResult ? result as SessionStateResult : null; - - UsageReadResult? get usageReadResult => - result is UsageReadResult ? result as UsageReadResult : null; - - BrokerStatusResult? get brokerStatusResult => - result is BrokerStatusResult ? result as BrokerStatusResult : null; -} - -final class ErrorFrame extends WireFrame { - const ErrorFrame({ - required this.code, - required this.message, - required this.requestId, - required this.details, - required super.raw, - }); - - final String code; - final String message; - final String? requestId; - final Map? details; -} - -final class GapFrame extends WireFrame { - const GapFrame({ - required this.hostId, - required this.sessionId, - required this.from, - required this.to, - required this.reason, - required super.raw, - }); - - final String hostId; - final String sessionId; - final TranscriptCursor from; - final TranscriptCursor to; - final String reason; -} - -final class PingFrame extends WireFrame { - const PingFrame({ - required this.nonce, - required this.timestamp, - required super.raw, - }); - - final String nonce; - final String timestamp; -} - -final class PongFrame extends WireFrame { - const PongFrame({ - required this.nonce, - required this.timestamp, - required super.raw, - }); - - final String nonce; - final String timestamp; -} - -/// A server request for an explicit command authorization decision. -final class ConfirmationFrame extends WireFrame { - const ConfirmationFrame({ - required this.confirmationId, - required this.commandId, - required this.hostId, - required this.sessionId, - required this.commandHash, - required this.revision, - required this.expiresAt, - required this.summary, - required this.preview, - required super.raw, - }); - - final String confirmationId; - final String commandId; - final String hostId; - final String? sessionId; - final String commandHash; - final String revision; - final String expiresAt; - final String summary; - final String? preview; -} - -/// Legacy aggregate agent frame retained by the pinned package union. -final class AgentFrame extends WireFrame { - const AgentFrame({ - required this.hostId, - required this.sessionId, - required this.agentId, - required this.state, - required this.progress, - required this.detail, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String agentId; - final String state; - final double? progress; - final Map? detail; -} - -/// Legacy aggregate terminal frame retained by the pinned package union. -final class TerminalFrame extends WireFrame { - const TerminalFrame({ - required this.hostId, - required this.sessionId, - required this.terminalId, - required this.stream, - required this.data, - required this.exitCode, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String terminalId; - final String stream; - final String? data; - final int? exitCode; -} - -/// Legacy aggregate file frame retained by the pinned package union. -final class FilesFrame extends WireFrame { - const FilesFrame({ - required this.hostId, - required this.sessionId, - required this.path, - required this.content, - required this.truncated, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String path; - final String? content; - final bool? truncated; -} - -final class ReviewFrame extends WireFrame { - const ReviewFrame({ - required this.hostId, - required this.sessionId, - required this.reviewId, - required this.status, - required this.path, - required this.findings, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String reviewId; - final String status; - final String? path; - final List> findings; -} - -final class AuditFrame extends WireFrame { - const AuditFrame({ - required this.hostId, - required this.sessionId, - required this.action, - required this.actor, - required this.timestamp, - required this.detail, - required super.raw, - }); - - final String hostId; - final String? sessionId; - final String action; - final String actor; - final String timestamp; - final Map? detail; -} - -/// A successful pairing response. Pairing IDs are never confirmation IDs. -final class PairOkFrame extends WireFrame { - const PairOkFrame({ - required this.requestId, - required this.pairingId, - required this.deviceId, - required this.deviceName, - required this.platform, - required this.requestedCapabilities, - required this.grantedCapabilities, - required this.deviceToken, - required this.expiresAt, - required super.raw, - }); - - final String requestId; - final String pairingId; - final String deviceId; - final String deviceName; - final String platform; - final List requestedCapabilities; - final List grantedCapabilities; - final String deviceToken; - final String expiresAt; -} - -final class PairErrorFrame extends WireFrame { - const PairErrorFrame({ - required this.code, - required this.message, - required this.requestId, - required super.raw, - }); - - final String code; - final String message; - final String? requestId; -} - -final class ByeFrame extends WireFrame { - const ByeFrame({ - required this.code, - required this.reason, - required this.retryable, - required super.raw, - }); - - final String code; - final String reason; - final bool retryable; -} - -sealed class WatchFrame extends WireFrame { - const WatchFrame({ - required this.frameType, - required this.hostId, - required this.revision, - required super.raw, - }); - - final String frameType; - final String hostId; - final String revision; -} - -final class HostWatchFrame extends WatchFrame { - const HostWatchFrame({ - required this.watchId, - required this.cursor, - required this.state, - required super.hostId, - required super.revision, - required super.raw, - }) : super(frameType: 'host.watch'); - - final String watchId; - final SessionIndexCursor cursor; - final String state; -} - -final class SessionWatchFrame extends WatchFrame { - const SessionWatchFrame({ - required this.watchId, - required this.sessionId, - required this.cursor, - required this.state, - required super.hostId, - required super.revision, - required super.raw, - }) : super(frameType: 'session.watch'); - - final String watchId; - final String sessionId; - final TranscriptCursor cursor; - final String state; -} - -final class SessionStateFrame extends WatchFrame { - const SessionStateFrame({ - required this.sessionId, - required this.cursor, - required this.state, - required super.hostId, - required super.revision, - required super.raw, - }) : super(frameType: 'session.state'); - - final String sessionId; - final TranscriptCursor cursor; - final String state; -} - -final class SessionDeltaFrame extends WatchFrame { - const SessionDeltaFrame({ - required this.sessionId, - required this.cursor, - required this.upsert, - required this.remove, - required super.hostId, - required super.revision, - required super.raw, - }) : super(frameType: 'session.delta'); - - final String sessionId; - final TranscriptCursor cursor; - final SessionRef? upsert; - final String? remove; -} - -/// One of the controller or prompt lease server frames. -final class LeaseFrame extends WireFrame { - const LeaseFrame({ - required this.frameType, - required this.hostId, - required this.sessionId, - required this.leaseId, - required this.cursor, - required this.kind, - required this.state, - required this.owner, - required this.expiresAt, - required this.revision, - required super.raw, - }); - - final String frameType; - final String hostId; - final String sessionId; - final String leaseId; - final TranscriptCursor cursor; - final String kind; - final String state; - final String owner; - final String expiresAt; - final String? revision; -} - -/// One of the five negotiated agent.* server frames. -final class AgentAdditiveFrame extends WireFrame { - const AgentAdditiveFrame({ - required this.frameType, - required this.hostId, - required this.sessionId, - required this.agentId, - required this.cursor, - required this.revision, - required this.state, - required this.lifecycle, - required this.progress, - required this.event, - required this.detail, - required this.data, - required this.entries, - required super.raw, - }); - - final String frameType; - final String hostId; - final String sessionId; - final String agentId; - final TranscriptCursor cursor; - final String revision; - final String? state; - final String? lifecycle; - final double? progress; - final String? event; - final Map? detail; - final Map? data; - final List? entries; -} - -final class TerminalOutputFrame extends WireFrame { - const TerminalOutputFrame({ - required this.hostId, - required this.sessionId, - required this.terminalId, - required this.cursor, - required this.stream, - required this.data, - required this.encoding, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String terminalId; - final TranscriptCursor cursor; - final String stream; - final String data; - final String? encoding; -} - -final class TerminalExitFrame extends WireFrame { - const TerminalExitFrame({ - required this.hostId, - required this.sessionId, - required this.terminalId, - required this.cursor, - required this.exitCode, - required this.signal, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String terminalId; - final TranscriptCursor cursor; - final int exitCode; - final String? signal; -} - -final class FileListEntry { - const FileListEntry({ - required this.path, - required this.kind, - required this.size, - required this.revision, - required this.raw, - }); - - final String path; - final String kind; - final int? size; - final String? revision; - final Map raw; -} - -/// One of files.list, files.read, files.write, files.patch, or files.diff. -final class FilesAdditiveFrame extends WireFrame { - const FilesAdditiveFrame({ - required this.frameType, - required this.hostId, - required this.sessionId, - required this.path, - required this.entries, - required this.content, - required this.encoding, - required this.patch, - required this.diff, - required this.cursor, - required this.revision, - required this.fromRevision, - required this.toRevision, - required super.raw, - }); - - final String frameType; - final String hostId; - final String sessionId; - final String path; - final List? entries; - final String? content; - final String? encoding; - final String? patch; - final String? diff; - final TranscriptCursor? cursor; - final String? revision; - final String? fromRevision; - final String? toRevision; -} - -final class AuditEvent { - const AuditEvent({ - required this.eventId, - required this.hostId, - required this.sessionId, - required this.action, - required this.actor, - required this.timestamp, - required this.detail, - required this.raw, - }); - - final String eventId; - final String hostId; - final String? sessionId; - final String action; - final String actor; - final String timestamp; - final Map? detail; - final Map raw; -} - -final class AuditTailFrame extends WireFrame { - const AuditTailFrame({ - required this.hostId, - required this.cursor, - required this.events, - required super.raw, - }); - - final String hostId; - final TranscriptCursor cursor; - final List events; -} - -final class AuditEventFrame extends WireFrame { - const AuditEventFrame({ - required this.hostId, - required this.cursor, - required this.event, - required super.raw, - }); - - final String hostId; - final TranscriptCursor cursor; - final AuditEvent event; -} - -final class CatalogItem { - const CatalogItem({ - required this.id, - required this.kind, - required this.name, - required this.description, - required this.capabilities, - required this.supported, - required this.reason, - required this.metadata, - required this.raw, - }); - - final String id; - final String kind; - final String name; - final String? description; - final List? capabilities; - final bool? supported; - final String? reason; - final Map? metadata; - final Map raw; -} - -final class CatalogFrame extends WireFrame { - const CatalogFrame({ - required this.hostId, - required this.revision, - required this.items, - this.operations, - required super.raw, - }); - - final String hostId; - final String revision; - final List items; - final List? operations; -} - -final class SettingsFrame extends WireFrame { - const SettingsFrame({ - required this.hostId, - required this.revision, - required this.settings, - required super.raw, - }); - - final String hostId; - final String revision; - final Map settings; -} - -final class PreviewSnapshot { - const PreviewSnapshot({ - required this.previewId, - required this.state, - required this.url, - required this.revision, - required this.cursor, - required this.title, - required this.canGoBack, - required this.canGoForward, - required this.viewport, - required this.capture, - required this.authority, - required this.availableActions, - }); - - final String previewId; - final String state; - final String url; - final String revision; - final TranscriptCursor cursor; - final String? title; - final bool? canGoBack; - final bool? canGoForward; - final Map? viewport; - final Map? capture; - final Map? authority; - final List? availableActions; -} - -/// One of the five preview.* frames in AdditiveServerFrame. -final class PreviewFrame extends WireFrame { - const PreviewFrame({ - required this.frameType, - required this.hostId, - required this.sessionId, - required this.snapshot, - required this.previewId, - required this.cursor, - required this.revision, - required this.code, - required this.message, - required this.error, - required super.raw, - }); - - final String frameType; - final String hostId; - final String sessionId; - final PreviewSnapshot? snapshot; - final String previewId; - final TranscriptCursor cursor; - final String revision; - final String? code; - final String? message; - final String? error; -} diff --git a/apps/flutter/lib/src/protocol/protocol.dart b/apps/flutter/lib/src/protocol/protocol.dart deleted file mode 100644 index 477fa6af..00000000 --- a/apps/flutter/lib/src/protocol/protocol.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'models.dart'; -export 'wire_decoder.dart'; -export 'wire_encoder.dart'; diff --git a/apps/flutter/lib/src/protocol/wire_decoder.dart b/apps/flutter/lib/src/protocol/wire_decoder.dart deleted file mode 100644 index c3bbce92..00000000 --- a/apps/flutter/lib/src/protocol/wire_decoder.dart +++ /dev/null @@ -1,2555 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; - -import 'models.dart'; - -const int _maxInboundBytes = 4 * 1024 * 1024; -const int _maxSafeInteger = 9007199254740991; -const int _maxArrayItems = 1000; -const int _maxMapKeys = 512; -const int _maxJsonDepth = 32; -const int _maxJsonNodes = 20000; - -/// Allocation-conscious boundary decoder for inbound omp-app/1 JSON frames. -abstract final class WireDecoder { - /// Decodes one complete JSON frame. - /// - /// The UTF-8 size is checked without first allocating an encoded byte list. - /// All maps and lists reachable from the returned frame are immutable views - /// over the single object graph produced by [jsonDecode]. - static WireFrame decode(String source) { - if (!_utf8LengthAtMost(source, _maxInboundBytes)) { - throw const WireFormatException( - 'inbound frame exceeds the 4 MiB UTF-8 limit', - ); - } - - final Object? decoded; - try { - decoded = jsonDecode(source); - } on FormatException catch (error) { - throw WireFormatException('invalid JSON: ${error.message}'); - } - - final frozen = _freezeJson(decoded, _JsonBudget(), 0); - final raw = _map(frozen, 'frame'); - _exactVersion(raw); - final type = _string(raw['type'], 'type', 128); - return switch (type) { - 'welcome' => _decodeWelcome(raw), - 'sessions' => _decodeSessions(raw), - 'snapshot' => _decodeSnapshot(raw), - 'entry' => _decodeEntryFrame(raw), - 'event' => _decodeEvent(raw), - 'agent' => _decodeAgent(raw), - 'terminal' => _decodeTerminal(raw), - 'files' => _decodeFiles(raw), - 'review' => _decodeReview(raw), - 'audit' => _decodeAudit(raw), - 'confirmation' => _decodeConfirmation(raw), - 'pair.ok' => _decodePairOk(raw), - 'pair.error' => _decodePairError(raw), - 'response' => _decodeResponse(raw), - 'gap' => _decodeGap(raw), - 'error' => _decodeError(raw), - 'pong' => _decodePong(raw), - 'bye' => _decodeBye(raw), - 'host.watch' || - 'session.watch' || - 'session.state' || - 'session.delta' => _decodeWatch(raw, type), - 'lease' || 'prompt.lease' => _decodeLease(raw, type), - 'agent.state' || - 'agent.lifecycle' || - 'agent.progress' || - 'agent.event' || - 'agent.transcript' => _decodeAgentAdditive(raw, type), - 'terminal.output' => _decodeTerminalOutput(raw), - 'terminal.exit' => _decodeTerminalExit(raw), - 'files.list' || - 'files.read' || - 'files.write' || - 'files.patch' || - 'files.diff' => _decodeFilesAdditive(raw, type), - 'audit.tail' => _decodeAuditTail(raw), - 'audit.event' => _decodeAuditEventFrame(raw), - 'catalog' => _decodeCatalog(raw), - 'settings' => _decodeSettings(raw), - 'preview.launch' || - 'preview.state' || - 'preview.navigation' || - 'preview.capture' || - 'preview.error' => _decodePreview(raw, type), - _ => throw WireFormatException('unknown top-level frame family', 'type'), - }; - } - - /// Strictly decodes an authoritative host.list/session.list result payload. - /// - /// A recursively immutable copy is returned and [value] is never mutated. - static SessionListResult decodeSessionListResult(Object? value) { - final frozen = _freezeJsonCopy(value, _JsonBudget(), 0); - return _decodeSessionListResult(_map(frozen, 'result')); - } - - /// Strictly decodes one session reference returned by session.create. - static SessionRef decodeSessionRef(Object? value) { - final frozen = _freezeJsonCopy(value, _JsonBudget(), 0); - return _sessionRef(frozen, 'session'); - } - - /// Strictly decodes a bounded transcript.page result payload. - static TranscriptPageResult decodeTranscriptPageResult(Object? value) { - final frozen = _freezeJsonCopy(value, _JsonBudget(), 0); - return _decodeTranscriptPageResult(_map(frozen, 'result')); - } -} - -WelcomeFrame _decodeWelcome(Map raw) { - final selectedProtocol = _string( - raw['selectedProtocol'], - 'selectedProtocol', - 64, - ); - if (selectedProtocol != ompAppProtocolVersion) { - throw const WireFormatException( - 'selected protocol must be omp-app/1', - 'selectedProtocol', - ); - } - final hostId = _id(raw['hostId'], 'hostId'); - _string(raw['ompVersion'], 'ompVersion', 64); - _string(raw['ompBuild'], 'ompBuild', 128); - _string(raw['appserverVersion'], 'appserverVersion', 64); - _string(raw['appserverBuild'], 'appserverBuild', 128); - final epoch = _string(raw['epoch'], 'epoch', 128); - final authentication = _string(raw['authentication'], 'authentication', 32); - if (authentication != 'local' && - authentication != 'pairing-required' && - authentication != 'paired') { - throw const WireFormatException( - 'invalid authentication state', - 'authentication', - ); - } - final capabilities = _stringList( - raw['grantedCapabilities'], - 'grantedCapabilities', - maxItems: 128, - ); - if (authentication == 'pairing-required' && capabilities.isNotEmpty) { - throw const WireFormatException( - 'pairing-required welcome cannot grant capabilities', - 'grantedCapabilities', - ); - } - final features = _stringList( - raw['grantedFeatures'], - 'grantedFeatures', - maxItems: 128, - ); - final limits = _map(raw['negotiatedLimits'], 'negotiatedLimits'); - final resumed = _bool(raw['resumed'], 'resumed'); - return WelcomeFrame( - hostId: hostId, - resumed: resumed, - selectedProtocol: selectedProtocol, - epoch: epoch, - authentication: authentication, - grantedCapabilities: capabilities, - grantedFeatures: features, - negotiatedLimits: limits, - raw: raw, - ); -} - -SessionsFrame _decodeSessions(Map raw) { - final hostId = raw.containsKey('hostId') - ? _id(raw['hostId'], 'hostId') - : null; - final cursor = _sessionIndexCursor(raw['cursor'], 'cursor'); - final values = _list(raw['sessions'], 'sessions'); - final sessions = []; - for (var index = 0; index < values.length; index++) { - sessions.add(_sessionRef(values[index], 'sessions[$index]')); - } - final totalCount = raw.containsKey('totalCount') - ? _safeInteger(raw['totalCount'], 'totalCount') - : sessions.length; - if (totalCount < sessions.length) { - throw const WireFormatException( - 'totalCount cannot be less than sessions length', - 'totalCount', - ); - } - final expectedTruncated = totalCount > sessions.length; - final truncated = raw.containsKey('truncated') - ? _bool(raw['truncated'], 'truncated') - : expectedTruncated; - if (truncated != expectedTruncated) { - throw const WireFormatException( - 'truncated does not match totalCount', - 'truncated', - ); - } - return SessionsFrame( - hostId: hostId, - cursor: cursor, - sessions: UnmodifiableListView(sessions), - totalCount: totalCount, - truncated: truncated, - raw: raw, - ); -} - -SessionListResult _decodeSessionListResult(Map raw) { - final cursor = _sessionIndexCursor(raw['cursor'], 'result.cursor'); - final values = _list(raw['sessions'], 'result.sessions'); - final sessions = []; - for (var index = 0; index < values.length; index++) { - sessions.add(_sessionRef(values[index], 'result.sessions[$index]')); - } - final totalCount = raw.containsKey('totalCount') - ? _safeInteger(raw['totalCount'], 'result.totalCount') - : sessions.length; - if (totalCount < sessions.length) { - throw const WireFormatException( - 'totalCount cannot be less than sessions length', - 'result.totalCount', - ); - } - final expectedTruncated = totalCount > sessions.length; - final truncated = raw.containsKey('truncated') - ? _bool(raw['truncated'], 'result.truncated') - : expectedTruncated; - if (truncated != expectedTruncated) { - throw const WireFormatException( - 'truncated does not match totalCount', - 'result', - ); - } - return SessionListResult( - cursor: cursor, - sessions: UnmodifiableListView(sessions), - totalCount: totalCount, - truncated: truncated, - raw: raw, - ); -} - -SessionRef _sessionRef(Object? value, String path) { - final raw = _map(value, path); - final hostId = _id(raw['hostId'], '$path.hostId'); - final sessionId = _id(raw['sessionId'], '$path.sessionId'); - final project = _map(raw['project'], '$path.project'); - _id(project['projectId'], '$path.project.projectId'); - if (project.containsKey('name')) { - _string(project['name'], '$path.project.name', 256); - } - final revision = _id(raw['revision'], '$path.revision'); - final title = _string(raw['title'], '$path.title', 512); - final status = _string(raw['status'], '$path.status', 64); - final updatedAt = _string(raw['updatedAt'], '$path.updatedAt', 128); - if (raw.containsKey('archivedAt')) { - _string(raw['archivedAt'], '$path.archivedAt', 128); - } - if (raw.containsKey('liveState')) { - _map(raw['liveState'], '$path.liveState'); - } - if (raw.containsKey('model')) { - _string(raw['model'], '$path.model', 256); - } - if (raw.containsKey('thinking')) { - _string(raw['thinking'], '$path.thinking', 256); - } - if (raw.containsKey('pendingApproval')) { - _bool(raw['pendingApproval'], '$path.pendingApproval'); - } - if (raw.containsKey('pendingUserInput')) { - _bool(raw['pendingUserInput'], '$path.pendingUserInput'); - } - if (raw.containsKey('proposedPlan')) { - _string(raw['proposedPlan'], '$path.proposedPlan', 4096); - } - return SessionRef( - hostId: hostId, - sessionId: sessionId, - title: title, - revision: revision, - status: status, - updatedAt: updatedAt, - project: project, - raw: raw, - ); -} - -SnapshotFrame _decodeSnapshot(Map raw) { - final hostId = _id(raw['hostId'], 'hostId'); - final sessionId = _id(raw['sessionId'], 'sessionId'); - final cursor = _transcriptCursor(raw['cursor'], 'cursor'); - final revision = _id(raw['revision'], 'revision'); - final values = _list(raw['entries'], 'entries'); - final entries = []; - for (var index = 0; index < values.length; index++) { - final entry = _durableEntry(values[index], 'entries[$index]'); - if (entry.hostId != hostId || entry.sessionId != sessionId) { - throw WireFormatException( - 'entry belongs to another session', - 'entries[$index]', - ); - } - entries.add(entry); - } - return SnapshotFrame( - hostId: hostId, - sessionId: sessionId, - cursor: cursor, - revision: revision, - entries: UnmodifiableListView(entries), - raw: raw, - ); -} - -EntryFrame _decodeEntryFrame(Map raw) { - final hostId = _id(raw['hostId'], 'hostId'); - final sessionId = _id(raw['sessionId'], 'sessionId'); - final entry = _durableEntry(raw['entry'], 'entry'); - if (entry.hostId != hostId || entry.sessionId != sessionId) { - throw const WireFormatException( - 'entry belongs to another session', - 'entry', - ); - } - return EntryFrame( - hostId: hostId, - sessionId: sessionId, - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - revision: _id(raw['revision'], 'revision'), - entry: entry, - raw: raw, - ); -} - -DurableEntry _durableEntry(Object? value, String path) { - final raw = _map(value, path); - final id = _id(raw['id'], '$path.id'); - if (!raw.containsKey('parentId')) { - throw WireFormatException('parentId is required', '$path.parentId'); - } - final parentId = raw['parentId'] == null - ? null - : _id(raw['parentId'], '$path.parentId'); - final hostId = _id(raw['hostId'], '$path.hostId'); - final sessionId = _id(raw['sessionId'], '$path.sessionId'); - final kind = _string(raw['kind'], '$path.kind', 128); - final timestamp = _string(raw['timestamp'], '$path.timestamp', 128); - final data = _map(raw['data'], '$path.data'); - return DurableEntry( - id: id, - parentId: parentId, - hostId: hostId, - sessionId: sessionId, - kind: kind, - timestamp: timestamp, - data: data, - raw: raw, - ); -} - -EventFrame _decodeEvent(Map raw) { - final event = _map(raw['event'], 'event'); - _string(event['type'], 'event.type', 128); - return EventFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - event: event, - raw: raw, - ); -} - -ResponseFrame _decodeResponse(Map raw) { - final requestId = _id(raw['requestId'], 'requestId'); - final commandId = raw.containsKey('commandId') - ? _id(raw['commandId'], 'commandId') - : null; - final hostId = _id(raw['hostId'], 'hostId'); - final sessionId = raw.containsKey('sessionId') - ? _id(raw['sessionId'], 'sessionId') - : null; - final command = raw.containsKey('command') - ? _string(raw['command'], 'command', 128) - : null; - final ok = _bool(raw['ok'], 'ok'); - final hasResult = raw.containsKey('result'); - Object? result; - WireResponseError? error; - if (ok) { - if (raw.containsKey('error')) { - throw const WireFormatException( - 'successful response cannot have an error', - 'error', - ); - } - if (hasResult && command == null) { - throw const WireFormatException( - 'successful response result requires command correlation', - 'command', - ); - } - if (hasResult) { - result = switch (command) { - 'session.list' || - 'host.list' => _decodeSessionListResult(_map(raw['result'], 'result')), - 'catalog.get' => _decodeCatalogResult(_map(raw['result'], 'result')), - 'settings.read' => _decodeSettingsResult(_map(raw['result'], 'result')), - 'transcript.search' => _decodeTranscriptSearchResult( - _map(raw['result'], 'result'), - ), - 'transcript.context' => _decodeTranscriptContextResult( - _map(raw['result'], 'result'), - ), - 'transcript.page' => _decodeTranscriptPageResult( - _map(raw['result'], 'result'), - ), - 'session.state.get' => _decodeSessionStateResult( - _map(raw['result'], 'result'), - ), - 'usage.read' => _decodeUsageReadResult(_map(raw['result'], 'result')), - 'broker.status' => _decodeBrokerStatusResult( - _map(raw['result'], 'result'), - ), - _ => raw['result'], - }; - } - } else { - if (hasResult) { - throw const WireFormatException( - 'failed response cannot have a result', - 'result', - ); - } - final errorRaw = _map(raw['error'], 'error'); - error = WireResponseError( - code: _string(errorRaw['code'], 'error.code', 128), - message: _nonemptyText(errorRaw['message'], 'error.message', 1024), - details: errorRaw.containsKey('details') - ? _map(errorRaw['details'], 'error.details') - : null, - raw: errorRaw, - ); - } - return ResponseFrame( - requestId: requestId, - commandId: commandId, - hostId: hostId, - sessionId: sessionId, - command: command, - ok: ok, - result: result, - error: error, - raw: raw, - ); -} - -SessionStateResult _decodeSessionStateResult(Map raw) { - const allowedKeys = { - 'isStreaming', - 'isCompacting', - 'isPaused', - 'messageCount', - 'queuedMessageCount', - 'steeringMode', - 'followUpMode', - 'interruptMode', - 'queuedMessages', - 'model', - 'thinking', - 'thinkingEffective', - 'thinkingResolved', - 'thinkingLevels', - 'thinkingSupported', - 'thinkingOffFloored', - 'fast', - 'fastAvailable', - 'fastActive', - 'sessionName', - 'contextUsage', - }; - for (final key in raw.keys) { - if (!allowedKeys.contains(key)) { - throw WireFormatException('unknown session state field', 'result.$key'); - } - } - _enumString(raw['steeringMode'], 'result.steeringMode', const { - 'all', - 'one-at-a-time', - }); - _enumString(raw['followUpMode'], 'result.followUpMode', const { - 'all', - 'one-at-a-time', - }); - _enumString(raw['interruptMode'], 'result.interruptMode', const { - 'immediate', - 'wait', - }); - - SessionStateModel? model; - if (raw['model'] case final Object value) { - final modelRaw = _map(value, 'result.model'); - const modelKeys = { - 'id', - 'provider', - 'displayName', - 'selector', - 'role', - }; - for (final key in modelRaw.keys) { - if (!modelKeys.contains(key)) { - throw WireFormatException( - 'unknown session model field', - 'result.model.$key', - ); - } - } - model = SessionStateModel( - id: _string(modelRaw['id'], 'result.model.id', 256), - provider: _string(modelRaw['provider'], 'result.model.provider', 256), - displayName: modelRaw.containsKey('displayName') - ? _string(modelRaw['displayName'], 'result.model.displayName', 256) - : null, - selector: modelRaw.containsKey('selector') - ? _string(modelRaw['selector'], 'result.model.selector', 512) - : null, - role: modelRaw.containsKey('role') - ? _string(modelRaw['role'], 'result.model.role', 256) - : null, - ); - } - - const configuredThinking = { - 'inherit', - 'off', - 'auto', - 'minimal', - 'low', - 'medium', - 'high', - 'xhigh', - 'max', - }; - const resolvedThinking = { - 'minimal', - 'low', - 'medium', - 'high', - 'xhigh', - 'max', - }; - final thinking = raw.containsKey('thinking') - ? _enumString(raw['thinking'], 'result.thinking', configuredThinking) - : null; - if (raw.containsKey('thinkingEffective')) { - _enumString(raw['thinkingEffective'], 'result.thinkingEffective', const { - 'off', - ...resolvedThinking, - }); - } - if (raw.containsKey('thinkingResolved')) { - _enumString( - raw['thinkingResolved'], - 'result.thinkingResolved', - resolvedThinking, - ); - } - List? thinkingLevels; - if (raw.containsKey('thinkingLevels')) { - thinkingLevels = _stringList( - raw['thinkingLevels'], - 'result.thinkingLevels', - maxItems: resolvedThinking.length, - ); - for (var index = 0; index < thinkingLevels.length; index++) { - if (!resolvedThinking.contains(thinkingLevels[index])) { - throw WireFormatException( - 'invalid thinking level', - 'result.thinkingLevels[$index]', - ); - } - } - if (thinkingLevels.toSet().length != thinkingLevels.length) { - throw const WireFormatException( - 'duplicate thinking level', - 'result.thinkingLevels', - ); - } - thinkingLevels = List.unmodifiable(thinkingLevels); - } - if (raw.containsKey('thinkingOffFloored')) { - _bool(raw['thinkingOffFloored'], 'result.thinkingOffFloored'); - } - if (raw.containsKey('sessionName')) { - _string(raw['sessionName'], 'result.sessionName', 512); - } - if (raw['contextUsage'] case final Object value) { - final usage = _map(value, 'result.contextUsage'); - if (usage.keys.toSet().difference(const {'used', 'limit'}).isNotEmpty) { - throw const WireFormatException( - 'unknown context usage field', - 'result.contextUsage', - ); - } - final used = _safeInteger(usage['used'], 'result.contextUsage.used'); - final limit = _safeInteger(usage['limit'], 'result.contextUsage.limit'); - if (used > limit) { - throw const WireFormatException( - 'context usage exceeds limit', - 'result.contextUsage', - ); - } - } - if (raw['queuedMessages'] case final Object value) { - final queues = _map(value, 'result.queuedMessages'); - if (queues.keys.toSet().difference(const { - 'steering', - 'followUp', - }).isNotEmpty) { - throw const WireFormatException( - 'unknown queued message field', - 'result.queuedMessages', - ); - } - for (final name in const ['steering', 'followUp']) { - final messages = _list(queues[name], 'result.queuedMessages.$name', 128); - for (var index = 0; index < messages.length; index++) { - _boundedText( - messages[index], - 'result.queuedMessages.$name[$index]', - 65_536, - ); - } - } - } - - return SessionStateResult( - isStreaming: _bool(raw['isStreaming'], 'result.isStreaming'), - isCompacting: _bool(raw['isCompacting'], 'result.isCompacting'), - isPaused: _bool(raw['isPaused'], 'result.isPaused'), - messageCount: _safeInteger(raw['messageCount'], 'result.messageCount'), - queuedMessageCount: _safeInteger( - raw['queuedMessageCount'], - 'result.queuedMessageCount', - ), - model: model, - thinking: thinking, - thinkingLevels: thinkingLevels, - thinkingSupported: raw.containsKey('thinkingSupported') - ? _bool(raw['thinkingSupported'], 'result.thinkingSupported') - : null, - fast: raw.containsKey('fast') ? _bool(raw['fast'], 'result.fast') : null, - fastAvailable: raw.containsKey('fastAvailable') - ? _bool(raw['fastAvailable'], 'result.fastAvailable') - : null, - fastActive: raw.containsKey('fastActive') - ? _bool(raw['fastActive'], 'result.fastActive') - : null, - ); -} - -ErrorFrame _decodeError(Map raw) { - return ErrorFrame( - code: _string(raw['code'], 'code', 128), - message: _string(raw['message'], 'message', 2048), - requestId: raw.containsKey('requestId') - ? _id(raw['requestId'], 'requestId') - : null, - details: raw.containsKey('details') - ? _map(raw['details'], 'details') - : null, - raw: raw, - ); -} - -GapFrame _decodeGap(Map raw) { - final from = _transcriptCursor(raw['from'], 'from'); - final to = _transcriptCursor(raw['to'], 'to'); - if (from.epoch != to.epoch || to.seq < from.seq) { - throw const WireFormatException('invalid gap cursor range', 'to'); - } - return GapFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - from: from, - to: to, - reason: _string(raw['reason'], 'reason', 256), - raw: raw, - ); -} - -AgentFrame _decodeAgent(Map raw) { - final progress = raw.containsKey('progress') - ? _finiteNumber(raw['progress'], 'progress') - : null; - if (progress != null && (progress < 0 || progress > 1)) { - throw const WireFormatException( - 'progress must be between zero and one', - 'progress', - ); - } - return AgentFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - agentId: _id(raw['agentId'], 'agentId'), - state: _string(raw['state'], 'state', 64), - progress: progress, - detail: raw.containsKey('detail') ? _map(raw['detail'], 'detail') : null, - raw: raw, - ); -} - -TerminalFrame _decodeTerminal(Map raw) { - final stream = _enumString(raw['stream'], 'stream', const { - 'stdout', - 'stderr', - 'exit', - }); - String? data; - int? exitCode; - if (stream == 'exit') { - if (raw.containsKey('data')) { - throw const WireFormatException('terminal exit cannot have data', 'data'); - } - exitCode = _signedSafeInteger(raw['exitCode'], 'exitCode'); - } else { - data = _boundedText(raw['data'], 'data', 256000); - if (raw.containsKey('exitCode')) { - throw const WireFormatException( - 'terminal output cannot have exitCode', - 'exitCode', - ); - } - } - return TerminalFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - terminalId: _id(raw['terminalId'], 'terminalId'), - stream: stream, - data: data, - exitCode: exitCode, - raw: raw, - ); -} - -FilesFrame _decodeFiles(Map raw) { - return FilesFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - path: _safeRelativePath(raw['path'], 'path'), - content: raw.containsKey('content') - ? _boundedText(raw['content'], 'content', 768 * 1024) - : null, - truncated: raw.containsKey('truncated') - ? _bool(raw['truncated'], 'truncated') - : null, - raw: raw, - ); -} - -ReviewFrame _decodeReview(Map raw) { - final values = _list(raw['findings'], 'findings'); - final findings = >[]; - for (var index = 0; index < values.length; index++) { - findings.add(_map(values[index], 'findings[$index]')); - } - return ReviewFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - reviewId: _id(raw['reviewId'], 'reviewId'), - status: _string(raw['status'], 'status', 64), - path: raw.containsKey('path') - ? _safeRelativePath(raw['path'], 'path') - : null, - findings: List>.unmodifiable(findings), - raw: raw, - ); -} - -AuditFrame _decodeAudit(Map raw) { - return AuditFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: raw.containsKey('sessionId') - ? _id(raw['sessionId'], 'sessionId') - : null, - action: _string(raw['action'], 'action', 128), - actor: _string(raw['actor'], 'actor', 256), - timestamp: _string(raw['timestamp'], 'timestamp', 128), - detail: raw.containsKey('detail') ? _map(raw['detail'], 'detail') : null, - raw: raw, - ); -} - -ConfirmationFrame _decodeConfirmation(Map raw) { - return ConfirmationFrame( - confirmationId: _id(raw['confirmationId'], 'confirmationId'), - commandId: _id(raw['commandId'], 'commandId'), - hostId: _id(raw['hostId'], 'hostId'), - sessionId: raw.containsKey('sessionId') - ? _id(raw['sessionId'], 'sessionId') - : null, - commandHash: _string(raw['commandHash'], 'commandHash', 256), - revision: _id(raw['revision'], 'revision'), - expiresAt: _string(raw['expiresAt'], 'expiresAt', 128), - summary: _nonemptyText(raw['summary'], 'summary', 2048), - preview: raw.containsKey('preview') - ? _nonemptyText(raw['preview'], 'preview', 8192) - : null, - raw: raw, - ); -} - -PairOkFrame _decodePairOk(Map raw) { - final token = _string(raw['deviceToken'], 'deviceToken', 512); - if (!RegExp(r'^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$').hasMatch(token)) { - throw const WireFormatException( - 'device token must be canonical base64url for 32 bytes', - 'deviceToken', - ); - } - return PairOkFrame( - requestId: _id(raw['requestId'], 'requestId'), - pairingId: _id(raw['pairingId'], 'pairingId'), - deviceId: _id(raw['deviceId'], 'deviceId'), - deviceName: _string(raw['deviceName'], 'deviceName', 256), - platform: _string(raw['platform'], 'platform', 128), - requestedCapabilities: _capabilityList( - raw['requestedCapabilities'], - 'requestedCapabilities', - ), - grantedCapabilities: _capabilityList( - raw['grantedCapabilities'], - 'grantedCapabilities', - ), - deviceToken: token, - expiresAt: _string(raw['expiresAt'], 'expiresAt', 128), - raw: raw, - ); -} - -PairErrorFrame _decodePairError(Map raw) { - return PairErrorFrame( - code: _string(raw['code'], 'code', 128), - message: _nonemptyText(raw['message'], 'message', 1024), - requestId: raw.containsKey('requestId') - ? _id(raw['requestId'], 'requestId') - : null, - raw: raw, - ); -} - -ByeFrame _decodeBye(Map raw) => ByeFrame( - code: _string(raw['code'], 'code', 128), - reason: _string(raw['reason'], 'reason', 1024), - retryable: _bool(raw['retryable'], 'retryable'), - raw: raw, -); - -WatchFrame _decodeWatch(Map raw, String type) { - final hostId = _id(raw['hostId'], 'hostId'); - final revision = _id(raw['revision'], 'revision'); - if (type == 'host.watch') { - return HostWatchFrame( - watchId: _id(raw['watchId'], 'watchId'), - hostId: hostId, - cursor: _sessionIndexCursor(raw['cursor'], 'cursor'), - state: _enumString(raw['state'], 'state', const { - 'started', - 'stopped', - 'ready', - }), - revision: revision, - raw: raw, - ); - } - final sessionId = _id(raw['sessionId'], 'sessionId'); - final cursor = _transcriptCursor(raw['cursor'], 'cursor'); - if (type == 'session.watch') { - return SessionWatchFrame( - watchId: _id(raw['watchId'], 'watchId'), - hostId: hostId, - sessionId: sessionId, - cursor: cursor, - state: _enumString(raw['state'], 'state', const { - 'started', - 'stopped', - 'ready', - }), - revision: revision, - raw: raw, - ); - } - if (type == 'session.state') { - return SessionStateFrame( - hostId: hostId, - sessionId: sessionId, - cursor: cursor, - state: _string(raw['state'], 'state', 128), - revision: revision, - raw: raw, - ); - } - SessionRef? upsert; - String? remove; - if (raw.containsKey('upsert')) { - upsert = _sessionRef(raw['upsert'], 'upsert'); - } - if (raw.containsKey('remove')) { - remove = _id(raw['remove'], 'remove'); - } - if ((upsert == null) == (remove == null)) { - throw const WireFormatException( - 'session delta requires exactly one of upsert or remove', - 'delta', - ); - } - if (upsert != null && - (upsert.hostId != hostId || upsert.sessionId != sessionId)) { - throw const WireFormatException( - 'upsert belongs to another session', - 'upsert', - ); - } - if (remove != null && remove != sessionId) { - throw const WireFormatException( - 'remove belongs to another session', - 'remove', - ); - } - return SessionDeltaFrame( - hostId: hostId, - sessionId: sessionId, - cursor: cursor, - revision: revision, - upsert: upsert, - remove: remove, - raw: raw, - ); -} - -LeaseFrame _decodeLease(Map raw, String type) { - final expectedKind = type == 'lease' ? 'controller' : 'prompt'; - if (raw['kind'] != expectedKind) { - throw const WireFormatException('lease kind does not match type', 'kind'); - } - return LeaseFrame( - frameType: type, - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - leaseId: _id(raw['leaseId'], 'leaseId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - kind: expectedKind, - state: _enumString(raw['state'], 'state', const { - 'acquired', - 'renewed', - 'released', - 'expired', - }), - owner: _id(raw['owner'], 'owner'), - expiresAt: _string(raw['expiresAt'], 'expiresAt', 128), - revision: raw.containsKey('revision') - ? _id(raw['revision'], 'revision') - : null, - raw: raw, - ); -} - -AgentAdditiveFrame _decodeAgentAdditive(Map raw, String type) { - String? state; - String? lifecycle; - double? progress; - String? event; - Map? detail; - Map? data; - List? entries; - const states = { - 'created', - 'started', - 'running', - 'completed', - 'failed', - 'cancelled', - }; - switch (type) { - case 'agent.state': - state = _enumString(raw['state'], 'state', states); - break; - case 'agent.lifecycle': - lifecycle = _enumString(raw['lifecycle'], 'lifecycle', states); - break; - case 'agent.progress': - progress = _finiteNumber(raw['progress'], 'progress'); - if (progress < 0 || progress > 1) { - throw const WireFormatException( - 'progress must be between zero and one', - 'progress', - ); - } - if (raw.containsKey('detail')) { - detail = _map(raw['detail'], 'detail'); - } - break; - case 'agent.event': - event = _string(raw['event'], 'event', 128); - if (raw.containsKey('data')) { - data = _map(raw['data'], 'data'); - } - break; - case 'agent.transcript': - final values = _list(raw['entries'], 'entries'); - final decoded = []; - for (var index = 0; index < values.length; index++) { - decoded.add(_durableEntry(values[index], 'entries[$index]')); - } - entries = List.unmodifiable(decoded); - break; - } - final hostId = _id(raw['hostId'], 'hostId'); - final sessionId = _id(raw['sessionId'], 'sessionId'); - if (entries != null) { - for (final entry in entries) { - if (entry.hostId != hostId || entry.sessionId != sessionId) { - throw const WireFormatException( - 'transcript entry belongs to another session', - 'entries', - ); - } - } - } - return AgentAdditiveFrame( - frameType: type, - hostId: hostId, - sessionId: sessionId, - agentId: _id(raw['agentId'], 'agentId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - revision: _id(raw['revision'], 'revision'), - state: state, - lifecycle: lifecycle, - progress: progress, - event: event, - detail: detail, - data: data, - entries: entries, - raw: raw, - ); -} - -TerminalOutputFrame _decodeTerminalOutput(Map raw) { - final encoding = raw.containsKey('encoding') - ? _enumString(raw['encoding'], 'encoding', const {'utf8', 'base64'}) - : null; - final data = encoding == 'base64' - ? _base64(raw['data'], 'data', 256000) - : _boundedText(raw['data'], 'data', 256000); - return TerminalOutputFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - terminalId: _id(raw['terminalId'], 'terminalId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - stream: _enumString(raw['stream'], 'stream', const {'stdout', 'stderr'}), - data: data, - encoding: encoding, - raw: raw, - ); -} - -TerminalExitFrame _decodeTerminalExit(Map raw) { - return TerminalExitFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - terminalId: _id(raw['terminalId'], 'terminalId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - exitCode: _signedSafeInteger(raw['exitCode'], 'exitCode'), - signal: raw.containsKey('signal') - ? _string(raw['signal'], 'signal', 128) - : null, - raw: raw, - ); -} - -FilesAdditiveFrame _decodeFilesAdditive(Map raw, String type) { - List? entries; - String? content; - String? encoding; - String? patch; - String? diff; - TranscriptCursor? cursor; - String? revision; - String? fromRevision; - String? toRevision; - switch (type) { - case 'files.list': - final values = _list(raw['entries'], 'entries'); - final decoded = []; - for (var index = 0; index < values.length; index++) { - decoded.add(_fileListEntry(values[index], 'entries[$index]')); - } - entries = List.unmodifiable(decoded); - if (raw.containsKey('cursor')) { - cursor = _transcriptCursor(raw['cursor'], 'cursor'); - } - if (raw.containsKey('revision')) { - revision = _id(raw['revision'], 'revision'); - } - break; - case 'files.read': - encoding = raw.containsKey('encoding') - ? _enumString(raw['encoding'], 'encoding', const {'utf8', 'base64'}) - : null; - content = encoding == 'base64' - ? _base64(raw['content'], 'content', 768 * 1024) - : _boundedText(raw['content'], 'content', 768 * 1024); - if (raw.containsKey('revision')) { - revision = _id(raw['revision'], 'revision'); - } - break; - case 'files.write': - encoding = raw.containsKey('encoding') - ? _enumString(raw['encoding'], 'encoding', const {'utf8', 'base64'}) - : null; - content = encoding == 'base64' - ? _base64(raw['content'], 'content', 768 * 1024) - : _boundedText(raw['content'], 'content', 768 * 1024); - revision = _id(raw['revision'], 'revision'); - break; - case 'files.patch': - patch = _boundedText(raw['patch'], 'patch', 768 * 1024); - revision = _id(raw['revision'], 'revision'); - break; - case 'files.diff': - diff = _boundedText(raw['diff'], 'diff', 768 * 1024); - if (raw.containsKey('fromRevision')) { - fromRevision = _id(raw['fromRevision'], 'fromRevision'); - } - if (raw.containsKey('toRevision')) { - toRevision = _id(raw['toRevision'], 'toRevision'); - } - break; - } - return FilesAdditiveFrame( - frameType: type, - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - path: _safeRelativePath(raw['path'], 'path'), - entries: entries, - content: content, - encoding: encoding, - patch: patch, - diff: diff, - cursor: cursor, - revision: revision, - fromRevision: fromRevision, - toRevision: toRevision, - raw: raw, - ); -} - -FileListEntry _fileListEntry(Object? value, String path) { - final raw = _map(value, path); - final size = raw.containsKey('size') - ? _safeInteger(raw['size'], '$path.size') - : null; - if (size != null && size > 768 * 1024 * 1024) { - throw WireFormatException('file size exceeds limit', '$path.size'); - } - return FileListEntry( - path: _safeRelativePath(raw['path'], '$path.path'), - kind: _enumString(raw['kind'], '$path.kind', const { - 'file', - 'directory', - 'symlink', - }), - size: size, - revision: raw.containsKey('revision') - ? _id(raw['revision'], '$path.revision') - : null, - raw: raw, - ); -} - -AuditTailFrame _decodeAuditTail(Map raw) { - final hostId = _id(raw['hostId'], 'hostId'); - final values = _list(raw['events'], 'events'); - final events = []; - for (var index = 0; index < values.length; index++) { - final event = _auditEvent(values[index], 'events[$index]'); - if (event.hostId != hostId) { - throw const WireFormatException( - 'audit event belongs to another host', - 'events', - ); - } - events.add(event); - } - return AuditTailFrame( - hostId: hostId, - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - events: List.unmodifiable(events), - raw: raw, - ); -} - -AuditEventFrame _decodeAuditEventFrame(Map raw) { - final hostId = _id(raw['hostId'], 'hostId'); - final event = _auditEvent(raw['event'], 'event'); - if (event.hostId != hostId) { - throw const WireFormatException( - 'audit event belongs to another host', - 'event.hostId', - ); - } - return AuditEventFrame( - hostId: hostId, - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - event: event, - raw: raw, - ); -} - -AuditEvent _auditEvent(Object? value, String path) { - final raw = _map(value, path); - return AuditEvent( - eventId: _id(raw['eventId'], '$path.eventId'), - hostId: _id(raw['hostId'], '$path.hostId'), - sessionId: raw.containsKey('sessionId') - ? _id(raw['sessionId'], '$path.sessionId') - : null, - action: _string(raw['action'], '$path.action', 128), - actor: _string(raw['actor'], '$path.actor', 256), - timestamp: _string(raw['timestamp'], '$path.timestamp', 128), - detail: raw.containsKey('detail') - ? _map(raw['detail'], '$path.detail') - : null, - raw: raw, - ); -} - -OperationExecution _operationExecution(Object? value, String path) { - return switch (_enumString(value, path, const { - 'typed', - 'headless', - 'terminal-only', - 'unavailable', - })) { - 'typed' => OperationExecution.typed, - 'headless' => OperationExecution.headless, - 'terminal-only' => OperationExecution.terminalOnly, - 'unavailable' => OperationExecution.unavailable, - _ => throw WireFormatException('unknown operation execution', path), - }; -} - -OperationDisabledReason _operationDisabledReason(Object? value, String path) { - final raw = _map(value, path); - return OperationDisabledReason( - code: _string(raw['code'], '$path.code', 128), - message: _boundedText(raw['message'], '$path.message', 2048), - raw: raw, - ); -} - -OperationCapability _operationCapability(Object? value, String path) { - final raw = _map(value, path); - final execution = _operationExecution(raw['execution'], '$path.execution'); - final supported = _bool(raw['supported'], '$path.supported'); - final disabledReason = raw.containsKey('disabledReason') - ? _operationDisabledReason(raw['disabledReason'], '$path.disabledReason') - : null; - if (!supported && disabledReason == null) { - throw WireFormatException( - 'unsupported operation requires disabledReason', - '$path.disabledReason', - ); - } - if (supported && disabledReason != null) { - throw WireFormatException( - 'supported operation cannot have disabledReason', - '$path.disabledReason', - ); - } - if (supported && - (execution == OperationExecution.terminalOnly || - execution == OperationExecution.unavailable)) { - throw WireFormatException( - 'terminal-only and unavailable operations cannot be supported', - '$path.supported', - ); - } - return OperationCapability( - operationId: _id(raw['operationId'], '$path.operationId'), - label: _string(raw['label'], '$path.label', 256), - description: raw.containsKey('description') - ? _boundedText(raw['description'], '$path.description', 4096) - : null, - execution: execution, - supported: supported, - disabledReason: disabledReason, - capabilities: raw.containsKey('capabilities') - ? _stringList(raw['capabilities'], '$path.capabilities', maxItems: 128) - : null, - raw: raw, - ); -} - -List? _operationCapabilities( - Map raw, - String path, -) { - if (!raw.containsKey('operations')) { - return null; - } - final values = _list(raw['operations'], path); - return List.unmodifiable([ - for (var index = 0; index < values.length; index++) - _operationCapability(values[index], '$path[$index]'), - ]); -} - -CatalogFrame _decodeCatalog(Map raw) { - final values = _list(raw['items'], 'items'); - final items = []; - for (var index = 0; index < values.length; index++) { - items.add(_catalogItem(values[index], 'items[$index]')); - } - return CatalogFrame( - hostId: _id(raw['hostId'], 'hostId'), - revision: _id(raw['revision'], 'revision'), - items: List.unmodifiable(items), - operations: _operationCapabilities(raw, 'operations'), - raw: raw, - ); -} - -CatalogItem _catalogItem(Object? value, String path) { - final raw = _map(value, path); - return CatalogItem( - id: _id(raw['id'], '$path.id'), - kind: _enumString(raw['kind'], '$path.kind', const { - 'tool', - 'model', - 'command', - 'setting', - 'skill', - 'agent', - 'provider', - 'mode', - }), - name: _string(raw['name'], '$path.name', 256), - description: raw.containsKey('description') - ? _boundedText(raw['description'], '$path.description', 4096) - : null, - capabilities: raw.containsKey('capabilities') - ? _stringList(raw['capabilities'], '$path.capabilities', maxItems: 128) - : null, - supported: raw.containsKey('supported') - ? _bool(raw['supported'], '$path.supported') - : null, - reason: raw.containsKey('reason') - ? _boundedText(raw['reason'], '$path.reason', 2048) - : null, - metadata: raw.containsKey('metadata') - ? _map(raw['metadata'], '$path.metadata') - : null, - raw: raw, - ); -} - -SettingsFrame _decodeSettings(Map raw) => SettingsFrame( - hostId: _id(raw['hostId'], 'hostId'), - revision: _id(raw['revision'], 'revision'), - settings: _map(raw['settings'], 'settings'), - raw: raw, -); - -CatalogResult _decodeCatalogResult(Map raw) { - final values = _list(raw['items'], 'result.items'); - final items = []; - for (var index = 0; index < values.length; index++) { - items.add(_catalogItem(values[index], 'result.items[$index]')); - } - return CatalogResult( - revision: _id(raw['revision'], 'result.revision'), - items: List.unmodifiable(items), - operations: _operationCapabilities(raw, 'result.operations'), - ); -} - -SettingsResult _decodeSettingsResult(Map raw) => - SettingsResult( - revision: _id(raw['revision'], 'result.revision'), - settings: _map(raw['settings'], 'result.settings'), - ); - -TranscriptSearchResult _decodeTranscriptSearchResult(Map raw) { - final values = _list(raw['items'], 'result.items', 50); - final items = []; - for (var index = 0; index < values.length; index++) { - final path = 'result.items[$index]'; - final item = _map(values[index], path); - final snippet = _boundedText(item['snippet'], '$path.snippet', 768); - final rawHighlights = _list(item['highlights'], '$path.highlights', 32); - final highlights = []; - for ( - var highlightIndex = 0; - highlightIndex < rawHighlights.length; - highlightIndex++ - ) { - final highlightPath = '$path.highlights[$highlightIndex]'; - final highlight = _map(rawHighlights[highlightIndex], highlightPath); - final start = _safeInteger(highlight['start'], '$highlightPath.start'); - final end = _safeInteger(highlight['end'], '$highlightPath.end'); - if (start >= end || end > snippet.length) { - throw WireFormatException( - 'highlight must be a non-empty range within the snippet', - highlightPath, - ); - } - highlights.add(TranscriptSearchHighlight(start: start, end: end)); - } - highlights.sort((left, right) { - final start = left.start.compareTo(right.start); - return start != 0 ? start : left.end.compareTo(right.end); - }); - items.add( - TranscriptSearchItem( - sessionId: _id(item['sessionId'], '$path.sessionId'), - projectId: _id(item['projectId'], '$path.projectId'), - sessionTitle: _boundedText( - item['sessionTitle'], - '$path.sessionTitle', - 512, - ), - archivedAt: item.containsKey('archivedAt') - ? _string(item['archivedAt'], '$path.archivedAt', 128) - : null, - anchorId: _id(item['anchorId'], '$path.anchorId'), - role: _transcriptSearchRole(item['role'], '$path.role'), - timestamp: _string(item['timestamp'], '$path.timestamp', 128), - snippet: snippet, - highlights: List.unmodifiable(highlights), - ), - ); - } - final index = _map(raw['index'], 'result.index'); - final indexedSessions = _safeInteger( - index['indexedSessions'], - 'result.index.indexedSessions', - ); - final knownSessions = _safeInteger( - index['knownSessions'], - 'result.index.knownSessions', - ); - if (indexedSessions > knownSessions) { - throw const WireFormatException( - 'indexedSessions must not exceed knownSessions', - 'result.index.indexedSessions', - ); - } - final state = _enumString(index['state'], 'result.index.state', const { - 'building', - 'ready', - 'stale', - }); - return TranscriptSearchResult( - items: List.unmodifiable(items), - nextCursor: raw.containsKey('nextCursor') - ? _string(raw['nextCursor'], 'result.nextCursor', 2048) - : null, - incomplete: _bool(raw['incomplete'], 'result.incomplete'), - index: TranscriptSearchIndexStatus( - state: TranscriptSearchIndexState.values.byName(state), - indexedSessions: indexedSessions, - knownSessions: knownSessions, - generation: _string(index['generation'], 'result.index.generation', 128), - ), - ); -} - -TranscriptPageResult _decodeTranscriptPageResult(Map raw) { - _onlyKeys(raw, 'result', const { - 'entries', - 'nextCursor', - 'hasMore', - 'generation', - }); - final values = _list(raw['entries'], 'result.entries', 128); - final entries = []; - for (var index = 0; index < values.length; index++) { - final entry = _durableEntry(values[index], 'result.entries[$index]'); - if (DateTime.tryParse(entry.timestamp) == null) { - throw WireFormatException( - 'entry timestamp must be ISO-compatible', - 'result.entries[$index].timestamp', - ); - } - entries.add(entry); - } - final hasMore = _bool(raw['hasMore'], 'result.hasMore'); - final nextCursor = raw.containsKey('nextCursor') - ? _string(raw['nextCursor'], 'result.nextCursor', 2048) - : null; - if (hasMore != (nextCursor != null)) { - throw const WireFormatException( - 'nextCursor must be present exactly when hasMore is true', - 'result.nextCursor', - ); - } - if (!_utf8LengthAtMost(jsonEncode(raw), 512 * 1024)) { - throw const WireFormatException( - 'transcript page result exceeds its wire budget', - 'result', - ); - } - return TranscriptPageResult( - entries: List.unmodifiable(entries), - nextCursor: nextCursor, - hasMore: hasMore, - generation: _string(raw['generation'], 'result.generation', 128), - ); -} - -TranscriptContextResult _decodeTranscriptContextResult( - Map raw, -) { - final anchorId = _id(raw['anchorId'], 'result.anchorId'); - final values = _list(raw['rows'], 'result.rows', 41); - final rows = []; - for (var index = 0; index < values.length; index++) { - final path = 'result.rows[$index]'; - final row = _map(values[index], path); - rows.add( - TranscriptContextRow( - anchorId: _id(row['anchorId'], '$path.anchorId'), - role: _transcriptSearchRole(row['role'], '$path.role'), - timestamp: _string(row['timestamp'], '$path.timestamp', 128), - text: _boundedText(row['text'], '$path.text', 16384), - ), - ); - } - final anchorIndex = _safeInteger(raw['anchorIndex'], 'result.anchorIndex'); - if (anchorIndex >= rows.length || rows[anchorIndex].anchorId != anchorId) { - throw const WireFormatException( - 'anchorIndex must identify the matching anchor row', - 'result.anchorIndex', - ); - } - return TranscriptContextResult( - anchorId: anchorId, - rows: List.unmodifiable(rows), - anchorIndex: anchorIndex, - hasBefore: _bool(raw['hasBefore'], 'result.hasBefore'), - hasAfter: _bool(raw['hasAfter'], 'result.hasAfter'), - generation: _string(raw['generation'], 'result.generation', 128), - ); -} - -TranscriptSearchRole _transcriptSearchRole(Object? value, String path) { - final role = _enumString(value, path, const {'user', 'assistant', 'summary'}); - return TranscriptSearchRole.values.byName(role); -} - -PreviewFrame _decodePreview(Map raw, String type) { - final hostId = _id(raw['hostId'], 'hostId'); - final sessionId = _id(raw['sessionId'], 'sessionId'); - if (type == 'preview.error') { - return PreviewFrame( - frameType: type, - hostId: hostId, - sessionId: sessionId, - snapshot: null, - previewId: _id(raw['previewId'], 'previewId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - revision: _id(raw['revision'], 'revision'), - code: _string(raw['code'], 'code', 128), - message: _boundedText(raw['message'], 'message', 2048), - error: null, - raw: raw, - ); - } - final snapshot = _previewSnapshot(raw); - if (type == 'preview.capture' && snapshot.capture == null) { - throw const WireFormatException( - 'preview capture frame requires capture metadata', - 'capture', - ); - } - return PreviewFrame( - frameType: type, - hostId: hostId, - sessionId: sessionId, - snapshot: snapshot, - previewId: snapshot.previewId, - cursor: snapshot.cursor, - revision: snapshot.revision, - code: null, - message: null, - error: type == 'preview.state' && raw.containsKey('error') - ? _boundedText(raw['error'], 'error', 2048) - : null, - raw: raw, - ); -} - -UsageReadResult _decodeUsageReadResult(Map raw) { - _onlyKeys(raw, 'result', const { - 'generatedAt', - 'reports', - 'accountsWithoutUsage', - 'capacity', - }); - if (utf8.encode(jsonEncode(raw)).length > 512 * 1024) { - throw const WireFormatException( - 'usage result exceeds its wire budget', - 'result', - ); - } - final reports = []; - final reportValues = _list(raw['reports'], 'result.reports', 64); - for (var index = 0; index < reportValues.length; index++) { - reports.add( - _decodeUsageReport(reportValues[index], 'result.reports[$index]'), - ); - } - final accounts = []; - final accountValues = _list( - raw['accountsWithoutUsage'], - 'result.accountsWithoutUsage', - 128, - ); - for (var index = 0; index < accountValues.length; index++) { - accounts.add( - _decodeUsageAccount( - accountValues[index], - 'result.accountsWithoutUsage[$index]', - ), - ); - } - final rawCapacity = _map(raw['capacity'], 'result.capacity'); - if (rawCapacity.length > 64) { - throw const WireFormatException( - 'usage capacity has too many providers', - 'result.capacity', - ); - } - final capacity = >{}; - for (final entry in rawCapacity.entries) { - _usageText(entry.key, 'result.capacity.${entry.key}', 128); - final windows = []; - final values = _list(entry.value, 'result.capacity.${entry.key}', 32); - for (var index = 0; index < values.length; index++) { - windows.add( - _decodeUsageCapacityWindow( - values[index], - 'result.capacity.${entry.key}[$index]', - ), - ); - } - capacity[entry.key] = List.unmodifiable(windows); - } - return UsageReadResult( - generatedAt: _usageInteger( - raw['generatedAt'], - 'result.generatedAt', - 8640000000000000, - ), - reports: List.unmodifiable(reports), - accountsWithoutUsage: List.unmodifiable( - accounts, - ), - capacity: Map>.unmodifiable(capacity), - ); -} - -UsageReport _decodeUsageReport(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'provider', - 'fetchedAt', - 'limits', - 'resetCredits', - 'notes', - 'metadata', - }); - final provider = _usageText(raw['provider'], '$path.provider', 128); - final limits = []; - final ids = {}; - final values = _list(raw['limits'], '$path.limits', 32); - for (var index = 0; index < values.length; index++) { - final limit = _decodeUsageLimit( - values[index], - '$path.limits[$index]', - provider, - ); - if (!ids.add(limit.id)) { - throw WireFormatException( - 'duplicate usage limit id', - '$path.limits[$index].id', - ); - } - limits.add(limit); - } - int? availableResetCredits; - if (raw.containsKey('resetCredits')) { - final credits = _map(raw['resetCredits'], '$path.resetCredits'); - _onlyKeys(credits, '$path.resetCredits', const { - 'availableCount', - 'credits', - }); - availableResetCredits = _usageInteger( - credits['availableCount'], - '$path.resetCredits.availableCount', - 64, - ); - if (credits.containsKey('credits')) { - final creditValues = _list( - credits['credits'], - '$path.resetCredits.credits', - 64, - ); - for (var index = 0; index < creditValues.length; index++) { - final creditPath = '$path.resetCredits.credits[$index]'; - final credit = _map(creditValues[index], creditPath); - _onlyKeys(credit, creditPath, const { - 'grantedAt', - 'expiresAt', - 'status', - }); - for (final field in const ['grantedAt', 'expiresAt']) { - if (credit.containsKey(field)) { - final timestamp = _usageText( - credit[field], - '$creditPath.$field', - 64, - ); - if (DateTime.tryParse(timestamp) == null) { - throw WireFormatException( - 'expected ISO timestamp', - '$creditPath.$field', - ); - } - } - } - if (credit.containsKey('status')) { - _usageText(credit['status'], '$creditPath.status', 64); - } - } - } - } - final metadata = raw.containsKey('metadata') - ? _decodeUsageMetadata(raw['metadata'], '$path.metadata') - : const {}; - return UsageReport( - provider: provider, - fetchedAt: _usageInteger( - raw['fetchedAt'], - '$path.fetchedAt', - 8640000000000000, - ), - limits: List.unmodifiable(limits), - availableResetCredits: availableResetCredits, - notes: raw.containsKey('notes') - ? _usageNotes(raw['notes'], '$path.notes', 8) - : const [], - metadata: metadata, - ); -} - -UsageLimit _decodeUsageLimit(Object? value, String path, String provider) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'id', - 'label', - 'scope', - 'window', - 'amount', - 'status', - 'notes', - }); - final scope = _decodeUsageScope(raw['scope'], '$path.scope'); - if (scope.provider != provider) { - throw WireFormatException( - 'usage limit provider does not match its report', - '$path.scope.provider', - ); - } - return UsageLimit( - id: _usageText(raw['id'], '$path.id', 256), - label: _usageText(raw['label'], '$path.label', 512), - scope: scope, - window: raw.containsKey('window') - ? _decodeUsageWindow(raw['window'], '$path.window') - : null, - amount: _decodeUsageAmount(raw['amount'], '$path.amount'), - status: raw.containsKey('status') - ? UsageStatus.values.byName( - _enumString(raw['status'], '$path.status', const { - 'ok', - 'warning', - 'exhausted', - 'unknown', - }), - ) - : null, - notes: raw.containsKey('notes') - ? _usageNotes(raw['notes'], '$path.notes', 8) - : const [], - ); -} - -UsageScope _decodeUsageScope(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'provider', - 'accountId', - 'projectId', - 'orgId', - 'modelId', - 'tier', - 'windowId', - 'shared', - }); - String? optional(String field, [int max = 512]) => raw.containsKey(field) - ? _usageText(raw[field], '$path.$field', max) - : null; - return UsageScope( - provider: _usageText(raw['provider'], '$path.provider', 128), - accountId: optional('accountId'), - projectId: optional('projectId'), - orgId: optional('orgId'), - modelId: optional('modelId'), - tier: optional('tier', 256), - windowId: optional('windowId', 256), - shared: raw.containsKey('shared') - ? _bool(raw['shared'], '$path.shared') - : null, - ); -} - -UsageWindow _decodeUsageWindow(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const {'id', 'label', 'durationMs', 'resetsAt'}); - return UsageWindow( - id: _usageText(raw['id'], '$path.id', 256), - label: _usageText(raw['label'], '$path.label', 512), - durationMs: raw.containsKey('durationMs') - ? _usageInteger(raw['durationMs'], '$path.durationMs', 315576000000) - : null, - resetsAt: raw.containsKey('resetsAt') - ? _usageInteger(raw['resetsAt'], '$path.resetsAt', 8640000000000000) - : null, - ); -} - -UsageAmount _decodeUsageAmount(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'used', - 'limit', - 'remaining', - 'usedFraction', - 'remainingFraction', - 'unit', - }); - double? amount(String field) => - raw.containsKey(field) ? _usageNumber(raw[field], '$path.$field') : null; - return UsageAmount( - unit: UsageUnit.values.byName( - _enumString(raw['unit'], '$path.unit', const { - 'percent', - 'tokens', - 'requests', - 'usd', - 'minutes', - 'bytes', - 'unknown', - }), - ), - used: amount('used'), - limit: amount('limit'), - remaining: amount('remaining'), - usedFraction: amount('usedFraction'), - remainingFraction: amount('remainingFraction'), - ); -} - -UsageAccountWithoutReport _decodeUsageAccount(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'provider', - 'type', - 'email', - 'accountId', - 'projectId', - 'enterpriseUrl', - 'orgId', - 'orgName', - }); - String? optional(String field, [int max = 512]) => raw.containsKey(field) - ? _usageText(raw[field], '$path.$field', max) - : null; - final enterpriseUrl = optional('enterpriseUrl', 2048); - if (enterpriseUrl != null) { - _safeHttpEndpoint(enterpriseUrl, '$path.enterpriseUrl'); - } - final type = _enumString(raw['type'], '$path.type', const { - 'api_key', - 'oauth', - }); - return UsageAccountWithoutReport( - provider: _usageText(raw['provider'], '$path.provider', 128), - type: type == 'api_key' ? UsageAccountType.apiKey : UsageAccountType.oauth, - email: optional('email'), - accountId: optional('accountId'), - projectId: optional('projectId'), - enterpriseUrl: enterpriseUrl, - orgId: optional('orgId'), - orgName: optional('orgName'), - ); -} - -UsageCapacityWindow _decodeUsageCapacityWindow(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'window', - 'durationMs', - 'accounts', - 'usedAccounts', - 'remainingAccounts', - }); - final accounts = _usageInteger(raw['accounts'], '$path.accounts', 2048); - return UsageCapacityWindow( - window: _usageText(raw['window'], '$path.window', 256), - durationMs: raw.containsKey('durationMs') - ? _usageInteger(raw['durationMs'], '$path.durationMs', 315576000000) - : null, - accounts: accounts, - usedAccounts: _usageBoundedNumber( - raw['usedAccounts'], - '$path.usedAccounts', - 0, - accounts.toDouble(), - ), - remainingAccounts: _usageBoundedNumber( - raw['remainingAccounts'], - '$path.remainingAccounts', - 0, - accounts.toDouble(), - ), - ); -} - -Map _decodeUsageMetadata(Object? value, String path) { - final raw = _map(value, path); - const stringKeys = { - 'email', - 'accountId', - 'projectId', - 'orgId', - 'orgName', - 'planType', - 'plan', - 'currentTierId', - 'currentTierName', - 'source', - 'period', - 'quotaResetDate', - }; - const booleanKeys = {'allowed', 'limitReached'}; - _onlyKeys(raw, path, const {...stringKeys, ...booleanKeys}); - final result = {}; - for (final entry in raw.entries) { - result[entry.key] = stringKeys.contains(entry.key) - ? _usageText( - entry.value, - '$path.${entry.key}', - entry.key == 'email' ? 512 : 256, - ) - : _bool(entry.value, '$path.${entry.key}'); - } - return Map.unmodifiable(result); -} - -BrokerStatusResult _decodeBrokerStatusResult(Map raw) { - final state = _enumString(raw['state'], 'result.state', const { - 'local', - 'connected', - 'missing-token', - 'unreachable', - }); - final endpointRequired = state == 'connected' || state == 'unreachable'; - _onlyKeys( - raw, - 'result', - state == 'local' - ? const {'state', 'generation'} - : const {'state', 'endpoint', 'generation'}, - ); - final endpoint = raw.containsKey('endpoint') - ? _safeHttpEndpoint( - _usageText(raw['endpoint'], 'result.endpoint', 2048), - 'result.endpoint', - ) - : null; - if (endpointRequired && endpoint == null) { - throw const WireFormatException( - 'broker endpoint is required', - 'result.endpoint', - ); - } - return BrokerStatusResult( - state: switch (state) { - 'local' => BrokerState.local, - 'connected' => BrokerState.connected, - 'missing-token' => BrokerState.missingToken, - _ => BrokerState.unreachable, - }, - generation: _usageInteger( - raw['generation'], - 'result.generation', - 2147483647, - ), - endpoint: endpoint, - ); -} - -String _safeHttpEndpoint(String value, String path) { - final uri = Uri.tryParse(value); - if (uri == null || - !uri.hasAuthority || - uri.host.isEmpty || - (uri.scheme != 'http' && uri.scheme != 'https') || - uri.userInfo.isNotEmpty || - uri.hasQuery || - uri.hasFragment) { - throw WireFormatException( - 'endpoint must be an HTTP(S) URL without credentials or parameters', - path, - ); - } - return uri.toString(); -} - -List _usageNotes(Object? value, String path, int max) { - final values = _list(value, path, max); - return List.unmodifiable( - List.generate( - values.length, - (index) => _usageText(values[index], '$path[$index]', 1024), - growable: false, - ), - ); -} - -String _usageText(Object? value, String path, int maxBytes) { - if (value is! String || - !_utf8LengthAtMost(value, maxBytes) || - _hasControlCharacter(value)) { - throw WireFormatException('expected bounded control-free text', path); - } - return value; -} - -double _usageBoundedNumber( - Object? value, - String path, - double minimum, - double maximum, -) { - final result = _usageNumber(value, path); - if (result < minimum || result > maximum) { - throw WireFormatException( - 'usage number is outside its allowed range', - path, - ); - } - return result; -} - -double _usageNumber(Object? value, String path) { - if (value is! num || - !value.isFinite || - value < -1000000000000000 || - value > 1000000000000000) { - throw WireFormatException( - 'usage number is outside its allowed range', - path, - ); - } - return value.toDouble(); -} - -int _usageInteger(Object? value, String path, int max) { - final result = _safeInteger(value, path); - if (result > max) throw WireFormatException('value exceeds limit', path); - return result; -} - -void _onlyKeys(Map value, String path, Set allowed) { - for (final key in value.keys) { - if (!allowed.contains(key)) { - throw WireFormatException('unknown field', '$path.$key'); - } - } -} - -PreviewSnapshot _previewSnapshot(Map raw) { - final url = _string(raw['url'], 'preview.url', 4096); - final uri = Uri.tryParse(url); - if (uri == null || - !uri.hasScheme || - (uri.scheme != 'http' && uri.scheme != 'https') || - uri.userInfo.isNotEmpty) { - throw const WireFormatException( - 'preview URL must be http(s) without credentials', - 'preview.url', - ); - } - Map? viewport; - if (raw.containsKey('viewport')) { - viewport = _map(raw['viewport'], 'preview.viewport'); - final width = _safeInteger(viewport['width'], 'preview.viewport.width'); - final height = _safeInteger(viewport['height'], 'preview.viewport.height'); - if (width == 0 || height == 0 || width * height > 16 * 1024 * 1024) { - throw const WireFormatException( - 'preview viewport dimensions exceed limit', - 'preview.viewport', - ); - } - if (viewport.containsKey('deviceScaleFactor')) { - final scale = _finiteNumber( - viewport['deviceScaleFactor'], - 'preview.viewport.deviceScaleFactor', - ); - if (scale <= 0 || scale > 8) { - throw const WireFormatException( - 'preview device scale factor exceeds limit', - 'preview.viewport.deviceScaleFactor', - ); - } - } - } - final capture = raw.containsKey('capture') - ? _previewCapture(raw['capture'], 'preview.capture') - : null; - final authority = raw.containsKey('authority') - ? _previewAuthority(raw['authority'], 'preview.authority') - : null; - List? actions; - if (raw.containsKey('availableActions')) { - actions = _stringList( - raw['availableActions'], - 'preview.availableActions', - maxItems: 15, - ); - const allowed = { - 'activate', - 'navigate', - 'back', - 'forward', - 'reload', - 'close', - 'capture', - 'click', - 'fill', - 'type', - 'press', - 'scroll', - 'select', - 'upload', - 'handoff', - }; - if (actions.any((action) => !allowed.contains(action)) || - actions.toSet().length != actions.length) { - throw const WireFormatException( - 'preview actions must be known and unique', - 'preview.availableActions', - ); - } - } - return PreviewSnapshot( - previewId: _id(raw['previewId'], 'preview.previewId'), - state: _enumString(raw['state'], 'preview.state', const { - 'launching', - 'ready', - 'running', - 'stopped', - 'failed', - }), - url: url, - revision: _id(raw['revision'], 'preview.revision'), - cursor: _transcriptCursor(raw['cursor'], 'preview.cursor'), - title: raw.containsKey('title') - ? _boundedText(raw['title'], 'preview.title', 512) - : null, - canGoBack: raw.containsKey('canGoBack') - ? _bool(raw['canGoBack'], 'preview.canGoBack') - : null, - canGoForward: raw.containsKey('canGoForward') - ? _bool(raw['canGoForward'], 'preview.canGoForward') - : null, - viewport: viewport, - capture: capture, - authority: authority, - availableActions: actions, - ); -} - -Map _previewCapture(Object? value, String path) { - final raw = _map(value, path); - _id(raw['captureId'], '$path.captureId'); - _enumString(raw['mimeType'], '$path.mimeType', const { - 'image/png', - 'image/jpeg', - 'image/webp', - }); - final size = _safeInteger(raw['size'], '$path.size'); - final width = _safeInteger(raw['width'], '$path.width'); - final height = _safeInteger(raw['height'], '$path.height'); - if (size == 0 || size > 8 * 1024 * 1024) { - throw WireFormatException( - 'preview capture size exceeds limit', - '$path.size', - ); - } - if (width == 0 || height == 0 || width * height > 16 * 1024 * 1024) { - throw WireFormatException('preview capture dimensions exceed limit', path); - } - _safeInteger(raw['capturedAt'], '$path.capturedAt'); - final digest = _string(raw['sha256'], '$path.sha256', 64); - if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(digest)) { - throw WireFormatException( - 'preview capture digest must be lowercase sha256', - '$path.sha256', - ); - } - return raw; -} - -Map _previewAuthority(Object? value, String path) { - final raw = _map(value, path); - _string(raw['id'], '$path.id', 128); - _boundedText(raw['label'], '$path.label', 256); - _enumString(raw['kind'], '$path.kind', const { - 'isolated-session', - 'authenticated-profile', - }); - _bool(raw['requiresExplicitOptIn'], '$path.requiresExplicitOptIn'); - return raw; -} - -PongFrame _decodePong(Map raw) => PongFrame( - nonce: _string(raw['nonce'], 'nonce', 128), - timestamp: _string(raw['timestamp'], 'timestamp', 128), - raw: raw, -); - -TranscriptCursor _transcriptCursor(Object? value, String path) { - final cursor = _map(value, path); - return TranscriptCursor( - epoch: _string(cursor['epoch'], '$path.epoch', 128), - seq: _safeInteger(cursor['seq'], '$path.seq'), - ); -} - -SessionIndexCursor _sessionIndexCursor(Object? value, String path) { - final cursor = _map(value, path); - return SessionIndexCursor( - epoch: _string(cursor['epoch'], '$path.epoch', 128), - seq: _safeInteger(cursor['seq'], '$path.seq'), - ); -} - -void _exactVersion(Map raw) { - if (raw['v'] != ompAppProtocolVersion) { - throw const WireFormatException( - 'protocol version must be exactly omp-app/1', - 'v', - ); - } -} - -Map _map(Object? value, String path) { - if (value is! Map) { - throw WireFormatException('expected object', path); - } - return value; -} - -List _list(Object? value, String path, [int max = _maxArrayItems]) { - if (value is! List || value.length > max) { - throw WireFormatException('expected array with at most $max items', path); - } - return value; -} - -List _stringList( - Object? value, - String path, { - int maxItems = _maxArrayItems, -}) { - final values = _list(value, path, maxItems); - for (var index = 0; index < values.length; index++) { - _string(values[index], '$path[$index]', 256); - } - return values.cast(); -} - -const Set _deviceCapabilities = { - 'sessions.read', - 'sessions.prompt', - 'sessions.control', - 'sessions.manage', - 'bash.run', - 'term.open', - 'term.input', - 'term.resize', - 'files.read', - 'files.write', - 'files.list', - 'files.diff', - 'agents.control', - 'audit.read', - 'config.read', - 'catalog.read', - 'config.write', - 'broker.read', - 'usage.read', - 'preview.read', - 'preview.control', - 'preview.input', -}; - -List _capabilityList(Object? value, String path) { - final capabilities = _stringList(value, path, maxItems: 128); - for (var index = 0; index < capabilities.length; index++) { - if (!_deviceCapabilities.contains(capabilities[index])) { - throw WireFormatException('unknown device capability', '$path[$index]'); - } - } - return capabilities; -} - -String _enumString(Object? value, String path, Set allowed) { - final result = _string(value, path, 128); - if (!allowed.contains(result)) { - throw WireFormatException('unknown discriminant $result', path); - } - return result; -} - -String _boundedText(Object? value, String path, int maxBytes) { - if (value is! String || !_utf8LengthAtMost(value, maxBytes)) { - throw WireFormatException('expected bounded text', path); - } - return value; -} - -double _finiteNumber(Object? value, String path) { - if (value is! num || !value.isFinite) { - throw WireFormatException('expected finite number', path); - } - return value.toDouble(); -} - -int _signedSafeInteger(Object? value, String path) { - if (value is! num || - !value.isFinite || - value.abs() > _maxSafeInteger || - value.truncateToDouble() != value) { - throw WireFormatException('expected a safe integer', path); - } - return value.toInt(); -} - -String _base64(Object? value, String path, int maxDecodedBytes) { - final text = _boundedText( - value, - path, - ((maxDecodedBytes * 4) / 3).ceil() + 4, - ); - final valid = RegExp( - r'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$', - ); - if (text.length % 4 != 0 || !valid.hasMatch(text)) { - throw WireFormatException('invalid base64 payload', path); - } - late final List decoded; - try { - decoded = base64.decode(text); - } on FormatException { - throw WireFormatException('invalid base64 payload', path); - } - if (decoded.length > maxDecodedBytes) { - throw WireFormatException('decoded payload exceeds protocol limit', path); - } - return text; -} - -String _safeRelativePath(Object? value, String path) { - final result = _string(value, path, 4096); - if (result.contains(r'\') || - result.startsWith('/') || - RegExp(r'^[A-Za-z]:').hasMatch(result) || - result.startsWith('~')) { - throw WireFormatException('path must be a safe relative POSIX path', path); - } - final parts = result.split('/'); - if (parts.any((part) => part.isEmpty || part == '.' || part == '..')) { - throw WireFormatException('path contains an unsafe segment', path); - } - return result; -} - -String _id(Object? value, String path) => _string(value, path, 256); - -String _string(Object? value, String path, int maxBytes) { - if (value is! String || - value.isEmpty || - !_utf8LengthAtMost(value, maxBytes) || - _hasControlCharacter(value)) { - throw WireFormatException('expected bounded non-empty string', path); - } - return value; -} - -String _nonemptyText(Object? value, String path, int maxBytes) { - if (value is! String || - value.isEmpty || - !_utf8LengthAtMost(value, maxBytes)) { - throw WireFormatException('expected bounded non-empty text', path); - } - return value; -} - -bool _bool(Object? value, String path) { - if (value is! bool) { - throw WireFormatException('expected boolean', path); - } - return value; -} - -int _safeInteger(Object? value, String path) { - if (value is! num || - !value.isFinite || - value < 0 || - value > _maxSafeInteger || - value.truncateToDouble() != value) { - throw WireFormatException('expected a safe nonnegative integer', path); - } - return value.toInt(); -} - -bool _hasControlCharacter(String value) { - for (final codeUnit in value.codeUnits) { - if (codeUnit <= 0x1f || codeUnit == 0x7f) { - return true; - } - } - return false; -} - -bool _utf8LengthAtMost(String value, int maximum) { - var bytes = 0; - for (var index = 0; index < value.length; index++) { - final codeUnit = value.codeUnitAt(index); - if (codeUnit <= 0x7f) { - bytes++; - } else if (codeUnit <= 0x7ff) { - bytes += 2; - } else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - if (index + 1 < value.length) { - final next = value.codeUnitAt(index + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4; - index++; - } else { - bytes += 3; - } - } else { - bytes += 3; - } - } else { - bytes += 3; - } - if (bytes > maximum) { - return false; - } - } - return true; -} - -final class _JsonBudget { - int nodes = 0; -} - -Object? _freezeJson(Object? value, _JsonBudget budget, int depth) { - budget.nodes++; - if (budget.nodes > _maxJsonNodes) { - throw const WireFormatException('JSON value has too many nodes'); - } - if (depth > _maxJsonDepth) { - throw const WireFormatException('JSON value is nested too deeply'); - } - if (value is Map) { - if (value.length > _maxMapKeys) { - throw const WireFormatException('JSON object has too many keys'); - } - value.updateAll((_, child) => _freezeJson(child, budget, depth + 1)); - return UnmodifiableMapView(value); - } - if (value is List) { - if (value.length > _maxArrayItems) { - throw const WireFormatException('JSON array has too many items'); - } - for (var index = 0; index < value.length; index++) { - value[index] = _freezeJson(value[index], budget, depth + 1); - } - return UnmodifiableListView(value); - } - return value; -} - -Object? _freezeJsonCopy(Object? value, _JsonBudget budget, int depth) { - budget.nodes++; - if (budget.nodes > _maxJsonNodes) { - throw const WireFormatException('JSON value has too many nodes'); - } - if (depth > _maxJsonDepth) { - throw const WireFormatException('JSON value is nested too deeply'); - } - if (value is Map) { - if (value.length > _maxMapKeys) { - throw const WireFormatException('JSON object has too many keys'); - } - final copy = {}; - for (final entry in value.entries) { - copy[entry.key] = _freezeJsonCopy(entry.value, budget, depth + 1); - } - return UnmodifiableMapView(copy); - } - if (value is List) { - if (value.length > _maxArrayItems) { - throw const WireFormatException('JSON array has too many items'); - } - return UnmodifiableListView( - value - .map((child) => _freezeJsonCopy(child, budget, depth + 1)) - .toList(growable: false), - ); - } - if (value == null || value is bool || value is String) { - return value; - } - if (value is num && value.isFinite) { - return value; - } - throw const WireFormatException('expected JSON-compatible value'); -} diff --git a/apps/flutter/lib/src/protocol/wire_encoder.dart b/apps/flutter/lib/src/protocol/wire_encoder.dart deleted file mode 100644 index 740026cb..00000000 --- a/apps/flutter/lib/src/protocol/wire_encoder.dart +++ /dev/null @@ -1,681 +0,0 @@ -import 'dart:convert'; - -import 'models.dart'; - -const int _maxSafeInteger = 9007199254740991; -const int _maxSavedCursors = 128; - -/// Explicit builders for every outbound omp-app/1 top-level frame family. -abstract final class WireEncoder { - static String hello({ - required ClientIdentity client, - Iterable requestedFeatures = const [], - Iterable savedCursors = const [], - Iterable capabilities = const [], - DeviceAuthentication? authentication, - }) { - final features = requestedFeatures.toList(growable: false); - final cursors = savedCursors.toList(growable: false); - final requestedCapabilities = capabilities.toList(growable: false); - _boundedStrings(features, 'requestedFeatures', 128); - _capabilityStrings(requestedCapabilities, 'capabilities'); - if (cursors.length > _maxSavedCursors) { - throw ArgumentError.value( - cursors.length, - 'savedCursors', - 'must contain at most $_maxSavedCursors cursors', - ); - } - - final clientJson = { - 'name': _controlString(client.name, 'client.name', 128), - 'version': _controlString(client.version, 'client.version', 64), - 'build': _controlString(client.build, 'client.build', 128), - 'platform': _controlString(client.platform, 'client.platform', 128), - }; - final cursorJson = >[]; - for (var index = 0; index < cursors.length; index++) { - final saved = cursors[index]; - cursorJson.add({ - 'hostId': _id(saved.hostId, 'savedCursors[$index].hostId'), - 'sessionId': _id(saved.sessionId, 'savedCursors[$index].sessionId'), - 'cursor': _cursorJson(saved.cursor, 'savedCursors[$index].cursor'), - }); - } - - final frame = { - 'v': ompAppProtocolVersion, - 'type': 'hello', - 'protocol': { - 'min': ompAppProtocolVersion, - 'max': ompAppProtocolVersion, - }, - 'client': clientJson, - 'requestedFeatures': features, - 'savedCursors': cursorJson, - if (requestedCapabilities.isNotEmpty) - 'capabilities': {'client': requestedCapabilities}, - if (authentication != null) - 'authentication': { - 'deviceId': _id(authentication.deviceId, 'authentication.deviceId'), - 'deviceToken': _deviceToken(authentication.deviceToken), - }, - }; - return jsonEncode(frame); - } - - static String sessionList({ - required String requestId, - required String commandId, - required String hostId, - }) { - return command( - requestId: requestId, - commandId: commandId, - hostId: hostId, - command: 'session.list', - args: const {}, - ); - } - - static String hostWatch({ - required String requestId, - required String commandId, - required String hostId, - required SessionIndexCursor cursor, - }) { - return command( - requestId: requestId, - commandId: commandId, - hostId: hostId, - command: 'host.watch', - args: { - 'cursor': _sessionIndexCursorJson(cursor, 'cursor'), - }, - ); - } - - static String sessionAttach({ - required String requestId, - required String commandId, - required String hostId, - required String sessionId, - TranscriptCursor? cursor, - }) { - return command( - requestId: requestId, - commandId: commandId, - hostId: hostId, - sessionId: sessionId, - command: 'session.attach', - args: { - if (cursor != null) 'cursor': _cursorJson(cursor, 'cursor'), - }, - ); - } - - static String sessionPrompt({ - required String requestId, - required String commandId, - required String hostId, - required String sessionId, - required String expectedRevision, - required String text, - List imageIds = const [], - }) { - if (imageIds.length > 8) { - throw ArgumentError.value(imageIds.length, 'imageIds', 'maximum is 8'); - } - if (text.isEmpty && imageIds.isEmpty) { - throw ArgumentError.value( - text, - 'text', - 'must not be empty without images', - ); - } - return command( - requestId: requestId, - commandId: commandId, - hostId: hostId, - sessionId: sessionId, - command: 'session.prompt', - expectedRevision: _id(expectedRevision, 'expectedRevision'), - args: { - 'message': _boundedText(text, 'text', 65536), - if (imageIds.isNotEmpty) - 'images': >[ - for (final imageId in imageIds) - {'imageId': _id(imageId, 'imageId')}, - ], - }, - ); - } - - /// Encodes the package-level command envelope for every pinned command. - /// - /// The command descriptor's host/session scope, revision policy, and - /// confirmation policy are enforced here. Command-specific argument - /// convenience methods may add narrower validation. - static String command({ - required String requestId, - required String commandId, - required String hostId, - required String command, - required Map args, - String? sessionId, - String? expectedRevision, - String? confirmationId, - }) { - if (!_knownCommands.contains(command)) { - throw ArgumentError.value(command, 'command', 'is not a pinned command'); - } - final hostScoped = _hostCommands.contains(command); - if (hostScoped && sessionId != null) { - throw ArgumentError.value( - sessionId, - 'sessionId', - 'is forbidden for a host command', - ); - } - if (!hostScoped && sessionId == null) { - throw ArgumentError.notNull('sessionId'); - } - if (_noRevisionCommands.contains(command) && expectedRevision != null) { - throw ArgumentError.value( - expectedRevision, - 'expectedRevision', - 'is forbidden for this command', - ); - } - if (_requiredRevisionCommands.contains(command) && - expectedRevision == null) { - throw ArgumentError.notNull('expectedRevision'); - } - if (!_confirmationCommands.contains(command) && confirmationId != null) { - throw ArgumentError.value( - confirmationId, - 'confirmationId', - 'is forbidden for this command', - ); - } - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'command', - 'requestId': _id(requestId, 'requestId'), - 'commandId': _id(commandId, 'commandId'), - 'hostId': _id(hostId, 'hostId'), - if (sessionId != null) 'sessionId': _id(sessionId, 'sessionId'), - 'command': command, - if (expectedRevision != null) - 'expectedRevision': _id(expectedRevision, 'expectedRevision'), - if (confirmationId != null) - 'confirmationId': _id(confirmationId, 'confirmationId'), - 'args': args, - }); - } - - static String confirm({ - required String requestId, - required String confirmationId, - required String commandId, - required String hostId, - required String decision, - String? sessionId, - }) { - if (decision != 'approve' && decision != 'deny') { - throw ArgumentError.value( - decision, - 'decision', - 'must be approve or deny', - ); - } - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'confirm', - 'requestId': _id(requestId, 'requestId'), - 'confirmationId': _id(confirmationId, 'confirmationId'), - 'commandId': _id(commandId, 'commandId'), - 'hostId': _id(hostId, 'hostId'), - if (sessionId != null) 'sessionId': _id(sessionId, 'sessionId'), - 'decision': decision, - }); - } - - static String pairStart({ - required String requestId, - required String code, - required String deviceId, - required String deviceName, - required String platform, - Iterable requestedCapabilities = const [], - }) { - if (!RegExp(r'^\d{6}$').hasMatch(code)) { - throw ArgumentError.value(code, 'code', 'must be exactly six digits'); - } - final capabilities = requestedCapabilities.toList(growable: false); - _capabilityStrings(capabilities, 'requestedCapabilities'); - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'pair.start', - 'requestId': _id(requestId, 'requestId'), - 'code': code, - 'deviceId': _id(deviceId, 'deviceId'), - 'deviceName': _controlString(deviceName, 'deviceName', 256), - 'platform': _controlString(platform, 'platform', 128), - 'requestedCapabilities': capabilities, - }); - } - - static String terminalInput({ - required String hostId, - required String sessionId, - required String terminalId, - required String data, - String? encoding, - }) { - if (encoding != null && encoding != 'utf8' && encoding != 'base64') { - throw ArgumentError.value(encoding, 'encoding', 'must be utf8 or base64'); - } - final payload = encoding == 'base64' - ? _base64(data, 'data', 256000) - : _boundedText(data, 'data', 256000); - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'terminal.input', - 'hostId': _id(hostId, 'hostId'), - 'sessionId': _id(sessionId, 'sessionId'), - 'terminalId': _id(terminalId, 'terminalId'), - 'data': payload, - 'encoding': ?encoding, - }); - } - - static String terminalResize({ - required String hostId, - required String sessionId, - required String terminalId, - required int cols, - required int rows, - }) { - if (cols < 1 || cols > 1000) { - throw ArgumentError.value(cols, 'cols', 'must be between 1 and 1000'); - } - if (rows < 1 || rows > 500) { - throw ArgumentError.value(rows, 'rows', 'must be between 1 and 500'); - } - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'terminal.resize', - 'hostId': _id(hostId, 'hostId'), - 'sessionId': _id(sessionId, 'sessionId'), - 'terminalId': _id(terminalId, 'terminalId'), - 'cols': cols, - 'rows': rows, - }); - } - - static String terminalClose({ - required String hostId, - required String sessionId, - required String terminalId, - String? reason, - }) { - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'terminal.close', - 'hostId': _id(hostId, 'hostId'), - 'sessionId': _id(sessionId, 'sessionId'), - 'terminalId': _id(terminalId, 'terminalId'), - if (reason != null) 'reason': _controlString(reason, 'reason', 256), - }); - } - - static String ping({required String nonce, required String timestamp}) { - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'ping', - 'nonce': _controlString(nonce, 'nonce', 128), - 'timestamp': _controlString(timestamp, 'timestamp', 128), - }); - } -} - -const Set _knownCommands = { - 'host.list', - 'session.list', - 'transcript.search', - 'transcript.context', - 'transcript.page', - 'session.create', - 'session.attach', - 'session.prompt', - 'session.image.begin', - 'session.image.chunk', - 'session.image.discard', - 'session.image.read', - 'session.state.get', - 'session.steer', - 'session.followUp', - 'session.rename', - 'session.retry', - 'session.compact', - 'session.pause', - 'session.resume', - 'session.archive', - 'session.restore', - 'session.delete', - 'session.model.set', - 'session.thinking.set', - 'session.fast.set', - 'session.ui.respond', - 'session.cancel', - 'session.close', - 'files.read', - 'files.write', - 'files.patch', - 'files.list', - 'files.search', - 'files.diff', - 'review.read', - 'review.apply', - 'agent.cancel', - 'bash.run', - 'term.open', - 'audit.read', - 'audit.tail', - 'config.write', - 'settings.read', - 'settings.write', - 'catalog.get', - 'broker.status', - 'usage.read', - 'host.watch', - 'session.watch', - 'controller.lease.acquire', - 'controller.lease.renew', - 'controller.lease.release', - 'prompt.lease.acquire', - 'prompt.lease.renew', - 'prompt.lease.release', - 'preview.launch', - 'preview.state', - 'preview.activate', - 'preview.navigate', - 'preview.back', - 'preview.forward', - 'preview.reload', - 'preview.close', - 'preview.capture', - 'preview.capture.read', - 'preview.click', - 'preview.fill', - 'preview.scroll', - 'preview.type', - 'preview.select', - 'preview.press', - 'preview.upload', - 'preview.policy.check', - 'preview.lease.acquire', - 'preview.lease.renew', - 'preview.lease.release', - 'preview.handoff', -}; - -const Set _hostCommands = { - 'host.list', - 'session.list', - 'transcript.search', - 'session.create', - 'audit.read', - 'audit.tail', - 'settings.read', - 'catalog.get', - 'broker.status', - 'usage.read', - 'host.watch', - 'config.write', - 'settings.write', -}; - -const Set _noRevisionCommands = { - 'host.list', - 'session.list', - 'transcript.search', - 'session.create', - 'audit.read', - 'audit.tail', - 'settings.read', - 'catalog.get', - 'broker.status', - 'usage.read', - 'host.watch', - 'transcript.context', - 'transcript.page', - 'session.attach', - 'session.image.begin', - 'session.image.chunk', - 'session.image.discard', - 'session.image.read', - 'session.state.get', - 'session.watch', - 'preview.capture.read', - 'preview.policy.check', -}; - -const Set _requiredRevisionCommands = { - 'session.rename', - 'session.retry', - 'session.compact', - 'session.pause', - 'session.resume', - 'session.archive', - 'session.restore', - 'session.model.set', - 'session.thinking.set', - 'session.fast.set', - 'controller.lease.acquire', - 'controller.lease.renew', - 'controller.lease.release', - 'prompt.lease.acquire', - 'prompt.lease.renew', - 'prompt.lease.release', - 'session.delete', - 'session.close', - 'files.write', - 'files.patch', - 'review.apply', - 'config.write', - 'settings.write', -}; - -const Set _confirmationCommands = { - 'session.delete', - 'session.close', - 'files.write', - 'files.patch', - 'review.apply', - 'session.cancel', - 'agent.cancel', - 'bash.run', - 'term.open', - 'preview.launch', - 'preview.navigate', - 'preview.upload', - 'config.write', - 'settings.write', -}; - -const Set _deviceCapabilities = { - 'sessions.read', - 'sessions.prompt', - 'sessions.control', - 'sessions.manage', - 'bash.run', - 'term.open', - 'term.input', - 'term.resize', - 'files.read', - 'files.write', - 'files.list', - 'files.diff', - 'agents.control', - 'audit.read', - 'config.read', - 'catalog.read', - 'config.write', - 'broker.read', - 'usage.read', - 'preview.read', - 'preview.control', - 'preview.input', -}; - -Map _cursorJson(TranscriptCursor cursor, String path) { - if (cursor.seq < 0 || cursor.seq > _maxSafeInteger) { - throw ArgumentError.value( - cursor.seq, - '$path.seq', - 'must be a safe nonnegative integer', - ); - } - return { - 'epoch': _controlString(cursor.epoch, '$path.epoch', 128), - 'seq': cursor.seq, - }; -} - -Map _sessionIndexCursorJson( - SessionIndexCursor cursor, - String path, -) { - if (cursor.seq < 0 || cursor.seq > _maxSafeInteger) { - throw ArgumentError.value( - cursor.seq, - '$path.seq', - 'must be a safe nonnegative integer', - ); - } - return { - 'epoch': _controlString(cursor.epoch, '$path.epoch', 128), - 'seq': cursor.seq, - }; -} - -void _boundedStrings(List values, String path, int maxItems) { - if (values.length > maxItems) { - throw ArgumentError.value( - values.length, - path, - 'must contain at most $maxItems values', - ); - } - for (var index = 0; index < values.length; index++) { - _controlString(values[index], '$path[$index]', 256); - } -} - -void _capabilityStrings(List values, String path) { - _boundedStrings(values, path, 128); - for (var index = 0; index < values.length; index++) { - if (!_deviceCapabilities.contains(values[index])) { - throw ArgumentError.value( - values[index], - '$path[$index]', - 'is not a pinned device capability', - ); - } - } -} - -String _base64(String value, String path, int maxDecodedBytes) { - _boundedText(value, path, ((maxDecodedBytes * 4) / 3).ceil() + 4); - final valid = RegExp( - r'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$', - ); - if (value.length % 4 != 0 || !valid.hasMatch(value)) { - throw ArgumentError.value(value, path, 'must be canonical base64'); - } - late final List decoded; - try { - decoded = base64.decode(value); - } on FormatException { - throw ArgumentError.value(value, path, 'must be canonical base64'); - } - if (decoded.length > maxDecodedBytes) { - throw ArgumentError.value(value, path, 'decoded payload exceeds limit'); - } - return value; -} - -String _id(String value, String path) => _controlString(value, path, 256); - -String _deviceToken(String value) { - final canonical = RegExp(r'^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$'); - if (!canonical.hasMatch(value)) { - throw ArgumentError.value( - value, - 'authentication.deviceToken', - 'must be canonical base64url for exactly 32 bytes', - ); - } - return value; -} - -String _controlString(String value, String path, int maxBytes) { - if (value.isEmpty || - !_utf8LengthAtMost(value, maxBytes) || - _hasControlCharacter(value)) { - throw ArgumentError.value( - value, - path, - 'must be a bounded non-empty string', - ); - } - return value; -} - -String _boundedText(String value, String path, int maxBytes) { - if (!_utf8LengthAtMost(value, maxBytes)) { - throw ArgumentError.value(value, path, 'exceeds the UTF-8 byte limit'); - } - return value; -} - -bool _hasControlCharacter(String value) { - for (final codeUnit in value.codeUnits) { - if (codeUnit <= 0x1f || codeUnit == 0x7f) { - return true; - } - } - return false; -} - -bool _utf8LengthAtMost(String value, int maximum) { - var bytes = 0; - for (var index = 0; index < value.length; index++) { - final codeUnit = value.codeUnitAt(index); - if (codeUnit <= 0x7f) { - bytes++; - } else if (codeUnit <= 0x7ff) { - bytes += 2; - } else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - if (index + 1 < value.length) { - final next = value.codeUnitAt(index + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4; - index++; - } else { - bytes += 3; - } - } else { - bytes += 3; - } - } else { - bytes += 3; - } - if (bytes > maximum) { - return false; - } - } - return true; -} diff --git a/apps/flutter/lib/src/ui/access_mode_selector.dart b/apps/flutter/lib/src/ui/access_mode_selector.dart deleted file mode 100644 index 2d3dd0d9..00000000 --- a/apps/flutter/lib/src/ui/access_mode_selector.dart +++ /dev/null @@ -1,169 +0,0 @@ -part of 't4_app.dart'; - -/// One selectable entry in an [AccessModeSelector] menu. -class AccessModeOption { - const AccessModeOption({ - required this.id, - required this.label, - this.detail, - this.selected = false, - }); - - /// Stable identifier passed to [AccessModeSelector.onSelected]. - final String id; - - /// Primary menu-row text. - final String label; - - /// Optional dimmed second line describing the option. - final String? detail; - - /// Whether this option renders with a leading check mark. - final bool selected; -} - -/// Quiet access-mode pill opening a themed [MenuAnchor] of options. -/// -/// Matches the composer pill look: hairline stadium border, 12 px label, -/// chevron, and a 14 px shield leading icon. With an empty [options] list the -/// pill is informational: the menu shows a single disabled item repeating -/// [label] instead of selectable entries. -class AccessModeSelector extends StatelessWidget { - const AccessModeSelector({ - required this.label, - required this.options, - required this.onSelected, - this.enabled = true, - this.readOnly = false, - super.key, - }); - - /// Text shown inside the pill (current mode). - final String label; - - /// Selectable modes; empty means display-only. - final List options; - - /// Called with the tapped option's [AccessModeOption.id]. - final ValueChanged onSelected; - - /// Disables the pill entirely when false. - final bool enabled; - - /// Renders options as a non-interactive granted-permissions status list: - /// rows are disabled and the check mark means "granted", not "chosen". - /// Use this until the wire protocol exposes a real access-mode command. - final bool readOnly; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - return Semantics( - label: 'Access mode: $label', - button: true, - enabled: enabled, - child: MenuAnchor( - style: const MenuStyle( - visualDensity: VisualDensity.compact, - maximumSize: WidgetStatePropertyAll(Size(320, 360)), - ), - alignmentOffset: const Offset(0, _T4Space.xs), - menuChildren: options.isEmpty - ? [ - MenuItemButton( - style: _accessMenuItemStyle, - onPressed: null, - child: Text(label), - ), - ] - : [ - for (final option in options) - MenuItemButton( - key: ValueKey('access-mode-${option.id}'), - style: _accessMenuItemStyle, - leadingIcon: option.selected - ? Icon(Icons.check, size: 16, color: scheme.primary) - : const SizedBox(width: 16), - onPressed: readOnly ? null : () => onSelected(option.id), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(option.label), - if (option.detail case final detail?) - Text( - detail, - style: TextStyle( - fontSize: 11, - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - builder: (context, controller, child) => _AccessModePill( - label: label, - onTap: enabled - ? () => controller.isOpen ? controller.close() : controller.open() - : null, - ), - ), - ); - } -} - -final ButtonStyle _accessMenuItemStyle = MenuItemButton.styleFrom( - visualDensity: VisualDensity.compact, - textStyle: const TextStyle(fontSize: 12), - minimumSize: const Size.fromHeight(36), -); - -/// Hairline stadium pill anchor for [AccessModeSelector]. -final class _AccessModePill extends StatelessWidget { - const _AccessModePill({required this.label, required this.onTap}); - - final String label; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final enabled = onTap != null; - final color = enabled ? scheme.onSurfaceVariant : scheme.outline; - final shape = StadiumBorder(side: BorderSide(color: scheme.outlineVariant)); - return Material( - color: Colors.transparent, - shape: shape, - child: InkWell( - customBorder: const StadiumBorder(), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - vertical: _T4Space.xxs + 1, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.shield_outlined, size: 14, color: color), - const SizedBox(width: _T4Space.xxs), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 160), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: color), - ), - ), - const SizedBox(width: 2), - Icon(Icons.keyboard_arrow_down, size: 14, color: color), - ], - ), - ), - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/adaptive_session_shell.dart b/apps/flutter/lib/src/ui/adaptive_session_shell.dart deleted file mode 100644 index e14fe1b0..00000000 --- a/apps/flutter/lib/src/ui/adaptive_session_shell.dart +++ /dev/null @@ -1,932 +0,0 @@ -part of 't4_app.dart'; - -final class _AdaptiveSessionShell extends StatefulWidget { - const _AdaptiveSessionShell({ - required this.state, - required this.actions, - required this.platformState, - required this.platformActions, - }); - - final T4ViewState state; - final T4Actions actions; - final PlatformLifecycleViewState platformState; - final PlatformLifecycleActions? platformActions; - - @override - State<_AdaptiveSessionShell> createState() => _AdaptiveSessionShellState(); -} - -final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { - final GlobalKey _scaffoldKey = GlobalKey(); - String? _selectingSessionId; - bool _connecting = false; - bool _disconnecting = false; - bool _showHostManager = false; - bool _showAttention = false; - bool _showDeveloper = false; - bool _showSettings = false; - bool _showSearch = false; - bool _showUsage = false; - bool _showContextPanel = false; - int _developerInitialTab = 0; - - Future _connect() async { - if (_connecting) return; - setState(() => _connecting = true); - try { - await widget.actions.connect(); - } on Object { - if (!mounted) return; - _showActionFailure('Could not connect. Try again.'); - } finally { - if (mounted) setState(() => _connecting = false); - } - } - - Future _disconnect() async { - if (_disconnecting) return; - setState(() => _disconnecting = true); - try { - await widget.actions.disconnect(); - } on Object { - if (!mounted) return; - _showActionFailure('Could not disconnect. Try again.'); - } finally { - if (mounted) setState(() => _disconnecting = false); - } - } - - Future _runConnectionAction() => - widget.state.connectionPhase.canDisconnect ? _disconnect() : _connect(); - - Future _selectSession( - String sessionId, { - required bool closeDrawer, - }) async { - if (_selectingSessionId != null) return; - if (sessionId == widget.state.selectedSessionId) { - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = false; - }); - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - return; - } - - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = false; - _selectingSessionId = sessionId; - }); - try { - await widget.actions.selectSession(sessionId); - if (!mounted) return; - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - } on Object { - if (!mounted) return; - _showActionFailure('Could not open that session. Try again.'); - } finally { - if (mounted) setState(() => _selectingSessionId = null); - } - } - - void _showActionFailure(String message) { - final messenger = ScaffoldMessenger.of(context); - messenger - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - void _openNavigation() => _scaffoldKey.currentState?.openDrawer(); - - void _openHostManager({required bool closeDrawer}) { - setState(() { - _showHostManager = true; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = false; - }); - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - } - - void _closeHostManager() => setState(() => _showHostManager = false); - - void _openAttention() => setState(() { - _showHostManager = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = false; - _showAttention = true; - }); - - void _closeAttention() => setState(() => _showAttention = false); - - void _openDeveloper({int initialTab = 0}) => setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = true; - _developerInitialTab = initialTab; - _showSettings = false; - _showSearch = false; - _showUsage = false; - }); - - void _closeDeveloper() => setState(() => _showDeveloper = false); - - bool get _canQuickOpen => - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.selectedSession != null && - widget.state.grantedCapabilities.contains('files.list') && - widget.state.grantedCapabilities.contains('files.read') && - widget.state.grantedFeatures.contains('files.search'); - - Future _openQuickOpen() async { - if (!_canQuickOpen) return; - final path = await showDialog( - context: context, - builder: (context) => _QuickOpenDialog(actions: widget.actions), - ); - if (path == null || !mounted) return; - try { - await widget.actions.readFile(path); - if (mounted) _openDeveloper(initialTab: 1); - } on Object { - if (mounted) _showActionFailure('Could not open that project file.'); - } - } - - void _openSettings({required bool closeDrawer}) { - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = true; - _showSearch = false; - _showUsage = false; - }); - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - } - - void _closeSettings() => setState(() => _showSettings = false); - - void _openSearch({required bool closeDrawer}) { - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showUsage = false; - _showSearch = true; - }); - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - } - - void _closeSearch() => setState(() => _showSearch = false); - - void _openUsage({required bool closeDrawer}) { - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = true; - }); - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - } - - void _closeUsage() => setState(() => _showUsage = false); - - void _toggleContextPanel() => - setState(() => _showContextPanel = !_showContextPanel); - - void _toggleSearch() { - if (_showSearch) { - _closeSearch(); - } else { - _openSearch(closeDrawer: false); - } - } - - void _toggleSettings() { - if (_showSettings) { - _closeSettings(); - } else { - _openSettings(closeDrawer: false); - } - } - - void _toggleDeveloper() { - if (_showDeveloper) { - _closeDeveloper(); - } else { - _openDeveloper(); - } - } - - /// Escape: returns to the conversation when any takeover surface is open. - void _dismissTakeovers() { - if (!_showHostManager && - !_showAttention && - !_showDeveloper && - !_showSettings && - !_showSearch && - !_showUsage) { - return; - } - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = false; - }); - } - - /// Selects the Nth session of the list the rail renders by default - /// (non-archived, unfiltered), using the same select action as the rail. - void _selectSessionAt(int index) { - final visible = widget.state.sessions - .where((session) => !session.archived) - .toList(growable: false); - if (index < 0 || index >= visible.length) return; - unawaited(_selectSession(visible[index].sessionId, closeDrawer: false)); - } - - /// Same create flow (gate + dialog) as the session rail's new-session - /// button, reachable from the keyboard. - Future _createSessionFromShortcut() async { - final canCreate = - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('sessions.manage') && - !widget.state.sessionOperationPending && - widget.state.sessions.isNotEmpty; - if (!canCreate) return; - final projects = {}; - for (final session in widget.state.sessions) { - projects.putIfAbsent(session.projectId, () => session.projectName); - } - if (projects.isEmpty) return; - await showDialog( - context: context, - builder: (context) => - _CreateSessionDialog(actions: widget.actions, projects: projects), - ); - } - - Map _shortcutBindings() { - final useMeta = - defaultTargetPlatform == TargetPlatform.macOS || - defaultTargetPlatform == TargetPlatform.iOS; - SingleActivator mod(LogicalKeyboardKey key, {bool shift = false}) => - SingleActivator(key, meta: useMeta, control: !useMeta, shift: shift); - - const digits = [ - LogicalKeyboardKey.digit1, - LogicalKeyboardKey.digit2, - LogicalKeyboardKey.digit3, - LogicalKeyboardKey.digit4, - LogicalKeyboardKey.digit5, - LogicalKeyboardKey.digit6, - LogicalKeyboardKey.digit7, - LogicalKeyboardKey.digit8, - LogicalKeyboardKey.digit9, - ]; - - return { - mod(LogicalKeyboardKey.keyK): () => unawaited(_openQuickOpen()), - mod(LogicalKeyboardKey.keyN): () => - unawaited(_createSessionFromShortcut()), - mod(LogicalKeyboardKey.keyF, shift: true): _toggleSearch, - mod(LogicalKeyboardKey.comma): _toggleSettings, - mod(LogicalKeyboardKey.keyJ): _toggleDeveloper, - mod(LogicalKeyboardKey.keyI): _toggleContextPanel, - mod(LogicalKeyboardKey.keyP, shift: true): () => - unawaited(_openCommandPalette()), - for (var i = 0; i < digits.length; i++) - mod(digits[i]): () => _selectSessionAt(i), - const SingleActivator(LogicalKeyboardKey.escape): _dismissTakeovers, - }; - } - - /// mod+shift+P: command palette over the shell's global actions. The - /// shortcut labels mirror the platform modifier used by [_shortcutBindings]. - Future _openCommandPalette() async { - final useMeta = - defaultTargetPlatform == TargetPlatform.macOS || - defaultTargetPlatform == TargetPlatform.iOS; - final mod = useMeta ? '\u2318' : 'Ctrl+'; - await showCommandPalette( - context, - commands: [ - PaletteCommand( - id: 'quick-open', - title: 'Quick open project file', - shortcutLabel: '${mod}K', - enabled: _canQuickOpen, - run: () => unawaited(_openQuickOpen()), - ), - PaletteCommand( - id: 'new-session', - title: 'New session', - shortcutLabel: '${mod}N', - enabled: - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('sessions.manage'), - run: () => unawaited(_createSessionFromShortcut()), - ), - PaletteCommand( - id: 'search-transcripts', - title: 'Search transcripts', - shortcutLabel: '$mod\u21e7F', - run: _toggleSearch, - ), - PaletteCommand( - id: 'developer-tools', - title: 'Toggle developer tools', - shortcutLabel: '${mod}J', - run: _toggleDeveloper, - ), - PaletteCommand( - id: 'context-panel', - title: 'Toggle context panel', - shortcutLabel: '${mod}I', - run: _toggleContextPanel, - ), - PaletteCommand( - id: 'usage', - title: 'Usage and accounts', - run: () => _openUsage(closeDrawer: false), - ), - PaletteCommand( - id: 'settings', - title: 'Settings', - shortcutLabel: '$mod,', - run: _toggleSettings, - ), - PaletteCommand( - id: 'manage-hosts', - title: 'Manage hosts', - run: () => _openHostManager(closeDrawer: false), - ), - ], - ); - } - - Widget _contextRow(BuildContext context, String label, String value) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.xxs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 88, - child: Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - value, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ), - ], - ), - ); - } - - /// Section list for the right context panel. Wave 4 extends this with - /// richer sections; keep it as the single mount point. - List _buildContextSections(BuildContext context) { - final session = widget.state.selectedSession; - final profile = widget.state.hostDirectory.activeProfile; - final modelLabel = widget.state.composer.modelLabel; - final capabilities = widget.state.grantedCapabilities; - final ready = - widget.state.connectionPhase == ConnectionPhase.ready && - session != null; - return [ - ContextPanelSection( - id: 'session', - title: 'Session', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _contextRow(context, 'Title', _displaySessionTitle(session)), - if (session != null) - _contextRow(context, 'Project', session.projectName), - if (session != null && session.status.trim().isNotEmpty) - _contextRow(context, 'Status', session.status), - _contextRow( - context, - 'Connection', - widget.state.connectionPhase.label, - ), - if (profile != null) _contextRow(context, 'Host', profile.label), - if (modelLabel != null) _contextRow(context, 'Model', modelLabel), - ], - ), - ), - if (ready && capabilities.contains('files.diff')) - ContextPanelSection( - id: 'review', - title: 'Review', - child: SizedBox( - height: 380, - child: ReviewPanelBody( - state: widget.state, - actions: widget.actions, - ), - ), - ), - if (ready && - capabilities.contains('files.list') && - capabilities.contains('files.read')) - ContextPanelSection( - id: 'files', - title: 'Files', - initiallyExpanded: false, - child: SizedBox( - height: 380, - child: FilesPanelBody(state: widget.state, actions: widget.actions), - ), - ), - if (ready && capabilities.contains('audit.read')) - ContextPanelSection( - id: 'activity', - title: 'Activity', - initiallyExpanded: false, - child: SizedBox( - height: 320, - child: ActivityPanelBody( - state: widget.state, - actions: widget.actions, - ), - ), - ), - ]; - } - - Widget _contextPanelToggle(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return IconButton( - onPressed: _toggleContextPanel, - tooltip: 'Toggle context panel', - iconSize: _T4Size.indicator, - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - constraints: const BoxConstraints.tightFor(width: 28, height: 28), - color: scheme.onSurfaceVariant, - icon: const Icon(Icons.view_sidebar_outlined), - ); - } - - Widget _surfaceNavigationEntries({ - required bool closeDrawer, - required bool rail, - }) { - final scheme = Theme.of(context).colorScheme; - return Material( - color: rail ? scheme.surfaceContainerLowest : scheme.surface, - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.xs, - _T4Space.xxs, - _T4Space.xs, - _T4Space.sm, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Semantics( - button: true, - selected: _showSearch, - label: 'Search transcripts', - child: ListTile( - selected: _showSearch, - selectedTileColor: scheme.secondaryContainer, - leading: const Icon(Icons.manage_search), - title: const Text('Search'), - trailing: const Icon(Icons.chevron_right), - onTap: () => _openSearch(closeDrawer: closeDrawer), - ), - ), - Semantics( - button: true, - selected: _showUsage, - label: 'Open usage and accounts', - child: ListTile( - selected: _showUsage, - selectedTileColor: scheme.secondaryContainer, - leading: const Icon(Icons.data_usage_outlined), - title: const Text('Usage'), - trailing: const Icon(Icons.chevron_right), - onTap: () => _openUsage(closeDrawer: closeDrawer), - ), - ), - Semantics( - button: true, - selected: _showSettings, - label: 'Open settings', - child: ListTile( - selected: _showSettings, - selectedTileColor: scheme.secondaryContainer, - leading: const Icon(Icons.settings_outlined), - title: const Text('Settings'), - trailing: const Icon(Icons.chevron_right), - onTap: () => _openSettings(closeDrawer: closeDrawer), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _primaryContent({required bool showHeader}) { - if (_showSearch) { - return _TranscriptSearchPane( - actions: widget.actions, - showHeader: showHeader, - onDone: _closeSearch, - onOpenSession: (sessionId) async { - await _selectSession(sessionId, closeDrawer: false); - if (mounted) _closeSearch(); - }, - ); - } - if (_showUsage) { - return _UsageStatusPane( - state: widget.state, - actions: widget.actions, - showHeader: showHeader, - onDone: _closeUsage, - ); - } - if (_showSettings) { - return _SettingsPane( - state: widget.state, - actions: widget.actions, - platformState: widget.platformState, - platformActions: widget.platformActions, - showHeader: showHeader, - onDone: _closeSettings, - ); - } - if (_showAttention) { - return _AttentionPane( - state: widget.state, - actions: widget.actions, - onDone: _closeAttention, - onOpenSession: (sessionId) async { - await _selectSession(sessionId, closeDrawer: false); - if (mounted) _closeAttention(); - }, - ); - } - if (_showHostManager) { - return _HostManagerPane( - state: widget.state, - actions: widget.actions, - onDone: _closeHostManager, - ); - } - - if (widget.state.authenticationPhase == - AuthenticationPhase.pairingRequired || - widget.state.authenticationPhase == AuthenticationPhase.pairing) { - return _PairingPane(state: widget.state, actions: widget.actions); - } - - if (_showDeveloper) { - return _DeveloperSurfacesPane( - state: widget.state, - actions: widget.actions, - initialTab: _developerInitialTab, - showHeader: showHeader, - onDone: _closeDeveloper, - ); - } - - return _ConversationPane( - state: widget.state, - actions: widget.actions, - showHeader: showHeader, - onConnect: _connect, - onOpenSessions: showHeader ? null : _openNavigation, - onOpenAttention: _openAttention, - onOpenDeveloper: _openDeveloper, - onOpenQuickOpen: _openQuickOpen, - onSelectSession: (sessionId) => - _selectSession(sessionId, closeDrawer: false), - ); - } - - @override - Widget build(BuildContext context) { - final needsOnboarding = - !widget.state.targetConfigured && - widget.state.hostDirectory.profiles.isEmpty && - widget.state.connectionPhase == ConnectionPhase.disconnected; - if (needsOnboarding) { - return _HostOnboardingPage(state: widget.state, actions: widget.actions); - } - - return CallbackShortcuts( - bindings: _shortcutBindings(), - child: Focus( - autofocus: true, - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth >= _T4Breakpoints.wide) { - return _buildWide(context); - } - return _buildCompact(context); - }, - ), - ), - ); - } - - Widget _buildWide(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Row( - children: [ - SizedBox( - width: _T4Layout.sessionRailWidth, - child: Column( - children: [ - Expanded( - child: _SessionNavigation( - state: widget.state, - actions: widget.actions, - mode: _SessionNavigationMode.rail, - connecting: _connecting, - disconnecting: _disconnecting, - selectingSessionId: _selectingSessionId, - showingHostManager: _showHostManager, - onConnect: _connect, - onDisconnect: _disconnect, - onManageHosts: () => _openHostManager(closeDrawer: false), - onSelectSession: (sessionId) => - _selectSession(sessionId, closeDrawer: false), - ), - ), - _surfaceNavigationEntries(closeDrawer: false, rail: true), - ], - ), - ), - const VerticalDivider(width: _T4Size.divider), - Expanded( - child: Stack( - children: [ - Positioned.fill(child: _primaryContent(showHeader: true)), - Positioned( - top: _T4Space.sm, - right: _T4Space.sm, - child: _contextPanelToggle(context), - ), - ], - ), - ), - if (_showContextPanel) - ContextPanel( - sections: _buildContextSections(context), - onClose: _toggleContextPanel, - ), - ], - ), - ), - ); - } - - Widget _buildCompact(BuildContext context) { - final phase = widget.state.connectionPhase; - final actionLabel = phase.actionLabel; - - return Scaffold( - key: _scaffoldKey, - appBar: AppBar( - toolbarHeight: _T4Layout.compactToolbarHeight, - leading: IconButton( - onPressed: _openNavigation, - tooltip: 'Open navigation', - icon: const Icon(Icons.menu), - ), - titleSpacing: 0, - title: _showSearch - ? Text('Search', style: Theme.of(context).textTheme.titleMedium) - : _showUsage - ? Text('Usage', style: Theme.of(context).textTheme.titleMedium) - : _showSettings - ? Text('Settings', style: Theme.of(context).textTheme.titleMedium) - : _showHostManager - ? Text('Hosts', style: Theme.of(context).textTheme.titleMedium) - : _showDeveloper - ? Text( - 'Developer tools', - style: Theme.of(context).textTheme.titleMedium, - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _displaySessionTitle(widget.state.selectedSession), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xxs), - _CompactConnectionLabel( - phase: phase, - actionPending: _connecting, - ), - ], - ), - actions: [ - if (_showSearch || _showUsage || _showSettings) - IconButton( - onPressed: _showSearch - ? _closeSearch - : _showUsage - ? _closeUsage - : _closeSettings, - tooltip: _showSearch - ? 'Close search' - : _showUsage - ? 'Close usage' - : 'Close settings', - icon: const Icon(Icons.close), - ) - else ...[ - if (!_showHostManager && - !_showAttention && - !_showDeveloper && - !_showSearch && - !_showUsage) ...[ - if (_canQuickOpen) - IconButton( - onPressed: () => unawaited(_openQuickOpen()), - tooltip: 'Quick open project file', - icon: const Icon(Icons.search), - ), - Badge( - isLabelVisible: widget.state.urgentAttentionCount > 0, - label: Text('${widget.state.urgentAttentionCount}'), - child: IconButton( - onPressed: _openAttention, - tooltip: 'Open inbox', - icon: const Icon(Icons.inbox_outlined), - ), - ), - ], - if (!_showHostManager && - !_showAttention && - !_showSearch && - !_showUsage) - IconButton( - onPressed: _showDeveloper ? _closeDeveloper : _openDeveloper, - tooltip: _showDeveloper - ? 'Close developer tools' - : 'Open developer tools', - icon: Icon(_showDeveloper ? Icons.close : Icons.code), - ), - if (!_showHostManager && !_showSearch && !_showUsage) - IconButton( - onPressed: _connecting || _disconnecting - ? null - : () => unawaited(_runConnectionAction()), - tooltip: actionLabel, - icon: Icon( - phase.canDisconnect - ? Icons.link_off - : phase == ConnectionPhase.failed - ? Icons.refresh - : Icons.power_settings_new, - ), - ), - if (!_showHostManager && !_showSearch && !_showUsage) - IconButton( - onPressed: () => - _scaffoldKey.currentState?.openEndDrawer(), - tooltip: 'Toggle context panel', - icon: const Icon(Icons.view_sidebar_outlined), - ), - ], - ], - ), - drawerEnableOpenDragGesture: true, - drawerEdgeDragWidth: _T4Layout.minimumTouchTarget, - drawer: Drawer( - child: Column( - children: [ - Expanded( - child: _SessionNavigation( - state: widget.state, - actions: widget.actions, - mode: _SessionNavigationMode.drawer, - connecting: _connecting, - selectingSessionId: _selectingSessionId, - disconnecting: _disconnecting, - showingHostManager: _showHostManager, - onConnect: _connect, - onDisconnect: _disconnect, - onManageHosts: () => _openHostManager(closeDrawer: true), - onSelectSession: (sessionId) => - _selectSession(sessionId, closeDrawer: true), - onClose: () => _scaffoldKey.currentState?.closeDrawer(), - ), - ), - _surfaceNavigationEntries(closeDrawer: true, rail: false), - ], - ), - ), - endDrawer: Drawer( - child: SafeArea( - child: ContextPanel( - sections: _buildContextSections(context), - onClose: () => _scaffoldKey.currentState?.closeEndDrawer(), - ), - ), - ), - body: _primaryContent(showHeader: false), - ); - } -} - -final class _CompactConnectionLabel extends StatelessWidget { - const _CompactConnectionLabel({ - required this.phase, - required this.actionPending, - }); - - final ConnectionPhase phase; - final bool actionPending; - - @override - Widget build(BuildContext context) { - final active = phase.isActive || actionPending; - final scheme = Theme.of(context).colorScheme; - - return Semantics( - label: 'Connection status: ${phase.label}', - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox.square( - dimension: _T4Space.sm, - child: active - ? CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - color: scheme.primary, - semanticsLabel: phase.label, - ) - : Icon( - Icons.circle, - size: _T4Space.xs, - color: phase == ConnectionPhase.ready - ? scheme.primary - : scheme.outline, - ), - ), - const SizedBox(width: _T4Space.xs), - Flexible( - child: Text( - phase.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ), - ], - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/attention_pane.dart b/apps/flutter/lib/src/ui/attention_pane.dart deleted file mode 100644 index 7af82245..00000000 --- a/apps/flutter/lib/src/ui/attention_pane.dart +++ /dev/null @@ -1,801 +0,0 @@ -part of 't4_app.dart'; - -final class _AttentionPane extends StatelessWidget { - const _AttentionPane({ - required this.state, - required this.actions, - required this.onDone, - required this.onOpenSession, - }); - - final T4ViewState state; - final T4Actions actions; - final VoidCallback onDone; - final Future Function(String sessionId) onOpenSession; - - @override - Widget build(BuildContext context) => InboxFlyoutContent( - state: state, - actions: actions, - onDone: onDone, - onOpenSession: onOpenSession, - ); -} - -/// The inbox content column (Needs you / Updates / Agents), embeddable in a -/// ~400 px anchored popover or, via [_AttentionPane], the full-width takeover. -final class InboxFlyoutContent extends StatefulWidget { - const InboxFlyoutContent({ - required this.state, - required this.actions, - required this.onDone, - required this.onOpenSession, - this.showTitle = true, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - final VoidCallback onDone; - final Future Function(String sessionId) onOpenSession; - final bool showTitle; - - @override - State createState() => _InboxFlyoutContentState(); -} - -final class _InboxFlyoutContentState extends State - with SingleTickerProviderStateMixin { - late final TabController _tabs; - final Set _responding = {}; - - @override - void initState() { - super.initState(); - _tabs = TabController(length: 3, vsync: this); - } - - @override - void dispose() { - _tabs.dispose(); - super.dispose(); - } - - Future _respond(AttentionItem item, AttentionResponse response) async { - if (!_responding.add(item.key)) return; - setState(() {}); - try { - final accepted = await widget.actions.respondToAttention(item, response); - if (!mounted) return; - if (!accepted) _showError('The host did not accept that response.'); - } on Object catch (error) { - if (mounted) _showError(error.toString().replaceFirst('Bad state: ', '')); - } finally { - if (mounted) setState(() => _responding.remove(item.key)); - } - } - - void _showError(String message) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - Future _askForText({ - required String title, - String hint = 'Add a note', - }) async { - final controller = TextEditingController(); - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - autofocus: true, - minLines: 2, - maxLines: 6, - decoration: InputDecoration(hintText: hint), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - child: const Text('Send'), - ), - ], - ), - ); - controller.dispose(); - return result; - } - - @override - Widget build(BuildContext context) { - final needsYou = widget.state.attentionItems - .where((item) => item.needsResponse) - .toList(growable: false); - final updates = widget.state.attentionItems - .where((item) => !item.needsResponse) - .toList(growable: false); - return Column( - children: [ - if (widget.showTitle) - _AttentionHeader( - urgentCount: widget.state.urgentAttentionCount, - onDone: widget.onDone, - ), - if (widget.state.attentionPartial) - MaterialBanner( - content: Text( - widget.state.omittedAttentionCount > 0 - ? '${widget.state.omittedAttentionCount} more requests are available on the host.' - : 'Some attention data could not be read. Refresh the host before acting.', - ), - actions: [ - TextButton(onPressed: widget.onDone, child: const Text('Close')), - ], - ), - TabBar( - controller: _tabs, - tabs: [ - Tab( - text: needsYou.isEmpty - ? 'Needs you' - : 'Needs you (${needsYou.length})', - ), - Tab( - text: updates.isEmpty ? 'Updates' : 'Updates (${updates.length})', - ), - Tab( - text: widget.state.agentActivities.isEmpty - ? 'Agents' - : 'Agents (${widget.state.agentActivities.length})', - ), - ], - ), - Expanded( - child: TabBarView( - controller: _tabs, - children: [ - _attentionList( - needsYou, - emptyLabel: 'Nothing needs your response.', - ), - _attentionList(updates, emptyLabel: 'No recent session updates.'), - _AgentActivityList( - activities: widget.state.agentActivities, - actions: widget.actions, - canControl: - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('agents.control'), - ), - ], - ), - ), - ], - ); - } - - Widget _attentionList( - List items, { - required String emptyLabel, - }) { - if (items.isEmpty) return _AttentionEmpty(label: emptyLabel); - return ListView.separated( - padding: const EdgeInsets.all(_T4Space.lg), - itemCount: items.length, - separatorBuilder: (_, _) => const SizedBox(height: _T4Space.md), - itemBuilder: (context, index) { - final item = items[index]; - return _AttentionCard( - item: item, - busy: _responding.contains(item.key), - canRespond: - widget.state.connectionPhase == ConnectionPhase.ready && - item.actionable, - onRespond: (response) => _respond(item, response), - onRevise: () async { - final note = await _askForText( - title: 'Request plan changes', - hint: 'What should change?', - ); - if (note != null) { - await _respond( - item, - AttentionResponse( - decision: AttentionDecision.revise, - text: note, - ), - ); - } - }, - onCustomAnswer: () async { - final answer = await _askForText( - title: item.title, - hint: 'Type your answer', - ); - if (answer != null && answer.isNotEmpty) { - await _respond( - item, - AttentionResponse( - decision: AttentionDecision.approve, - text: answer, - ), - ); - } - }, - onOpenSession: () => widget.onOpenSession(item.sessionId), - onRetry: item.isProblem - ? () async { - try { - await widget.actions.retrySession(item.sessionId); - } on Object catch (error) { - if (mounted) _showError(error.toString()); - } - } - : null, - ); - }, - ); - } -} - -final class _AttentionHeader extends StatelessWidget { - const _AttentionHeader({required this.urgentCount, required this.onDone}); - - final int urgentCount; - final VoidCallback onDone; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.lg, - _T4Space.md, - _T4Space.sm, - _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - urgentCount == 0 ? 'Inbox' : 'Inbox · $urgentCount waiting', - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - IconButton( - onPressed: onDone, - tooltip: 'Close inbox', - icon: const Icon(Icons.close), - ), - ], - ), - ); -} - -IconData _attentionKindIcon(AttentionKind kind) => switch (kind) { - AttentionKind.approval => Icons.shield_outlined, - AttentionKind.question => Icons.help_outline, - AttentionKind.plan => Icons.account_tree_outlined, - AttentionKind.confirmation => Icons.verified_user_outlined, - AttentionKind.completed => Icons.check_circle_outline, - AttentionKind.failed => Icons.error_outline, - AttentionKind.cancelled => Icons.cancel_outlined, -}; - -Color _attentionKindColor(AttentionKind kind, ColorScheme scheme) => - switch (kind) { - AttentionKind.failed => scheme.error, - AttentionKind.cancelled => scheme.onSurfaceVariant, - AttentionKind.completed => scheme.primary, - _ => scheme.tertiary, - }; - -/// Question choice buttons shared by [_AttentionCard] and -/// [InlineApprovalCard]: one outlined button per choice plus the optional -/// free-text answer affordance. -final class _AttentionQuestionActions extends StatelessWidget { - const _AttentionQuestionActions({ - required this.item, - required this.enabled, - required this.onRespond, - required this.onCustomAnswer, - }); - - final AttentionItem item; - final bool enabled; - final ValueChanged onRespond; - final VoidCallback onCustomAnswer; - - @override - Widget build(BuildContext context) => Wrap( - spacing: _T4Space.sm, - runSpacing: _T4Space.sm, - children: [ - for (final choice in item.choices) - OutlinedButton( - onPressed: !enabled - ? null - : () => onRespond( - AttentionResponse( - decision: AttentionDecision.approve, - optionIds: [choice.id], - ), - ), - child: Text(choice.label), - ), - if (item.allowText) - FilledButton.tonal( - onPressed: !enabled ? null : onCustomAnswer, - child: const Text('Type an answer'), - ), - ], - ); -} - -/// Approve / deny / reject / revise buttons shared by [_AttentionCard] and -/// [InlineApprovalCard]; label and decision semantics depend on the item kind. -final class _AttentionDecisionActions extends StatelessWidget { - const _AttentionDecisionActions({ - required this.item, - required this.enabled, - required this.onRespond, - required this.onRevise, - }); - - final AttentionItem item; - final bool enabled; - final ValueChanged onRespond; - final VoidCallback onRevise; - - @override - Widget build(BuildContext context) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (item.kind == AttentionKind.plan) ...[ - TextButton( - onPressed: !enabled ? null : onRevise, - child: const Text('Revise'), - ), - const SizedBox(width: _T4Space.sm), - ], - OutlinedButton( - onPressed: !enabled - ? null - : () => onRespond( - AttentionResponse( - decision: item.kind == AttentionKind.plan - ? AttentionDecision.reject - : AttentionDecision.deny, - ), - ), - child: Text(item.kind == AttentionKind.plan ? 'Reject' : 'Deny'), - ), - const SizedBox(width: _T4Space.sm), - FilledButton( - onPressed: !enabled - ? null - : () => onRespond( - const AttentionResponse(decision: AttentionDecision.approve), - ), - child: const Text('Approve'), - ), - ], - ); -} - -final class _AttentionCard extends StatelessWidget { - const _AttentionCard({ - required this.item, - required this.busy, - required this.canRespond, - required this.onRespond, - required this.onRevise, - required this.onCustomAnswer, - required this.onOpenSession, - this.onRetry, - }); - - final AttentionItem item; - final bool busy; - final bool canRespond; - final ValueChanged onRespond; - final VoidCallback onRevise; - final VoidCallback onCustomAnswer; - final VoidCallback onOpenSession; - final VoidCallback? onRetry; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(_T4Space.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - _attentionKindIcon(item.kind), - size: 20, - color: _attentionKindColor(item.kind, scheme), - ), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Text( - item.title, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - if (busy) - const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - const SizedBox(height: _T4Space.sm), - Text(item.summary), - const SizedBox(height: _T4Space.sm), - TextButton.icon( - onPressed: busy ? null : onOpenSession, - icon: const Icon(Icons.forum_outlined), - label: Text(item.sessionTitle), - ), - if (item.needsResponse) ...[ - const SizedBox(height: _T4Space.sm), - if (item.kind == AttentionKind.question) - _AttentionQuestionActions( - item: item, - enabled: canRespond && !busy, - onRespond: onRespond, - onCustomAnswer: onCustomAnswer, - ) - else - _AttentionDecisionActions( - item: item, - enabled: canRespond && !busy, - onRespond: onRespond, - onRevise: onRevise, - ), - if (!canRespond) - Padding( - padding: const EdgeInsets.only(top: _T4Space.sm), - child: Text( - 'Connect with write access to respond.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), - ] else if (onRetry != null) ...[ - const SizedBox(height: _T4Space.sm), - OutlinedButton.icon( - onPressed: busy ? null : onRetry, - icon: const Icon(Icons.refresh), - label: const Text('Retry session'), - ), - ], - ], - ), - ), - ); - } -} - -/// A single actionable attention item (approval or question) rendered inline -/// in the conversation transcript. Shares choice/decision button semantics -/// with the inbox cards via [_AttentionQuestionActions] and -/// [_AttentionDecisionActions]. -final class InlineApprovalCard extends StatelessWidget { - const InlineApprovalCard({ - required this.item, - required this.onRespond, - required this.onRevise, - required this.onCustomAnswer, - required this.onOpenInbox, - this.busy = false, - this.canRespond = true, - super.key, - }); - - final AttentionItem item; - final ValueChanged onRespond; - final VoidCallback onRevise; - final VoidCallback onCustomAnswer; - final VoidCallback onOpenInbox; - final bool busy; - final bool canRespond; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final enabled = canRespond && !busy && item.actionable; - return Align( - alignment: Alignment.centerLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Container( - padding: const EdgeInsets.all(_T4Space.md), - decoration: BoxDecoration( - color: scheme.surface, - borderRadius: BorderRadius.circular(_T4Radius.md), - border: Border.all( - width: 1, - color: scheme.tertiary.withValues(alpha: 0.4), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - _attentionKindIcon(item.kind), - size: 18, - color: _attentionKindColor(item.kind, scheme), - ), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall, - ), - ), - if (busy) - const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - IconButton( - onPressed: onOpenInbox, - tooltip: 'Open inbox', - iconSize: 18, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.inbox_outlined), - ), - ], - ), - const SizedBox(height: _T4Space.xs), - Text( - item.summary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - if (item.needsResponse) ...[ - const SizedBox(height: _T4Space.sm), - if (item.kind == AttentionKind.question) - _AttentionQuestionActions( - item: item, - enabled: enabled, - onRespond: onRespond, - onCustomAnswer: onCustomAnswer, - ) - else - _AttentionDecisionActions( - item: item, - enabled: enabled, - onRespond: onRespond, - onRevise: onRevise, - ), - ], - ], - ), - ), - ), - ); - } -} - -final class _AgentActivityList extends StatefulWidget { - const _AgentActivityList({ - required this.activities, - required this.actions, - required this.canControl, - }); - - final List activities; - final T4Actions actions; - final bool canControl; - - @override - State<_AgentActivityList> createState() => _AgentActivityListState(); -} - -final class _AgentActivityListState extends State<_AgentActivityList> { - final Set _cancelling = {}; - - Future _cancel(AgentActivity activity) async { - if (_cancelling.contains(activity.agentId)) return; - try { - final approved = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Stop background agent?'), - content: Text( - '${activity.label} will be asked to stop. Work already completed is kept.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Keep running'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Stop agent'), - ), - ], - ), - ); - if (approved != true || !mounted) return; - _cancelling.add(activity.agentId); - setState(() {}); - await widget.actions.cancelAgent(activity.agentId); - } on Object catch (error) { - if (!mounted) return; - final message = error.toString().replaceFirst('Bad state: ', ''); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } finally { - if (mounted) setState(() => _cancelling.remove(activity.agentId)); - } - } - - @override - Widget build(BuildContext context) { - if (widget.activities.isEmpty) { - return const _AttentionEmpty(label: 'No background agent activity.'); - } - final rows = _agentHierarchy(widget.activities); - return ListView.separated( - padding: const EdgeInsets.all(_T4Space.lg), - itemCount: rows.length, - separatorBuilder: (_, _) => const Divider(height: _T4Space.lg), - itemBuilder: (context, index) { - final row = rows[index]; - final activity = row.activity; - final terminal = const { - 'completed', - 'failed', - 'cancelled', - }.contains(activity.status); - final detail = [ - activity.status, - ?activity.model, - ?activity.currentTool, - ].join(' · '); - return Padding( - padding: EdgeInsets.only(left: row.depth * _T4Space.lg), - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - row.depth == 0 - ? Icons.hub_outlined - : Icons.subdirectory_arrow_right, - ), - title: Text(activity.label), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(detail), - if (activity.description case final description?) - Text( - description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (activity.evidence case final evidence?) - Text( - evidence, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - if (activity.progress case final progress?) - Padding( - padding: const EdgeInsets.only(top: _T4Space.xs), - child: LinearProgressIndicator(value: progress), - ), - ], - ), - trailing: terminal - ? Icon( - activity.status == 'completed' - ? Icons.check_circle_outline - : Icons.stop_circle_outlined, - ) - : IconButton( - onPressed: - widget.canControl && - !_cancelling.contains(activity.agentId) - ? () => unawaited(_cancel(activity)) - : null, - tooltip: widget.canControl - ? 'Stop ${activity.label}' - : 'Agent control unavailable', - icon: _cancelling.contains(activity.agentId) - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.stop_circle_outlined), - ), - ), - ); - }, - ); - } -} - -List<({AgentActivity activity, double depth})> _agentHierarchy( - List activities, -) { - final byId = {for (final activity in activities) activity.agentId: activity}; - final children = >{}; - final roots = []; - for (final activity in activities) { - final parentId = activity.parentAgentId; - if (parentId == null || !byId.containsKey(parentId)) { - roots.add(activity); - } else { - children.putIfAbsent(parentId, () => []).add(activity); - } - } - int recentFirst(AgentActivity left, AgentActivity right) => - right.updatedAt.compareTo(left.updatedAt); - roots.sort(recentFirst); - for (final values in children.values) { - values.sort(recentFirst); - } - final rows = <({AgentActivity activity, double depth})>[]; - final visited = {}; - void append(AgentActivity activity, int depth) { - if (!visited.add(activity.agentId)) return; - rows.add((activity: activity, depth: depth.clamp(0, 4).toDouble())); - for (final child in children[activity.agentId] ?? const []) { - append(child, depth + 1); - } - } - - for (final root in roots) { - append(root, 0); - } - for (final activity in activities) { - append(activity, 0); - } - return rows; -} - -final class _AttentionEmpty extends StatelessWidget { - const _AttentionEmpty({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) => Center( - child: Padding( - padding: const EdgeInsets.all(_T4Space.xl), - child: Text( - label, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ); -} diff --git a/apps/flutter/lib/src/ui/command_palette.dart b/apps/flutter/lib/src/ui/command_palette.dart deleted file mode 100644 index d138a489..00000000 --- a/apps/flutter/lib/src/ui/command_palette.dart +++ /dev/null @@ -1,341 +0,0 @@ -part of 't4_app.dart'; - -/// One executable entry in the command palette. -class PaletteCommand { - const PaletteCommand({ - required this.id, - required this.title, - this.shortcutLabel, - this.enabled = true, - required this.run, - }); - - /// Stable identifier (used for widget keys and de-duplication by callers). - final String id; - - /// Human-readable command title; the fuzzy filter matches against this. - final String title; - - /// Optional right-aligned shortcut hint (e.g. `⌘K`). - final String? shortcutLabel; - - /// Disabled commands render dimmed and cannot be selected or run. - final bool enabled; - - /// Invoked after the palette dialog has been popped. - final VoidCallback run; -} - -/// Shows the centered command palette dialog over [context]. -/// -/// Fully keyboard drivable: the filter field autofocuses, ArrowUp/Down move -/// the active row, Enter pops the dialog and runs the active command, Escape -/// closes. Clicking a row does the same as Enter for that row. -Future showCommandPalette( - BuildContext context, { - required List commands, -}) { - return showDialog( - context: context, - barrierDismissible: true, - builder: (context) => _CommandPaletteDialog(commands: commands), - ); -} - -/// A [PaletteCommand] paired with the title indices its match highlighted. -final class _PaletteMatch { - const _PaletteMatch({required this.command, required this.highlight}); - - final PaletteCommand command; - final Set highlight; -} - -/// Case-insensitive subsequence match of [query] inside [title]. -/// -/// Characters at word starts are preferred so multi-word queries behave like -/// word-prefix matching (`"op set"` matches **Op**en **Set**tings). Returns -/// the matched character indices for highlighting, or null when [query] is -/// not a subsequence of [title]. -Set? _fuzzyMatch(String query, String title) { - final needle = query.toLowerCase(); - final haystack = title.toLowerCase(); - if (needle.isEmpty) return const {}; - final matched = {}; - var from = 0; - for (var i = 0; i < needle.length; i++) { - final ch = needle[i]; - if (ch == ' ') continue; - var at = -1; - // Prefer a word-start occurrence at/after the cursor, else any occurrence. - for (var j = from; j < haystack.length; j++) { - if (haystack[j] != ch) continue; - final wordStart = j == 0 || haystack[j - 1] == ' '; - if (wordStart) { - at = j; - break; - } - if (at < 0) at = j; - } - if (at < 0) return null; - matched.add(at); - from = at + 1; - } - return matched; -} - -final class _CommandPaletteDialog extends StatefulWidget { - const _CommandPaletteDialog({required this.commands}); - - final List commands; - - @override - State<_CommandPaletteDialog> createState() => _CommandPaletteDialogState(); -} - -final class _CommandPaletteDialogState extends State<_CommandPaletteDialog> { - final TextEditingController _queryController = TextEditingController(); - final FocusNode _fieldFocus = FocusNode(debugLabel: 'Command palette query'); - final ScrollController _scrollController = ScrollController(); - List<_PaletteMatch> _matches = const <_PaletteMatch>[]; - int _active = -1; - - @override - void initState() { - super.initState(); - _refilter(''); - } - - @override - void dispose() { - _queryController.dispose(); - _fieldFocus.dispose(); - _scrollController.dispose(); - super.dispose(); - } - - void _refilter(String query) { - final matches = <_PaletteMatch>[]; - for (final command in widget.commands) { - final highlight = _fuzzyMatch(query.trim(), command.title); - if (highlight == null) continue; - matches.add(_PaletteMatch(command: command, highlight: highlight)); - } - setState(() { - _matches = matches; - _active = matches.indexWhere((match) => match.command.enabled); - }); - } - - /// Moves the active row by [delta], skipping disabled commands. - void _moveActive(int delta) { - if (_matches.isEmpty) return; - var index = _active; - for (var step = 0; step < _matches.length; step++) { - index = (index + delta) % _matches.length; - if (index < 0) index += _matches.length; - if (_matches[index].command.enabled) { - setState(() => _active = index); - _revealActive(); - return; - } - } - } - - void _revealActive() { - if (_active < 0 || !_scrollController.hasClients) return; - const rowExtent = 40.0; - final viewport = _scrollController.position.viewportDimension; - final top = _active * rowExtent; - final offset = _scrollController.offset; - if (top < offset) { - _scrollController.jumpTo(top); - } else if (top + rowExtent > offset + viewport) { - _scrollController.jumpTo(top + rowExtent - viewport); - } - } - - void _runCommand(PaletteCommand command) { - if (!command.enabled) return; - Navigator.of(context).pop(); - command.run(); - } - - KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyUpEvent) return KeyEventResult.ignored; - final key = event.logicalKey; - if (key == LogicalKeyboardKey.arrowDown) { - _moveActive(1); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.arrowUp) { - _moveActive(-1); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.numpadEnter) { - if (_active >= 0 && _active < _matches.length) { - _runCommand(_matches[_active].command); - } - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.escape) { - Navigator.of(context).pop(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final topInset = MediaQuery.sizeOf(context).height * 0.15; - return SafeArea( - child: Align( - alignment: Alignment.topCenter, - child: Padding( - padding: EdgeInsets.only(top: topInset), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560, maxHeight: 420), - child: Material( - color: scheme.surface, - elevation: 8, - borderRadius: BorderRadius.circular(_T4Radius.md), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Focus( - onKeyEvent: _onKeyEvent, - child: TextField( - controller: _queryController, - focusNode: _fieldFocus, - autofocus: true, - onChanged: _refilter, - style: theme.textTheme.bodyMedium, - decoration: const InputDecoration( - hintText: 'Type a command…', - prefixIcon: Icon(Icons.search, size: 18), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: _T4Space.sm, - vertical: _T4Space.sm, - ), - ), - ), - ), - Divider(height: 1, color: scheme.outlineVariant), - Flexible( - child: _matches.isEmpty - ? Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Text( - 'No matching commands.', - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ) - : ListView.builder( - controller: _scrollController, - shrinkWrap: true, - padding: const EdgeInsets.symmetric( - vertical: _T4Space.xxs, - ), - itemExtent: 40, - itemCount: _matches.length, - itemBuilder: (context, index) => _PaletteRow( - match: _matches[index], - active: index == _active, - onTap: () => _runCommand(_matches[index].command), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -final class _PaletteRow extends StatelessWidget { - const _PaletteRow({ - required this.match, - required this.active, - required this.onTap, - }); - - final _PaletteMatch match; - final bool active; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final command = match.command; - final enabled = command.enabled; - final baseColor = enabled ? scheme.onSurface : scheme.outline; - final title = Text.rich( - TextSpan( - children: [ - for (var i = 0; i < command.title.length; i++) - TextSpan( - text: command.title[i], - style: match.highlight.contains(i) - ? TextStyle( - color: scheme.primary, - fontWeight: FontWeight.w600, - ) - : null, - ), - ], - style: TextStyle(fontSize: 13, color: baseColor), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - return InkWell( - key: ValueKey('palette-command-${command.id}'), - onTap: enabled ? onTap : null, - child: Container( - color: active ? scheme.primary.withValues(alpha: 0.08) : null, - padding: const EdgeInsets.symmetric(horizontal: _T4Space.sm), - alignment: Alignment.centerLeft, - child: Row( - children: [ - Expanded(child: title), - if (command.shortcutLabel case final shortcut?) ...[ - const SizedBox(width: _T4Space.xs), - Container( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xxs + 1, - vertical: 1, - ), - decoration: BoxDecoration( - border: Border.all(color: scheme.outlineVariant), - borderRadius: BorderRadius.circular(_T4Radius.xs - 2), - ), - child: Text( - shortcut, - style: TextStyle( - fontFamily: _T4Typography.monoFamily, - fontSize: 11, - color: scheme.onSurfaceVariant, - ), - ), - ), - ], - ], - ), - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/context_panel.dart b/apps/flutter/lib/src/ui/context_panel.dart deleted file mode 100644 index cf1421fb..00000000 --- a/apps/flutter/lib/src/ui/context_panel.dart +++ /dev/null @@ -1,180 +0,0 @@ -part of 't4_app.dart'; - -/// One collapsible section hosted by a [ContextPanel]. -final class ContextPanelSection { - const ContextPanelSection({ - required this.id, - required this.title, - required this.child, - this.trailing, - this.initiallyExpanded = true, - }); - - final String id; - final String title; - final Widget child; - final Widget? trailing; - final bool initiallyExpanded; -} - -/// Right-docked contextual side panel with collapsible sections. -/// -/// Renders a fixed-width column with a hairline left divider, one header row -/// per section (title, optional trailing widget, collapse chevron), and a -/// single shared scroll view. Hosts no [Scaffold] or [AppBar] — the shell -/// mounts it directly beside the primary surface. -final class ContextPanel extends StatelessWidget { - const ContextPanel({ - required this.sections, - this.onClose, - this.width = 340, - super.key, - }); - - final List sections; - final VoidCallback? onClose; - final double width; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - width: width, - decoration: BoxDecoration( - color: scheme.surface, - border: Border( - left: BorderSide( - color: scheme.outlineVariant, - width: _T4Size.divider, - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (onClose case final close?) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.xs, - _T4Space.xs, - 0, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Context', - style: Theme.of(context).textTheme.titleSmall, - ), - ), - IconButton( - onPressed: close, - tooltip: 'Close context panel', - iconSize: _T4Size.indicator, - visualDensity: VisualDensity.compact, - color: scheme.onSurfaceVariant, - icon: const Icon(Icons.close), - ), - ], - ), - ), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final section in sections) - _ContextPanelSectionView( - key: ValueKey('context-section-${section.id}'), - section: section, - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -final class _ContextPanelSectionView extends StatefulWidget { - const _ContextPanelSectionView({required this.section, super.key}); - - final ContextPanelSection section; - - @override - State<_ContextPanelSectionView> createState() => - _ContextPanelSectionViewState(); -} - -final class _ContextPanelSectionViewState - extends State<_ContextPanelSectionView> { - late bool _expanded = widget.section.initiallyExpanded; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final section = widget.section; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - button: true, - expanded: _expanded, - label: '${section.title} section', - child: InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.sm, - _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - section.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall, - ), - ), - if (section.trailing case final trailing?) ...[ - trailing, - const SizedBox(width: _T4Space.xs), - ], - Icon( - _expanded ? Icons.expand_less : Icons.expand_more, - size: _T4Size.indicator, - color: scheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ), - if (_expanded) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - 0, - _T4Space.md, - _T4Space.sm, - ), - child: section.child, - ), - Divider( - height: _T4Size.divider, - thickness: _T4Size.divider, - color: scheme.outlineVariant, - ), - ], - ); - } -} diff --git a/apps/flutter/lib/src/ui/conversation_pane.dart b/apps/flutter/lib/src/ui/conversation_pane.dart deleted file mode 100644 index 9bb6b47b..00000000 --- a/apps/flutter/lib/src/ui/conversation_pane.dart +++ /dev/null @@ -1,2364 +0,0 @@ -part of 't4_app.dart'; - -final class _ConversationPane extends StatelessWidget { - const _ConversationPane({ - required this.state, - required this.actions, - required this.showHeader, - required this.onConnect, - this.onOpenSessions, - required this.onOpenAttention, - required this.onOpenDeveloper, - required this.onOpenQuickOpen, - required this.onSelectSession, - }); - - final T4ViewState state; - final T4Actions actions; - final bool showHeader; - final Future Function() onConnect; - final VoidCallback? onOpenSessions; - final VoidCallback onOpenAttention; - final VoidCallback onOpenDeveloper; - final Future Function() onOpenQuickOpen; - final Future Function(String sessionId) onSelectSession; - - @override - Widget build(BuildContext context) { - final error = state.errorMessage?.trim(); - final showError = - (error != null && error.isNotEmpty) || - state.connectionPhase == ConnectionPhase.failed; - - return Column( - children: [ - if (showHeader) - _ConversationHeader( - state: state, - actions: actions, - onOpenDeveloper: onOpenDeveloper, - onOpenQuickOpen: onOpenQuickOpen, - onSelectSession: onSelectSession, - ), - if (showError) - _ConnectionErrorBanner( - message: error == null || error.isEmpty - ? 'The connection could not be established.' - : error, - canRetry: state.connectionPhase == ConnectionPhase.failed, - onRetry: onConnect, - ), - Expanded( - child: _TranscriptView( - state: state, - actions: actions, - onOpenSessions: onOpenSessions, - onOpenAttention: onOpenAttention, - onOpenDeveloper: onOpenDeveloper, - ), - ), - _PromptComposer(state: state, actions: actions), - ], - ); - } -} - -/// Wide-layout conversation header. Only the wide shell renders this widget -/// (compact keeps the app bar), so the inbox button anchors a 400 px flyout -/// here instead of toggling the full-screen attention takeover. -final class _ConversationHeader extends StatefulWidget { - const _ConversationHeader({ - required this.state, - required this.actions, - required this.onOpenDeveloper, - required this.onOpenQuickOpen, - required this.onSelectSession, - }); - - final T4ViewState state; - final T4Actions actions; - final VoidCallback onOpenDeveloper; - final Future Function() onOpenQuickOpen; - final Future Function(String sessionId) onSelectSession; - - @override - State<_ConversationHeader> createState() => _ConversationHeaderState(); -} - -final class _ConversationHeaderState extends State<_ConversationHeader> { - final MenuController _inboxMenu = MenuController(); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final state = widget.state; - final session = state.selectedSession; - final streaming = state.composer.turnActive; - - return DecoratedBox( - decoration: BoxDecoration( - color: scheme.surface, - border: Border(bottom: BorderSide(color: scheme.outlineVariant)), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.lg, - vertical: _T4Space.md, - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _displaySessionTitle(session), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleLarge, - ), - if (session != null && session.status.trim().isNotEmpty) ...[ - const SizedBox(height: _T4Space.xxs), - Text( - session.status, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - IconButton( - onPressed: - state.connectionPhase == ConnectionPhase.ready && - state.grantedFeatures.contains('files.search') && - state.grantedCapabilities.contains('files.list') && - state.grantedCapabilities.contains('files.read') && - session != null - ? () => unawaited(widget.onOpenQuickOpen()) - : null, - tooltip: 'Quick open project file', - icon: const Icon(Icons.search), - ), - IconButton( - onPressed: widget.onOpenDeveloper, - tooltip: 'Open developer tools', - icon: const Icon(Icons.code), - ), - Badge( - isLabelVisible: state.urgentAttentionCount > 0, - label: Text('${state.urgentAttentionCount}'), - child: MenuAnchor( - controller: _inboxMenu, - alignmentOffset: const Offset(0, _T4Space.xs), - menuChildren: [ - SizedBox( - width: 400, - height: 480, - child: InboxFlyoutContent( - state: state, - actions: widget.actions, - showTitle: false, - onDone: _inboxMenu.close, - onOpenSession: (sessionId) async { - await widget.onSelectSession(sessionId); - if (mounted) _inboxMenu.close(); - }, - ), - ), - ], - builder: (context, controller, child) => IconButton( - onPressed: () => _toggleMenu(controller), - tooltip: 'Open inbox', - icon: const Icon(Icons.inbox_outlined), - ), - ), - ), - const SizedBox(width: _T4Space.sm), - if (streaming) const _StreamingLabel(), - ], - ), - ), - ); - } -} - -final class _StreamingLabel extends StatelessWidget { - const _StreamingLabel(); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Semantics( - liveRegion: true, - label: 'Assistant is streaming a response', - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - color: scheme.primary, - ), - ), - const SizedBox(width: _T4Space.xs), - Text( - 'Streaming', - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(color: scheme.primary), - ), - ], - ), - ); - } -} - -final class _ConnectionErrorBanner extends StatelessWidget { - const _ConnectionErrorBanner({ - required this.message, - required this.canRetry, - required this.onRetry, - }); - - final String message; - final bool canRetry; - final Future Function() onRetry; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Semantics( - container: true, - liveRegion: true, - label: 'Connection error: $message', - child: ColoredBox( - color: scheme.errorContainer, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.xs, - ), - child: Row( - children: [ - Icon(Icons.error_outline, color: scheme.onErrorContainer), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Text( - message, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onErrorContainer, - ), - ), - ), - if (canRetry) ...[ - const SizedBox(width: _T4Space.xs), - TextButton( - onPressed: () => unawaited(onRetry()), - style: TextButton.styleFrom( - foregroundColor: scheme.onErrorContainer, - ), - child: const Text('Retry'), - ), - ], - ], - ), - ), - ), - ); - } -} - -final class _TranscriptView extends StatefulWidget { - const _TranscriptView({ - required this.state, - required this.actions, - required this.onOpenAttention, - this.onOpenSessions, - this.onOpenDeveloper, - }); - - final T4ViewState state; - final T4Actions actions; - final VoidCallback onOpenAttention; - final VoidCallback? onOpenSessions; - final VoidCallback? onOpenDeveloper; - - @override - State<_TranscriptView> createState() => _TranscriptViewState(); -} - -final class _TranscriptViewState extends State<_TranscriptView> { - final ScrollController _scrollController = ScrollController(); - final Set _expandedWorkGroups = {}; - final Set _respondingAttention = {}; - bool _followEnd = true; - bool _userScrolling = false; - - @override - void initState() { - super.initState(); - _scheduleScrollToEnd(animate: false); - } - - @override - void didUpdateWidget(covariant _TranscriptView oldWidget) { - super.didUpdateWidget(oldWidget); - final sessionChanged = - oldWidget.state.selectedSessionId != widget.state.selectedSessionId; - final messagesChanged = _visibleMessagesChanged( - oldWidget.state.messages, - widget.state.messages, - ); - final historyPrepended = - !sessionChanged && - !_followEnd && - _historyWasPrepended(oldWidget.state.messages, widget.state.messages); - if (historyPrepended && _scrollController.hasClients) { - final position = _scrollController.position; - _restoreHistoryDistanceFromEnd( - position.maxScrollExtent - position.pixels, - ); - } - if (!sessionChanged && !messagesChanged) return; - if (sessionChanged) _followEnd = true; - - final shouldFollow = sessionChanged || _followEnd; - if (shouldFollow) _scheduleScrollToEnd(animate: !sessionChanged); - } - - bool get _isNearEnd { - if (!_scrollController.hasClients) return true; - final position = _scrollController.position; - return position.maxScrollExtent - position.pixels <= - _T4Layout.followScrollThreshold; - } - - bool _trackScroll(ScrollNotification notification) { - if (notification is UserScrollNotification) { - if (notification.direction == ScrollDirection.idle) { - _followEnd = _isNearEnd; - _userScrolling = false; - } else { - _userScrolling = true; - _followEnd = false; - } - } else if (notification is ScrollStartNotification && - notification.dragDetails != null) { - _userScrolling = true; - _followEnd = false; - } else if (_userScrolling && notification is ScrollEndNotification) { - _followEnd = _isNearEnd; - _userScrolling = false; - } - return false; - } - - bool _trackScrollMetrics(ScrollMetricsNotification notification) { - if (_followEnd) _scheduleScrollToEnd(animate: false); - return false; - } - - bool _visibleMessagesChanged( - List previous, - List current, - ) { - if (previous.length != current.length) return true; - if (previous.isEmpty) return false; - final oldLast = previous.last; - final newLast = current.last; - return oldLast.id != newLast.id || - oldLast.text != newLast.text || - oldLast.streaming != newLast.streaming; - } - - bool _historyWasPrepended( - List previous, - List current, - ) { - if (previous.isEmpty || current.length <= previous.length) return false; - final offset = current.indexWhere( - (message) => message.id == previous.first.id, - ); - if (offset <= 0 || current.length < offset + previous.length) return false; - for (var index = 0; index < previous.length; index++) { - if (current[offset + index].id != previous[index].id) return false; - } - return true; - } - - void _restoreHistoryDistanceFromEnd( - double distanceFromEnd, { - int framesRemaining = 3, - }) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_scrollController.hasClients) return; - final position = _scrollController.position; - final target = (position.maxScrollExtent - distanceFromEnd).clamp( - position.minScrollExtent, - position.maxScrollExtent, - ); - if ((position.pixels - target).abs() > 0.5) { - position.jumpTo(target); - } - if (framesRemaining > 1) { - _restoreHistoryDistanceFromEnd( - distanceFromEnd, - framesRemaining: framesRemaining - 1, - ); - } - }); - } - - void _scheduleScrollToEnd({required bool animate}) { - _followEnd = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_scrollController.hasClients || !_followEnd) return; - final end = _scrollController.position.maxScrollExtent; - if (animate) { - unawaited( - _scrollController.animateTo( - end, - duration: _T4Motion.short, - curve: _T4Motion.standard, - ), - ); - } else { - _scrollController.jumpTo(end); - } - }); - } - - /// Collapses each maximal run of two or more completed tool/reasoning - /// steps between chat messages into one collapsible work group. The - /// trailing run of the currently streaming turn stays expanded/ungrouped. - List<_TranscriptEntry> _transcriptEntries() { - final messages = widget.state.messages; - final entries = <_TranscriptEntry>[]; - var index = 0; - while (index < messages.length) { - if (!_isWorkedMessage(messages[index])) { - entries.add(_SingleTranscriptEntry(messages[index])); - index += 1; - continue; - } - var end = index; - while (end < messages.length && _isWorkedMessage(messages[end])) { - end += 1; - } - final run = messages.sublist(index, end); - final trailingActiveRun = - end == messages.length && widget.state.composer.turnActive; - if (run.length < 2 || trailingActiveRun) { - entries.addAll(run.map(_SingleTranscriptEntry.new)); - } else { - entries.add(_WorkGroupTranscriptEntry(_TranscriptWorkGroup(run))); - } - index = end; - } - return entries; - } - - void _toggleWorkGroup(String key) { - setState(() { - if (!_expandedWorkGroups.remove(key)) _expandedWorkGroups.add(key); - }); - } - - void _showError(String message) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - /// Mirrors the inbox pane's respond wiring (busy set, acceptance check, - /// error surfacing) for the inline approval cards. - Future _respondAttention( - AttentionItem item, - AttentionResponse response, - ) async { - if (!_respondingAttention.add(item.key)) return; - setState(() {}); - try { - final accepted = await widget.actions.respondToAttention(item, response); - if (!mounted) return; - if (!accepted) _showError('The host did not accept that response.'); - } on Object catch (error) { - if (mounted) _showError(error.toString().replaceFirst('Bad state: ', '')); - } finally { - if (mounted) setState(() => _respondingAttention.remove(item.key)); - } - } - - Future _askForText({ - required String title, - String hint = 'Add a note', - }) async { - final controller = TextEditingController(); - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - autofocus: true, - minLines: 2, - maxLines: 6, - decoration: InputDecoration(hintText: hint), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - child: const Text('Send'), - ), - ], - ), - ); - controller.dispose(); - return result; - } - - Future _reviseAttention(AttentionItem item) async { - final note = await _askForText( - title: 'Request plan changes', - hint: 'What should change?', - ); - if (note != null) { - await _respondAttention( - item, - AttentionResponse(decision: AttentionDecision.revise, text: note), - ); - } - } - - Future _customAnswerAttention(AttentionItem item) async { - final answer = await _askForText( - title: item.title, - hint: 'Type your answer', - ); - if (answer != null && answer.isNotEmpty) { - await _respondAttention( - item, - AttentionResponse(decision: AttentionDecision.approve, text: answer), - ); - } - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final session = widget.state.selectedSession; - if (session == null) { - return _TranscriptEmptyState( - title: 'Choose a session', - message: widget.onOpenSessions == null - ? 'Select a session from the session rail.' - : 'Open sessions to choose a conversation.', - actionLabel: widget.onOpenSessions == null ? null : 'Open sessions', - onAction: widget.onOpenSessions, - ); - } - - if (widget.state.messages.isEmpty) { - final ready = widget.state.connectionPhase == ConnectionPhase.ready; - return _TranscriptEmptyState( - title: 'Start the conversation', - message: ready - ? 'Send a prompt when you’re ready.' - : 'You can send a prompt once the connection is ready.', - ); - } - - final showHistoryControl = - widget.state.transcriptHistoryLoading || - widget.state.transcriptHistoryHasMore || - widget.state.transcriptHistoryError != null; - final messageOffset = showHistoryControl ? 1 : 0; - final entries = _transcriptEntries(); - final inlineAttention = widget.state.attentionItems - .where( - (item) => - item.sessionId == widget.state.selectedSessionId && - item.needsResponse && - item.actionable, - ) - .toList(growable: false); - - return Column( - children: [ - if (widget.state.transcriptTailFromCache) - const _CachedTranscriptNotice(), - Expanded( - child: NotificationListener( - onNotification: _trackScrollMetrics, - child: NotificationListener( - onNotification: _trackScroll, - child: Scrollbar( - controller: _scrollController, - child: ListView.separated( - controller: _scrollController, - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.lg, - _T4Space.md, - _T4Space.xl, - ), - itemCount: - entries.length + messageOffset + inlineAttention.length, - separatorBuilder: (context, index) => - const SizedBox(height: _T4Space.lg), - itemBuilder: (context, index) { - final Widget item; - if (showHistoryControl && index == 0) { - item = _TranscriptHistoryControl( - loading: widget.state.transcriptHistoryLoading, - hasMore: widget.state.transcriptHistoryHasMore, - error: widget.state.transcriptHistoryError, - onLoad: widget.actions.loadEarlierTranscript, - ); - } else if (index - messageOffset < entries.length) { - item = switch (entries[index - messageOffset]) { - _SingleTranscriptEntry(:final message) => - _TranscriptMessageView( - message: message, - actions: widget.actions, - ), - _WorkGroupTranscriptEntry(:final group) => - _WorkGroupView( - group: group, - expanded: _expandedWorkGroups.contains(group.key), - onToggle: () => _toggleWorkGroup(group.key), - actions: widget.actions, - onReview: widget.onOpenDeveloper, - ), - }; - } else { - final attention = - inlineAttention[index - - messageOffset - - entries.length]; - item = InlineApprovalCard( - item: attention, - busy: _respondingAttention.contains(attention.key), - canRespond: - widget.state.connectionPhase == - ConnectionPhase.ready && - attention.actionable, - onRespond: (response) => - unawaited(_respondAttention(attention, response)), - onRevise: () => unawaited(_reviseAttention(attention)), - onCustomAnswer: () => - unawaited(_customAnswerAttention(attention)), - onOpenInbox: widget.onOpenAttention, - ); - } - // Share one centered column axis with the composer: the - // transcript content is capped at the same - // [_T4Layout.contentMaxWidth] the composer uses. - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: SizedBox(width: double.infinity, child: item), - ), - ); - }, - ), - ), - ), - ), - ), - ], - ); - } -} - -/// A transcript row: either one message or a collapsed run of work steps. -sealed class _TranscriptEntry { - const _TranscriptEntry(); -} - -final class _SingleTranscriptEntry extends _TranscriptEntry { - const _SingleTranscriptEntry(this.message); - - final TranscriptMessage message; -} - -final class _WorkGroupTranscriptEntry extends _TranscriptEntry { - const _WorkGroupTranscriptEntry(this.group); - - final _TranscriptWorkGroup group; -} - -final class _TranscriptWorkGroup { - _TranscriptWorkGroup(this.messages) - : editedFiles = _editedFilePaths(messages); - - final List messages; - - /// Best-effort file paths touched by file-mutating tool calls in the group. - final List editedFiles; - - String get key => messages.first.id; -} - -/// Whether a message renders as pure background work (a completed tool card -/// or a text-less reasoning disclosure) and may collapse into a work group. -bool _isWorkedMessage(TranscriptMessage message) { - if (message.streaming) return false; - if (message.kind == TranscriptKind.tool) return !message.toolRunning; - return message.kind == TranscriptKind.message && - message.role == MessageRole.assistant && - message.text.isEmpty && - message.images.isEmpty && - message.reasoning.isNotEmpty; -} - -/// Matches the file-mutating wire commands (`files.write`, `files.patch`, -/// `review.apply`, apply_patch-style names) without inventing new ones. -bool _isFileMutatingToolName(String? name) { - if (name == null) return false; - final lower = name.toLowerCase(); - if (lower == 'review.apply' || lower.contains('apply_patch')) return true; - if (!lower.startsWith('files.')) return false; - final verb = lower.substring('files.'.length); - return verb.startsWith('write') || - verb.startsWith('patch') || - verb.startsWith('apply'); -} - -/// Derives file paths from tool-call argument JSON ('path'/'file'/'files'). -List _editedFilePaths(List messages) { - final paths = {}; - for (final message in messages) { - if (message.kind != TranscriptKind.tool) continue; - if (!_isFileMutatingToolName(message.toolName)) continue; - final raw = message.toolArguments; - if (raw == null || raw.isEmpty) continue; - Object? decoded; - try { - decoded = jsonDecode(raw); - } on FormatException { - continue; - } - if (decoded is! Map) continue; - void addPath(Object? value) { - if (value is String && value.trim().isNotEmpty) paths.add(value.trim()); - } - - addPath(decoded['path']); - addPath(decoded['file']); - if (decoded['files'] case final List files) { - for (final entry in files) { - if (entry is Map) { - addPath(entry['path']); - addPath(entry['file']); - } else { - addPath(entry); - } - } - } - } - return paths.toList(growable: false); -} - -/// Collapsible 'Worked · N steps' row wrapping a run of completed steps. -/// Expanded rendering is identical to the ungrouped transcript. -final class _WorkGroupView extends StatelessWidget { - const _WorkGroupView({ - required this.group, - required this.expanded, - required this.onToggle, - required this.actions, - this.onReview, - }); - - final _TranscriptWorkGroup group; - final bool expanded; - final VoidCallback onToggle; - final T4Actions actions; - final VoidCallback? onReview; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final labelStyle = Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: scheme.onSurfaceVariant); - final steps = group.messages.length; - final edited = group.editedFiles; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.xxs), - child: Semantics( - button: true, - label: - 'Worked · $steps steps, ${expanded ? 'expanded' : 'collapsed'}', - child: InkWell( - borderRadius: BorderRadius.circular(_T4Radius.sm), - onTap: onToggle, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xxs, - vertical: _T4Space.xxs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - expanded ? Icons.expand_more : Icons.chevron_right, - size: 14, - color: scheme.onSurfaceVariant, - ), - const SizedBox(width: _T4Space.xxs), - Text('Worked · $steps steps', style: labelStyle), - ], - ), - ), - ), - ), - ), - if (edited.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - left: _T4Space.xxs, - bottom: _T4Space.xxs, - ), - child: Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xxs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - edited.length == 1 - ? 'Edited 1 file' - : 'Edited ${edited.length} files', - style: labelStyle, - ), - for (final path in edited.take(3)) _EditedFileChip(path: path), - if (edited.length > 3) - Text('+${edited.length - 3} more', style: labelStyle), - if (onReview != null) - TextButton( - onPressed: onReview, - style: TextButton.styleFrom( - foregroundColor: scheme.onSurfaceVariant, - textStyle: const TextStyle(fontSize: 12), - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: const Text('Review'), - ), - ], - ), - ), - if (expanded) ...[ - const SizedBox(height: _T4Space.xs), - for (var index = 0; index < group.messages.length; index++) ...[ - if (index > 0) const SizedBox(height: _T4Space.lg), - _TranscriptMessageView( - message: group.messages[index], - actions: actions, - ), - ], - ], - ], - ); - } -} - -final class _EditedFileChip extends StatelessWidget { - const _EditedFileChip({required this.path}); - - final String path; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final basename = path.split('/').last.split('\\').last; - return Tooltip( - message: path, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - vertical: 1, - ), - decoration: BoxDecoration( - color: scheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 140), - child: Text( - basename, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: _T4Typography.monoFamily, - fontSize: 11, - color: scheme.onSurfaceVariant, - ), - ), - ), - ), - ); - } -} - -final class _CachedTranscriptNotice extends StatelessWidget { - const _CachedTranscriptNotice(); - - @override - Widget build(BuildContext context) => Semantics( - liveRegion: true, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.xs, - ), - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: Text( - 'Showing encrypted saved messages while the live transcript connects.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ); -} - -final class _TranscriptHistoryControl extends StatelessWidget { - const _TranscriptHistoryControl({ - required this.loading, - required this.hasMore, - required this.error, - required this.onLoad, - }); - - final bool loading; - final bool hasMore; - final String? error; - final Future Function() onLoad; - - @override - Widget build(BuildContext context) { - return Semantics( - container: true, - label: loading - ? 'Loading earlier messages' - : 'Earlier transcript history', - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (error != null) - Padding( - padding: const EdgeInsets.only(bottom: _T4Space.xs), - child: Text( - error!, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - OutlinedButton.icon( - onPressed: loading || !hasMore ? null : () => unawaited(onLoad()), - icon: loading - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.history, size: 18), - label: Text( - loading ? 'Loading earlier messages' : 'Load earlier messages', - ), - ), - ], - ), - ); - } -} - -final class _TranscriptEmptyState extends StatelessWidget { - const _TranscriptEmptyState({ - required this.title, - required this.message, - this.actionLabel, - this.onAction, - }); - - final String title; - final String message; - final String? actionLabel; - final VoidCallback? onAction; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: _T4Layout.contentMaxWidth), - child: Padding( - padding: const EdgeInsets.all(_T4Space.xl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.chat_bubble_outline, - size: _T4Size.emptyIcon, - color: scheme.outline, - ), - const SizedBox(height: _T4Space.md), - Text( - title, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xs), - Text( - message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - if (actionLabel case final label?) ...[ - const SizedBox(height: _T4Space.lg), - TextButton(onPressed: onAction, child: Text(label)), - ], - ], - ), - ), - ), - ); - } -} - -extension on MessageRole { - String get label => switch (this) { - MessageRole.user => 'You', - MessageRole.assistant => 'Assistant', - MessageRole.system => 'System', - MessageRole.tool => 'Tool', - }; -} - -final class _TranscriptMessageView extends StatelessWidget { - const _TranscriptMessageView({required this.message, required this.actions}); - - final TranscriptMessage message; - final T4Actions actions; - - @override - Widget build(BuildContext context) { - if (message.kind == TranscriptKind.tool) { - return _ToolTranscriptCard(message: message); - } - final scheme = Theme.of(context).colorScheme; - final isUser = message.role == MessageRole.user; - final isAuxiliary = message.role == MessageRole.system; - final label = message.kind == TranscriptKind.compaction - ? 'Earlier chat summary' - : message.role.label; - // User and assistant messages carry no visible role text; screen readers - // still announce the role through the enclosing [Semantics] label. - final showVisualLabel = - isAuxiliary || message.kind == TranscriptKind.compaction; - final background = isUser - ? scheme.surfaceContainerHigh - : isAuxiliary - ? scheme.surfaceContainerLow - : Colors.transparent; - - Widget block = DecoratedBox( - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(_T4Radius.md), - border: isAuxiliary ? Border.all(color: scheme.outlineVariant) : null, - ), - child: Padding( - padding: isUser || isAuxiliary - ? const EdgeInsets.all(_T4Space.md) - : const EdgeInsets.symmetric(vertical: _T4Space.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showVisualLabel) - Text( - label.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - if (message.reasoning.isNotEmpty) ...[ - if (showVisualLabel) const SizedBox(height: _T4Space.xs), - _ReasoningDisclosure( - reasoning: message.reasoning, - streaming: message.streaming, - ), - ], - if (message.text.isNotEmpty) ...[ - if (showVisualLabel || message.reasoning.isNotEmpty) - const SizedBox(height: _T4Space.xs), - TranscriptMarkdown(data: message.text), - ], - if (message.images.isNotEmpty) ...[ - const SizedBox(height: _T4Space.sm), - Wrap( - spacing: _T4Space.sm, - runSpacing: _T4Space.sm, - children: [ - for (final image in message.images) - _TranscriptImage( - entryId: message.id, - image: image, - actions: actions, - ), - ], - ), - ], - if (message.streaming) ...[ - const SizedBox(height: _T4Space.sm), - const _StreamingLabel(), - ], - ], - ), - ), - ); - if (message.role == MessageRole.assistant && - message.kind == TranscriptKind.message && - message.text.isNotEmpty) { - block = _MessageCopyAffordance(markdown: message.text, child: block); - } - - return Align( - alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: _T4Layout.contentMaxWidth), - child: Semantics( - container: true, - label: '$label message${message.streaming ? ', streaming' : ''}', - child: block, - ), - ), - ); - } -} - -/// Hover-revealed (or long-press-revealed on touch) copy affordance for an -/// assistant message; copies the raw markdown source. Icon swaps to a check -/// briefly, matching the [CodeBlock] copy pattern. -final class _MessageCopyAffordance extends StatefulWidget { - const _MessageCopyAffordance({required this.markdown, required this.child}); - - final String markdown; - final Widget child; - - @override - State<_MessageCopyAffordance> createState() => _MessageCopyAffordanceState(); -} - -final class _MessageCopyAffordanceState extends State<_MessageCopyAffordance> { - static const Duration _copiedFeedback = Duration(milliseconds: 1500); - static const Duration _longPressReveal = Duration(seconds: 4); - - bool _hovering = false; - bool _revealed = false; - bool _copied = false; - Timer? _copyTimer; - Timer? _revealTimer; - - @override - void dispose() { - _copyTimer?.cancel(); - _revealTimer?.cancel(); - super.dispose(); - } - - void _revealFromLongPress() { - setState(() => _revealed = true); - _revealTimer?.cancel(); - _revealTimer = Timer(_longPressReveal, () { - if (mounted) setState(() => _revealed = false); - }); - } - - Future _copy() async { - await Clipboard.setData(ClipboardData(text: widget.markdown)); - if (!mounted) return; - setState(() => _copied = true); - _copyTimer?.cancel(); - _copyTimer = Timer(_copiedFeedback, () { - if (mounted) setState(() => _copied = false); - }); - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final visible = _hovering || _revealed || _copied; - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onLongPress: _revealFromLongPress, - child: Stack( - clipBehavior: Clip.none, - children: [ - widget.child, - if (visible) - Positioned( - top: 0, - right: 0, - child: IconButton( - onPressed: () => unawaited(_copy()), - tooltip: _copied ? 'Copied' : 'Copy message', - iconSize: 14, - visualDensity: VisualDensity.compact, - icon: Icon( - _copied ? Icons.check_rounded : Icons.copy_rounded, - color: _copied ? scheme.primary : scheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - ); - } -} - -final class _ReasoningDisclosure extends StatelessWidget { - const _ReasoningDisclosure({ - required this.reasoning, - required this.streaming, - }); - - final String reasoning; - final bool streaming; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return DecoratedBox( - decoration: BoxDecoration( - color: scheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: ExpansionTile( - dense: true, - shape: const Border(), - collapsedShape: const Border(), - leading: Icon( - streaming ? Icons.psychology_alt_outlined : Icons.psychology_outlined, - size: 20, - ), - title: Text(streaming ? 'Reasoning · streaming' : 'Reasoning'), - childrenPadding: const EdgeInsets.fromLTRB( - _T4Space.md, - 0, - _T4Space.md, - _T4Space.md, - ), - children: [TranscriptMarkdown(data: reasoning)], - ), - ); - } -} - -final class _ToolTranscriptCard extends StatelessWidget { - const _ToolTranscriptCard({required this.message}); - - final TranscriptMessage message; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final semantic = T4SemanticColors.of(context); - final statusIcon = message.toolRunning - ? Icons.sync - : message.toolSucceeded == false - ? Icons.error_outline - : Icons.check_circle_outline; - final statusColor = message.toolRunning - ? semantic.statusWorking - : message.toolSucceeded == false - ? semantic.statusError - : semantic.statusDone; - return Align( - alignment: Alignment.centerLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: _T4Layout.contentMaxWidth), - child: DecoratedBox( - decoration: BoxDecoration( - color: scheme.surfaceContainerLow, - border: Border.all(color: scheme.outlineVariant), - borderRadius: BorderRadius.circular(_T4Radius.md), - ), - child: ExpansionTile( - shape: const Border(), - collapsedShape: const Border(), - leading: Icon(statusIcon, color: statusColor), - title: Text(message.toolTitle ?? message.toolName ?? 'Tool'), - subtitle: message.text.isEmpty ? null : Text(message.text), - trailing: message.toolRunning - ? SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - value: message.toolProgress, - strokeWidth: _T4Size.thinStroke, - ), - ) - : null, - childrenPadding: const EdgeInsets.fromLTRB( - _T4Space.md, - 0, - _T4Space.md, - _T4Space.md, - ), - children: [ - if (message.toolArguments case final arguments?) - _ToolPayload(label: 'Arguments', value: arguments), - if (message.toolOutput case final output?) - _ToolPayload(label: 'Result', value: output), - ], - ), - ), - ), - ); - } -} - -final class _ToolPayload extends StatelessWidget { - const _ToolPayload({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.only(top: _T4Space.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - label.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(height: _T4Space.xs), - DecoratedBox( - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: SelectionArea( - child: Text( - value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: _T4Typography.monoFamily, - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -final class _TranscriptImage extends StatefulWidget { - const _TranscriptImage({ - required this.entryId, - required this.image, - required this.actions, - }); - - final String entryId; - final TranscriptImageMetadata image; - final T4Actions actions; - - @override - State<_TranscriptImage> createState() => _TranscriptImageState(); -} - -final class _TranscriptImageState extends State<_TranscriptImage> { - late Future _image; - - @override - void initState() { - super.initState(); - _image = widget.actions.readTranscriptImage(widget.entryId, widget.image); - } - - @override - void didUpdateWidget(covariant _TranscriptImage oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.entryId != widget.entryId || - oldWidget.image.sha256 != widget.image.sha256) { - _image = widget.actions.readTranscriptImage(widget.entryId, widget.image); - } - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return FutureBuilder( - future: _image, - builder: (context, snapshot) { - if (snapshot.hasData) { - return ClipRRect( - borderRadius: BorderRadius.circular(_T4Radius.sm), - child: Image.memory( - snapshot.data!, - width: 160, - height: 120, - fit: BoxFit.cover, - semanticLabel: 'Transcript image', - ), - ); - } - return Container( - width: 160, - height: 120, - alignment: Alignment.center, - decoration: BoxDecoration( - color: scheme.surfaceContainerLow, - border: Border.all(color: scheme.outlineVariant), - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: snapshot.hasError - ? IconButton( - tooltip: 'Retry image', - onPressed: () => setState(() { - _image = widget.actions.readTranscriptImage( - widget.entryId, - widget.image, - ); - }), - icon: const Icon(Icons.refresh), - ) - : const CircularProgressIndicator(), - ); - }, - ); - } -} - -final class _PromptComposer extends StatefulWidget { - const _PromptComposer({required this.state, required this.actions}); - - final T4ViewState state; - final T4Actions actions; - - @override - State<_PromptComposer> createState() => _PromptComposerState(); -} - -final class _PromptComposerState extends State<_PromptComposer> { - static const int _maximumImages = 8; - static const int _maximumImageBytes = 20 * 1024 * 1024; - - final TextEditingController _textController = TextEditingController(); - final FocusNode _focusNode = FocusNode(debugLabel: 'Prompt composer'); - final Map _drafts = {}; - final Map> _attachments = - >{}; - String? _sessionId; - bool _hasText = false; - bool _sending = false; - - List get _currentAttachments => _sessionId == null - ? const [] - : _attachments[_sessionId] ?? const []; - - bool get _ready => - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.selectedSession != null && - widget.state.grantedCapabilities.contains('sessions.prompt') && - !widget.state.composer.isPaused && - !_sending; - - bool get _canSubmit => - _ready && - (_hasText || _currentAttachments.isNotEmpty) && - (!widget.state.composer.turnActive || _currentAttachments.isEmpty); - - bool get _canQueue => _ready && widget.state.composer.turnActive && _hasText; - - @override - void initState() { - super.initState(); - _sessionId = widget.state.selectedSessionId; - _textController.addListener(_handleTextChanged); - } - - @override - void didUpdateWidget(covariant _PromptComposer oldWidget) { - super.didUpdateWidget(oldWidget); - final nextSessionId = widget.state.selectedSessionId; - if (nextSessionId == _sessionId) return; - final previousSessionId = _sessionId; - if (previousSessionId != null) { - _drafts[previousSessionId] = _textController.text; - } - _sessionId = nextSessionId; - _textController.text = nextSessionId == null - ? '' - : _drafts[nextSessionId] ?? ''; - _textController.selection = TextSelection.collapsed( - offset: _textController.text.length, - ); - } - - void _handleTextChanged() { - final sessionId = _sessionId; - if (sessionId != null) _drafts[sessionId] = _textController.text; - final hasText = _textController.text.trim().isNotEmpty; - if (hasText != _hasText && mounted) { - setState(() => _hasText = hasText); - } else if (mounted && _textController.text.startsWith('/')) { - setState(() {}); - } - } - - void _showError(String message) { - final messenger = ScaffoldMessenger.of(context); - messenger - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - Future _submit() async { - if (!_canSubmit) return; - final message = _textController.text.trim(); - final attachments = List.of(_currentAttachments); - final draftAtSubmission = _textController.text; - setState(() => _sending = true); - try { - final accepted = await widget.actions.submitPrompt( - message, - images: attachments, - ); - if (!mounted || !accepted) return; - if (_textController.text == draftAtSubmission) _textController.clear(); - final sessionId = _sessionId; - if (sessionId != null) _attachments.remove(sessionId); - _focusNode.requestFocus(); - } on Object { - if (mounted) _showError('Could not send the prompt. Try again.'); - } finally { - if (mounted) setState(() => _sending = false); - } - } - - Future _queue() async { - if (!_canQueue) return; - final message = _textController.text.trim(); - try { - final accepted = await widget.actions.queuePrompt(message); - if (!mounted || !accepted) return; - _textController.clear(); - _focusNode.requestFocus(); - } on Object { - if (mounted) _showError('Could not queue the follow-up.'); - } - } - - Future _pickImages() async { - final available = _maximumImages - _currentAttachments.length; - if (!_ready || available <= 0) return; - try { - final files = await openFiles( - acceptedTypeGroups: const [ - XTypeGroup( - label: 'Images', - extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'], - ), - ], - ); - if (!mounted || files.isEmpty) return; - final selected = []; - for (final file in files.take(available)) { - final size = await file.length(); - if (size <= 0) continue; - if (size > _maximumImageBytes) { - _showError('${file.name} is larger than 20 MB.'); - continue; - } - final extension = file.name.split('.').last.toLowerCase(); - final mimeType = switch (extension) { - 'png' => 'image/png', - 'jpg' || 'jpeg' => 'image/jpeg', - 'gif' => 'image/gif', - 'webp' => 'image/webp', - _ => null, - }; - if (mimeType == null) continue; - final bytes = await file.readAsBytes(); - selected.add( - PromptImageAttachment( - id: '${DateTime.now().microsecondsSinceEpoch}-${file.name}', - name: file.name, - mimeType: mimeType, - bytes: bytes, - ), - ); - } - final sessionId = _sessionId; - if (sessionId != null && selected.isNotEmpty) { - setState(() { - _attachments[sessionId] = [ - ..._currentAttachments, - ...selected, - ]; - }); - } - } on Object { - if (mounted) _showError('Could not open the selected image.'); - } - } - - void _removeAttachment(String id) { - final sessionId = _sessionId; - if (sessionId == null) return; - setState(() { - _attachments[sessionId] = _currentAttachments - .where((attachment) => attachment.id != id) - .toList(growable: false); - }); - } - - void _selectSlashCommand(ComposerSlashCommand command) { - if (command.disabledReason != null) return; - _textController - ..text = command.insert - ..selection = TextSelection.collapsed(offset: command.insert.length); - _focusNode.requestFocus(); - } - - Future _runControl(Future Function() operation) async { - try { - await operation(); - } on Object { - if (mounted) _showError('Could not update the session control.'); - } - } - - @override - void dispose() { - _textController - ..removeListener(_handleTextChanged) - ..dispose(); - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final composer = widget.state.composer; - final showControls = - widget.state.selectedSession != null && - widget.state.grantedCapabilities.contains('sessions.control'); - final canControl = - showControls && - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('sessions.control') && - !widget.state.sessionOperationPending; - final slashQuery = _textController.text.startsWith('/') - ? _textController.text.split(RegExp(r'\s')).first.toLowerCase() - : ''; - final slashCommands = slashQuery.isEmpty - ? const [] - : composer.slashCommands - .where( - (command) => - command.name.toLowerCase().contains(slashQuery) || - command.aliases.any( - (alias) => alias.toLowerCase().contains(slashQuery), - ), - ) - .take(5) - .toList(growable: false); - - return DecoratedBox( - decoration: BoxDecoration(color: scheme.surface), - child: SafeArea( - top: false, - minimum: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.md, - _T4Space.sm, - ), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (slashCommands.isNotEmpty) - Card( - margin: const EdgeInsets.only(bottom: _T4Space.xs), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final command in slashCommands) - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - enabled: command.disabledReason == null, - title: Text( - command.name, - style: const TextStyle(fontSize: 12), - ), - subtitle: Text( - command.disabledReason ?? - (command.aliases.isEmpty - ? command.description - : '${command.description} · ${command.aliases.join(' ')}'), - style: const TextStyle(fontSize: 12), - ), - onTap: command.disabledReason == null - ? () => _selectSlashCommand(command) - : null, - ), - ], - ), - ), - // Single Codex-style composer container: text field on top, - // controls embedded along the bottom edge. - Container( - decoration: BoxDecoration( - color: scheme.surface, - borderRadius: BorderRadius.circular(_composerRadius), - border: Border.all(color: scheme.outlineVariant), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_currentAttachments.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.sm, - _T4Space.xs, - _T4Space.sm, - 0, - ), - child: SizedBox( - height: 72, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _currentAttachments.length, - separatorBuilder: (context, index) => - const SizedBox(width: _T4Space.xs), - itemBuilder: (context, index) { - final attachment = _currentAttachments[index]; - return InputChip( - visualDensity: VisualDensity.compact, - labelStyle: const TextStyle(fontSize: 12), - avatar: ClipRRect( - borderRadius: BorderRadius.circular( - _T4Radius.sm, - ), - child: Image.memory( - attachment.bytes, - width: 40, - height: 40, - fit: BoxFit.cover, - ), - ), - label: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 120, - ), - child: Text( - attachment.name, - overflow: TextOverflow.ellipsis, - ), - ), - onDeleted: _sending - ? null - : () => _removeAttachment(attachment.id), - ); - }, - ), - ), - ), - CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.enter): () => - unawaited(_submit()), - }, - child: Semantics( - textField: true, - label: 'Prompt message', - child: TextField( - controller: _textController, - focusNode: _focusNode, - readOnly: _sending, - minLines: 1, - maxLines: 6, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - hintText: widget.state.selectedSession == null - ? 'Choose a session to begin' - : composer.isPaused - ? 'Resume the session to continue' - : composer.turnActive - ? 'Steer the active turn' - : 'Message T4', - isDense: true, - filled: false, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - contentPadding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.md, - _T4Space.xs, - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.xxs, - 0, - _T4Space.xs, - _T4Space.xxs, - ), - child: Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - runSpacing: _T4Space.xxs, - children: [ - Wrap( - spacing: _T4Space.xxs, - runSpacing: _T4Space.xxs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - IconButton( - tooltip: 'Attach image', - visualDensity: VisualDensity.compact, - iconSize: 18, - onPressed: - _ready && - _currentAttachments.length < - _maximumImages - ? () => unawaited(_pickImages()) - : null, - icon: const Icon(Icons.attach_file), - ), - if (showControls && composer.isPaused) - _ComposerQuietButton( - icon: Icons.play_arrow, - label: 'Resume', - onPressed: canControl - ? () => unawaited( - _runControl( - widget.actions.resumeSession, - ), - ) - : null, - ) - else if (showControls && composer.turnActive) - _ComposerQuietButton( - icon: Icons.pause, - label: 'Pause', - onPressed: canControl - ? () => unawaited( - _runControl( - widget.actions.pauseSession, - ), - ) - : null, - ), - if (composer.turnActive) ...[ - _ComposerQuietButton( - icon: Icons.stop, - label: 'Stop', - onPressed: () => unawaited( - _runControl(widget.actions.cancelTurn), - ), - ), - _ComposerQuietButton( - icon: Icons.playlist_add, - label: composer.queuedFollowUpCount == 0 - ? 'Queue' - : 'Queue (${composer.queuedFollowUpCount})', - onPressed: _canQueue - ? () => unawaited(_queue()) - : null, - ), - ], - ], - ), - Wrap( - spacing: _T4Space.xxs, - runSpacing: _T4Space.xxs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _AccessSummaryPill(state: widget.state), - _ThinkingSelectorMenu( - composer: composer, - onSelect: (level) => unawaited( - _runControl( - () => widget.actions.setSessionThinking( - level, - ), - ), - ), - ), - _ModelSelectorMenu( - composer: composer, - onSelect: (selector) => unawaited( - _runControl( - () => widget.actions.setSessionModel( - selector, - ), - ), - ), - onToggleFast: (enabled) => unawaited( - _runControl( - () => widget.actions.setSessionFast( - enabled, - ), - ), - ), - showCompact: - showControls && - !composer.isPaused && - !composer.turnActive, - onCompact: canControl - ? () => unawaited( - _runControl( - widget.actions.compactSession, - ), - ) - : null, - ), - SizedBox.square( - dimension: _composerSendSize, - child: IconButton.filled( - tooltip: composer.turnActive - ? 'Steer' - : 'Send', - style: IconButton.styleFrom( - minimumSize: const Size.square( - _composerSendSize, - ), - fixedSize: const Size.square( - _composerSendSize, - ), - padding: EdgeInsets.zero, - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, - ), - iconSize: 16, - onPressed: _canSubmit - ? () => unawaited(_submit()) - : null, - icon: _sending - ? const SizedBox.square( - dimension: 14, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Sending', - ), - ) - : const Icon(Icons.arrow_upward), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -/// Composer control metrics shared by the redesigned prompt container. -const double _composerRadius = 12; -const double _composerSendSize = 32; - -/// Shared style for composer selector menus. The pills sit on the bottom edge -/// of a bottom-docked composer, so the menu layout always flips the surface -/// upward above the anchor (with the [Offset] gap) instead of clipping below -/// or overlapping the text field row. -const MenuStyle _composerMenuStyle = MenuStyle( - visualDensity: VisualDensity.compact, - maximumSize: WidgetStatePropertyAll(Size(280, 360)), -); -const Offset _composerMenuOffset = Offset(0, _T4Space.xs); - -final ButtonStyle _composerMenuItemStyle = MenuItemButton.styleFrom( - visualDensity: VisualDensity.compact, - textStyle: const TextStyle(fontSize: 12), - minimumSize: const Size.fromHeight(36), -); - -/// Quiet 12 px text control for the composer's turn controls -/// (pause/resume/stop/queue). -final class _ComposerQuietButton extends StatelessWidget { - const _ComposerQuietButton({ - required this.icon, - required this.label, - required this.onPressed, - }); - - final IconData icon; - final String label; - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return TextButton( - onPressed: onPressed, - style: TextButton.styleFrom( - foregroundColor: scheme.onSurfaceVariant, - textStyle: const TextStyle(fontSize: 12), - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - vertical: _T4Space.xxs, - ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: const StadiumBorder(), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14), - const SizedBox(width: _T4Space.xxs), - Flexible( - child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), - ), - ], - ), - ); - } -} - -/// Quiet pill anchor shared by the thinking and model selector menus. -final class _ComposerPill extends StatelessWidget { - const _ComposerPill({ - required this.label, - required this.onTap, - this.maxLabelWidth, - this.tooltip, - }); - - final String label; - final VoidCallback? onTap; - final double? maxLabelWidth; - final String? tooltip; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final enabled = onTap != null; - final color = enabled ? scheme.onSurfaceVariant : scheme.outline; - Widget pill = Material( - color: Colors.transparent, - shape: const StadiumBorder(), - child: InkWell( - customBorder: const StadiumBorder(), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - vertical: _T4Space.xxs + 1, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: maxLabelWidth ?? double.infinity, - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: color), - ), - ), - const SizedBox(width: 2), - Icon(Icons.keyboard_arrow_up, size: 14, color: color), - ], - ), - ), - ), - ); - if (tooltip case final message?) { - pill = Tooltip(message: message, child: pill); - } - return pill; - } -} - -/// Thinking-level selector as a quiet pill; the menu opens upward above the -/// pill and never overlaps the text field. -final class _ThinkingSelectorMenu extends StatelessWidget { - const _ThinkingSelectorMenu({required this.composer, required this.onSelect}); - - final SessionComposerState composer; - final ValueChanged onSelect; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final enabled = composer.thinkingLevels.isNotEmpty; - return Semantics( - label: 'Thinking: ${composer.thinking ?? 'Default'}', - button: true, - child: MenuAnchor( - style: _composerMenuStyle, - alignmentOffset: _composerMenuOffset, - menuChildren: [ - for (final level in composer.thinkingLevels) - MenuItemButton( - key: ValueKey('thinking-$level'), - style: _composerMenuItemStyle, - leadingIcon: composer.thinking == level - ? Icon( - Icons.check, - size: 16, - color: theme.colorScheme.primary, - ) - : null, - onPressed: () => onSelect(level), - child: Text(level), - ), - ], - builder: (context, controller, child) => _ComposerPill( - label: composer.thinking ?? 'Thinking', - maxLabelWidth: 88, - tooltip: 'Choose thinking level', - onTap: enabled ? () => _toggleMenu(controller) : null, - ), - ), - ); - } -} - -void _toggleMenu(MenuController controller) { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } -} - -/// Provider-grouped model selector for the composer. -/// -/// Replaces the giant flat catalog list with a navigable provider → model menu -/// that mirrors the Electron/web T4 model picker. Human model names render as -/// the primary label; raw `provider/modelId` selectors are carried through as -/// the submitted values. Unknown providers/models stay selectable under an -/// 'Other models' group. A single-provider host collapses to a flat list so -/// the user is never forced through a one-item submenu. -/// -/// The menu also hosts the session's Fast toggle (checkable item) and the -/// manual Compact action, so the pill stays enabled whenever any of those -/// entries are present. -final class _ModelSelectorMenu extends StatelessWidget { - const _ModelSelectorMenu({ - required this.composer, - required this.onSelect, - this.onToggleFast, - this.showCompact = false, - this.onCompact, - }); - - final SessionComposerState composer; - final ValueChanged onSelect; - - /// Receives the requested Fast state; the item only renders when the host - /// advertises Fast availability. - final ValueChanged? onToggleFast; - final bool showCompact; - final VoidCallback? onCompact; - - @override - Widget build(BuildContext context) { - final groups = composer.modelGroups; - final hasModels = groups.any((group) => group.choices.isNotEmpty); - final showFast = composer.fastAvailable && onToggleFast != null; - final enabled = hasModels || showFast || showCompact; - final theme = Theme.of(context); - return Semantics( - label: 'Model: ${composer.modelLabel ?? 'None'}', - button: true, - child: MenuAnchor( - style: _composerMenuStyle, - alignmentOffset: _composerMenuOffset, - menuChildren: [ - if (groups.length == 1) - for (final choice in groups.single.choices) - _modelItem(choice, theme) - else - for (final group in groups) - if (group.choices.isNotEmpty) - SubmenuButton( - key: ValueKey('model-provider-${group.provider}'), - style: _composerMenuItemStyle, - menuStyle: _composerMenuStyle, - leadingIcon: const Icon(Icons.folder_outlined, size: 18), - menuChildren: [ - for (final choice in group.choices) - _modelItem(choice, theme), - ], - child: Text(group.label, overflow: TextOverflow.ellipsis), - ), - if (hasModels && (showFast || showCompact)) const Divider(height: 1), - if (showFast) - CheckboxMenuButton( - value: composer.fastEnabled, - style: _composerMenuItemStyle, - onChanged: (checked) => onToggleFast!(checked ?? false), - child: const Text('Fast'), - ), - if (showCompact) - MenuItemButton( - style: _composerMenuItemStyle, - leadingIcon: const Icon(Icons.compress, size: 16), - onPressed: onCompact, - child: const Text('Compact'), - ), - ], - builder: (context, controller, child) => _ComposerPill( - label: composer.modelLabel ?? 'Model', - maxLabelWidth: 120, - tooltip: 'Choose model', - onTap: enabled ? () => _toggleMenu(controller) : null, - ), - ), - ); - } - - Widget _modelItem(ResolvedModelChoice choice, ThemeData theme) { - final selected = composer.modelSelector == choice.selector; - final label = choice.supported - ? choice.label - : '${choice.label} · ${choice.reason ?? 'Unavailable'}'; - return MenuItemButton( - key: ValueKey('model-${choice.selector}'), - style: _composerMenuItemStyle, - onPressed: choice.supported ? () => onSelect(choice.selector) : null, - leadingIcon: selected - ? Icon(Icons.check, size: 18, color: theme.colorScheme.primary) - : null, - child: Text(label, overflow: TextOverflow.ellipsis), - ); - } -} - -/// Informational access pill for the composer controls: summarizes the -/// capabilities the host actually granted. Selection is a no-op — the wire -/// protocol has no capability-mutation command. -final class _AccessSummaryPill extends StatelessWidget { - const _AccessSummaryPill({required this.state}); - - final T4ViewState state; - - /// Display order and labels for the summarized capability groups, keyed by - /// the capability prefix used on the wire (`term.input`, `files.write`, …). - static const Map _groupLabels = { - 'files': 'Files', - 'term': 'Terminal', - 'preview': 'Preview', - 'sessions': 'Sessions', - 'usage': 'Usage', - }; - - static List _options(Set granted) { - final byGroup = >{}; - for (final capability in granted) { - final prefix = capability.split('.').first; - if (!_groupLabels.containsKey(prefix)) continue; - byGroup.putIfAbsent(prefix, () => []).add(capability); - } - return [ - for (final entry in _groupLabels.entries) - if (byGroup[entry.key] case final capabilities?) - AccessModeOption( - id: entry.key, - label: entry.value, - detail: (capabilities..sort()).join(', '), - selected: true, - ), - ]; - } - - @override - Widget build(BuildContext context) { - final granted = state.grantedCapabilities; - final canWriteFiles = granted.contains('files.write'); - final label = canWriteFiles && granted.contains('term.input') - ? 'Full access' - : canWriteFiles - ? 'Write access' - : 'Read only'; - return Tooltip( - message: 'Granted permissions', - child: AccessModeSelector( - label: label, - options: _options(granted), - onSelected: (_) {}, - enabled: state.connectionPhase == ConnectionPhase.ready, - readOnly: true, - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/developer_surfaces.dart b/apps/flutter/lib/src/ui/developer_surfaces.dart deleted file mode 100644 index 74e05929..00000000 --- a/apps/flutter/lib/src/ui/developer_surfaces.dart +++ /dev/null @@ -1,2422 +0,0 @@ -part of 't4_app.dart'; - -final class _DeveloperSurfacesPane extends StatefulWidget { - const _DeveloperSurfacesPane({ - required this.state, - required this.actions, - required this.initialTab, - required this.onDone, - required this.showHeader, - }); - - final T4ViewState state; - final T4Actions actions; - final int initialTab; - final VoidCallback onDone; - final bool showHeader; - - @override - State<_DeveloperSurfacesPane> createState() => _DeveloperSurfacesPaneState(); -} - -final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> - with SingleTickerProviderStateMixin { - late final TabController _tabs; - late final DeveloperPanelActionController _panelActions; - late final ActivityPanelController _activity; - late final FilesPanelController _files; - late final PreviewPanelController _preview; - - bool get _busy => - _panelActions.busyAction != null || - widget.state.developerOperationPending; - - @override - void initState() { - super.initState(); - _tabs = TabController( - length: 5, - initialIndex: _boundedDeveloperTab(widget.initialTab), - vsync: this, - ); - _panelActions = DeveloperPanelActionController() - ..addListener(_onPanelChanged); - _activity = ActivityPanelController(widget.state) - ..addListener(_onPanelChanged); - _files = FilesPanelController(widget.state)..addListener(_onPanelChanged); - _preview = PreviewPanelController(widget.state) - ..addListener(_onPanelChanged); - } - - void _onPanelChanged() { - if (mounted) setState(() {}); - } - - @override - void didUpdateWidget(covariant _DeveloperSurfacesPane oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.initialTab != widget.initialTab && - _tabs.index != widget.initialTab) { - _tabs.animateTo(_boundedDeveloperTab(widget.initialTab)); - } - _activity.syncFromState(oldWidget.state, widget.state); - _preview.syncFromState(oldWidget.state, widget.state); - _files.syncFromState(oldWidget.state, widget.state); - } - - @override - void dispose() { - _tabs.dispose(); - _panelActions.dispose(); - _activity.dispose(); - _files.dispose(); - _preview.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - if (widget.showHeader) _DeveloperSurfacesHeader(onDone: widget.onDone), - if (_panelActions.actionError case final error?) - _DeveloperErrorBanner( - message: error, - onDismiss: _panelActions.clearError, - ), - if (_busy) - const LinearProgressIndicator( - minHeight: _T4Size.thinStroke, - semanticsLabel: 'Developer operation in progress', - ), - TabBar( - controller: _tabs, - isScrollable: true, - tabAlignment: TabAlignment.start, - tabs: const [ - Tab(icon: Icon(Icons.receipt_long_outlined), text: 'Activity'), - Tab(icon: Icon(Icons.folder_outlined), text: 'Files'), - Tab(icon: Icon(Icons.difference_outlined), text: 'Review'), - Tab(icon: Icon(Icons.terminal), text: 'Terminal'), - Tab(icon: Icon(Icons.preview_outlined), text: 'Preview'), - ], - ), - Expanded( - child: TabBarView( - controller: _tabs, - children: [ - ActivityPanelBody( - state: widget.state, - actions: widget.actions, - controller: _activity, - actionController: _panelActions, - ), - FilesPanelBody( - state: widget.state, - actions: widget.actions, - controller: _files, - actionController: _panelActions, - ), - ReviewPanelBody( - state: widget.state, - actions: widget.actions, - selectedFilePath: _files.selectedFilePath, - onOpenFiles: () => _tabs.animateTo(1), - actionController: _panelActions, - ), - TerminalPanelBody(state: widget.state, actions: widget.actions), - PreviewPanelBody( - state: widget.state, - actions: widget.actions, - controller: _preview, - actionController: _panelActions, - ), - ], - ), - ), - ], - ); - } -} - -/// Base for developer panel state holders: setState-style [mutate] with safe -/// change notification after disposal. -abstract base class DeveloperPanelController extends ChangeNotifier { - bool _panelControllerDisposed = false; - - void mutate(VoidCallback fn) { - fn(); - if (!_panelControllerDisposed) notifyListeners(); - } - - @override - void dispose() { - _panelControllerDisposed = true; - super.dispose(); - } -} - -/// Shared busy/error state for developer panel operations. The tabbed -/// takeover owns one instance across all tabs; an embedded panel body creates -/// its own when none is supplied. -final class DeveloperPanelActionController extends DeveloperPanelController { - String? _busyAction; - String? _actionError; - - String? get busyAction => _busyAction; - String? get actionError => _actionError; - - void clearError() => mutate(() => _actionError = null); - - Future run( - BuildContext context, { - required bool externallyPending, - required String key, - required String failureMessage, - required Future Function() action, - }) async { - if (_busyAction != null || externallyPending) return; - final messenger = ScaffoldMessenger.maybeOf(context); - mutate(() { - _busyAction = key; - _actionError = null; - }); - try { - await action(); - } on Object catch (error) { - if (_panelControllerDisposed) return; - final detail = error.toString().replaceFirst('Bad state: ', '').trim(); - mutate(() { - _actionError = detail.isEmpty ? failureMessage : detail; - }); - messenger - ?..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(failureMessage))); - } finally { - if (!_panelControllerDisposed) { - mutate(() => _busyAction = null); - } - } - } -} - -/// Per-tab state for the Activity panel. -final class ActivityPanelController extends DeveloperPanelController { - ActivityPanelController(T4ViewState state) - : snapshot = List.of(state.activities); - - List snapshot; - String? category; - bool paused = false; - - void selectCategory(String? value) => mutate(() => category = value); - - void togglePause(T4ViewState state) => mutate(() { - paused = !paused; - if (!paused) { - snapshot = List.of(state.activities); - } - }); - - /// Refreshes the snapshot on state updates. Intentionally silent: callers - /// invoke this from didUpdateWidget and rebuild right after. - void syncFromState(T4ViewState oldState, T4ViewState newState) { - if (!paused && !identical(oldState.activities, newState.activities)) { - snapshot = List.of(newState.activities); - } - } -} - -/// Per-tab state for the Files panel; also feeds the Review panel's selected -/// file when both are driven by the tabbed takeover. -final class FilesPanelController extends DeveloperPanelController { - FilesPanelController(T4ViewState state) { - final workspace = state.fileWorkspace; - if (workspace.content != null && workspace.path.isNotEmpty) { - selectedFilePath = workspace.path; - directoryPath = _parentPath(workspace.path); - } else { - directoryPath = workspace.path; - } - syncEditor(state, force: true); - } - - final TextEditingController editor = TextEditingController(); - String? selectedFilePath; - String directoryPath = ''; - String? editedFilePath; - bool dirty = false; - - void syncEditor(T4ViewState state, {bool force = false}) { - final workspace = state.fileWorkspace; - final content = workspace.content; - if (content == null || workspace.path.isEmpty) return; - selectedFilePath = workspace.path; - if (!force && dirty && editedFilePath == workspace.path) return; - editedFilePath = workspace.path; - dirty = false; - editor.value = TextEditingValue( - text: content, - selection: TextSelection.collapsed(offset: content.length), - ); - } - - /// Mirrors the editor sync previously done in the pane's didUpdateWidget. - void syncFromState(T4ViewState oldState, T4ViewState newState) { - final oldWorkspace = oldState.fileWorkspace; - final workspace = newState.fileWorkspace; - if (oldWorkspace.path != workspace.path || - oldWorkspace.content != workspace.content) { - syncEditor(newState); - } - } - - @override - void dispose() { - editor.dispose(); - super.dispose(); - } -} - -/// Per-tab state for the Preview panel. -final class PreviewPanelController extends DeveloperPanelController { - PreviewPanelController(T4ViewState state) { - syncAddress(state, force: true); - } - - final TextEditingController launchUrl = TextEditingController(); - final TextEditingController navigationUrl = TextEditingController(); - final FocusNode navigationFocus = FocusNode(); - - void syncAddress(T4ViewState state, {bool force = false}) { - if (!force && navigationFocus.hasFocus) return; - final url = state.activePreview?.url ?? ''; - navigationUrl.value = TextEditingValue( - text: url, - selection: TextSelection.collapsed(offset: url.length), - ); - } - - /// Mirrors the address sync previously done in the pane's didUpdateWidget. - void syncFromState(T4ViewState oldState, T4ViewState newState) { - final oldPreview = oldState.activePreview; - final preview = newState.activePreview; - if (oldPreview?.previewId != preview?.previewId || - oldPreview?.url != preview?.url) { - syncAddress(newState); - } - } - - @override - void dispose() { - launchUrl.dispose(); - navigationUrl.dispose(); - navigationFocus.dispose(); - super.dispose(); - } -} - -/// Error banner and progress indicator wrapper used by panel bodies that own -/// their action controller (embedded use). The tabbed takeover renders these -/// itself, above the tab strip. -final class _PanelActionChrome extends StatelessWidget { - const _PanelActionChrome({ - required this.controller, - required this.busy, - required this.child, - }); - - final DeveloperPanelActionController controller; - final bool busy; - final Widget child; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (controller.actionError case final error?) - _DeveloperErrorBanner( - message: error, - onDismiss: controller.clearError, - ), - if (busy) - const LinearProgressIndicator( - minHeight: _T4Size.thinStroke, - semanticsLabel: 'Developer operation in progress', - ), - Expanded(child: child), - ], - ); - } -} - -/// Embeddable body of the developer Activity tab. -final class ActivityPanelBody extends StatefulWidget { - const ActivityPanelBody({ - required this.state, - required this.actions, - this.controller, - this.actionController, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - - /// Optional external per-tab state; the body owns its own when omitted. - final ActivityPanelController? controller; - - /// Optional shared busy/error state; the body owns its own (and renders its - /// own error banner and progress indicator) when omitted. - final DeveloperPanelActionController? actionController; - - @override - State createState() => _ActivityPanelBodyState(); -} - -final class _ActivityPanelBodyState extends State { - late ActivityPanelController _activity; - late DeveloperPanelActionController _action; - - bool get _ownsController => widget.controller == null; - bool get _ownsActionController => widget.actionController == null; - bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; - bool get _busy => - _action.busyAction != null || widget.state.developerOperationPending; - - bool _hasCapability(String capability) => - widget.state.grantedCapabilities.contains(capability); - - @override - void initState() { - super.initState(); - _activity = widget.controller ?? ActivityPanelController(widget.state); - _action = widget.actionController ?? DeveloperPanelActionController(); - _activity.addListener(_onPanelChanged); - _action.addListener(_onPanelChanged); - } - - @override - void didUpdateWidget(covariant ActivityPanelBody oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - _activity.removeListener(_onPanelChanged); - if (oldWidget.controller == null) _activity.dispose(); - _activity = widget.controller ?? ActivityPanelController(widget.state); - _activity.addListener(_onPanelChanged); - } - if (oldWidget.actionController != widget.actionController) { - _action.removeListener(_onPanelChanged); - if (oldWidget.actionController == null) _action.dispose(); - _action = widget.actionController ?? DeveloperPanelActionController(); - _action.addListener(_onPanelChanged); - } - if (_ownsController) { - _activity.syncFromState(oldWidget.state, widget.state); - } - } - - @override - void dispose() { - _activity.removeListener(_onPanelChanged); - _action.removeListener(_onPanelChanged); - if (_ownsController) _activity.dispose(); - if (_ownsActionController) _action.dispose(); - super.dispose(); - } - - void _onPanelChanged() { - if (mounted) setState(() {}); - } - - Future _runAction( - String key, - String failureMessage, - Future Function() action, - ) => _action.run( - context, - externallyPending: widget.state.developerOperationPending, - key: key, - failureMessage: failureMessage, - action: action, - ); - - @override - Widget build(BuildContext context) { - final content = _buildContent(context); - if (!_ownsActionController) return content; - return _PanelActionChrome(controller: _action, busy: _busy, child: content); - } - - Widget _buildContent(BuildContext context) { - if (!_connected && _activity.snapshot.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.cloud_off_outlined, - title: 'Activity is offline', - message: 'Connect to a host to inspect protocol activity.', - ); - } - if (!_hasCapability('audit.read')) { - return const _DeveloperUnavailable( - icon: Icons.lock_outline, - title: 'Activity is unavailable', - message: 'The paired host did not grant the audit.read capability.', - ); - } - - final categories = - _activity.snapshot - .map((activity) => activity.category.trim()) - .where((category) => category.isNotEmpty) - .toSet() - .toList(growable: false) - ..sort(); - if (_activity.category != null && - !categories.contains(_activity.category)) { - _activity.category = null; - } - final activities = - _activity.snapshot - .where( - (activity) => - _activity.category == null || - activity.category == _activity.category, - ) - .toList(growable: false) - ..sort((a, b) => b.at.compareTo(a.at)); - - return Column( - children: [ - _ActivityToolbar( - categories: categories, - selectedCategory: _activity.category, - paused: _activity.paused, - busy: _busy, - onSelectCategory: _activity.selectCategory, - onTogglePause: () => _activity.togglePause(widget.state), - onRefresh: !_connected - ? null - : () => _runAction( - 'activity-refresh', - 'Could not refresh activity.', - widget.actions.refreshActivity, - ), - ), - if (_activity.paused) - const _DeveloperNotice( - icon: Icons.pause_circle_outline, - message: 'Activity is paused. New rows are held until you resume.', - ), - Expanded( - child: activities.isEmpty - ? _DeveloperUnavailable( - icon: Icons.filter_alt_off_outlined, - title: _activity.snapshot.isEmpty - ? 'No activity yet' - : 'No matching activity', - message: _activity.snapshot.isEmpty - ? 'Refresh to request the latest host activity.' - : 'Choose a different category filter.', - ) - : ListView.separated( - padding: const EdgeInsets.all(_T4Space.md), - itemCount: activities.length, - separatorBuilder: (_, _) => - const SizedBox(height: _T4Space.xs), - itemBuilder: (context, index) => - _ActivityRow(activity: activities[index]), - ), - ), - ], - ); - } -} - -/// Embeddable body of the developer Files tab. -final class FilesPanelBody extends StatefulWidget { - const FilesPanelBody({ - required this.state, - required this.actions, - this.controller, - this.actionController, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - - /// Optional external per-tab state; the body owns its own when omitted. - final FilesPanelController? controller; - - /// Optional shared busy/error state; the body owns its own (and renders its - /// own error banner and progress indicator) when omitted. - final DeveloperPanelActionController? actionController; - - @override - State createState() => _FilesPanelBodyState(); -} - -final class _FilesPanelBodyState extends State { - late FilesPanelController _files; - late DeveloperPanelActionController _action; - - bool get _ownsController => widget.controller == null; - bool get _ownsActionController => widget.actionController == null; - bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; - bool get _busy => - _action.busyAction != null || widget.state.developerOperationPending; - - bool _hasCapability(String capability) => - widget.state.grantedCapabilities.contains(capability); - - @override - void initState() { - super.initState(); - _files = widget.controller ?? FilesPanelController(widget.state); - _action = widget.actionController ?? DeveloperPanelActionController(); - _files.addListener(_onPanelChanged); - _action.addListener(_onPanelChanged); - } - - @override - void didUpdateWidget(covariant FilesPanelBody oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - _files.removeListener(_onPanelChanged); - if (oldWidget.controller == null) _files.dispose(); - _files = widget.controller ?? FilesPanelController(widget.state); - _files.addListener(_onPanelChanged); - } - if (oldWidget.actionController != widget.actionController) { - _action.removeListener(_onPanelChanged); - if (oldWidget.actionController == null) _action.dispose(); - _action = widget.actionController ?? DeveloperPanelActionController(); - _action.addListener(_onPanelChanged); - } - if (_ownsController) { - _files.syncFromState(oldWidget.state, widget.state); - } - } - - @override - void dispose() { - _files.removeListener(_onPanelChanged); - _action.removeListener(_onPanelChanged); - if (_ownsController) _files.dispose(); - if (_ownsActionController) _action.dispose(); - super.dispose(); - } - - void _onPanelChanged() { - if (mounted) setState(() {}); - } - - Future _runAction( - String key, - String failureMessage, - Future Function() action, - ) => _action.run( - context, - externallyPending: widget.state.developerOperationPending, - key: key, - failureMessage: failureMessage, - action: action, - ); - - Future _confirmDiscardFileChanges() async { - if (!_files.dirty) return true; - final discard = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Discard unsaved changes?'), - content: Text( - 'Your edits to ${_files.editedFilePath ?? 'this file'} have not been saved.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Keep editing'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Discard'), - ), - ], - ), - ); - if (discard == true && mounted) { - _files.mutate(() => _files.dirty = false); - } - return discard == true; - } - - void _onFileChanged(String content) { - final workspace = widget.state.fileWorkspace; - final dirty = - _files.editedFilePath == workspace.path && content != workspace.content; - if (dirty != _files.dirty) _files.mutate(() => _files.dirty = dirty); - } - - Future _saveFile() async { - final path = _files.editedFilePath; - if (path == null || !_files.dirty) return; - await _runAction('files-write', 'Could not save the file.', () async { - await widget.actions.writeFile(path, _files.editor.text); - _files.mutate(() => _files.dirty = false); - }); - } - - Future _listDirectory(String path) async { - if (!await _confirmDiscardFileChanges() || !mounted) return; - _files.mutate(() { - _files.directoryPath = path; - _files.selectedFilePath = null; - _files.editedFilePath = null; - _files.editor.clear(); - }); - await _runAction( - 'files-list', - 'Could not list that directory.', - () => widget.actions.listFiles(path), - ); - } - - Future _selectFile(String path) async { - if (path != _files.editedFilePath && - (!await _confirmDiscardFileChanges() || !mounted)) { - return; - } - _files.mutate(() => _files.selectedFilePath = path); - await _runAction( - 'files-read', - 'Could not read that file.', - () => widget.actions.readFile(path), - ); - } - - @override - Widget build(BuildContext context) { - final content = _buildContent(context); - if (!_ownsActionController) return content; - return _PanelActionChrome(controller: _action, busy: _busy, child: content); - } - - Widget _buildContent(BuildContext context) { - final canList = _hasCapability('files.list'); - final canRead = _hasCapability('files.read'); - if (!_connected && widget.state.fileWorkspace.entries.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.cloud_off_outlined, - title: 'Files are offline', - message: 'Connect to a host to browse the selected session workspace.', - ); - } - if (!canList || !canRead) { - return _DeveloperUnavailable( - icon: Icons.lock_outline, - title: 'Files are unavailable', - message: - 'Browsing requires ${!canList ? 'files.list' : 'files.read'} access from the paired host.', - ); - } - - final workspace = widget.state.fileWorkspace; - final filePath = - _files.selectedFilePath ?? - (workspace.content == null ? null : workspace.path); - final content = filePath == workspace.path ? workspace.content : null; - return LayoutBuilder( - builder: (context, constraints) { - final list = _FileBrowser( - directoryPath: _files.directoryPath, - entries: workspace.entries, - selectedPath: filePath, - busy: _busy || workspace.loading, - error: workspace.error, - onRefresh: _connected && !_busy - ? () => _listDirectory(_files.directoryPath) - : null, - onParent: _connected && !_busy && _files.directoryPath.isNotEmpty - ? () => _listDirectory(_parentPath(_files.directoryPath)) - : null, - onOpenDirectory: _connected && !_busy ? _listDirectory : null, - onOpenFile: _connected && !_busy ? _selectFile : null, - ); - final viewer = _SourceViewer( - path: filePath, - content: content, - loading: _action.busyAction == 'files-read', - controller: _files.editor, - editable: _hasCapability('files.write') && _connected && !_busy, - dirty: _files.dirty, - onChanged: _onFileChanged, - onSave: _files.dirty && !_busy ? _saveFile : null, - onDiscard: _files.dirty - ? () => _files.mutate(() { - _files.editor.text = widget.state.fileWorkspace.content ?? ''; - _files.dirty = false; - }) - : null, - ); - if (constraints.maxWidth >= _T4Layout.contentMaxWidth) { - return Row( - children: [ - SizedBox(width: _T4Layout.sessionRailWidth, child: list), - const VerticalDivider(width: _T4Size.divider), - Expanded(child: viewer), - ], - ); - } - return Column( - children: [ - Flexible(child: list), - const Divider(height: _T4Size.divider), - Expanded(child: viewer), - ], - ); - }, - ); - } -} - -/// Embeddable body of the developer Review tab. -final class ReviewPanelBody extends StatefulWidget { - const ReviewPanelBody({ - required this.state, - required this.actions, - this.selectedFilePath, - this.onOpenFiles, - this.actionController, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - - /// Externally selected file (from the Files panel); falls back to the - /// workspace's loaded file when omitted. - final String? selectedFilePath; - - /// Invoked by the "Open files" affordance; the affordance is disabled when - /// omitted. - final VoidCallback? onOpenFiles; - - /// Optional shared busy/error state; the body owns its own (and renders its - /// own error banner and progress indicator) when omitted. - final DeveloperPanelActionController? actionController; - - @override - State createState() => _ReviewPanelBodyState(); -} - -final class _ReviewPanelBodyState extends State { - late DeveloperPanelActionController _action; - - bool get _ownsActionController => widget.actionController == null; - bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; - bool get _busy => - _action.busyAction != null || widget.state.developerOperationPending; - - bool _hasCapability(String capability) => - widget.state.grantedCapabilities.contains(capability); - - @override - void initState() { - super.initState(); - _action = widget.actionController ?? DeveloperPanelActionController(); - _action.addListener(_onPanelChanged); - } - - @override - void didUpdateWidget(covariant ReviewPanelBody oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.actionController != widget.actionController) { - _action.removeListener(_onPanelChanged); - if (oldWidget.actionController == null) _action.dispose(); - _action = widget.actionController ?? DeveloperPanelActionController(); - _action.addListener(_onPanelChanged); - } - } - - @override - void dispose() { - _action.removeListener(_onPanelChanged); - if (_ownsActionController) _action.dispose(); - super.dispose(); - } - - void _onPanelChanged() { - if (mounted) setState(() {}); - } - - Future _runAction( - String key, - String failureMessage, - Future Function() action, - ) => _action.run( - context, - externallyPending: widget.state.developerOperationPending, - key: key, - failureMessage: failureMessage, - action: action, - ); - - @override - Widget build(BuildContext context) { - final content = _buildContent(context); - if (!_ownsActionController) return content; - return _PanelActionChrome(controller: _action, busy: _busy, child: content); - } - - Widget _buildContent(BuildContext context) { - final workspace = widget.state.fileWorkspace; - final reviews = widget.state.reviews; - final selectedPath = - widget.selectedFilePath ?? - (workspace.content == null ? null : workspace.path); - if (!_connected && workspace.diff == null && reviews.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.cloud_off_outlined, - title: 'Review is offline', - message: 'Connect to load reviews and file diffs.', - ); - } - - final canReadReview = _hasCapability('files.read'); - final canApplyReview = _hasCapability('files.write'); - final canLoadDiff = _hasCapability('files.diff'); - final diff = selectedPath != null && workspace.path == selectedPath - ? workspace.diff - : null; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (reviews.isNotEmpty) ...[ - SizedBox( - height: (112 + reviews.length * 96).clamp(0, 300).toDouble(), - child: _ReviewQueue( - reviews: reviews, - busy: _busy, - canRead: _connected && canReadReview, - canApply: _connected && canApplyReview, - onRefresh: (review) => _runAction( - 'review-read-${review.reviewId}', - 'Could not refresh the review.', - () => widget.actions.refreshReview(review.reviewId), - ), - onApply: (review) => _runAction( - 'review-apply-${review.reviewId}', - 'Could not apply the review.', - () => widget.actions.applyReview(review.reviewId), - ), - ), - ), - const Divider(height: _T4Size.divider), - ], - if (selectedPath == null || selectedPath.isEmpty) - Expanded( - child: _DeveloperUnavailable( - icon: Icons.find_in_page_outlined, - title: reviews.isEmpty - ? 'Choose a file first' - : 'No file diff selected', - message: 'Select a file in Files to inspect its current diff.', - action: TextButton.icon( - onPressed: widget.onOpenFiles, - icon: const Icon(Icons.folder_open_outlined), - label: const Text('Open files'), - ), - ), - ) - else if (!canLoadDiff) - const Expanded( - child: _DeveloperUnavailable( - icon: Icons.lock_outline, - title: 'File diff unavailable', - message: 'The paired host did not grant files.diff access.', - ), - ) - else ...[ - Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Wrap( - spacing: _T4Space.sm, - runSpacing: _T4Space.xs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Icon( - Icons.description_outlined, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - Text( - selectedPath, - style: Theme.of(context).textTheme.titleSmall, - ), - FilledButton.tonalIcon( - onPressed: _connected && !_busy - ? () => _runAction( - 'files-diff', - 'Could not load the file diff.', - widget.actions.loadSessionDiff, - ) - : null, - icon: const Icon(Icons.refresh), - label: Text(diff == null ? 'Load diff' : 'Reload diff'), - ), - ], - ), - ), - const Divider(height: _T4Size.divider), - Expanded( - child: diff == null - ? const _DeveloperUnavailable( - icon: Icons.difference_outlined, - title: 'Diff not loaded', - message: 'Load the selected file diff to begin review.', - ) - : diff.trim().isEmpty - ? const _DeveloperUnavailable( - icon: Icons.check_circle_outline, - title: 'No changes', - message: 'The selected file has no changes to review.', - ) - : _CodeViewer( - text: diff, - semanticsLabel: 'Diff for $selectedPath', - ), - ), - ], - ], - ); - } -} - -/// Embeddable body of the developer Terminal tab. -final class TerminalPanelBody extends StatelessWidget { - const TerminalPanelBody({ - required this.state, - required this.actions, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - - @override - Widget build(BuildContext context) => - _TerminalPane(state: state, actions: actions); -} - -/// Embeddable body of the developer Preview tab. -final class PreviewPanelBody extends StatefulWidget { - const PreviewPanelBody({ - required this.state, - required this.actions, - this.controller, - this.actionController, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - - /// Optional external per-tab state; the body owns its own when omitted. - final PreviewPanelController? controller; - - /// Optional shared busy/error state; the body owns its own (and renders its - /// own error banner and progress indicator) when omitted. - final DeveloperPanelActionController? actionController; - - @override - State createState() => _PreviewPanelBodyState(); -} - -final class _PreviewPanelBodyState extends State { - late PreviewPanelController _preview; - late DeveloperPanelActionController _action; - - bool get _ownsController => widget.controller == null; - bool get _ownsActionController => widget.actionController == null; - bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; - bool get _busy => - _action.busyAction != null || widget.state.developerOperationPending; - - bool _hasCapability(String capability) => - widget.state.grantedCapabilities.contains(capability); - - @override - void initState() { - super.initState(); - _preview = widget.controller ?? PreviewPanelController(widget.state); - _action = widget.actionController ?? DeveloperPanelActionController(); - _preview.addListener(_onPanelChanged); - _action.addListener(_onPanelChanged); - } - - @override - void didUpdateWidget(covariant PreviewPanelBody oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - _preview.removeListener(_onPanelChanged); - if (oldWidget.controller == null) _preview.dispose(); - _preview = widget.controller ?? PreviewPanelController(widget.state); - _preview.addListener(_onPanelChanged); - } - if (oldWidget.actionController != widget.actionController) { - _action.removeListener(_onPanelChanged); - if (oldWidget.actionController == null) _action.dispose(); - _action = widget.actionController ?? DeveloperPanelActionController(); - _action.addListener(_onPanelChanged); - } - if (_ownsController) { - _preview.syncFromState(oldWidget.state, widget.state); - } - } - - @override - void dispose() { - _preview.removeListener(_onPanelChanged); - _action.removeListener(_onPanelChanged); - if (_ownsController) _preview.dispose(); - if (_ownsActionController) _action.dispose(); - super.dispose(); - } - - void _onPanelChanged() { - if (mounted) setState(() {}); - } - - Future _runAction( - String key, - String failureMessage, - Future Function() action, - ) => _action.run( - context, - externallyPending: widget.state.developerOperationPending, - key: key, - failureMessage: failureMessage, - action: action, - ); - - Future _launchPreview() async { - final url = _preview.launchUrl.text.trim(); - if (url.isEmpty) return; - await _runAction( - 'preview-launch', - 'Could not launch the preview.', - () async { - await widget.actions.launchPreview(url); - _preview.launchUrl.clear(); - }, - ); - } - - Future _navigatePreview(PreviewWorkspaceState preview) async { - final url = _preview.navigationUrl.text.trim(); - if (url.isEmpty || url == preview.url) return; - await _runAction( - 'preview-navigate', - 'Could not navigate the preview.', - () => widget.actions.navigatePreview(preview.previewId, url), - ); - } - - Future _previewAction(PreviewWorkspaceState preview, String action) => - _runAction( - 'preview-$action', - 'Could not $action the preview.', - () => widget.actions.runPreviewAction(preview.previewId, action), - ); - - Future _openPreviewInteraction( - PreviewWorkspaceState preview, { - required bool allowInput, - required bool allowHandoff, - }) async { - final request = await showModalBottomSheet<_PreviewInteractionRequest>( - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (context) => _PreviewInteractionSheet( - allowInput: allowInput, - allowHandoff: allowHandoff, - ), - ); - if (request == null || !mounted) return; - await _runAction( - 'preview-${request.action}', - 'Could not ${request.action} in the preview.', - () => widget.actions.runPreviewInteraction( - preview.previewId, - request.action, - request.args, - ), - ); - } - - @override - Widget build(BuildContext context) { - final content = _buildContent(context); - if (!_ownsActionController) return content; - return _PanelActionChrome(controller: _action, busy: _busy, child: content); - } - - Widget _buildContent(BuildContext context) { - final canRead = _hasCapability('preview.read'); - final canControl = _hasCapability('preview.control'); - final canInput = _hasCapability('preview.input'); - if (!_connected && widget.state.previews.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.cloud_off_outlined, - title: 'Preview is offline', - message: 'Connect to a host to open a protocol-rendered preview.', - ); - } - if (!canRead && !canControl && !canInput) { - return const _DeveloperUnavailable( - icon: Icons.lock_outline, - title: 'Preview is unavailable', - message: - 'The paired host did not grant preview.read, preview.control, or preview.input.', - ); - } - - final preview = widget.state.activePreview; - return Column( - children: [ - _PreviewLaunchBar( - controller: _preview.launchUrl, - enabled: _connected && canControl && !_busy, - onLaunch: _launchPreview, - ), - if (widget.state.previews.isNotEmpty) - _PreviewSelector( - previews: widget.state.previews, - activePreviewId: preview?.previewId, - enabled: _connected && canControl && !_busy, - onSelected: (previewId) => _runAction( - 'preview-select', - 'Could not select the preview.', - () => widget.actions.selectPreview(previewId), - ), - ), - const Divider(height: _T4Size.divider), - Expanded( - child: preview == null - ? const _DeveloperUnavailable( - icon: Icons.preview_outlined, - title: 'No preview open', - message: - 'Enter a URL above. T4 will display only captured image bytes returned by the host protocol.', - ) - : _buildActivePreview(preview, canRead, canControl, canInput), - ), - ], - ); - } - - Widget _buildActivePreview( - PreviewWorkspaceState preview, - bool canRead, - bool canControl, - bool canInput, - ) { - final controlsEnabled = _connected && canControl && !_busy; - return Column( - children: [ - _PreviewNavigationBar( - preview: preview, - controller: _preview.navigationUrl, - focusNode: _preview.navigationFocus, - controlsEnabled: controlsEnabled, - captureEnabled: _connected && canRead && !_busy, - interactionEnabled: _connected && (canInput || canControl) && !_busy, - onNavigate: () => _navigatePreview(preview), - onAction: (action) => _previewAction(preview, action), - onInteract: () => _openPreviewInteraction( - preview, - allowInput: canInput, - allowHandoff: canControl, - ), - onCapture: () => _runAction( - 'preview-capture', - 'Could not capture the preview.', - () => widget.actions.capturePreview(preview.previewId), - ), - ), - _PreviewStatus(preview: preview), - if (preview.error case final error?) - _DeveloperErrorBanner(message: error), - const _DeveloperNotice( - icon: Icons.verified_user_outlined, - message: - 'Safe preview: page code never runs in this app. Only capture bytes delivered by the paired host protocol are rendered.', - ), - Expanded(child: _PreviewCapture(preview: preview)), - ], - ); - } -} - -int _boundedDeveloperTab(int index) => index < 0 - ? 0 - : index > 4 - ? 4 - : index; - -final class _DeveloperSurfacesHeader extends StatelessWidget { - const _DeveloperSurfacesHeader({required this.onDone}); - - final VoidCallback onDone; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.sm, - vertical: _T4Space.xs, - ), - child: Row( - children: [ - IconButton( - onPressed: onDone, - tooltip: 'Close developer tools', - icon: const Icon(Icons.arrow_back), - ), - const SizedBox(width: _T4Space.xs), - Expanded( - child: Text( - 'Developer tools', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ], - ), - ), - ); - } -} - -final class _DeveloperErrorBanner extends StatelessWidget { - const _DeveloperErrorBanner({required this.message, this.onDismiss}); - - final String message; - final VoidCallback? onDismiss; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Semantics( - container: true, - liveRegion: true, - label: 'Developer tools error: $message', - child: ColoredBox( - color: scheme.errorContainer, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.xs, - ), - child: Row( - children: [ - Icon(Icons.error_outline, color: scheme.onErrorContainer), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Text( - message, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onErrorContainer, - ), - ), - ), - if (onDismiss != null) - IconButton( - onPressed: onDismiss, - tooltip: 'Dismiss error', - color: scheme.onErrorContainer, - icon: const Icon(Icons.close), - ), - ], - ), - ), - ), - ); - } -} - -final class _DeveloperNotice extends StatelessWidget { - const _DeveloperNotice({required this.icon, required this.message}); - - final IconData icon; - final String message; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return ColoredBox( - color: scheme.surfaceContainerLow, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.xs, - ), - child: Row( - children: [ - Icon(icon, size: _T4Space.md, color: scheme.onSurfaceVariant), - const SizedBox(width: _T4Space.xs), - Expanded( - child: Text( - message, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ), - ], - ), - ), - ); - } -} - -final class _DeveloperUnavailable extends StatelessWidget { - const _DeveloperUnavailable({ - required this.icon, - required this.title, - required this.message, - this.action, - }); - - final IconData icon; - final String title; - final String message; - final Widget? action; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(_T4Space.xl), - child: Semantics( - container: true, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: _T4Size.emptyIcon, color: scheme.outline), - const SizedBox(height: _T4Space.md), - Text( - title, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xs), - ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Text( - message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), - if (action != null) ...[ - const SizedBox(height: _T4Space.md), - action!, - ], - ], - ), - ), - ), - ); - } -} - -final class _ActivityToolbar extends StatelessWidget { - const _ActivityToolbar({ - required this.categories, - required this.selectedCategory, - required this.paused, - required this.busy, - required this.onSelectCategory, - required this.onTogglePause, - required this.onRefresh, - }); - - final List categories; - final String? selectedCategory; - final bool paused; - final bool busy; - final ValueChanged onSelectCategory; - final VoidCallback onTogglePause; - final VoidCallback? onRefresh; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Wrap( - spacing: _T4Space.xs, - children: [ - FilterChip( - label: const Text('All'), - selected: selectedCategory == null, - onSelected: (_) => onSelectCategory(null), - ), - for (final category in categories) - FilterChip( - label: Text(category), - selected: selectedCategory == category, - onSelected: (_) => onSelectCategory(category), - ), - ], - ), - ), - ), - const SizedBox(width: _T4Space.xs), - IconButton( - onPressed: busy ? null : onTogglePause, - tooltip: paused ? 'Resume activity' : 'Pause activity', - icon: Icon(paused ? Icons.play_arrow : Icons.pause), - ), - IconButton( - onPressed: busy ? null : onRefresh, - tooltip: 'Refresh activity', - icon: const Icon(Icons.refresh), - ), - ], - ), - ); - } -} - -final class _ActivityRow extends StatelessWidget { - const _ActivityRow({required this.activity}); - - final DeveloperActivity activity; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final raw = _redactActivityRaw(activity.raw); - return Material( - color: scheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - clipBehavior: Clip.antiAlias, - child: ExpansionTile( - key: ValueKey(activity.id), - leading: const Icon(Icons.data_object), - title: Text(activity.title), - subtitle: Text( - '${_formatActivityTime(activity.at)} · ${activity.category}\n${activity.detail}', - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - expandedCrossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Divider(height: _T4Size.divider), - Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Redacted protocol JSON', - style: Theme.of(context).textTheme.labelLarge, - ), - ), - IconButton( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: raw)); - if (!context.mounted) return; - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - const SnackBar( - content: Text('Redacted activity JSON copied.'), - ), - ); - }, - tooltip: 'Copy redacted JSON', - icon: const Icon(Icons.copy_outlined), - ), - ], - ), - _CodeViewer( - text: raw.isEmpty ? '{}' : raw, - semanticsLabel: - 'Redacted protocol JSON for ${activity.title}', - expand: false, - ), - ], - ), - ), - ], - ), - ); - } -} - -final class _ReviewQueue extends StatelessWidget { - const _ReviewQueue({ - required this.reviews, - required this.busy, - required this.canRead, - required this.canApply, - required this.onRefresh, - required this.onApply, - }); - - final List reviews; - final bool busy; - final bool canRead; - final bool canApply; - final ValueChanged onRefresh; - final ValueChanged onApply; - - @override - Widget build(BuildContext context) { - return ListView.separated( - padding: const EdgeInsets.all(_T4Space.sm), - itemCount: reviews.length, - separatorBuilder: (_, _) => const SizedBox(height: _T4Space.xs), - itemBuilder: (context, index) { - final review = reviews[index]; - final complete = - review.status == 'applied' || review.status == 'discarded'; - final finding = review.findings - .map(_reviewFindingLabel) - .where((label) => label.isNotEmpty) - .firstOrNull; - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - complete - ? Icons.task_alt_outlined - : Icons.rate_review_outlined, - color: complete - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - review.path ?? review.reviewId, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - ), - Text( - '${review.status} · ${review.findings.length} ${review.findings.length == 1 ? 'finding' : 'findings'}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (finding != null) - Text( - finding, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: _T4Space.sm), - Column( - children: [ - IconButton( - tooltip: 'Refresh review', - onPressed: canRead && !busy - ? () => onRefresh(review) - : null, - icon: const Icon(Icons.refresh), - ), - IconButton.filledTonal( - tooltip: complete ? 'Review complete' : 'Apply review', - onPressed: canApply && !busy && !complete - ? () => onApply(review) - : null, - icon: const Icon(Icons.done_all), - ), - ], - ), - ], - ), - ), - ); - }, - ); - } -} - -String _reviewFindingLabel(Map finding) { - for (final key in const ['message', 'summary', 'title', 'description']) { - final value = finding[key]; - if (value is String && value.trim().isNotEmpty) return value.trim(); - } - return ''; -} - -final class _FileBrowser extends StatelessWidget { - const _FileBrowser({ - required this.directoryPath, - required this.entries, - required this.selectedPath, - required this.busy, - required this.error, - required this.onRefresh, - required this.onParent, - required this.onOpenDirectory, - required this.onOpenFile, - }); - - final String directoryPath; - final List entries; - final String? selectedPath; - final bool busy; - final String? error; - final VoidCallback? onRefresh; - final VoidCallback? onParent; - final ValueChanged? onOpenDirectory; - final ValueChanged? onOpenFile; - - @override - Widget build(BuildContext context) { - final sorted = List.of(entries) - ..sort((a, b) { - final aDirectory = _isDirectory(a); - final bDirectory = _isDirectory(b); - if (aDirectory != bDirectory) return aDirectory ? -1 : 1; - return a.path.toLowerCase().compareTo(b.path.toLowerCase()); - }); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - children: [ - IconButton( - onPressed: onParent, - tooltip: 'Open parent directory', - icon: const Icon(Icons.arrow_upward), - ), - const SizedBox(width: _T4Space.xs), - Expanded( - child: Tooltip( - message: directoryPath.isEmpty - ? 'Workspace root' - : directoryPath, - child: Text( - directoryPath.isEmpty ? 'Workspace root' : directoryPath, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - ), - ), - ), - IconButton( - onPressed: onRefresh, - tooltip: 'Refresh directory', - icon: const Icon(Icons.refresh), - ), - ], - ), - ), - if (error case final message?) _DeveloperErrorBanner(message: message), - Expanded( - child: busy && sorted.isEmpty - ? const Center( - child: CircularProgressIndicator( - semanticsLabel: 'Loading files', - ), - ) - : sorted.isEmpty - ? const _DeveloperUnavailable( - icon: Icons.folder_off_outlined, - title: 'Directory is empty', - message: 'Refresh to check for workspace files.', - ) - : ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: _T4Space.xs), - itemCount: sorted.length, - itemBuilder: (context, index) { - final entry = sorted[index]; - final directory = _isDirectory(entry); - return ListTile( - selected: entry.path == selectedPath, - enabled: directory - ? onOpenDirectory != null - : onOpenFile != null, - leading: Icon( - directory - ? Icons.folder_outlined - : Icons.insert_drive_file_outlined, - ), - title: Text( - _baseName(entry.path), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: directory - ? const Text('Directory') - : entry.size == null - ? null - : Text(_formatByteCount(entry.size!)), - trailing: directory - ? const Icon(Icons.chevron_right) - : null, - onTap: () => directory - ? onOpenDirectory?.call(entry.path) - : onOpenFile?.call(entry.path), - ); - }, - ), - ), - ], - ); - } -} - -final class _SourceViewer extends StatelessWidget { - const _SourceViewer({ - required this.path, - required this.content, - required this.loading, - required this.controller, - required this.editable, - required this.dirty, - required this.onChanged, - this.onSave, - this.onDiscard, - }); - - final String? path; - final String? content; - final bool loading; - final TextEditingController controller; - final bool editable; - final bool dirty; - final ValueChanged onChanged; - final VoidCallback? onSave; - final VoidCallback? onDiscard; - - @override - Widget build(BuildContext context) { - if (loading) { - return const Center( - child: CircularProgressIndicator(semanticsLabel: 'Reading file'), - ); - } - if (path == null) { - return const _DeveloperUnavailable( - icon: Icons.code_outlined, - title: 'Select a source file', - message: 'Choose a file to inspect its protocol-provided contents.', - ); - } - if (content == null) { - return const _DeveloperUnavailable( - icon: Icons.error_outline, - title: 'Source unavailable', - message: 'Select the file again to read its contents.', - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Row( - children: [ - Expanded( - child: Text( - path!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - ), - ), - if (dirty) - TextButton(onPressed: onDiscard, child: const Text('Discard')), - FilledButton.tonalIcon( - onPressed: onSave, - icon: const Icon(Icons.save_outlined), - label: Text(dirty ? 'Save' : 'Saved'), - ), - ], - ), - ), - const Divider(height: _T4Size.divider), - Expanded( - child: ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - child: Semantics( - label: 'Source editor for $path', - textField: true, - child: TextField( - controller: controller, - enabled: editable, - readOnly: !editable, - expands: true, - minLines: null, - maxLines: null, - textAlignVertical: TextAlignVertical.top, - keyboardType: TextInputType.multiline, - onChanged: onChanged, - decoration: const InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.all(_T4Space.md), - ), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: _T4Typography.monoFamily, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ), - ), - ], - ); - } -} - -final class _CodeViewer extends StatelessWidget { - const _CodeViewer({ - required this.text, - required this.semanticsLabel, - this.expand = true, - }); - - final String text; - final String semanticsLabel; - final bool expand; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final viewer = ColoredBox( - color: scheme.surfaceContainerLowest, - child: Scrollbar( - child: SingleChildScrollView( - padding: const EdgeInsets.all(_T4Space.md), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SelectionArea( - child: Text( - text, - semanticsLabel: semanticsLabel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: _T4Typography.monoFamily, - color: scheme.onSurface, - ), - ), - ), - ), - ), - ), - ); - return expand ? SizedBox.expand(child: viewer) : viewer; - } -} - -final class _PreviewLaunchBar extends StatelessWidget { - const _PreviewLaunchBar({ - required this.controller, - required this.enabled, - required this.onLaunch, - }); - - final TextEditingController controller; - final bool enabled; - final Future Function() onLaunch; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - children: [ - Expanded( - child: TextField( - controller: controller, - enabled: enabled, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.go, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Launch URL', - hintText: 'https://localhost:3000', - prefixIcon: Icon(Icons.add_link), - ), - onSubmitted: enabled ? (_) => onLaunch() : null, - ), - ), - const SizedBox(width: _T4Space.sm), - FilledButton.icon( - onPressed: enabled ? onLaunch : null, - icon: const Icon(Icons.rocket_launch_outlined), - label: const Text('Launch'), - ), - ], - ), - ); - } -} - -final class _PreviewSelector extends StatelessWidget { - const _PreviewSelector({ - required this.previews, - required this.activePreviewId, - required this.enabled, - required this.onSelected, - }); - - final List previews; - final String? activePreviewId; - final bool enabled; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: _T4Layout.minimumTouchTarget + _T4Space.sm, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: _T4Space.sm), - itemCount: previews.length, - separatorBuilder: (_, _) => const SizedBox(width: _T4Space.xs), - itemBuilder: (context, index) { - final preview = previews[index]; - final title = preview.title?.trim(); - return ChoiceChip( - label: Text( - title == null || title.isEmpty ? preview.url : title, - overflow: TextOverflow.ellipsis, - ), - selected: preview.previewId == activePreviewId, - onSelected: enabled ? (_) => onSelected(preview.previewId) : null, - ); - }, - ), - ); - } -} - -final class _PreviewNavigationBar extends StatelessWidget { - const _PreviewNavigationBar({ - required this.preview, - required this.controller, - required this.focusNode, - required this.controlsEnabled, - required this.captureEnabled, - required this.interactionEnabled, - required this.onNavigate, - required this.onAction, - required this.onInteract, - required this.onCapture, - }); - - final PreviewWorkspaceState preview; - final TextEditingController controller; - final FocusNode focusNode; - final bool controlsEnabled; - final bool captureEnabled; - final bool interactionEnabled; - final Future Function() onNavigate; - final ValueChanged onAction; - final VoidCallback onInteract; - final VoidCallback onCapture; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: controlsEnabled && preview.canGoBack - ? () => onAction('back') - : null, - tooltip: 'Preview back', - icon: const Icon(Icons.arrow_back), - ), - IconButton( - onPressed: controlsEnabled && preview.canGoForward - ? () => onAction('forward') - : null, - tooltip: 'Preview forward', - icon: const Icon(Icons.arrow_forward), - ), - IconButton( - onPressed: controlsEnabled ? () => onAction('reload') : null, - tooltip: 'Reload preview', - icon: const Icon(Icons.refresh), - ), - const SizedBox(width: _T4Space.xs), - Expanded( - child: TextField( - controller: controller, - focusNode: focusNode, - enabled: controlsEnabled, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.go, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Preview address', - prefixIcon: Icon(Icons.link), - ), - onSubmitted: controlsEnabled ? (_) => onNavigate() : null, - ), - ), - const SizedBox(width: _T4Space.xs), - IconButton.filledTonal( - onPressed: controlsEnabled ? onNavigate : null, - tooltip: 'Navigate preview', - icon: const Icon(Icons.arrow_forward), - ), - ], - ), - const SizedBox(height: _T4Space.xs), - Wrap( - alignment: WrapAlignment.end, - spacing: _T4Space.xs, - children: [ - TextButton.icon( - onPressed: interactionEnabled ? onInteract : null, - icon: const Icon(Icons.touch_app_outlined), - label: const Text('Interact'), - ), - TextButton.icon( - onPressed: captureEnabled ? onCapture : null, - icon: const Icon(Icons.camera_alt_outlined), - label: const Text('Capture'), - ), - TextButton.icon( - onPressed: controlsEnabled ? () => onAction('close') : null, - icon: const Icon(Icons.close), - label: const Text('Close'), - ), - ], - ), - ], - ), - ); - } -} - -final class _PreviewInteractionRequest { - const _PreviewInteractionRequest({required this.action, required this.args}); - - final String action; - final Map args; -} - -final class _PreviewInteractionSheet extends StatefulWidget { - const _PreviewInteractionSheet({ - required this.allowInput, - required this.allowHandoff, - }); - - final bool allowInput; - final bool allowHandoff; - - @override - State<_PreviewInteractionSheet> createState() => - _PreviewInteractionSheetState(); -} - -final class _PreviewInteractionSheetState - extends State<_PreviewInteractionSheet> { - static const _templates = { - 'click': '{"selector":"button"}', - 'fill': '{"selector":"input","text":"value"}', - 'type': '{"selector":"input","text":"value"}', - 'select': '{"selector":"select","value":"option"}', - 'press': '{"key":"Enter"}', - 'scroll': '{"deltaX":0,"deltaY":480}', - 'upload': '{"selector":"input[type=file]","path":"relative/file.txt"}', - 'handoff': '{"message":"Complete this step","mode":"manual"}', - }; - - late final List _actions; - late String _action; - late final TextEditingController _argumentsController; - String? _error; - - @override - void initState() { - super.initState(); - _actions = [ - if (widget.allowInput) ...const [ - 'click', - 'fill', - 'type', - 'select', - 'press', - 'scroll', - 'upload', - ], - if (widget.allowHandoff) 'handoff', - ]; - _action = _actions.first; - _argumentsController = TextEditingController(text: _templates[_action]); - } - - @override - void dispose() { - _argumentsController.dispose(); - super.dispose(); - } - - void _selectAction(String? action) { - if (action == null) return; - setState(() { - _action = action; - _argumentsController.text = _templates[action] ?? '{}'; - _error = null; - }); - } - - void _submit() { - try { - final decoded = jsonDecode(_argumentsController.text); - if (decoded is! Map) { - throw const FormatException('Arguments must be a JSON object.'); - } - Navigator.pop( - context, - _PreviewInteractionRequest( - action: _action, - args: Map.from(decoded), - ), - ); - } on FormatException catch (error) { - setState(() => _error = error.message); - } - } - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.md, - _T4Space.md, - MediaQuery.viewInsetsOf(context).bottom + _T4Space.md, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Preview interaction', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: _T4Space.md), - DropdownButtonFormField( - initialValue: _action, - decoration: const InputDecoration(labelText: 'Action'), - items: [ - for (final action in _actions) - DropdownMenuItem(value: action, child: Text(action)), - ], - onChanged: _selectAction, - ), - const SizedBox(height: _T4Space.md), - TextField( - controller: _argumentsController, - minLines: 5, - maxLines: 10, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: 'Arguments (JSON)', - alignLabelWithHint: true, - errorText: _error, - ), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: _T4Typography.monoFamily, - ), - ), - const SizedBox(height: _T4Space.md), - FilledButton.icon( - onPressed: _submit, - icon: const Icon(Icons.play_arrow), - label: Text('Run $_action'), - ), - ], - ), - ); - } -} - -final class _PreviewStatus extends StatelessWidget { - const _PreviewStatus({required this.preview}); - - final PreviewWorkspaceState preview; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final captured = preview.capture != null; - return Semantics( - container: true, - label: - 'Preview status ${preview.state}. ${captured ? 'Capture available' : 'No capture available'}', - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.xs, - ), - child: Row( - children: [ - Icon( - preview.error == null ? Icons.circle : Icons.error, - size: _T4Space.xs, - color: preview.error == null ? scheme.primary : scheme.error, - ), - const SizedBox(width: _T4Space.xs), - Expanded( - child: Text( - '${preview.state} · ${captured ? 'Protocol capture ready' : 'Capture required'}', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ), - if (preview.captureMimeType case final mimeType?) - Text( - mimeType, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } -} - -final class _PreviewCapture extends StatelessWidget { - const _PreviewCapture({required this.preview}); - - final PreviewWorkspaceState preview; - - @override - Widget build(BuildContext context) { - final capture = preview.capture; - if (capture == null || capture.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.photo_outlined, - title: 'No protocol capture', - message: - 'Capture this preview to request rendered image bytes from the host. T4 does not execute the page or its HTML.', - ); - } - return Semantics( - container: true, - label: 'Protocol capture of ${preview.title ?? preview.url}', - image: true, - child: ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - child: Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Center( - child: Image.memory( - capture, - fit: BoxFit.contain, - gaplessPlayback: true, - semanticLabel: - 'Captured preview of ${preview.title ?? preview.url}', - errorBuilder: (context, error, stackTrace) => - const _DeveloperUnavailable( - icon: Icons.broken_image_outlined, - title: 'Capture could not be displayed', - message: - 'The host returned image bytes that this device cannot decode.', - ), - ), - ), - ), - ), - ); - } -} - -bool _isDirectory(DeveloperFileEntry entry) { - final kind = entry.kind.toLowerCase(); - return kind == 'directory' || kind == 'dir' || kind == 'folder'; -} - -String _parentPath(String path) { - final normalized = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), ''); - final separator = normalized.lastIndexOf('/'); - return separator <= 0 ? '' : normalized.substring(0, separator); -} - -String _baseName(String path) { - final normalized = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), ''); - final separator = normalized.lastIndexOf('/'); - return separator < 0 ? normalized : normalized.substring(separator + 1); -} - -String _formatByteCount(int bytes) { - if (bytes < 1024) return '$bytes B'; - final kibibytes = bytes / 1024; - if (kibibytes < 1024) return '${kibibytes.toStringAsFixed(1)} KiB'; - return '${(kibibytes / 1024).toStringAsFixed(1)} MiB'; -} - -String _formatActivityTime(DateTime value) { - final local = value.toLocal(); - String twoDigits(int number) => number.toString().padLeft(2, '0'); - return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} ' - '${twoDigits(local.hour)}:${twoDigits(local.minute)}:${twoDigits(local.second)}'; -} - -String _redactActivityRaw(String raw) { - const sensitiveKeys = - r'authorization|cookie|password|passphrase|secret|token|api[_-]?key|credential|pairingCode'; - final keyedValue = RegExp( - '("(?:$sensitiveKeys)"\\s*:\\s*)("(?:\\\\.|[^"\\\\])*"|[^,}\\s]+)', - caseSensitive: false, - ); - final bearer = RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false); - return raw - .replaceAllMapped(keyedValue, (match) => '${match.group(1)}"[REDACTED]"') - .replaceAll(bearer, 'Bearer [REDACTED]'); -} diff --git a/apps/flutter/lib/src/ui/host_management.dart b/apps/flutter/lib/src/ui/host_management.dart deleted file mode 100644 index 4b06627c..00000000 --- a/apps/flutter/lib/src/ui/host_management.dart +++ /dev/null @@ -1,760 +0,0 @@ -part of 't4_app.dart'; - -const int _pairingCodeLength = 6; - -String _hostActionError(Object error, String fallback) { - final Object? message = switch (error) { - FormatException() => error.message, - StateError() => error.message, - ArgumentError() => error.message, - _ => null, - }; - if (message case final String text when text.trim().isNotEmpty) { - return text.trim(); - } - return fallback; -} - -extension on AuthenticationPhase { - String get label => switch (this) { - AuthenticationPhase.unknown => 'Authentication pending', - AuthenticationPhase.local => 'Local access', - AuthenticationPhase.pairingRequired => 'Pairing required', - AuthenticationPhase.pairing => 'Pairing', - AuthenticationPhase.paired => 'Paired', - }; -} - -final class _HostOnboardingPage extends StatelessWidget { - const _HostOnboardingPage({required this.state, required this.actions}); - - final T4ViewState state; - final T4Actions actions; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: SingleChildScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.all(_T4Space.lg), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Connect to T4', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: _T4Space.sm), - Text( - 'Add the private Tailnet address shown by T4 on your computer. ' - 'The app will remember this host on this device.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: _T4Space.xl), - _HostForm( - actions: actions, - operationPending: state.hostOperationPending, - submitLabel: 'Add host', - clearOnSuccess: false, - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -final class _HostManagerPane extends StatefulWidget { - const _HostManagerPane({ - required this.state, - required this.actions, - required this.onDone, - }); - - final T4ViewState state; - final T4Actions actions; - final VoidCallback onDone; - - @override - State<_HostManagerPane> createState() => _HostManagerPaneState(); -} - -final class _HostManagerPaneState extends State<_HostManagerPane> { - String? _pendingProfileKey; - - Future _runProfileAction({ - required String endpointKey, - required Future Function() action, - required String failureMessage, - }) async { - if (_pendingProfileKey != null || widget.state.hostOperationPending) return; - setState(() => _pendingProfileKey = endpointKey); - try { - await action(); - } on Object catch (error) { - if (!mounted) return; - final messenger = ScaffoldMessenger.of(context); - messenger - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar(content: Text(_hostActionError(error, failureMessage))), - ); - } finally { - if (mounted) setState(() => _pendingProfileKey = null); - } - } - - Future _confirmRemove({ - required String endpointKey, - required String label, - }) async { - if (_pendingProfileKey != null || widget.state.hostOperationPending) return; - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('Remove $label?'), - content: const Text( - 'This removes the saved address and pairing credential from this ' - 'device. The host and its sessions are not changed.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Keep host'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Remove host'), - ), - ], - ), - ); - if (confirmed != true || !mounted) return; - await _runProfileAction( - endpointKey: endpointKey, - action: () => widget.actions.removeHost(endpointKey), - failureMessage: 'Could not remove this host. Try again.', - ); - } - - Widget _buildProfile(BuildContext context, int index) { - final profile = widget.state.hostDirectory.profiles[index]; - final active = - profile.endpointKey == widget.state.hostDirectory.activeEndpointKey; - final pending = - widget.state.hostOperationPending || - _pendingProfileKey == profile.endpointKey; - final scheme = Theme.of(context).colorScheme; - final status = active - ? 'Current host · ${widget.state.connectionPhase.label} · ' - '${widget.state.authenticationPhase.label}' - : 'Saved host'; - - return Semantics( - container: true, - label: '${profile.label}, $status', - child: Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - profile.label, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xxs), - Text( - profile.origin, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - const SizedBox(height: _T4Space.xxs), - Text( - 'Profile: ${profile.profileId}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - if (active) - Padding( - padding: const EdgeInsets.only(left: _T4Space.sm), - child: Icon( - Icons.check_circle, - color: scheme.primary, - semanticLabel: 'Current host', - ), - ), - ], - ), - const SizedBox(height: _T4Space.xs), - Text( - status, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: active ? scheme.primary : scheme.onSurfaceVariant, - ), - ), - if (active && - widget.state.authenticationPhase != - AuthenticationPhase.unknown) ...[ - const SizedBox(height: _T4Space.xxs), - _HostPermissionSummary(state: widget.state), - ], - const SizedBox(height: _T4Space.xs), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - children: [ - if (!active) - Semantics( - button: true, - label: 'Switch to ${profile.label}', - child: OutlinedButton( - onPressed: pending - ? null - : () => unawaited( - _runProfileAction( - endpointKey: profile.endpointKey, - action: () => widget.actions.activateHost( - profile.endpointKey, - ), - failureMessage: - 'Could not switch hosts. Try again.', - ), - ), - child: const Text('Switch'), - ), - ), - Semantics( - button: true, - label: 'Remove ${profile.label}', - child: TextButton( - onPressed: pending - ? null - : () => unawaited( - _confirmRemove( - endpointKey: profile.endpointKey, - label: profile.label, - ), - ), - child: pending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Updating saved host', - ), - ) - : const Text('Remove'), - ), - ), - ], - ), - ], - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final profiles = widget.state.hostDirectory.profiles; - return SafeArea( - top: false, - child: CustomScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ - SliverPadding( - padding: const EdgeInsets.fromLTRB( - _T4Space.lg, - _T4Space.md, - _T4Space.lg, - _T4Space.xl, - ), - sliver: SliverToBoxAdapter( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Saved hosts', - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - TextButton( - onPressed: widget.onDone, - child: const Text('Done'), - ), - ], - ), - const SizedBox(height: _T4Space.sm), - Text( - 'Choose which T4 host this app connects to.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: _T4Space.lg), - for (var index = 0; index < profiles.length; index++) ...[ - if (index > 0) const Divider(), - _buildProfile(context, index), - ], - const SizedBox(height: _T4Space.xl), - const Divider(), - const SizedBox(height: _T4Space.lg), - Text( - 'Add another host', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: _T4Space.md), - _HostForm( - actions: widget.actions, - operationPending: widget.state.hostOperationPending, - submitLabel: 'Add host', - clearOnSuccess: true, - ), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -final class _HostPermissionSummary extends StatelessWidget { - const _HostPermissionSummary({required this.state}); - - final T4ViewState state; - - @override - Widget build(BuildContext context) { - final granted = state.grantedCapabilities; - final missing = t4RequestedCapabilities - .where((capability) => !granted.contains(capability)) - .toList(growable: false); - final colors = Theme.of(context).colorScheme; - return Semantics( - container: true, - label: - 'Granted ${granted.length} of ' - '${t4RequestedCapabilities.length} requested permissions', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Permissions: ${granted.length} of ' - '${t4RequestedCapabilities.length} granted', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), - ), - if (missing.isNotEmpty) - Text( - 'Not granted: ${missing.join(', ')}', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colors.error), - ), - ], - ), - ); - } -} - -final class _HostForm extends StatefulWidget { - const _HostForm({ - required this.actions, - required this.operationPending, - required this.submitLabel, - required this.clearOnSuccess, - }); - - final T4Actions actions; - final bool operationPending; - final String submitLabel; - final bool clearOnSuccess; - - @override - State<_HostForm> createState() => _HostFormState(); -} - -final class _HostFormState extends State<_HostForm> { - final TextEditingController _addressController = TextEditingController(); - final TextEditingController _profileController = TextEditingController(); - final FocusNode _profileFocusNode = FocusNode(debugLabel: 'Host profile ID'); - String? _errorMessage; - bool _submitting = false; - - bool get _pending => widget.operationPending || _submitting; - - Future _submit() async { - if (_pending) return; - setState(() { - _submitting = true; - _errorMessage = null; - }); - try { - final profileId = _profileController.text; - if (profileId.trim().isEmpty) { - await widget.actions.addHost(_addressController.text); - } else { - await widget.actions.addHost( - _addressController.text, - profileId: profileId, - ); - } - if (!mounted) return; - if (widget.clearOnSuccess) { - _addressController.clear(); - _profileController.clear(); - } - } on Object catch (error) { - if (!mounted) return; - setState( - () => _errorMessage = _hostActionError( - error, - 'Could not add this host. Check the address and try again.', - ), - ); - } finally { - if (mounted) setState(() => _submitting = false); - } - } - - @override - void dispose() { - widget.actions.cancelHostProbe(); - _addressController.dispose(); - _profileController.dispose(); - _profileFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final error = _errorMessage; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - textField: true, - label: 'Tailnet HTTPS address', - child: TextField( - controller: _addressController, - enabled: !_pending, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.next, - autofillHints: const [AutofillHints.url], - onSubmitted: (_) => _profileFocusNode.requestFocus(), - decoration: const InputDecoration( - labelText: 'Tailnet HTTPS address', - hintText: 'https://your-host.your-tailnet.ts.net', - ), - ), - ), - const SizedBox(height: _T4Space.md), - Semantics( - textField: true, - label: 'Profile ID, optional', - child: TextField( - controller: _profileController, - focusNode: _profileFocusNode, - enabled: !_pending, - textInputAction: TextInputAction.done, - onSubmitted: (_) => unawaited(_submit()), - decoration: const InputDecoration( - labelText: 'Profile ID (optional)', - hintText: 'default', - ), - ), - ), - if (error != null) ...[ - const SizedBox(height: _T4Space.sm), - Semantics( - liveRegion: true, - label: 'Host error: $error', - child: Text( - error, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ], - const SizedBox(height: _T4Space.lg), - Semantics( - button: true, - label: _pending ? 'Adding host' : widget.submitLabel, - child: FilledButton( - onPressed: _pending ? null : () => unawaited(_submit()), - child: _pending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Adding host', - ), - ) - : Text(widget.submitLabel), - ), - ), - ], - ); - } -} - -final class _PairingPane extends StatefulWidget { - const _PairingPane({required this.state, required this.actions}); - - final T4ViewState state; - final T4Actions actions; - - @override - State<_PairingPane> createState() => _PairingPaneState(); -} - -final class _PairingPaneState extends State<_PairingPane> { - final TextEditingController _codeController = TextEditingController(); - final FocusNode _focusNode = FocusNode(debugLabel: 'Pairing code'); - bool _hasCompleteCode = false; - bool _submitting = false; - String? _errorMessage; - bool _copiedCommand = false; - - bool get _pending => - widget.state.authenticationPhase == AuthenticationPhase.pairing || - widget.state.hostOperationPending || - _submitting; - - @override - void initState() { - super.initState(); - _codeController.addListener(_handleCodeChanged); - } - - void _handleCodeChanged() { - final complete = _codeController.text.length == _pairingCodeLength; - if (complete == _hasCompleteCode) return; - setState(() => _hasCompleteCode = complete); - } - - Future _submit() async { - if (_pending || !_hasCompleteCode) return; - final code = _codeController.text; - _codeController.clear(); - setState(() { - _submitting = true; - _errorMessage = null; - }); - try { - await widget.actions.pairHost(code); - } on Object { - if (!mounted) return; - setState( - () => _errorMessage = 'Pairing failed. Check the code and try again.', - ); - _focusNode.requestFocus(); - } finally { - if (mounted) setState(() => _submitting = false); - } - } - - Future _copyPairCommand() async { - await Clipboard.setData(ClipboardData(text: t4PairCommand)); - if (!mounted) return; - setState(() => _copiedCommand = true); - } - - @override - void dispose() { - _codeController - ..removeListener(_handleCodeChanged) - ..dispose(); - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final error = - _errorMessage ?? - (widget.state.authenticationPhase == AuthenticationPhase.pairingRequired - ? widget.state.errorMessage - : null); - return SafeArea( - top: false, - child: SingleChildScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.all(_T4Space.lg), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Pair this device', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: _T4Space.sm), - Text( - 'Enter the six-digit code shown by T4 on your computer.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - Text( - 'On the host computer, run this command to create a ' - 'one-time code:', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: _T4Space.sm), - DecoratedBox( - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(_T4Radius.md), - ), - child: Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: SelectableText( - t4PairCommand, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - fontFamily: _T4Typography.monoFamily, - ), - ), - ), - IconButton( - onPressed: _copyPairCommand, - tooltip: 'Copy pair command', - icon: Icon( - _copiedCommand ? Icons.check : Icons.copy, - ), - ), - ], - ), - ), - ), - Semantics( - liveRegion: true, - child: Text( - _copiedCommand ? 'Pair command copied.' : '', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - const SizedBox(height: _T4Space.lg), - const SizedBox(height: _T4Space.xl), - Semantics( - textField: true, - label: 'Six-digit pairing code', - child: TextField( - controller: _codeController, - focusNode: _focusNode, - enabled: !_pending, - autofocus: true, - obscureText: true, - obscuringCharacter: '•', - keyboardType: TextInputType.number, - textInputAction: TextInputAction.done, - autofillHints: const [AutofillHints.oneTimeCode], - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(_pairingCodeLength), - ], - onSubmitted: (_) => unawaited(_submit()), - decoration: const InputDecoration( - labelText: 'Pairing code', - hintText: '6 digits', - ), - ), - ), - if (error != null) ...[ - const SizedBox(height: _T4Space.sm), - Semantics( - liveRegion: true, - label: error, - child: Text( - error, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ], - const SizedBox(height: _T4Space.lg), - Semantics( - button: true, - label: _pending ? 'Pairing device' : 'Pair device', - child: FilledButton( - onPressed: _hasCompleteCode && !_pending - ? () => unawaited(_submit()) - : null, - child: _pending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Pairing device', - ), - ) - : const Text('Pair device'), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/quick_open_dialog.dart b/apps/flutter/lib/src/ui/quick_open_dialog.dart deleted file mode 100644 index 8e271d46..00000000 --- a/apps/flutter/lib/src/ui/quick_open_dialog.dart +++ /dev/null @@ -1,205 +0,0 @@ -part of 't4_app.dart'; - -final class _QuickOpenDialog extends StatefulWidget { - const _QuickOpenDialog({required this.actions}); - - final T4Actions actions; - - @override - State<_QuickOpenDialog> createState() => _QuickOpenDialogState(); -} - -final class _QuickOpenDialogState extends State<_QuickOpenDialog> { - final TextEditingController _queryController = TextEditingController(); - final FocusNode _queryFocus = FocusNode(debugLabel: 'Quick open query'); - Timer? _debounce; - int _requestGeneration = 0; - List _paths = const []; - bool _loading = false; - bool _truncated = false; - String? _error; - - @override - void dispose() { - _debounce?.cancel(); - _queryController.dispose(); - _queryFocus.dispose(); - super.dispose(); - } - - void _scheduleSearch(String value) { - _debounce?.cancel(); - final query = value.trim(); - if (query.isEmpty) { - _requestGeneration += 1; - setState(() { - _paths = const []; - _loading = false; - _truncated = false; - _error = null; - }); - return; - } - _requestGeneration += 1; - setState(() { - _paths = const []; - _loading = true; - _truncated = false; - _error = null; - }); - _debounce = Timer(const Duration(milliseconds: 220), () { - unawaited(_search(query)); - }); - } - - Future _search(String query) async { - final generation = ++_requestGeneration; - setState(() { - _loading = true; - _error = null; - }); - try { - final result = await widget.actions.searchProjectFiles(query); - if (!mounted || generation != _requestGeneration) return; - setState(() { - _paths = result.paths; - _truncated = result.truncated; - _loading = false; - }); - } on Object { - if (!mounted || generation != _requestGeneration) return; - setState(() { - _paths = const []; - _truncated = false; - _loading = false; - _error = 'Project search failed. Try again.'; - }); - } - } - - @override - Widget build(BuildContext context) { - final query = _queryController.text.trim(); - return Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 620, maxHeight: 560), - child: Padding( - padding: const EdgeInsets.all(_T4Space.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Quick open', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - IconButton( - onPressed: () => Navigator.pop(context), - tooltip: 'Close quick open', - icon: const Icon(Icons.close), - ), - ], - ), - const SizedBox(height: _T4Space.sm), - TextField( - controller: _queryController, - focusNode: _queryFocus, - autofocus: true, - onChanged: _scheduleSearch, - onSubmitted: (value) { - _debounce?.cancel(); - final normalized = value.trim(); - if (normalized.isNotEmpty) unawaited(_search(normalized)); - }, - decoration: const InputDecoration( - prefixIcon: Icon(Icons.search), - labelText: 'Find a project file', - hintText: 'Type part of a file name or path', - ), - ), - const SizedBox(height: _T4Space.sm), - if (_loading) const LinearProgressIndicator(), - if (_error case final error?) ...[ - const SizedBox(height: _T4Space.sm), - Text( - error, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - Flexible( - child: query.isEmpty - ? const _QuickOpenMessage( - icon: Icons.keyboard_outlined, - message: 'Start typing to search this project.', - ) - : !_loading && _error == null && _paths.isEmpty - ? const _QuickOpenMessage( - icon: Icons.search_off_outlined, - message: 'No matching project files.', - ) - : ListView.builder( - shrinkWrap: true, - itemCount: _paths.length, - itemBuilder: (context, index) { - final path = _paths[index]; - return ListTile( - leading: const Icon( - Icons.insert_drive_file_outlined, - ), - title: Text( - path.split('/').last, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - path, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: () => Navigator.pop(context, path), - ); - }, - ), - ), - if (_truncated) - const Padding( - padding: EdgeInsets.only(top: _T4Space.sm), - child: Text( - 'More matches exist. Keep typing to narrow the list.', - ), - ), - ], - ), - ), - ), - ); - } -} - -final class _QuickOpenMessage extends StatelessWidget { - const _QuickOpenMessage({required this.icon, required this.message}); - - final IconData icon; - final String message; - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(_T4Space.xl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: _T4Space.sm), - Text(message, textAlign: TextAlign.center), - ], - ), - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/session_navigation.dart b/apps/flutter/lib/src/ui/session_navigation.dart deleted file mode 100644 index 40df6866..00000000 --- a/apps/flutter/lib/src/ui/session_navigation.dart +++ /dev/null @@ -1,899 +0,0 @@ -part of 't4_app.dart'; - -enum _SessionNavigationMode { rail, drawer } - -enum _SessionListView { current, archived } - -extension on ConnectionPhase { - String get label => switch (this) { - ConnectionPhase.disconnected => 'Disconnected', - ConnectionPhase.connecting => 'Connecting', - ConnectionPhase.synchronizing => 'Synchronizing', - ConnectionPhase.ready => 'Ready', - ConnectionPhase.retrying => 'Retrying', - ConnectionPhase.failed => 'Connection failed', - }; - - bool get isActive => switch (this) { - ConnectionPhase.connecting || - ConnectionPhase.synchronizing || - ConnectionPhase.retrying => true, - ConnectionPhase.disconnected || - ConnectionPhase.ready || - ConnectionPhase.failed => false, - }; - - bool get canDisconnect => switch (this) { - ConnectionPhase.disconnected || ConnectionPhase.failed => false, - ConnectionPhase.connecting || - ConnectionPhase.synchronizing || - ConnectionPhase.ready || - ConnectionPhase.retrying => true, - }; - - String get actionLabel => canDisconnect - ? 'Disconnect' - : switch (this) { - ConnectionPhase.disconnected => 'Connect', - ConnectionPhase.failed => 'Retry', - ConnectionPhase.connecting || - ConnectionPhase.synchronizing || - ConnectionPhase.ready || - ConnectionPhase.retrying => throw StateError( - 'disconnectable phase has no connection action', - ), - }; -} - -String _displaySessionTitle(SessionSummary? session) { - final title = session?.title.trim(); - return title == null || title.isEmpty ? 'No session selected' : title; -} - -final class _SessionNavigation extends StatefulWidget { - const _SessionNavigation({ - required this.state, - required this.actions, - required this.mode, - required this.connecting, - required this.selectingSessionId, - required this.disconnecting, - required this.showingHostManager, - required this.onConnect, - required this.onDisconnect, - required this.onManageHosts, - required this.onSelectSession, - this.onClose, - }); - - final T4ViewState state; - final T4Actions actions; - final _SessionNavigationMode mode; - final bool disconnecting; - final bool connecting; - final String? selectingSessionId; - final bool showingHostManager; - final Future Function() onDisconnect; - final Future Function() onConnect; - final VoidCallback onManageHosts; - final Future Function(String sessionId) onSelectSession; - final VoidCallback? onClose; - - @override - State<_SessionNavigation> createState() => _SessionNavigationState(); -} - -final class _SessionNavigationState extends State<_SessionNavigation> { - final TextEditingController _searchController = TextEditingController(); - _SessionListView _view = _SessionListView.current; - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - Future _createSession() async { - final projects = {}; - for (final session in widget.state.sessions) { - projects.putIfAbsent(session.projectId, () => session.projectName); - } - if (projects.isEmpty) return; - final created = await showDialog( - context: context, - builder: (context) => - _CreateSessionDialog(actions: widget.actions, projects: projects), - ); - if (created == true && widget.mode == _SessionNavigationMode.drawer) { - widget.onClose?.call(); - } - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final activeProfile = widget.state.hostDirectory.activeProfile; - final query = _searchController.text.trim().toLowerCase(); - final visible = widget.state.sessions - .where((session) { - if (session.archived != (_view == _SessionListView.archived)) { - return false; - } - if (query.isEmpty) return true; - return session.title.toLowerCase().contains(query) || - session.projectName.toLowerCase().contains(query); - }) - .toList(growable: false); - final groups = >{}; - for (final session in visible) { - groups - .putIfAbsent(session.projectId, () => []) - .add(session); - } - final canCreate = - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('sessions.manage') && - !widget.state.sessionOperationPending && - widget.state.sessions.isNotEmpty; - - return Material( - color: widget.mode == _SessionNavigationMode.rail - ? scheme.surfaceContainerLowest - : scheme.surface, - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.xs, - _T4Space.xs, - ), - child: Row( - children: [ - Expanded( - child: Text( - widget.mode == _SessionNavigationMode.rail - ? 'T4' - : 'Navigation', - style: Theme.of(context).textTheme.titleSmall, - ), - ), - if (widget.onClose case final close?) - IconButton( - onPressed: close, - tooltip: 'Close navigation', - icon: const Icon(Icons.close), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - 0, - _T4Space.md, - _T4Space.xxs, - ), - child: Align( - alignment: Alignment.centerLeft, - child: _HostChip( - phase: widget.state.connectionPhase, - hostLabel: activeProfile?.label ?? 'No host', - actionPending: widget.connecting || widget.disconnecting, - showingHostManager: widget.showingHostManager, - onConnect: widget.onConnect, - onDisconnect: widget.onDisconnect, - onManageHosts: widget.onManageHosts, - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.xs, - 0, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'SESSIONS', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), - IconButton( - onPressed: canCreate - ? () => unawaited(_createSession()) - : null, - tooltip: canCreate - ? 'New session' - : 'Connect with session management permission to create', - icon: const Icon(Icons.add), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: _T4Space.md), - child: TextField( - controller: _searchController, - onChanged: (_) => setState(() {}), - textInputAction: TextInputAction.search, - decoration: InputDecoration( - hintText: 'Search sessions', - prefixIcon: const Icon(Icons.search), - suffixIcon: query.isEmpty - ? null - : IconButton( - onPressed: () { - _searchController.clear(); - setState(() {}); - }, - tooltip: 'Clear search', - icon: const Icon(Icons.close), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.md, - _T4Space.xs, - ), - child: SegmentedButton<_SessionListView>( - segments: const [ - ButtonSegment( - value: _SessionListView.current, - label: Text('Current'), - ), - ButtonSegment( - value: _SessionListView.archived, - label: Text('Archived'), - ), - ], - selected: <_SessionListView>{_view}, - showSelectedIcon: false, - onSelectionChanged: (selection) { - setState(() => _view = selection.single); - }, - ), - ), - Expanded( - child: Semantics( - label: 'Sessions', - explicitChildNodes: true, - child: visible.isEmpty - ? _EmptySessions( - phase: widget.state.connectionPhase, - filtered: widget.state.sessions.isNotEmpty, - ) - : ListView( - padding: const EdgeInsets.fromLTRB( - _T4Space.xs, - 0, - _T4Space.xs, - _T4Space.md, - ), - children: [ - for (final entry in groups.entries) ...[ - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.sm, - _T4Space.xs, - _T4Space.sm, - _T4Space.xxs, - ), - child: Text( - entry.value.first.projectName.toUpperCase(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: scheme.onSurfaceVariant, - letterSpacing: 0.6, - ), - ), - ), - for (final session in entry.value) - Padding( - padding: const EdgeInsets.only( - bottom: _T4Space.xxs, - ), - child: _SessionTile( - session: session, - state: widget.state, - actions: widget.actions, - selected: - session.sessionId == - widget.state.selectedSessionId, - pending: - session.sessionId == - widget.selectingSessionId, - onTap: () => - widget.onSelectSession(session.sessionId), - ), - ), - ], - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -final class _HostChip extends StatelessWidget { - const _HostChip({ - required this.phase, - required this.hostLabel, - required this.actionPending, - required this.showingHostManager, - required this.onConnect, - required this.onDisconnect, - required this.onManageHosts, - }); - - final ConnectionPhase phase; - final String hostLabel; - final bool actionPending; - final bool showingHostManager; - final Future Function() onConnect; - final Future Function() onDisconnect; - final VoidCallback onManageHosts; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final semantic = T4SemanticColors.of(context); - final dotColor = switch (phase) { - ConnectionPhase.ready => semantic.statusDone, - ConnectionPhase.connecting || - ConnectionPhase.synchronizing || - ConnectionPhase.retrying => semantic.statusWorking, - ConnectionPhase.failed => semantic.statusError, - ConnectionPhase.disconnected => scheme.outline, - }; - final action = phase.canDisconnect ? onDisconnect : onConnect; - - return Semantics( - container: true, - label: 'Connection status: ${phase.label}', - child: MenuAnchor( - menuChildren: [ - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.sm, - _T4Space.xs, - _T4Space.sm, - _T4Space.xxs, - ), - child: Text( - '${phase.label} · $hostLabel', - style: theme.textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), - MenuItemButton( - onPressed: onManageHosts, - leadingIcon: const Icon(Icons.dns_outlined), - child: const Text('Manage hosts'), - ), - MenuItemButton( - onPressed: actionPending ? null : () => unawaited(action()), - style: phase.canDisconnect - ? MenuItemButton.styleFrom(foregroundColor: scheme.error) - : null, - child: Text(actionPending ? 'Working…' : phase.actionLabel), - ), - ], - builder: (context, controller, _) => Tooltip( - message: 'Host menu', - child: Semantics( - button: true, - child: InkWell( - borderRadius: BorderRadius.circular(_T4Radius.lg), - onTap: () => - controller.isOpen ? controller.close() : controller.open(), - child: Ink( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.sm, - vertical: _T4Space.xxs, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(_T4Radius.lg), - border: Border.all( - color: showingHostManager - ? scheme.primary - : scheme.outlineVariant, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.circle, size: 6, color: dotColor), - const SizedBox(width: _T4Space.xs), - Flexible( - child: Text( - hostLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ), - const SizedBox(width: _T4Space.xxs), - Icon( - Icons.expand_more, - size: _T4Size.indicator, - color: scheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -final class _EmptySessions extends StatelessWidget { - const _EmptySessions({required this.phase, this.filtered = false}); - - final ConnectionPhase phase; - final bool filtered; - - @override - Widget build(BuildContext context) { - final ready = phase == ConnectionPhase.ready; - return Center( - child: Padding( - padding: const EdgeInsets.all(_T4Space.lg), - child: Text( - filtered - ? 'No matching sessions.' - : ready - ? 'No sessions are available.' - : 'Connect to load your sessions.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ); - } -} - -final class _SessionTile extends StatefulWidget { - const _SessionTile({ - required this.session, - required this.state, - required this.actions, - required this.selected, - required this.pending, - required this.onTap, - }); - - final SessionSummary session; - final T4ViewState state; - final T4Actions actions; - final bool selected; - final bool pending; - final Future Function() onTap; - - @override - State<_SessionTile> createState() => _SessionTileState(); -} - -enum _SessionAction { rename, terminate, archive, restore, delete } - -final class _SessionTileState extends State<_SessionTile> { - bool _hovered = false; - - bool get _canManage => - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('sessions.manage') && - !widget.state.sessionOperationPending; - - Future _run(_SessionAction action) async { - try { - switch (action) { - case _SessionAction.rename: - await _rename(); - case _SessionAction.terminate: - if (await _confirm( - title: 'Terminate runtime?', - message: - 'This stops the running agent for “${widget.session.title}”. ' - 'The session and transcript remain available.', - actionLabel: 'Terminate', - )) { - await widget.actions.terminateSession(widget.session.sessionId); - } - case _SessionAction.archive: - await widget.actions.archiveSession(widget.session.sessionId); - case _SessionAction.restore: - await widget.actions.restoreSession(widget.session.sessionId); - case _SessionAction.delete: - if (await _confirmDelete()) { - await widget.actions.deleteSession(widget.session.sessionId); - } - } - } on Object catch (error) { - if (!mounted) return; - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar(content: Text('Session action failed: $error')), - ); - } - } - - Future _rename() async { - final controller = TextEditingController(text: widget.session.title); - final title = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Rename session'), - content: TextField( - controller: controller, - autofocus: true, - maxLength: 512, - decoration: const InputDecoration(labelText: 'Session title'), - onSubmitted: (value) { - if (value.trim().isNotEmpty) { - Navigator.of(context).pop(value.trim()); - } - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final value = controller.text.trim(); - if (value.isNotEmpty) Navigator.of(context).pop(value); - }, - child: const Text('Rename'), - ), - ], - ), - ); - controller.dispose(); - if (title != null && title != widget.session.title) { - await widget.actions.renameSession(widget.session.sessionId, title); - } - } - - Future _confirm({ - required String title, - required String message, - required String actionLabel, - }) async { - return await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text(actionLabel), - ), - ], - ), - ) ?? - false; - } - - Future _confirmDelete() async { - final controller = TextEditingController(); - final confirmationText = widget.session.title.trim().isEmpty - ? 'delete' - : widget.session.title; - var valid = false; - final confirmed = await showDialog( - context: context, - builder: (context) => StatefulBuilder( - builder: (context, setDialogState) => AlertDialog( - title: const Text('Permanently delete session?'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'This permanently deletes the session and its transcript. ' - 'This cannot be undone.', - ), - const SizedBox(height: _T4Space.md), - Text('Type “$confirmationText” to confirm.'), - const SizedBox(height: _T4Space.xs), - TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration(labelText: 'Session title'), - onChanged: (value) { - setDialogState( - () => valid = value.trim() == confirmationText, - ); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: valid ? () => Navigator.of(context).pop(true) : null, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, - ), - child: const Text('Delete permanently'), - ), - ], - ), - ), - ); - controller.dispose(); - return confirmed ?? false; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final session = widget.session; - final title = session.title.trim().isEmpty - ? 'Untitled session' - : session.title; - final status = session.status.trim().isEmpty ? 'idle' : session.status; - final canArchiveOrDelete = _canManage && !session.working; - final touch = switch (theme.platform) { - TargetPlatform.android || - TargetPlatform.iOS || - TargetPlatform.fuchsia => true, - TargetPlatform.linux || - TargetPlatform.macOS || - TargetPlatform.windows => false, - }; - final showMenu = touch || _hovered || widget.selected; - final dotColor = session.archived || session.status == 'closed' - ? scheme.onSurfaceVariant.withValues(alpha: 0.5) - : session.working - ? scheme.primary - : scheme.outlineVariant; - - return Semantics( - button: true, - selected: widget.selected, - label: - '$title, ${session.projectName}, ' - '${session.archived ? 'archived, ' : ''}${session.status}', - child: MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: ListTile( - selected: widget.selected, - selectedTileColor: scheme.secondaryContainer, - title: Tooltip( - message: '$title — $status', - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith(fontSize: 13), - ), - ), - trailing: widget.pending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Selecting session', - ), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (session.working) - SizedBox.square( - dimension: 12, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - color: scheme.primary, - semanticsLabel: 'Session working', - ), - ) - else - Icon(Icons.circle, size: 6, color: dotColor), - Visibility( - visible: showMenu, - maintainSize: true, - maintainAnimation: true, - maintainState: true, - child: PopupMenuButton<_SessionAction>( - tooltip: 'Session actions', - enabled: _canManage, - onSelected: (action) => unawaited(_run(action)), - itemBuilder: (context) => [ - if (!session.archived) - const PopupMenuItem( - value: _SessionAction.rename, - child: Text('Rename'), - ), - if (!session.archived && session.status != 'closed') - const PopupMenuItem( - value: _SessionAction.terminate, - child: Text('Terminate runtime'), - ), - if (!session.archived) - PopupMenuItem( - value: _SessionAction.archive, - enabled: canArchiveOrDelete, - child: Text( - session.working - ? 'Archive (terminate runtime first)' - : 'Archive', - ), - ), - if (session.archived) - const PopupMenuItem( - value: _SessionAction.restore, - child: Text('Restore'), - ), - PopupMenuItem( - value: _SessionAction.delete, - enabled: canArchiveOrDelete, - child: Text( - session.working - ? 'Delete (terminate runtime first)' - : 'Delete permanently', - ), - ), - ], - icon: widget.selected - ? const Icon(Icons.more_horiz) - : const Icon(Icons.more_vert), - ), - ), - ], - ), - onTap: widget.pending ? null : () => unawaited(widget.onTap()), - ), - ), - ); - } -} - -final class _CreateSessionDialog extends StatefulWidget { - const _CreateSessionDialog({required this.actions, required this.projects}); - - final T4Actions actions; - final Map projects; - - @override - State<_CreateSessionDialog> createState() => _CreateSessionDialogState(); -} - -final class _CreateSessionDialogState extends State<_CreateSessionDialog> { - final TextEditingController _titleController = TextEditingController(); - late String _projectId = widget.projects.keys.first; - bool _pending = false; - String? _error; - - @override - void dispose() { - _titleController.dispose(); - super.dispose(); - } - - Future _submit() async { - if (_pending) return; - setState(() { - _pending = true; - _error = null; - }); - try { - await widget.actions.createSession( - _projectId, - title: _titleController.text, - ); - if (mounted) Navigator.of(context).pop(true); - } on Object catch (error) { - if (!mounted) return; - setState(() { - _pending = false; - _error = 'Could not create session: $error'; - }); - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('New session'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DropdownButtonFormField( - initialValue: _projectId, - isExpanded: true, - decoration: const InputDecoration(labelText: 'Project'), - items: [ - for (final entry in widget.projects.entries) - DropdownMenuItem( - value: entry.key, - child: Text( - entry.value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - onChanged: _pending - ? null - : (value) { - if (value != null) setState(() => _projectId = value); - }, - ), - const SizedBox(height: _T4Space.md), - TextField( - controller: _titleController, - enabled: !_pending, - autofocus: true, - maxLength: 512, - textInputAction: TextInputAction.done, - decoration: const InputDecoration( - labelText: 'Title', - hintText: 'Session', - ), - onSubmitted: (_) => unawaited(_submit()), - ), - if (_error case final error?) ...[ - const SizedBox(height: _T4Space.xs), - Text( - error, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ], - ], - ), - actions: [ - TextButton( - onPressed: _pending ? null : () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: _pending ? null : () => unawaited(_submit()), - child: Text(_pending ? 'Creating…' : 'Create'), - ), - ], - ); - } -} diff --git a/apps/flutter/lib/src/ui/settings_pane.dart b/apps/flutter/lib/src/ui/settings_pane.dart deleted file mode 100644 index 0ddef5fd..00000000 --- a/apps/flutter/lib/src/ui/settings_pane.dart +++ /dev/null @@ -1,1681 +0,0 @@ -part of 't4_app.dart'; - -const String _settingsReadCapability = 'config.read'; -const String _settingsWriteCapability = 'config.write'; -const String _diagnosticsKind = 't4-code.flutter-diagnostics'; - -enum _SettingsCategory { - appearance('Appearance', Icons.palette_outlined), - host('OMP settings', Icons.tune_outlined), - app('App and runtime', Icons.system_update_alt_outlined), - diagnostics('Diagnostics', Icons.monitor_heart_outlined); - - const _SettingsCategory(this.label, this.icon); - - final String label; - final IconData icon; -} - -/// Builds the allowlisted, redacted diagnostics payload used by the settings UI. -/// -/// This deliberately does not serialize [T4ViewState]. Credentials, raw wire -/// frames, transcripts, terminal contents, and file contents therefore cannot -/// enter the export by accident as new state fields are added. -String buildT4DiagnosticsJson( - T4ViewState state, { - DateTime? generatedAt, - PlatformLifecycleViewState? platformState, -}) { - final capabilities = state.grantedCapabilities.toList()..sort(); - final features = state.grantedFeatures.toList()..sort(); - final settings = >[]; - for (final entry in state.settings.entries) { - final redacted = - entry.sensitive || entry.control == HostSettingControlKind.secret; - final diagnostic = { - 'path': entry.path, - 'section': entry.section, - 'control': entry.control.name, - 'configured': entry.configured, - 'effectiveSource': entry.effectiveSource, - 'restartRequired': entry.restartRequired, - 'available': entry.available, - 'redacted': redacted, - }; - if (!redacted && _isSafeDiagnosticValue(entry.effectiveValue)) { - diagnostic['effectiveValue'] = _copySafeDiagnosticValue( - entry.effectiveValue, - ); - } - settings.add(diagnostic); - } - - final payload = { - 'kind': _diagnosticsKind, - 'schemaVersion': 1, - 'generatedAt': (generatedAt ?? DateTime.now().toUtc()).toIso8601String(), - 'connection': state.connectionPhase.name, - 'authentication': state.authenticationPhase.name, - 'hostLabel': state.hostDirectory.activeProfile?.label, - 'settingsRevision': state.settings.revision, - 'lifecycle': state.lifecyclePhase.name, - 'capabilityNames': capabilities, - 'featureNames': features, - 'protocolIssues': List.of(state.settings.issues), - 'settings': settings, - if (platformState != null) - 'platform': { - 'runtime': { - 'supported': platformState.runtime.supported, - 'available': platformState.runtime.available, - 'definition': platformState.runtime.definition.name, - 'service': platformState.runtime.service.name, - 'issueCode': platformState.runtime.issueCode, - }, - 'update': { - 'supported': platformState.update.supported, - 'currentVersion': platformState.update.currentVersion, - 'phase': platformState.update.phase.name, - 'latestVersion': platformState.update.latestVersion, - 'checkedAt': platformState.update.checkedAt, - 'revision': platformState.update.revision, - 'error': platformState.update.error, - }, - }, - }; - return const JsonEncoder.withIndent(' ').convert(payload); -} - -bool _isSafeDiagnosticValue(Object? value) { - if (value == null || value is bool || value is String) return true; - if (value case final num number) return number.isFinite; - if (value case final List values) { - return values.every(_isSafeDiagnosticValue); - } - if (value case final Map values) { - return values.entries.every( - (entry) => entry.key is String && _isSafeDiagnosticValue(entry.value), - ); - } - return false; -} - -Object? _copySafeDiagnosticValue(Object? value) { - if (value case final List values) { - return values.map(_copySafeDiagnosticValue).toList(growable: false); - } - if (value case final Map values) { - return { - for (final entry in values.entries) - entry.key as String: _copySafeDiagnosticValue(entry.value), - }; - } - return value; -} - -final class _SettingsPane extends StatefulWidget { - const _SettingsPane({ - required this.state, - required this.actions, - required this.showHeader, - required this.platformState, - required this.platformActions, - required this.onDone, - }); - - final T4ViewState state; - final T4Actions actions; - final bool showHeader; - final VoidCallback onDone; - final PlatformLifecycleViewState platformState; - final PlatformLifecycleActions? platformActions; - - @override - State<_SettingsPane> createState() => _SettingsPaneState(); -} - -final class _SettingsPaneState extends State<_SettingsPane> { - final Map _staged = {}; - final Map _scopeByPath = {}; - final Map _validationErrors = {}; - String? _baseRevision; - String? _hostKey; - int _draftGeneration = 0; - bool _saving = false; - bool _refreshing = false; - bool _themePending = false; - late T4ThemePreference _themeSelection; - _SettingsCategory _activeCategory = _SettingsCategory.appearance; - String? _activeHostGroup; - - @override - void initState() { - super.initState(); - _baseRevision = widget.state.settings.revision; - _hostKey = widget.state.hostDirectory.activeProfile?.endpointKey; - _themeSelection = widget.state.themePreference; - if (_canReadHostSettings(widget.state)) unawaited(_refresh(silent: true)); - } - - @override - void didUpdateWidget(covariant _SettingsPane oldWidget) { - super.didUpdateWidget(oldWidget); - final nextHostKey = widget.state.hostDirectory.activeProfile?.endpointKey; - if (nextHostKey != _hostKey) { - _hostKey = nextHostKey; - _activeHostGroup = null; - _discardDrafts(); - _baseRevision = widget.state.settings.revision; - } else if (_staged.isEmpty && !_saving) { - _baseRevision = widget.state.settings.revision; - } - if (!_themePending) _themeSelection = widget.state.themePreference; - } - - bool get _hasConflict => - _staged.isNotEmpty && - _baseRevision != null && - widget.state.settings.revision != null && - _baseRevision != widget.state.settings.revision; - - bool get _operationPending => - _saving || widget.state.settingsOperationPending; - - Future _refresh({bool silent = false}) async { - if (_refreshing) return; - setState(() => _refreshing = true); - try { - await widget.actions.refreshSettings(); - } on Object { - if (!silent && mounted) { - _showMessage('Could not refresh host settings. Try again.'); - } - } finally { - if (mounted) setState(() => _refreshing = false); - } - } - - Future _setTheme(T4ThemePreference preference) async { - if (_themePending || preference == _themeSelection) return; - setState(() { - _themeSelection = preference; - _themePending = true; - }); - try { - await widget.actions.setThemePreference(preference); - } on Object { - if (!mounted) return; - setState(() => _themeSelection = widget.state.themePreference); - _showMessage('Could not update appearance. Try again.'); - } finally { - if (mounted) setState(() => _themePending = false); - } - } - - void _stage(HostSettingEntry entry, {Object? value, bool reset = false}) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - final scope = _scopeByPath[entry.path] ?? entry.writableScopes.first; - setState(() { - _validationErrors.remove(entry.path); - _staged[entry.path] = _StagedSetting( - scope: scope, - value: value, - reset: reset, - ); - }); - } - - void _setValidationError(String path, String? message) { - setState(() { - if (message == null) { - _validationErrors.remove(path); - } else { - _validationErrors[path] = message; - _staged.remove(path); - } - }); - } - - void _undo(String path) { - setState(() { - _staged.remove(path); - _validationErrors.remove(path); - _draftGeneration += 1; - }); - } - - void _discardDrafts() { - setState(() { - _staged.clear(); - _validationErrors.clear(); - _scopeByPath.clear(); - _draftGeneration += 1; - _baseRevision = widget.state.settings.revision; - }); - } - - Future _save() async { - if (_operationPending || _staged.isEmpty || _hasConflict) return; - if (_validationErrors.isNotEmpty) { - _showMessage('Resolve invalid values before saving.'); - return; - } - final changes = List>.of(_staged.entries); - setState(() => _saving = true); - try { - for (final change in changes) { - await widget.actions.writeSetting( - change.key, - change.value.scope, - value: change.value.value, - reset: change.value.reset, - ); - } - if (!mounted) return; - setState(() { - _staged.clear(); - _validationErrors.clear(); - _draftGeneration += 1; - _baseRevision = widget.state.settings.revision; - }); - _showMessage( - 'Saved. Host-scoped changes may still need approval in the inbox.', - ); - } on Object { - if (mounted) _showMessage('Could not save settings. No draft was lost.'); - } finally { - if (mounted) setState(() => _saving = false); - } - } - - Future _exportDiagnostics() async { - final contents = buildT4DiagnosticsJson( - widget.state, - platformState: widget.platformState, - ); - final bytes = Uint8List.fromList(utf8.encode(contents)); - try { - final location = await getSaveLocation( - suggestedName: 't4-diagnostics.json', - acceptedTypeGroups: const [ - XTypeGroup( - label: 'JSON', - extensions: ['json'], - mimeTypes: ['application/json'], - ), - ], - ); - if (location == null) return; - final file = XFile.fromData( - bytes, - mimeType: 'application/json', - name: 't4-diagnostics.json', - ); - await file.saveTo(location.path); - if (mounted) _showMessage('Redacted diagnostics saved.'); - return; - } on Object { - // Save dialogs are not available on every Flutter target. The clipboard - // fallback preserves the same allowlisted payload. - } - - try { - await Clipboard.setData(ClipboardData(text: contents)); - if (mounted) _showMessage('Redacted diagnostics copied to clipboard.'); - } on Object { - if (mounted) _showMessage('Could not export diagnostics on this device.'); - } - } - - Future _runPlatformAction( - Future Function() operation, - String failureMessage, - ) async { - try { - await operation(); - } on Object { - if (mounted) _showMessage(failureMessage); - } - } - - void _showMessage(String message) { - final messenger = ScaffoldMessenger.of(context); - messenger - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - @override - Widget build(BuildContext context) { - final horizontal = widget.showHeader ? _T4Space.xl : _T4Space.md; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.showHeader) _buildHeader(context), - if (widget.state.settings.loading || - _refreshing || - widget.platformState.runtimeOperationPending || - widget.platformState.updateOperationPending) - const LinearProgressIndicator( - semanticsLabel: 'Loading settings and platform status', - ), - Expanded(child: _buildCategoryLayout(context, horizontal)), - if (_staged.isNotEmpty || _validationErrors.isNotEmpty) - _buildSaveBar(context), - ], - ); - } - - Widget _buildCategoryLayout(BuildContext context, double horizontal) { - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth >= _T4Breakpoints.wide) { - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: 240, - child: NavigationRail( - key: const Key('settings-category-rail'), - extended: true, - minExtendedWidth: 240, - groupAlignment: -1, - selectedIndex: _activeCategory.index, - onDestinationSelected: (index) { - setState(() { - _activeCategory = _SettingsCategory.values[index]; - }); - }, - destinations: [ - for (final category in _SettingsCategory.values) - NavigationRailDestination( - icon: Icon(category.icon), - label: Text(category.label), - ), - ], - ), - ), - const VerticalDivider(width: 1), - Expanded( - child: _buildActiveCategory(context, horizontal: horizontal), - ), - ], - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: EdgeInsets.fromLTRB( - horizontal, - _T4Space.md, - horizontal, - 0, - ), - child: DropdownButtonFormField<_SettingsCategory>( - key: const Key('settings-category-picker'), - initialValue: _activeCategory, - decoration: const InputDecoration(labelText: 'Category'), - isExpanded: true, - items: [ - for (final category in _SettingsCategory.values) - DropdownMenuItem( - value: category, - child: Row( - children: [ - Icon(category.icon, size: 20), - const SizedBox(width: _T4Space.sm), - Text(category.label), - ], - ), - ), - ], - onChanged: (category) { - if (category == null || category == _activeCategory) return; - setState(() => _activeCategory = category); - }, - ), - ), - Expanded( - child: _buildActiveCategory(context, horizontal: horizontal), - ), - ], - ); - }, - ); - } - - Widget _buildActiveCategory( - BuildContext context, { - required double horizontal, - }) { - final section = switch (_activeCategory) { - _SettingsCategory.appearance => _buildAppearance(context), - _SettingsCategory.host => _buildHostSettings(context), - _SettingsCategory.app => _buildAppStatus(context), - _SettingsCategory.diagnostics => _buildDiagnostics(context), - }; - return ListView( - key: PageStorageKey( - 'settings-pane-${_activeCategory.name}-scroll', - ), - padding: EdgeInsets.fromLTRB( - horizontal, - _T4Space.lg, - horizontal, - _T4Space.xl, - ), - children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: section, - ), - ), - ], - ); - } - - Widget _buildHeader(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.xl, - _T4Space.lg, - _T4Space.md, - _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Settings', - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - IconButton( - onPressed: widget.onDone, - tooltip: 'Close settings', - icon: const Icon(Icons.close), - ), - ], - ), - ); - } - - Widget _buildAppearance(BuildContext context) { - return _SettingsSection( - title: 'Appearance', - description: 'Choose how T4 follows your device appearance.', - child: Semantics( - label: 'Theme preference', - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: T4ThemePreference.system, - icon: Icon(Icons.brightness_auto_outlined), - label: Text('System'), - ), - ButtonSegment( - value: T4ThemePreference.light, - icon: Icon(Icons.light_mode_outlined), - label: Text('Light'), - ), - ButtonSegment( - value: T4ThemePreference.dark, - icon: Icon(Icons.dark_mode_outlined), - label: Text('Dark'), - ), - ], - selected: {_themeSelection}, - showSelectedIcon: false, - onSelectionChanged: _themePending - ? null - : (selection) => unawaited(_setTheme(selection.single)), - ), - ), - ); - } - - Widget _buildAppStatus(BuildContext context) { - final platform = widget.platformState; - final actions = widget.platformActions; - final runtime = platform.runtime; - final update = platform.update; - final runtimeBusy = platform.runtimeOperationPending; - final updateBusy = platform.updateOperationPending; - final hasNativeSurface = runtime.supported || update.supported; - - return _SettingsSection( - title: 'App and runtime', - description: - 'Inspect app lifecycle, the desktop OMP service, and signed release updates.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _DiagnosticLine( - label: 'App lifecycle', - value: widget.state.lifecyclePhase.name, - ), - if (platform.errorMessage case final error?) - _SettingsNotice( - icon: Icons.error_outline, - title: 'Platform operation failed', - message: error, - error: true, - ), - if (!hasNativeSurface) - const _SettingsNotice( - icon: Icons.info_outline, - title: 'Managed by this platform', - message: - 'Desktop runtime controls and direct update controls are not available on this target.', - ), - if (runtime.supported) ...[ - const Divider(), - Text('OMP runtime', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: _T4Space.xs), - _DiagnosticLine( - label: 'Executable', - value: runtime.available ? 'Compatible' : 'Not available', - ), - _DiagnosticLine(label: 'Service', value: runtime.service.name), - _DiagnosticLine( - label: 'Definition', - value: runtime.definition.name, - ), - if (runtime.message case final message? when message.isNotEmpty) - _SettingsNotice( - icon: runtime.issueCode == null - ? Icons.info_outline - : Icons.warning_amber_outlined, - title: runtime.issueCode == null - ? 'Runtime status' - : 'Runtime needs attention', - message: message, - error: runtime.issueCode != null, - ), - if (runtime.diagnostics.isNotEmpty) - _SettingsNotice( - icon: Icons.terminal_outlined, - title: 'Runtime diagnostic', - message: runtime.diagnostics, - ), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - children: [ - OutlinedButton.icon( - key: const Key('runtime-check'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.refreshPlatformState, - 'Could not inspect the OMP runtime.', - ), - ), - icon: const Icon(Icons.refresh), - label: const Text('Check'), - ), - if (runtime.available && - runtime.definition != RuntimeDefinitionState.current) - FilledButton.icon( - key: const Key('runtime-install'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.installRuntime, - 'Could not install the OMP service.', - ), - ), - icon: const Icon(Icons.install_desktop_outlined), - label: const Text('Install service'), - ), - if (runtime.available && - runtime.definition == RuntimeDefinitionState.current && - runtime.service != RuntimeServicePhase.running && - runtime.service != RuntimeServicePhase.starting) - FilledButton.icon( - key: const Key('runtime-start'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.startRuntime, - 'Could not start the OMP service.', - ), - ), - icon: const Icon(Icons.play_arrow), - label: const Text('Start'), - ), - if (runtime.service == RuntimeServicePhase.running || - runtime.service == RuntimeServicePhase.starting) - OutlinedButton.icon( - key: const Key('runtime-stop'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.stopRuntime, - 'Could not stop the OMP service.', - ), - ), - icon: const Icon(Icons.stop), - label: const Text('Stop'), - ), - if (runtime.service == RuntimeServicePhase.running) - OutlinedButton.icon( - key: const Key('runtime-restart'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.restartRuntime, - 'Could not restart the OMP service.', - ), - ), - icon: const Icon(Icons.restart_alt), - label: const Text('Restart'), - ), - if (runtime.definition != RuntimeDefinitionState.missing) - TextButton.icon( - key: const Key('runtime-uninstall'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.uninstallRuntime, - 'Could not remove the OMP service.', - ), - ), - icon: const Icon(Icons.delete_outline), - label: const Text('Remove service'), - ), - ], - ), - ], - if (update.supported) ...[ - const Divider(), - Text('App updates', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: _T4Space.xs), - _DiagnosticLine( - label: 'Current version', - value: update.currentVersion, - ), - _DiagnosticLine(label: 'Status', value: update.phase.name), - if (update.latestVersion case final version?) - _DiagnosticLine(label: 'Latest version', value: version), - if (update.progressPercent case final progress?) - _DiagnosticLine( - label: 'Download', - value: '${progress.toStringAsFixed(0)}%', - ), - if (update.message case final message? when message.isNotEmpty) - _SettingsNotice( - icon: update.phase == PlatformUpdatePhase.error - ? Icons.error_outline - : Icons.info_outline, - title: update.phase == PlatformUpdatePhase.error - ? 'Update failed' - : 'Update status', - message: message, - error: update.phase == PlatformUpdatePhase.error, - ), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - children: [ - OutlinedButton.icon( - key: const Key('update-check'), - onPressed: actions == null || updateBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.checkForUpdates, - 'Could not check for updates.', - ), - ), - icon: const Icon(Icons.system_update_alt), - label: const Text('Check for updates'), - ), - if (update.phase == PlatformUpdatePhase.available || - update.phase == PlatformUpdatePhase.manual) - FilledButton.icon( - key: const Key('update-download'), - onPressed: actions == null || updateBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.downloadUpdate, - 'Could not download the update.', - ), - ), - icon: const Icon(Icons.download), - label: const Text('Download'), - ), - if (update.phase == PlatformUpdatePhase.installer || - update.phase == PlatformUpdatePhase.manual) - OutlinedButton.icon( - key: const Key('update-install'), - onPressed: actions == null || updateBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.installUpdate, - 'Could not open the update installer.', - ), - ), - icon: const Icon(Icons.install_mobile_outlined), - label: Text( - update.phase == PlatformUpdatePhase.manual - ? 'Installation help' - : 'Install', - ), - ), - ], - ), - ], - ], - ), - ); - } - - Widget _buildHostSettings(BuildContext context) { - final state = widget.state; - final settings = state.settings; - final hasReadPermission = state.grantedCapabilities.contains( - _settingsReadCapability, - ); - final canRead = _canReadHostSettings(state); - final groups = >{}; - for (final entry in settings.entries) { - final group = _hostSettingGroupLabel(entry); - groups.putIfAbsent(group, () => []).add(entry); - } - final selectedGroup = groups.containsKey(_activeHostGroup) - ? _activeHostGroup - : groups.isEmpty - ? null - : groups.keys.first; - final selectedEntries = selectedGroup == null - ? const [] - : groups[selectedGroup]!; - - return _SettingsSection( - title: 'OMP settings', - description: - 'Changes are staged here. Save sends them to the host, where host-scoped changes may require inbox approval.', - trailing: IconButton( - onPressed: _refreshing || !_canReadHostSettings(state) - ? null - : () => unawaited(_refresh()), - tooltip: 'Refresh settings', - icon: const Icon(Icons.refresh), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_hasConflict) - _SettingsNotice( - icon: Icons.sync_problem_outlined, - title: 'Settings changed on the host', - message: - 'Discard the staged changes and review the latest values before saving.', - actionLabel: 'Discard draft', - onAction: _discardDrafts, - ), - if (settings.error case final error?) - _SettingsNotice( - icon: Icons.error_outline, - title: 'Could not load settings', - message: error, - actionLabel: canRead ? 'Retry' : null, - onAction: canRead ? () => unawaited(_refresh()) : null, - error: true, - ), - if (state.connectionPhase != ConnectionPhase.ready) - const _SettingsNotice( - icon: Icons.link_off, - title: 'Host settings unavailable', - message: 'Connect to a host to review and change its settings.', - ) - else if (!hasReadPermission) - const _SettingsNotice( - icon: Icons.lock_outline, - title: 'Permission denied', - message: 'This device was not granted config.read on this host.', - error: true, - ) - else if (!canRead) - const _SettingsNotice( - icon: Icons.tune_outlined, - title: 'Settings metadata unavailable', - message: - 'This host did not grant the catalog and settings metadata required for live controls.', - ) - else if (settings.entries.isEmpty && - !settings.loading && - settings.error == null) - const _SettingsNotice( - icon: Icons.tune_outlined, - title: 'No settings published', - message: 'Refresh after the host publishes its settings catalog.', - ) - else ...[ - DropdownButtonFormField( - key: const Key('host-settings-group-picker'), - initialValue: selectedGroup, - decoration: const InputDecoration(labelText: 'Setting group'), - isExpanded: true, - items: [ - for (final group in groups.entries) - DropdownMenuItem( - value: group.key, - child: Text('${group.key} (${group.value.length})'), - ), - ], - onChanged: (group) { - if (group == null || group == selectedGroup) return; - setState(() => _activeHostGroup = group); - }, - ), - const SizedBox(height: _T4Space.sm), - for (final entry in selectedEntries) ...[ - _buildSettingRow(context, entry), - const Divider(), - ], - ], - ], - ), - ); - } - - Widget _buildSettingRow(BuildContext context, HostSettingEntry entry) { - final scheme = Theme.of(context).colorScheme; - final metadataValid = _settingMetadataIsValid(entry); - final writeGranted = widget.state.grantedCapabilities.contains( - _settingsWriteCapability, - ); - final sensitive = - entry.sensitive || entry.control == HostSettingControlKind.secret; - final canEdit = - entry.available && - entry.writableScopes.isNotEmpty && - writeGranted && - metadataValid && - !sensitive && - entry.control != HostSettingControlKind.unsupported; - final staged = _staged[entry.path]; - final error = _validationErrors[entry.path]; - - final status = switch (( - entry.available, - metadataValid, - writeGranted, - entry.writableScopes.isNotEmpty, - sensitive, - entry.control, - )) { - (false, _, _, _, _, _) => 'Unavailable', - (_, false, _, _, _, _) => 'Invalid metadata', - (_, _, _, _, true, _) => - entry.configured ? 'Configured' : 'Not configured', - (_, _, false, _, _, _) => 'Permission denied', - (_, _, _, false, _, _) => 'Read only', - (_, _, _, _, _, HostSettingControlKind.unsupported) => 'Unsupported', - _ => null, - }; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - entry.label, - style: Theme.of(context).textTheme.titleSmall, - ), - if (entry.help.isNotEmpty) ...[ - const SizedBox(height: _T4Space.xxs), - Text( - entry.help, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - const SizedBox(width: _T4Space.sm), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xxs, - alignment: WrapAlignment.end, - children: [ - if (entry.restartRequired) - const _SettingsStatus( - label: 'Restart required', - warning: true, - ), - if (status != null) - _SettingsStatus( - label: status, - error: - status == 'Permission denied' || - status == 'Invalid metadata', - ), - ], - ), - ], - ), - const SizedBox(height: _T4Space.sm), - if (sensitive) - Text( - entry.configured - ? 'A value is configured. Its contents are hidden.' - : 'No value is configured.', - style: Theme.of(context).textTheme.bodyMedium, - ) - else ...[ - Text( - _effectiveDescription(entry), - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - if (canEdit) ...[ - const SizedBox(height: _T4Space.sm), - if (staged?.reset ?? false) - _SettingsNotice( - icon: Icons.restore, - title: 'Reset staged', - message: - 'Save to remove the configured value and use the inherited value.', - actionLabel: 'Undo', - onAction: () => _undo(entry.path), - ) - else - _buildControl(context, entry, staged), - if (error != null) ...[ - const SizedBox(height: _T4Space.xs), - Text( - error, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.error), - ), - ], - const SizedBox(height: _T4Space.xs), - Row( - children: [ - if (entry.writableScopes.length > 1) - Expanded(child: _buildScopeSelector(entry)), - if (entry.writableScopes.length > 1) - const SizedBox(width: _T4Space.sm), - if (entry.configured && !(staged?.reset ?? false)) - TextButton.icon( - onPressed: _operationPending - ? null - : () => _stage(entry, reset: true), - icon: const Icon(Icons.restore), - label: const Text('Reset'), - ), - if (staged != null && !staged.reset) - TextButton( - onPressed: _operationPending - ? null - : () => _undo(entry.path), - child: const Text('Undo'), - ), - ], - ), - ] else if (status != null) ...[ - const SizedBox(height: _T4Space.xs), - Text( - _disabledReason(status), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ], - ], - ], - ), - ); - } - - Widget _buildControl( - BuildContext context, - HostSettingEntry entry, - _StagedSetting? staged, - ) { - final current = staged?.value ?? entry.effectiveValue; - final fieldKey = ValueKey( - 'setting-${entry.path}-$_draftGeneration', - ); - switch (entry.control) { - case HostSettingControlKind.boolean: - final enabled = current is bool && current; - return Semantics( - label: entry.label, - child: Row( - children: [ - Expanded(child: Text(enabled ? 'Enabled' : 'Disabled')), - Switch( - key: Key('setting-control-${entry.path}'), - value: enabled, - onChanged: _operationPending - ? null - : (value) => _stage(entry, value: value), - ), - ], - ), - ); - case HostSettingControlKind.number: - return TextFormField( - key: fieldKey, - initialValue: current?.toString() ?? '', - enabled: !_operationPending, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, - ), - decoration: InputDecoration( - labelText: entry.unit == null - ? entry.label - : '${entry.label} (${entry.unit})', - ), - onChanged: (raw) { - final value = num.tryParse(raw.trim()); - if (value == null) { - _setValidationError(entry.path, 'Enter a valid number.'); - } else if (entry.min case final minimum? when value < minimum) { - _setValidationError(entry.path, 'Enter ${entry.min} or greater.'); - } else if (entry.max case final maximum? when value > maximum) { - _setValidationError(entry.path, 'Enter ${entry.max} or less.'); - } else { - _stage(entry, value: value); - } - }, - ); - case HostSettingControlKind.text: - return TextFormField( - key: fieldKey, - initialValue: current as String? ?? '', - enabled: !_operationPending, - decoration: InputDecoration(labelText: entry.label), - onChanged: (value) => _stage(entry, value: value), - ); - case HostSettingControlKind.enumeration: - final selected = - current is String && - entry.options.any((option) => option.value == current) - ? current - : null; - return DropdownButtonFormField( - key: fieldKey, - initialValue: selected, - isExpanded: true, - decoration: InputDecoration(labelText: entry.label), - items: [ - for (final option in entry.options) - DropdownMenuItem( - value: option.value, - child: Text(option.label, overflow: TextOverflow.ellipsis), - ), - ], - onChanged: _operationPending - ? null - : (value) { - if (value != null) _stage(entry, value: value); - }, - ); - case HostSettingControlKind.list: - case HostSettingControlKind.map: - return TextFormField( - key: fieldKey, - initialValue: _jsonEditorValue(current, entry.control), - enabled: !_operationPending, - minLines: 2, - maxLines: 6, - keyboardType: TextInputType.multiline, - decoration: InputDecoration( - labelText: entry.control == HostSettingControlKind.list - ? '${entry.label} (JSON list)' - : '${entry.label} (JSON object)', - alignLabelWithHint: true, - ), - onChanged: (raw) { - try { - final value = jsonDecode(raw); - final valid = entry.control == HostSettingControlKind.list - ? value is List - : value is Map; - if (!valid || !_isSafeDiagnosticValue(value)) { - _setValidationError( - entry.path, - entry.control == HostSettingControlKind.list - ? 'Enter a valid JSON list.' - : 'Enter a valid JSON object.', - ); - return; - } - _stage(entry, value: _copySafeDiagnosticValue(value)); - } on FormatException { - _setValidationError( - entry.path, - entry.control == HostSettingControlKind.list - ? 'Enter a valid JSON list.' - : 'Enter a valid JSON object.', - ); - } - }, - ); - case HostSettingControlKind.secret: - case HostSettingControlKind.unsupported: - return const SizedBox.shrink(); - } - } - - Widget _buildScopeSelector(HostSettingEntry entry) { - final scope = _scopeByPath[entry.path] ?? entry.writableScopes.first; - return DropdownButtonFormField( - initialValue: scope, - isExpanded: true, - decoration: const InputDecoration(labelText: 'Write scope'), - items: [ - for (final candidate in entry.writableScopes) - DropdownMenuItem(value: candidate, child: Text(candidate)), - ], - onChanged: _operationPending - ? null - : (value) { - if (value == null) return; - setState(() { - _scopeByPath[entry.path] = value; - final staged = _staged[entry.path]; - if (staged != null) { - _staged[entry.path] = staged.withScope(value); - } - }); - }, - ); - } - - Widget _buildDiagnostics(BuildContext context) { - final state = widget.state; - final profile = state.hostDirectory.activeProfile; - final capabilities = state.grantedCapabilities.toList()..sort(); - final features = state.grantedFeatures.toList()..sort(); - return _SettingsSection( - title: 'Diagnostics', - description: - 'Safe connection metadata for troubleshooting. Exports are allowlisted and redact sensitive setting values.', - trailing: OutlinedButton.icon( - key: const Key('export-diagnostics'), - onPressed: _exportDiagnostics, - icon: const Icon(Icons.ios_share_outlined), - label: const Text('Export'), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _DiagnosticLine( - label: 'Connection', - value: state.connectionPhase.label, - ), - _DiagnosticLine( - label: 'Authentication', - value: state.authenticationPhase.label, - ), - _DiagnosticLine( - label: 'Host', - value: profile?.label ?? 'Not configured', - ), - _DiagnosticLine( - label: 'Settings revision', - value: state.settings.revision ?? 'Not received', - ), - _DiagnosticLine(label: 'Lifecycle', value: state.lifecyclePhase.name), - const SizedBox(height: _T4Space.md), - Text('Capabilities', style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: _T4Space.xs), - _NameWrap(names: capabilities, emptyLabel: 'None granted'), - const SizedBox(height: _T4Space.md), - Text( - 'Negotiated features', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: _T4Space.xs), - _NameWrap(names: features, emptyLabel: 'None published'), - const SizedBox(height: _T4Space.md), - Text( - 'Protocol issues', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: _T4Space.xs), - if (state.settings.issues.isEmpty) - Text( - 'No protocol issues reported.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ) - else - for (final issue in state.settings.issues) - Padding( - padding: const EdgeInsets.only(bottom: _T4Space.xs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.warning_amber_outlined, size: _T4Space.lg), - const SizedBox(width: _T4Space.xs), - Expanded(child: Text(issue)), - ], - ), - ), - ], - ), - ); - } - - Widget _buildSaveBar(BuildContext context) { - final count = _staged.length; - final scheme = Theme.of(context).colorScheme; - return Material( - color: scheme.surfaceContainer, - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Divider(), - Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - _hasConflict - ? 'Review the host conflict before saving.' - : _validationErrors.isNotEmpty - ? 'Resolve invalid values before saving.' - : '$count staged ${count == 1 ? 'change' : 'changes'}', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - TextButton( - onPressed: _operationPending ? null : _discardDrafts, - child: const Text('Discard'), - ), - const SizedBox(width: _T4Space.xs), - FilledButton.icon( - key: const Key('save-settings'), - onPressed: - _operationPending || - _hasConflict || - _validationErrors.isNotEmpty || - count == 0 - ? null - : () => unawaited(_save()), - icon: _operationPending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - ), - ) - : const Icon(Icons.save_outlined), - label: const Text('Save'), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ); - } -} - -final class _StagedSetting { - const _StagedSetting({ - required this.scope, - required this.value, - required this.reset, - }); - - final String scope; - final Object? value; - final bool reset; - - _StagedSetting withScope(String scope) => - _StagedSetting(scope: scope, value: value, reset: reset); -} - -final class _SettingsSection extends StatelessWidget { - const _SettingsSection({ - required this.title, - required this.description, - required this.child, - this.trailing, - }); - - final String title; - final String description; - final Widget child; - final Widget? trailing; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Semantics( - container: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: _T4Space.xxs), - Text( - description, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - if (trailing case final widget?) ...[ - const SizedBox(width: _T4Space.sm), - widget, - ], - ], - ), - const SizedBox(height: _T4Space.md), - child, - ], - ), - ); - } -} - -final class _SettingsNotice extends StatelessWidget { - const _SettingsNotice({ - required this.icon, - required this.title, - required this.message, - this.actionLabel, - this.onAction, - this.error = false, - }); - - final IconData icon; - final String title; - final String message; - final String? actionLabel; - final VoidCallback? onAction; - final bool error; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final background = error ? scheme.errorContainer : scheme.surfaceContainer; - final foreground = error ? scheme.onErrorContainer : scheme.onSurface; - return Padding( - padding: const EdgeInsets.only(bottom: _T4Space.sm), - child: Material( - color: background, - borderRadius: BorderRadius.circular(_T4Radius.sm), - child: Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, color: foreground), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(color: foreground), - ), - const SizedBox(height: _T4Space.xxs), - Text( - message, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: foreground), - ), - ], - ), - ), - if (actionLabel != null && onAction != null) ...[ - const SizedBox(width: _T4Space.xs), - TextButton(onPressed: onAction, child: Text(actionLabel!)), - ], - ], - ), - ), - ), - ); - } -} - -final class _SettingsStatus extends StatelessWidget { - const _SettingsStatus({ - required this.label, - this.warning = false, - this.error = false, - }); - - final String label; - final bool warning; - final bool error; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final background = error - ? scheme.errorContainer - : warning - ? scheme.tertiaryContainer - : scheme.secondaryContainer; - final foreground = error - ? scheme.onErrorContainer - : warning - ? scheme.onTertiaryContainer - : scheme.onSecondaryContainer; - return Semantics( - label: label, - child: DecoratedBox( - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - vertical: _T4Space.xxs, - ), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: foreground), - ), - ), - ), - ); - } -} - -final class _DiagnosticLine extends StatelessWidget { - const _DiagnosticLine({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.xs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: Text(label)), - const SizedBox(width: _T4Space.md), - Expanded( - child: Text( - value, - textAlign: TextAlign.end, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ); - } -} - -final class _NameWrap extends StatelessWidget { - const _NameWrap({required this.names, required this.emptyLabel}); - - final List names; - final String emptyLabel; - - @override - Widget build(BuildContext context) { - if (names.isEmpty) { - return Text( - emptyLabel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ); - } - return Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - children: [for (final name in names) Chip(label: Text(name))], - ); - } -} - -bool _canReadHostSettings(T4ViewState state) => - state.connectionPhase == ConnectionPhase.ready && - state.grantedCapabilities.contains('catalog.read') && - state.grantedFeatures.contains('catalog.metadata') && - state.grantedCapabilities.contains(_settingsReadCapability) && - state.grantedFeatures.contains('settings.metadata'); -String _hostSettingGroupLabel(HostSettingEntry entry) { - final separator = entry.label.indexOf(' · '); - if (separator > 0) return entry.label.substring(0, separator); - final section = entry.section.trim(); - if (section.isEmpty) return 'General'; - return '${section[0].toUpperCase()}${section.substring(1)}'; -} - -bool _settingMetadataIsValid(HostSettingEntry entry) { - if (entry.path.trim().isEmpty || - entry.section.trim().isEmpty || - entry.label.trim().isEmpty) { - return false; - } - if (entry.min != null && !entry.min!.isFinite) return false; - if (entry.max != null && !entry.max!.isFinite) return false; - if (entry.min != null && entry.max != null && entry.min! > entry.max!) { - return false; - } - final value = entry.effectiveValue; - return switch (entry.control) { - HostSettingControlKind.boolean => value == null || value is bool, - HostSettingControlKind.number => - value == null || - value is num && - value.isFinite && - (entry.min == null || value >= entry.min!) && - (entry.max == null || value <= entry.max!), - HostSettingControlKind.text => value == null || value is String, - HostSettingControlKind.enumeration => - entry.options.isNotEmpty && - entry.options.every((option) => option.value.isNotEmpty) && - entry.options.map((option) => option.value).toSet().length == - entry.options.length && - (value == null || - value is String && - entry.options.any((option) => option.value == value)), - HostSettingControlKind.list => - (value == null || value is List) && - _isSafeDiagnosticValue(value), - HostSettingControlKind.map => - (value == null || value is Map) && - _isSafeDiagnosticValue(value), - HostSettingControlKind.secret => true, - HostSettingControlKind.unsupported => false, - }; -} - -String _effectiveDescription(HostSettingEntry entry) { - final value = entry.effectiveValue; - final formatted = value == null - ? 'Not set' - : value is String - ? value - : const JsonEncoder.withIndent(' ').convert(value); - final source = entry.effectiveSource; - return source == null - ? 'Effective: $formatted' - : 'Effective: $formatted · Source: $source'; -} - -String _jsonEditorValue(Object? value, HostSettingControlKind control) { - if (value != null) return const JsonEncoder.withIndent(' ').convert(value); - return control == HostSettingControlKind.list ? '[]' : '{}'; -} - -String _disabledReason(String status) => switch (status) { - 'Unavailable' => 'This setting is unavailable on the connected host.', - 'Invalid metadata' => - 'The host published metadata that cannot be edited safely.', - 'Permission denied' => - 'This device was not granted settings.write on this host.', - 'Read only' => 'The host did not publish a writable scope for this setting.', - 'Unsupported' => 'This version of T4 cannot edit the published control type.', - _ => '', -}; diff --git a/apps/flutter/lib/src/ui/t4_app.dart b/apps/flutter/lib/src/ui/t4_app.dart deleted file mode 100644 index 99ed6496..00000000 --- a/apps/flutter/lib/src/ui/t4_app.dart +++ /dev/null @@ -1,193 +0,0 @@ -library; - -import 'dart:async'; -import 'dart:convert'; - -import 'package:file_selector/file_selector.dart'; -import 'package:flutter/foundation.dart' show defaultTargetPlatform; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:xterm/xterm.dart'; - -import 'transcript_markdown.dart'; -import '../client/app_state.dart'; -import '../client/model_labels.dart'; -import '../platform/platform_lifecycle.dart'; -import '../protocol/protocol.dart'; -import '../platform/platform_lifecycle_controller.dart'; - -part 'adaptive_session_shell.dart'; -part 'access_mode_selector.dart'; -part 'command_palette.dart'; -part 'attention_pane.dart'; -part 'conversation_pane.dart'; -part 'context_panel.dart'; -part 'developer_surfaces.dart'; -part 'host_management.dart'; -part 'quick_open_dialog.dart'; -part 'transcript_search_pane.dart'; -part 'usage_status_pane.dart'; -part 'settings_pane.dart'; -part 't4_theme.dart'; -part 'session_navigation.dart'; -part 'terminal_pane.dart'; - -/// Material 3 application shell for T4. -/// -/// Protocol and connection behavior stay behind [T4Actions]; this widget only -/// renders the immutable [T4ViewState] supplied by its owner. -final class T4App extends StatelessWidget { - const T4App({ - required this.state, - required this.actions, - required this.credentialsAreVolatile, - this.demoMode = false, - this.platformState = const PlatformLifecycleViewState.initial(), - this.platformActions, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - final bool credentialsAreVolatile; - final bool demoMode; - final PlatformLifecycleViewState platformState; - final PlatformLifecycleActions? platformActions; - - @override - Widget build(BuildContext context) { - return MaterialApp( - debugShowCheckedModeBanner: false, - title: 'T4', - theme: _T4Theme.light(), - darkTheme: _T4Theme.dark(), - themeMode: switch (state.themePreference) { - T4ThemePreference.system => ThemeMode.system, - T4ThemePreference.light => ThemeMode.light, - T4ThemePreference.dark => ThemeMode.dark, - }, - home: demoMode - ? _DemoModeShell( - child: _AdaptiveSessionShell( - state: state, - actions: actions, - platformState: platformState, - platformActions: platformActions, - ), - ) - : credentialsAreVolatile - ? _VolatileCredentialsShell( - child: _AdaptiveSessionShell( - state: state, - actions: actions, - platformState: platformState, - platformActions: platformActions, - ), - ) - : _AdaptiveSessionShell( - state: state, - actions: actions, - platformState: platformState, - platformActions: platformActions, - ), - ); - } -} - -final class _DemoModeShell extends StatelessWidget { - const _DemoModeShell({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return Column( - children: [ - Semantics( - container: true, - label: 'Public preview using sample data. Actions are disabled.', - child: Material( - color: colors.secondaryContainer, - child: SafeArea( - bottom: false, - child: SizedBox( - height: 32, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.visibility_outlined, - size: 16, - color: colors.onSecondaryContainer, - ), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Public preview · sample data · actions disabled', - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium - ?.copyWith(color: colors.onSecondaryContainer), - ), - ), - ], - ), - ), - ), - ), - ), - Expanded(child: child), - ], - ); - } -} - -final class _VolatileCredentialsShell extends StatelessWidget { - const _VolatileCredentialsShell({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return Column( - children: [ - Semantics( - container: true, - label: - 'Unsigned development mode. Credentials reset when the app quits.', - child: Material( - color: colors.tertiaryContainer, - child: SafeArea( - bottom: false, - child: SizedBox( - height: 32, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.lock_open_outlined, - size: 16, - color: colors.onTertiaryContainer, - ), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Unsigned development · credentials reset on quit', - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium - ?.copyWith(color: colors.onTertiaryContainer), - ), - ), - ], - ), - ), - ), - ), - ), - Expanded(child: child), - ], - ); - } -} diff --git a/apps/flutter/lib/src/ui/t4_theme.dart b/apps/flutter/lib/src/ui/t4_theme.dart deleted file mode 100644 index 724e16a1..00000000 --- a/apps/flutter/lib/src/ui/t4_theme.dart +++ /dev/null @@ -1,599 +0,0 @@ -part of 't4_app.dart'; - -abstract final class _T4Breakpoints { - static const double wide = 980; -} - -abstract final class _T4Layout { - static const double sessionRailWidth = 300; - static const double contentMaxWidth = 760; - static const double compactToolbarHeight = 72; - static const double minimumTouchTarget = 44; - static const double followScrollThreshold = 96; -} - -abstract final class _T4Space { - static const double xxs = 4; - static const double xs = 8; - static const double sm = 12; - static const double md = 16; - static const double lg = 24; - static const double xl = 32; -} - -abstract final class _T4Radius { - static const double xs = 6; - static const double sm = 8; - static const double md = 10; - static const double lg = 16; -} - -abstract final class _T4Size { - static const double indicator = 16; - static const double emptyIcon = 32; - static const double thinStroke = 2; - static const double divider = 1; -} - -abstract final class _T4Motion { - static const Duration short = Duration(milliseconds: 120); - static const Curve standard = Curves.easeOut; -} - -abstract final class _T4Typography { - static const String sansFamily = 'DM Sans'; - static const String monoFamily = 'JetBrains Mono'; -} - -/// Semantic T4 colors that do not map cleanly onto Material's color roles. -/// -/// These values mirror `packages/ui/src/tokens.css`; widgets consume this -/// extension instead of defining platform-specific colors. -@immutable -final class T4SemanticColors extends ThemeExtension { - const T4SemanticColors({ - required this.brand, - required this.accentText, - required this.info, - required this.infoForeground, - required this.success, - required this.successForeground, - required this.warning, - required this.warningForeground, - required this.statusWorking, - required this.statusApproval, - required this.statusInput, - required this.statusPlan, - required this.statusDone, - required this.statusError, - }); - - final Color brand; - final Color accentText; - final Color info; - final Color infoForeground; - final Color success; - final Color successForeground; - final Color warning; - final Color warningForeground; - final Color statusWorking; - final Color statusApproval; - final Color statusInput; - final Color statusPlan; - final Color statusDone; - final Color statusError; - - static T4SemanticColors of(BuildContext context) => - Theme.of(context).extension()!; - - @override - T4SemanticColors copyWith({ - Color? brand, - Color? accentText, - Color? info, - Color? infoForeground, - Color? success, - Color? successForeground, - Color? warning, - Color? warningForeground, - Color? statusWorking, - Color? statusApproval, - Color? statusInput, - Color? statusPlan, - Color? statusDone, - Color? statusError, - }) => T4SemanticColors( - brand: brand ?? this.brand, - accentText: accentText ?? this.accentText, - info: info ?? this.info, - infoForeground: infoForeground ?? this.infoForeground, - success: success ?? this.success, - successForeground: successForeground ?? this.successForeground, - warning: warning ?? this.warning, - warningForeground: warningForeground ?? this.warningForeground, - statusWorking: statusWorking ?? this.statusWorking, - statusApproval: statusApproval ?? this.statusApproval, - statusInput: statusInput ?? this.statusInput, - statusPlan: statusPlan ?? this.statusPlan, - statusDone: statusDone ?? this.statusDone, - statusError: statusError ?? this.statusError, - ); - - @override - T4SemanticColors lerp(covariant T4SemanticColors? other, double t) { - if (other == null) return this; - return T4SemanticColors( - brand: Color.lerp(brand, other.brand, t)!, - accentText: Color.lerp(accentText, other.accentText, t)!, - info: Color.lerp(info, other.info, t)!, - infoForeground: Color.lerp(infoForeground, other.infoForeground, t)!, - success: Color.lerp(success, other.success, t)!, - successForeground: Color.lerp( - successForeground, - other.successForeground, - t, - )!, - warning: Color.lerp(warning, other.warning, t)!, - warningForeground: Color.lerp( - warningForeground, - other.warningForeground, - t, - )!, - statusWorking: Color.lerp(statusWorking, other.statusWorking, t)!, - statusApproval: Color.lerp(statusApproval, other.statusApproval, t)!, - statusInput: Color.lerp(statusInput, other.statusInput, t)!, - statusPlan: Color.lerp(statusPlan, other.statusPlan, t)!, - statusDone: Color.lerp(statusDone, other.statusDone, t)!, - statusError: Color.lerp(statusError, other.statusError, t)!, - ); - } -} - -abstract final class _T4Palette { - static const Color brand = Color(0xffe83174); - - static const ColorScheme lightScheme = ColorScheme( - brightness: Brightness.light, - primary: Color(0xffb8245b), - onPrimary: Color(0xffffffff), - primaryContainer: Color(0xfff8e9ef), - onPrimaryContainer: Color(0xffb8245b), - secondary: Color(0xfff5f5f5), - onSecondary: Color(0xff262626), - secondaryContainer: Color(0xfff5f5f5), - onSecondaryContainer: Color(0xff262626), - tertiary: Color(0xff1447e6), - onTertiary: Color(0xffffffff), - tertiaryContainer: Color(0xffedf2fc), - onTertiaryContainer: Color(0xff1447e6), - error: Color(0xfffb2c36), - onError: Color(0xffffffff), - errorContainer: Color(0xffffe9ea), - onErrorContainer: Color(0xffc10007), - surface: Color(0xffffffff), - onSurface: Color(0xff262626), - surfaceDim: Color(0xfff5f5f5), - surfaceBright: Color(0xffffffff), - surfaceContainerLowest: Color(0xffffffff), - surfaceContainerLow: Color(0xfffafafa), - surfaceContainer: Color(0xfff5f5f5), - surfaceContainerHigh: Color(0xfff0f0f0), - surfaceContainerHighest: Color(0xffe8e8e8), - onSurfaceVariant: Color(0xff696969), - outline: Color(0x1a000000), - outlineVariant: Color(0x14000000), - shadow: Color(0x1a000000), - scrim: Color(0x66000000), - inverseSurface: Color(0xff262626), - onInverseSurface: Color(0xfff5f5f5), - inversePrimary: Color(0xfff67399), - ); - - static const T4SemanticColors lightSemantic = T4SemanticColors( - brand: brand, - accentText: Color(0xffb8245b), - info: Color(0xff2b7fff), - infoForeground: Color(0xff1447e6), - success: Color(0xff00bc7d), - successForeground: Color(0xff007a55), - warning: Color(0xfffe9a00), - warningForeground: Color(0xffbb4d00), - statusWorking: Color(0xff0084d1), - statusApproval: Color(0xffe17100), - statusInput: Color(0xff4f39f6), - statusPlan: Color(0xff7f22fe), - statusDone: Color(0xff009966), - statusError: Color(0xffe7000b), - ); - - static const ColorScheme darkScheme = ColorScheme( - brightness: Brightness.dark, - primary: Color(0xfff67399), - onPrimary: Color(0xff0a0a0a), - primaryContainer: Color(0xff3a252b), - onPrimaryContainer: Color(0xfffc93ae), - secondary: Color(0xff1f1f1f), - onSecondary: Color(0xfff5f5f5), - secondaryContainer: Color(0xff1f1f1f), - onSecondaryContainer: Color(0xfff5f5f5), - tertiary: Color(0xff51a2ff), - onTertiary: Color(0xff0a0a0a), - tertiaryContainer: Color(0xff1f2d42), - onTertiaryContainer: Color(0xff51a2ff), - error: Color(0xfffc414a), - onError: Color(0xffffffff), - errorContainer: Color(0xff3b1d1f), - onErrorContainer: Color(0xffff6467), - surface: Color(0xff161616), - onSurface: Color(0xfff5f5f5), - surfaceDim: Color(0xff0a0a0a), - surfaceBright: Color(0xff292929), - surfaceContainerLowest: Color(0xff1b1b1b), - surfaceContainerLow: Color(0xff1f1f1f), - surfaceContainer: Color(0xff1f1f1f), - surfaceContainerHigh: Color(0xff242424), - surfaceContainerHighest: Color(0xff292929), - onSurfaceVariant: Color(0xffa1a1a1), - outline: Color(0x14ffffff), - outlineVariant: Color(0x0fffffff), - shadow: Color(0x80000000), - scrim: Color(0x99000000), - inverseSurface: Color(0xfff5f5f5), - onInverseSurface: Color(0xff262626), - inversePrimary: Color(0xffb8245b), - ); - - static const T4SemanticColors darkSemantic = T4SemanticColors( - brand: brand, - accentText: Color(0xfffc93ae), - info: Color(0xff2b7fff), - infoForeground: Color(0xff51a2ff), - success: Color(0xff00bc7d), - successForeground: Color(0xff00d492), - warning: Color(0xfffe9a00), - warningForeground: Color(0xffffb900), - statusWorking: Color(0xff00a6f4), - statusApproval: Color(0xffffb900), - statusInput: Color(0xff7c6cff), - statusPlan: Color(0xffa970ff), - statusDone: Color(0xff00d492), - statusError: Color(0xffff6467), - ); -} - -abstract final class _T4Theme { - static ThemeData light() => _build( - scheme: _T4Palette.lightScheme, - semantic: _T4Palette.lightSemantic, - ); - - static ThemeData dark() => - _build(scheme: _T4Palette.darkScheme, semantic: _T4Palette.darkSemantic); - - /// Compact density on desktop targets (native and desktop web); standard on - /// touch platforms. On web, [defaultTargetPlatform] reflects the host OS, so - /// desktop browsers resolve to macOS/Windows/Linux and stay compact. - static VisualDensity get _adaptiveDensity { - switch (defaultTargetPlatform) { - case TargetPlatform.macOS: - case TargetPlatform.windows: - case TargetPlatform.linux: - return VisualDensity.compact; - case TargetPlatform.android: - case TargetPlatform.iOS: - case TargetPlatform.fuchsia: - return VisualDensity.standard; - } - } - - static ThemeData _build({ - required ColorScheme scheme, - required T4SemanticColors semantic, - }) { - final base = ThemeData( - useMaterial3: true, - brightness: scheme.brightness, - colorScheme: scheme, - fontFamily: _T4Typography.sansFamily, - ); - final baseText = base.textTheme.apply( - fontFamily: _T4Typography.sansFamily, - bodyColor: scheme.onSurface, - displayColor: scheme.onSurface, - ); - final textTheme = baseText.copyWith( - headlineSmall: baseText.headlineSmall?.copyWith( - fontSize: 20, - fontWeight: FontWeight.w600, - fontVariations: const [FontVariation('wght', 650)], - ), - titleLarge: baseText.titleLarge?.copyWith( - fontSize: 17, - fontWeight: FontWeight.w600, - fontVariations: const [FontVariation('wght', 650)], - ), - titleMedium: baseText.titleMedium?.copyWith( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - titleSmall: baseText.titleSmall?.copyWith( - fontSize: 13, - fontWeight: FontWeight.w600, - ), - bodyLarge: baseText.bodyLarge?.copyWith(fontSize: 14), - bodyMedium: baseText.bodyMedium?.copyWith(fontSize: 13, height: 1.45), - bodySmall: baseText.bodySmall?.copyWith(fontSize: 12), - labelLarge: baseText.labelLarge?.copyWith( - fontSize: 13, - fontWeight: FontWeight.w600, - ), - labelMedium: baseText.labelMedium?.copyWith( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - labelSmall: baseText.labelSmall?.copyWith( - fontSize: 11, - fontWeight: FontWeight.w500, - letterSpacing: 0.2, - ), - ); - const minimumSize = WidgetStatePropertyAll(Size(0, 32)); - final touchPlatform = - defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; - final iconButtonMinimumSize = WidgetStatePropertyAll( - Size.square(touchPlatform ? _T4Layout.minimumTouchTarget : 28), - ); - final iconButtonPadding = WidgetStatePropertyAll( - EdgeInsets.all(touchPlatform ? _T4Space.xs : _T4Space.xxs), - ); - final controlShape = WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(_T4Radius.sm)), - ); - const controlPadding = WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 14, vertical: 8), - ); - final menuShape = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.md), - side: BorderSide(color: scheme.outlineVariant), - ); - final menuStyle = MenuStyle( - backgroundColor: WidgetStatePropertyAll( - scheme.surfaceContainerLowest, - ), - surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), - elevation: const WidgetStatePropertyAll(6), - shape: WidgetStatePropertyAll(menuShape), - minimumSize: const WidgetStatePropertyAll(Size(180, 0)), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(vertical: _T4Space.xxs), - ), - ); - final isDark = scheme.brightness == Brightness.dark; - final tooltipBackground = isDark - ? scheme.surfaceContainerHighest - : scheme.inverseSurface; - final tooltipForeground = isDark - ? scheme.onSurface - : scheme.onInverseSurface; - - return base.copyWith( - scaffoldBackgroundColor: scheme.surface, - textTheme: textTheme, - visualDensity: _adaptiveDensity, - iconTheme: base.iconTheme.copyWith(size: 20), - extensions: >[semantic], - appBarTheme: AppBarTheme( - backgroundColor: scheme.surface, - foregroundColor: scheme.onSurface, - surfaceTintColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - titleTextStyle: textTheme.titleMedium, - ), - dividerTheme: DividerThemeData( - color: scheme.outlineVariant, - space: _T4Size.divider, - thickness: _T4Size.divider, - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: scheme.onSurface.withValues(alpha: 0.03), - isDense: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), - hintStyle: textTheme.bodyMedium?.copyWith( - fontSize: 13, - color: scheme.onSurfaceVariant.withValues(alpha: 0.75), - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - borderSide: BorderSide(color: scheme.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - borderSide: BorderSide(color: scheme.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - borderSide: BorderSide(color: scheme.primary, width: 1.5), - ), - disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - borderSide: BorderSide(color: scheme.outlineVariant), - ), - floatingLabelStyle: TextStyle(color: scheme.primary), - ), - filledButtonTheme: FilledButtonThemeData( - style: ButtonStyle( - minimumSize: minimumSize, - shape: controlShape, - padding: controlPadding, - elevation: const WidgetStatePropertyAll(0), - textStyle: WidgetStatePropertyAll(textTheme.labelLarge), - ), - ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: ButtonStyle( - minimumSize: minimumSize, - shape: controlShape, - padding: controlPadding, - side: WidgetStatePropertyAll( - BorderSide(color: scheme.outline), - ), - textStyle: WidgetStatePropertyAll(textTheme.labelLarge), - ), - ), - textButtonTheme: TextButtonThemeData( - style: ButtonStyle( - minimumSize: minimumSize, - shape: controlShape, - padding: controlPadding, - textStyle: WidgetStatePropertyAll(textTheme.labelLarge), - ), - ), - iconButtonTheme: IconButtonThemeData( - style: ButtonStyle( - iconSize: const WidgetStatePropertyAll(19), - minimumSize: iconButtonMinimumSize, - padding: iconButtonPadding, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: controlShape, - ), - ), - cardTheme: CardThemeData( - color: scheme.surfaceContainerLowest, - surfaceTintColor: Colors.transparent, - elevation: 0, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.md), - side: BorderSide(color: scheme.outlineVariant), - ), - ), - dialogTheme: DialogThemeData( - backgroundColor: scheme.surfaceContainerLowest, - surfaceTintColor: Colors.transparent, - elevation: 16, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: scheme.outlineVariant), - ), - titleTextStyle: textTheme.titleMedium, - contentTextStyle: textTheme.bodyMedium, - ), - bottomSheetTheme: BottomSheetThemeData( - backgroundColor: scheme.surfaceContainerLowest, - modalBackgroundColor: scheme.surfaceContainerLowest, - surfaceTintColor: Colors.transparent, - elevation: 16, - modalElevation: 16, - shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(_T4Radius.lg), - ), - side: BorderSide(color: scheme.outlineVariant), - ), - ), - chipTheme: base.chipTheme.copyWith( - backgroundColor: scheme.surfaceContainer, - selectedColor: scheme.primaryContainer, - disabledColor: scheme.surfaceContainer, - side: BorderSide(color: scheme.outlineVariant), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.xs), - ), - labelStyle: textTheme.labelMedium, - padding: const EdgeInsets.symmetric(horizontal: _T4Space.xs), - ), - listTileTheme: ListTileThemeData( - dense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: _T4Space.sm), - minVerticalPadding: 6, - horizontalTitleGap: _T4Space.xs, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - iconColor: scheme.onSurfaceVariant, - ), - drawerTheme: DrawerThemeData( - backgroundColor: scheme.surface, - surfaceTintColor: Colors.transparent, - shape: const RoundedRectangleBorder(), - ), - navigationDrawerTheme: NavigationDrawerThemeData( - backgroundColor: scheme.surface, - surfaceTintColor: Colors.transparent, - indicatorColor: scheme.primaryContainer, - indicatorShape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - ), - menuTheme: MenuThemeData(style: menuStyle), - menuButtonTheme: MenuButtonThemeData( - style: ButtonStyle( - minimumSize: const WidgetStatePropertyAll(Size(0, 34)), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric( - horizontal: _T4Space.sm, - vertical: _T4Space.xxs, - ), - ), - textStyle: WidgetStatePropertyAll(textTheme.labelMedium), - ), - ), - dropdownMenuTheme: DropdownMenuThemeData( - textStyle: textTheme.labelMedium, - menuStyle: menuStyle, - ), - popupMenuTheme: PopupMenuThemeData( - color: scheme.surfaceContainerLowest, - surfaceTintColor: Colors.transparent, - elevation: 6, - shape: menuShape, - textStyle: textTheme.labelMedium, - menuPadding: const EdgeInsets.symmetric(vertical: _T4Space.xxs), - ), - snackBarTheme: SnackBarThemeData( - behavior: SnackBarBehavior.floating, - backgroundColor: scheme.inverseSurface, - contentTextStyle: textTheme.bodyMedium?.copyWith( - color: scheme.onInverseSurface, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - ), - tooltipTheme: TooltipThemeData( - waitDuration: const Duration(milliseconds: 450), - decoration: BoxDecoration( - color: tooltipBackground, - borderRadius: BorderRadius.circular(_T4Radius.xs), - ), - textStyle: textTheme.labelSmall?.copyWith( - fontSize: 11.5, - color: tooltipForeground, - ), - ), - scrollbarTheme: ScrollbarThemeData( - radius: const Radius.circular(3), - thickness: const WidgetStatePropertyAll(6), - thumbColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.hovered) - ? scheme.onSurface.withValues(alpha: 0.25) - : scheme.onSurface.withValues(alpha: 0.15), - ), - ), - textSelectionTheme: TextSelectionThemeData( - cursorColor: scheme.primary, - selectionColor: scheme.primary.withValues(alpha: 0.22), - selectionHandleColor: scheme.primary, - ), - progressIndicatorTheme: ProgressIndicatorThemeData( - color: scheme.primary, - linearTrackColor: scheme.surfaceContainerHighest, - circularTrackColor: scheme.surfaceContainerHighest, - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/terminal_pane.dart b/apps/flutter/lib/src/ui/terminal_pane.dart deleted file mode 100644 index 9ed687b9..00000000 --- a/apps/flutter/lib/src/ui/terminal_pane.dart +++ /dev/null @@ -1,932 +0,0 @@ -part of 't4_app.dart'; - -const int _terminalScrollbackLines = 5000; -const int _terminalOutputOverlapProbeLength = 64; -const int _terminalPastePreviewCharacters = 1000; -const int _terminalPastePreviewLines = 8; - -final class _TerminalPane extends StatefulWidget { - const _TerminalPane({required this.state, required this.actions}); - - final T4ViewState state; - final T4Actions actions; - - @override - State<_TerminalPane> createState() => _TerminalPaneState(); -} - -final class _TerminalPaneState extends State<_TerminalPane> { - final Map _bindings = {}; - String? _selectedTerminalId; - bool _opening = false; - bool _reconnecting = false; - - @override - void initState() { - super.initState(); - _selectedTerminalId = _preferredTerminalId(widget.state); - _synchronizeBindings(); - } - - @override - void didUpdateWidget(covariant _TerminalPane oldWidget) { - super.didUpdateWidget(oldWidget); - _synchronizeBindings(); - - final availableIds = widget.state.terminals - .map((terminal) => terminal.terminalId) - .toSet(); - final selectedStillExists = availableIds.contains(_selectedTerminalId); - final activeId = widget.state.activeTerminalId; - final activeWasAdded = - activeId != null && - !oldWidget.state.terminals.any( - (terminal) => terminal.terminalId == activeId, - ); - - if (!selectedStillExists || activeWasAdded) { - _selectedTerminalId = _preferredTerminalId(widget.state); - } - } - - String? _preferredTerminalId(T4ViewState state) { - final activeId = state.activeTerminalId; - if (activeId != null && - state.terminals.any((terminal) => terminal.terminalId == activeId)) { - return activeId; - } - return state.terminals.isEmpty ? null : state.terminals.first.terminalId; - } - - void _synchronizeBindings() { - final currentIds = widget.state.terminals - .map((terminal) => terminal.terminalId) - .toSet(); - final removedIds = _bindings.keys - .where((terminalId) => !currentIds.contains(terminalId)) - .toList(growable: false); - for (final terminalId in removedIds) { - _bindings.remove(terminalId)?.dispose(); - } - - for (final session in widget.state.terminals) { - final binding = _bindings.putIfAbsent( - session.terminalId, - () => _TerminalBinding( - terminalId: session.terminalId, - onOutput: _forwardOutput, - onResize: _forwardResize, - ), - ); - _appendUnseenOutput(binding, session.output); - } - } - - void _appendUnseenOutput(_TerminalBinding binding, String output) { - final initialSynchronization = !binding.hasSynchronizedOutput; - final previous = binding.modelOutput; - if (output == previous) { - binding.hasSynchronizedOutput = true; - return; - } - - var unseenStart = 0; - var resetBaseline = false; - if (output.startsWith(previous)) { - unseenStart = previous.length; - } else { - unseenStart = _terminalOutputOverlap(previous, output); - resetBaseline = unseenStart == 0 && previous.isNotEmpty; - } - - final unseen = output.substring(unseenStart); - binding.modelOutput = output; - binding.hasSynchronizedOutput = true; - if (unseen.isEmpty && !resetBaseline) return; - - binding.replayingInitialOutput = initialSynchronization || resetBaseline; - try { - if (resetBaseline) _clearTerminal(binding.terminal); - binding.terminal.write(unseen); - } finally { - binding.replayingInitialOutput = false; - } - } - - void _clearTerminal(Terminal terminal) { - terminal.useMainBuffer(); - terminal.mainBuffer.clear(); - terminal.clearAltBuffer(); - terminal.setCursor(0, 0); - terminal.resetCursorStyle(); - } - - int _terminalOutputOverlap(String previous, String current) { - final maximum = previous.length < current.length - ? previous.length - : current.length; - if (maximum == 0) return 0; - - final probeLength = maximum < _terminalOutputOverlapProbeLength - ? maximum - : _terminalOutputOverlapProbeLength; - final probe = current.substring(0, probeLength); - var candidate = previous.indexOf(probe, previous.length - maximum); - while (candidate >= 0) { - final overlap = previous.length - candidate; - if (overlap <= current.length && - _regionsMatch(previous, candidate, current, overlap)) { - return overlap; - } - candidate = previous.indexOf(probe, candidate + 1); - } - for (var overlap = probeLength - 1; overlap > 0; overlap -= 1) { - if (_regionsMatch( - previous, - previous.length - overlap, - current, - overlap, - )) { - return overlap; - } - } - return 0; - } - - bool _regionsMatch( - String previous, - int previousStart, - String current, - int length, - ) { - for (var index = 0; index < length; index += 1) { - if (previous.codeUnitAt(previousStart + index) != - current.codeUnitAt(index)) { - return false; - } - } - return true; - } - - TerminalSession? _sessionFor(String terminalId) { - for (final session in widget.state.terminals) { - if (session.terminalId == terminalId) return session; - } - return null; - } - - TerminalSession? get _selectedSession { - final terminalId = _selectedTerminalId; - return terminalId == null ? null : _sessionFor(terminalId); - } - - bool _canInput(TerminalSession session) => - session.running && - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('term.input'); - - bool _canResize(TerminalSession session) => - session.running && - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('term.resize'); - - bool get _canOpen => - widget.state.selectedSession != null && - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('term.open') && - !widget.state.developerOperationPending && - !_opening; - - void _forwardOutput(String terminalId, String data) { - final binding = _bindings[terminalId]; - final session = _sessionFor(terminalId); - if (binding == null || - binding.replayingInitialOutput || - session == null || - !_canInput(session)) { - return; - } - widget.actions.sendTerminalInput(terminalId, data); - } - - void _forwardResize(String terminalId, int columns, int rows) { - final binding = _bindings[terminalId]; - final session = _sessionFor(terminalId); - if (binding == null || - session == null || - !_canResize(session) || - (binding.lastColumns == columns && binding.lastRows == rows)) { - return; - } - binding - ..lastColumns = columns - ..lastRows = rows; - widget.actions.resizeTerminal(terminalId, columns, rows); - } - - Future _openTerminal() async { - if (!_canOpen) return; - setState(() => _opening = true); - try { - final terminalId = await widget.actions.openTerminal(); - if (mounted) setState(() => _selectedTerminalId = terminalId); - } on Object catch (error) { - if (mounted) { - _showFailure( - _actionErrorMessage(error, 'Could not open a terminal. Try again.'), - ); - } - } finally { - if (mounted) setState(() => _opening = false); - } - } - - Future _reconnect() async { - if (_reconnecting || - (widget.state.connectionPhase != ConnectionPhase.disconnected && - widget.state.connectionPhase != ConnectionPhase.failed)) { - return; - } - setState(() => _reconnecting = true); - try { - await widget.actions.connect(); - } on Object catch (error) { - if (mounted) { - _showFailure( - _actionErrorMessage(error, 'Could not reconnect. Try again.'), - ); - } - } finally { - if (mounted) setState(() => _reconnecting = false); - } - } - - String _actionErrorMessage(Object error, String fallback) { - final message = error.toString().replaceFirst('Bad state: ', '').trim(); - return message.isEmpty ? fallback : message; - } - - void _showFailure(String message) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - void _closeSelectedTerminal() { - final terminalId = _selectedTerminalId; - if (terminalId == null) return; - widget.actions.closeTerminal(terminalId); - } - - KeyEventResult _handleTerminalKey( - TerminalSession session, - _TerminalBinding binding, - KeyEvent event, - ) { - final keyboard = HardwareKeyboard.instance; - final platform = Theme.of(context).platform; - final isApple = - platform == TargetPlatform.iOS || platform == TargetPlatform.macOS; - final pasteShortcut = - event.logicalKey == LogicalKeyboardKey.keyV && - (isApple ? keyboard.isMetaPressed : keyboard.isControlPressed); - if (!pasteShortcut) return KeyEventResult.ignored; - - if (event is KeyDownEvent && _canInput(session)) { - unawaited(_guardedPaste(session.terminalId, binding)); - } - return KeyEventResult.handled; - } - - Future _guardedPaste( - String terminalId, - _TerminalBinding binding, - ) async { - final session = _sessionFor(terminalId); - if (session == null || !_canInput(session)) return; - - ClipboardData? clipboard; - try { - clipboard = await Clipboard.getData(Clipboard.kTextPlain); - } on Object { - if (mounted) _showFailure('Could not read the clipboard.'); - return; - } - if (!mounted) return; - final text = clipboard?.text; - if (text == null || text.isEmpty) { - _showFailure('The clipboard does not contain text.'); - return; - } - - final lineCount = '\n'.allMatches(text).length + 1; - final preview = text.length > _terminalPastePreviewCharacters - ? '${text.substring(0, _terminalPastePreviewCharacters)}…' - : text; - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Paste into terminal?'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - lineCount == 1 - ? '${text.length} characters may execute immediately.' - : '$lineCount lines may execute immediately.', - ), - const SizedBox(height: _T4Space.sm), - SingleChildScrollView( - child: SelectableText( - preview, - maxLines: _terminalPastePreviewLines, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Paste'), - ), - ], - ), - ); - if (confirmed != true || !mounted) return; - - final current = _sessionFor(terminalId); - if (current == null || !_canInput(current)) { - _showFailure('The terminal is no longer writable.'); - return; - } - binding.terminal.paste(text); - } - - @override - void dispose() { - for (final binding in _bindings.values) { - binding.dispose(); - } - _bindings.clear(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final selected = _selectedSession; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _TerminalTabStrip( - sessions: widget.state.terminals, - selectedTerminalId: selected?.terminalId, - canOpen: _canOpen, - opening: _opening, - onSelected: (terminalId) { - setState(() => _selectedTerminalId = terminalId); - _bindings[terminalId]?.focusNode.requestFocus(); - }, - onOpen: _openTerminal, - onClose: selected == null ? null : _closeSelectedTerminal, - ), - const Divider(height: _T4Size.divider), - Expanded( - child: selected == null - ? _TerminalEmptyState( - state: widget.state, - canOpen: _canOpen, - opening: _opening, - reconnecting: _reconnecting, - onOpen: _openTerminal, - onReconnect: _reconnect, - ) - : _buildTerminal(selected), - ), - ], - ); - } - - Widget _buildTerminal(TerminalSession session) { - final binding = _bindings[session.terminalId]!; - final writable = _canInput(session); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _TerminalStatusBar( - session: session, - phase: widget.state.connectionPhase, - writable: writable, - reconnecting: _reconnecting, - onReconnect: - widget.state.connectionPhase == ConnectionPhase.disconnected || - widget.state.connectionPhase == ConnectionPhase.failed - ? _reconnect - : null, - onPaste: writable - ? () => _guardedPaste(session.terminalId, binding) - : null, - ), - Expanded( - child: Semantics( - container: true, - label: 'Terminal output for ${_terminalTitle(session)}', - child: TerminalView( - binding.terminal, - key: ValueKey(session.terminalId), - controller: binding.controller, - scrollController: binding.scrollController, - focusNode: binding.focusNode, - theme: _terminalTheme(Theme.of(context).colorScheme), - autofocus: true, - readOnly: !writable, - padding: const EdgeInsets.all(_T4Space.sm), - onKeyEvent: (focusNode, event) => - _handleTerminalKey(session, binding, event), - ), - ), - ), - ], - ); - } -} - -final class _TerminalBinding { - _TerminalBinding({ - required this.terminalId, - required void Function(String terminalId, String data) onOutput, - required void Function(String terminalId, int columns, int rows) onResize, - }) : terminal = Terminal(maxLines: _terminalScrollbackLines) { - terminal.onOutput = (data) => onOutput(terminalId, data); - terminal.onResize = (columns, rows, pixelWidth, pixelHeight) => - onResize(terminalId, columns, rows); - } - - final String terminalId; - final Terminal terminal; - final TerminalController controller = TerminalController(); - final ScrollController scrollController = ScrollController(); - final FocusNode focusNode = FocusNode(); - String modelOutput = ''; - bool hasSynchronizedOutput = false; - bool replayingInitialOutput = false; - int? lastColumns; - int? lastRows; - - void dispose() { - terminal - ..onOutput = null - ..onResize = null; - controller.dispose(); - scrollController.dispose(); - focusNode.dispose(); - } -} - -final class _TerminalTabStrip extends StatelessWidget { - const _TerminalTabStrip({ - required this.sessions, - required this.selectedTerminalId, - required this.canOpen, - required this.opening, - required this.onSelected, - required this.onOpen, - required this.onClose, - }); - - final List sessions; - final String? selectedTerminalId; - final bool canOpen; - final bool opening; - final ValueChanged onSelected; - final Future Function() onOpen; - final VoidCallback? onClose; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Material( - color: scheme.surfaceContainerLow, - child: SizedBox( - height: _T4Layout.minimumTouchTarget, - child: Row( - children: [ - Expanded( - child: LayoutBuilder( - builder: (context, constraints) => Semantics( - container: true, - explicitChildNodes: true, - label: 'Terminal tabs', - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < sessions.length; index += 1) - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth, - minHeight: _T4Layout.minimumTouchTarget, - ), - child: _TerminalTab( - session: sessions[index], - index: index, - count: sessions.length, - selected: - sessions[index].terminalId == - selectedTerminalId, - onTap: () => - onSelected(sessions[index].terminalId), - ), - ), - ], - ), - ), - ), - ), - ), - IconButton( - onPressed: canOpen ? () => unawaited(onOpen()) : null, - tooltip: canOpen - ? 'New terminal' - : 'Connect with terminal access to open a terminal', - icon: opening - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - ), - ) - : const Icon(Icons.add), - ), - IconButton( - onPressed: onClose, - tooltip: onClose == null - ? 'No terminal to close' - : 'Close terminal', - icon: const Icon(Icons.close), - ), - ], - ), - ), - ); - } -} - -final class _TerminalTab extends StatelessWidget { - const _TerminalTab({ - required this.session, - required this.index, - required this.count, - required this.selected, - required this.onTap, - }); - - final TerminalSession session; - final int index; - final int count; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final status = session.running ? 'running' : _terminalExitLabel(session); - return Semantics( - button: true, - selected: selected, - label: - 'Terminal tab ${index + 1} of $count: ${_terminalTitle(session)}, $status', - excludeSemantics: true, - child: InkWell( - onTap: onTap, - child: AnimatedContainer( - duration: _T4Motion.short, - curve: _T4Motion.standard, - padding: const EdgeInsets.symmetric(horizontal: _T4Space.md), - decoration: BoxDecoration( - color: selected ? scheme.secondaryContainer : Colors.transparent, - border: Border( - bottom: BorderSide( - color: selected ? scheme.primary : Colors.transparent, - width: _T4Size.thinStroke, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - session.running ? Icons.terminal : Icons.stop_circle_outlined, - size: _T4Size.indicator, - color: selected - ? scheme.onSecondaryContainer - : scheme.onSurfaceVariant, - ), - const SizedBox(width: _T4Space.xs), - Flexible( - child: Text( - _terminalTitle(session), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected - ? scheme.onSecondaryContainer - : scheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -final class _TerminalStatusBar extends StatelessWidget { - const _TerminalStatusBar({ - required this.session, - required this.phase, - required this.writable, - required this.reconnecting, - required this.onReconnect, - required this.onPaste, - }); - - final TerminalSession session; - final ConnectionPhase phase; - final bool writable; - final bool reconnecting; - final Future Function()? onReconnect; - final Future Function()? onPaste; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final semantic = T4SemanticColors.of(context); - final status = _terminalStatus( - session, - phase, - writable, - reconnecting, - scheme, - semantic, - ); - return Material( - color: scheme.surfaceContainerLowest, - child: Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.xxs, - _T4Space.xs, - _T4Space.xxs, - ), - child: Wrap( - spacing: _T4Space.sm, - runSpacing: _T4Space.xxs, - crossAxisAlignment: WrapCrossAlignment.center, - alignment: WrapAlignment.spaceBetween, - children: [ - Semantics( - liveRegion: true, - label: 'Terminal status: ${status.label}', - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - status.icon, - size: _T4Size.indicator, - color: status.color, - ), - const SizedBox(width: _T4Space.xs), - Text( - status.label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (onReconnect != null) - TextButton.icon( - onPressed: reconnecting - ? null - : () => unawaited(onReconnect!()), - icon: reconnecting - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - ), - ) - : const Icon(Icons.refresh), - label: const Text('Reconnect'), - ), - TextButton.icon( - onPressed: onPaste == null - ? null - : () => unawaited(onPaste!()), - icon: const Icon(Icons.content_paste_outlined), - label: const Text('Paste'), - ), - ], - ), - ], - ), - ), - ); - } -} - -final class _TerminalEmptyState extends StatelessWidget { - const _TerminalEmptyState({ - required this.state, - required this.canOpen, - required this.opening, - required this.reconnecting, - required this.onOpen, - required this.onReconnect, - }); - - final T4ViewState state; - final bool canOpen; - final bool opening; - final bool reconnecting; - final Future Function() onOpen; - final Future Function() onReconnect; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final offline = - state.connectionPhase == ConnectionPhase.disconnected || - state.connectionPhase == ConnectionPhase.failed; - final message = offline - ? 'Reconnect to open a terminal for this session.' - : !state.grantedCapabilities.contains('term.open') - ? 'Terminal access was not granted for this host.' - : state.selectedSession == null - ? 'Choose a session before opening a terminal.' - : 'Open a terminal to run commands and inspect their output.'; - - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(_T4Space.lg), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.terminal, - size: _T4Size.emptyIcon, - color: scheme.outline, - ), - const SizedBox(height: _T4Space.md), - Text( - 'No terminal open', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xs), - Text( - message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - const SizedBox(height: _T4Space.md), - if (offline) - FilledButton.icon( - onPressed: reconnecting - ? null - : () => unawaited(onReconnect()), - icon: reconnecting - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - ), - ) - : const Icon(Icons.refresh), - label: const Text('Reconnect'), - ) - else - FilledButton.icon( - onPressed: canOpen ? () => unawaited(onOpen()) : null, - icon: opening - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - ), - ) - : const Icon(Icons.add), - label: const Text('New terminal'), - ), - ], - ), - ), - ), - ); - } -} - -final class _TerminalStatus { - const _TerminalStatus(this.label, this.icon, this.color); - - final String label; - final IconData icon; - final Color color; -} - -_TerminalStatus _terminalStatus( - TerminalSession session, - ConnectionPhase phase, - bool writable, - bool reconnecting, - ColorScheme scheme, - T4SemanticColors semantic, -) { - if (!session.running) { - return _TerminalStatus( - _terminalExitLabel(session), - Icons.stop_circle_outlined, - scheme.outline, - ); - } - if (reconnecting || phase == ConnectionPhase.retrying) { - return _TerminalStatus('Reconnecting', Icons.sync, semantic.statusWorking); - } - if (phase == ConnectionPhase.connecting || - phase == ConnectionPhase.synchronizing) { - return _TerminalStatus('Connecting', Icons.sync, semantic.statusWorking); - } - if (phase != ConnectionPhase.ready) { - return _TerminalStatus( - 'Offline', - Icons.cloud_off_outlined, - semantic.statusError, - ); - } - if (!writable) { - return _TerminalStatus( - 'Read-only', - Icons.lock_outline, - semantic.warningForeground, - ); - } - return _TerminalStatus('Connected', Icons.circle, semantic.statusDone); -} - -String _terminalTitle(TerminalSession session) { - final title = session.title.trim(); - return title.isEmpty ? 'Terminal' : title; -} - -String _terminalExitLabel(TerminalSession session) { - final signal = session.signal?.trim(); - if (signal != null && signal.isNotEmpty) return 'Exited · $signal'; - final exitCode = session.exitCode; - return exitCode == null ? 'Exited' : 'Exited · code $exitCode'; -} - -TerminalTheme _terminalTheme(ColorScheme scheme) { - return TerminalTheme( - cursor: scheme.primary, - selection: scheme.secondaryContainer, - foreground: scheme.onSurface, - background: scheme.surfaceContainerLowest, - black: scheme.shadow, - white: scheme.surfaceContainerHighest, - red: scheme.error, - green: scheme.primary, - yellow: scheme.tertiary, - blue: scheme.primary, - magenta: scheme.secondary, - cyan: scheme.tertiary, - brightBlack: scheme.outline, - brightRed: scheme.onErrorContainer, - brightGreen: scheme.onPrimaryContainer, - brightYellow: scheme.onTertiaryContainer, - brightBlue: scheme.onPrimaryContainer, - brightMagenta: scheme.onSecondaryContainer, - brightCyan: scheme.onTertiaryContainer, - brightWhite: scheme.onSurface, - searchHitBackground: scheme.tertiaryContainer, - searchHitBackgroundCurrent: scheme.primaryContainer, - searchHitForeground: scheme.onTertiaryContainer, - ); -} diff --git a/apps/flutter/lib/src/ui/transcript_markdown.dart b/apps/flutter/lib/src/ui/transcript_markdown.dart deleted file mode 100644 index 742ebcb5..00000000 --- a/apps/flutter/lib/src/ui/transcript_markdown.dart +++ /dev/null @@ -1,370 +0,0 @@ -// Markdown rendering for transcript content. -// -// Standalone library (not a `part of` t4_app.dart) so it can be consumed by -// later waves without pulling in the shell. Styling is derived entirely from -// `Theme.of(context)`; no private theme tokens are referenced. - -// The `markdown` package is a direct dependency of flutter_markdown_plus and -// is required here only for the `MarkdownElementBuilder` override signatures. -// ignore_for_file: depend_on_referenced_packages - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:markdown/markdown.dart' as md; -import 'package:re_highlight/languages/bash.dart'; -import 'package:re_highlight/languages/c.dart'; -import 'package:re_highlight/languages/cpp.dart'; -import 'package:re_highlight/languages/css.dart'; -import 'package:re_highlight/languages/dart.dart'; -import 'package:re_highlight/languages/go.dart'; -import 'package:re_highlight/languages/java.dart'; -import 'package:re_highlight/languages/javascript.dart'; -import 'package:re_highlight/languages/json.dart'; -import 'package:re_highlight/languages/kotlin.dart'; -import 'package:re_highlight/languages/markdown.dart'; -import 'package:re_highlight/languages/python.dart'; -import 'package:re_highlight/languages/rust.dart'; -import 'package:re_highlight/languages/sql.dart'; -import 'package:re_highlight/languages/swift.dart'; -import 'package:re_highlight/languages/typescript.dart'; -import 'package:re_highlight/languages/xml.dart'; -import 'package:re_highlight/languages/yaml.dart'; -import 'package:re_highlight/re_highlight.dart'; -import 'package:re_highlight/styles/atom-one-dark.dart'; -import 'package:re_highlight/styles/atom-one-light.dart'; - -const String _monoFamily = 'JetBrains Mono'; - -/// Renders markdown transcript content with T4 typography. -/// -/// Fenced code blocks render through [CodeBlock]. Body text uses plain -/// `Text.rich` spans, so the widget is selectable when embedded in an -/// ambient [SelectionArea]; set [selectable] to false to opt a subtree out. -class TranscriptMarkdown extends StatelessWidget { - const TranscriptMarkdown({ - required this.data, - this.selectable = true, - super.key, - }); - - /// Raw markdown source. - final String data; - - /// Whether an ambient [SelectionArea] may select this content. - final bool selectable; - - @override - Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - final Widget body = MarkdownBody( - data: data, - // Plain RichText spans cooperate with an ambient SelectionArea; - // SelectableText (selectable: true) would fight it. - selectable: false, - styleSheet: _styleSheetFor(theme), - builders: {'code': _FencedCodeBuilder()}, - ); - if (selectable) { - return SelectionArea(child: body); - } - return SelectionContainer.disabled(child: body); - } - - static MarkdownStyleSheet _styleSheetFor(ThemeData theme) { - final ColorScheme scheme = theme.colorScheme; - final TextTheme text = theme.textTheme; - final TextStyle body = (text.bodyMedium ?? const TextStyle()).copyWith( - fontSize: 13, - height: 1.55, - ); - TextStyle heading(TextStyle? base, double size) => - (base ?? body).copyWith(fontSize: size, height: 1.3); - - return MarkdownStyleSheet.fromTheme(theme).copyWith( - p: body, - pPadding: EdgeInsets.zero, - blockSpacing: 10, - listIndent: 20, - listBullet: body.copyWith(color: scheme.onSurfaceVariant), - h1: heading(text.titleLarge, 19), - h2: heading(text.titleMedium, 16.5), - h3: heading(text.titleSmall, 14.5), - h4: heading(text.labelLarge, 13.5), - h5: heading(text.labelLarge, 13), - h6: heading(text.labelMedium, 12.5), - // Inline code chip; fenced blocks are replaced by CodeBlock below. - code: body.copyWith( - fontFamily: _monoFamily, - fontSize: 12.5, - height: 1.4, - backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.5), - ), - blockquote: body.copyWith(color: scheme.onSurfaceVariant), - blockquotePadding: const EdgeInsets.fromLTRB(12, 4, 8, 4), - blockquoteDecoration: BoxDecoration( - border: Border( - left: BorderSide( - color: scheme.primary.withValues(alpha: 0.45), - width: 3, - ), - ), - ), - tableHead: body.copyWith(fontSize: 12.5, fontWeight: FontWeight.w600), - tableBody: body.copyWith(fontSize: 12.5), - tableBorder: TableBorder.all(color: scheme.outlineVariant, width: 1), - tableCellsPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - tableHeadCellsDecoration: BoxDecoration( - color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), - ), - // CodeBlock paints its own container; keep the default `pre` wrapper - // from painting a second background behind it. - codeblockDecoration: const BoxDecoration(), - codeblockPadding: EdgeInsets.zero, - horizontalRuleDecoration: BoxDecoration( - border: Border(top: BorderSide(color: scheme.outlineVariant, width: 1)), - ), - ); - } -} - -/// Routes fenced/indented code blocks to [CodeBlock]. -/// -/// Registered under the `code` tag: returning a widget replaces the default -/// `pre` scroll view, while returning null for inline `code` spans keeps the -/// stylesheet-driven inline rendering (and unbroken text flow). -class _FencedCodeBuilder extends MarkdownElementBuilder { - @override - Widget? visitElementAfterWithContext( - BuildContext context, - md.Element element, - TextStyle? preferredStyle, - TextStyle? parentStyle, - ) { - final String classAttribute = element.attributes['class'] ?? ''; - final String? language = classAttribute.startsWith('language-') - ? classAttribute.substring('language-'.length) - : null; - String code = element.textContent; - final bool isBlock = language != null || code.contains('\n'); - if (!isBlock) { - return null; - } - if (code.endsWith('\n')) { - code = code.substring(0, code.length - 1); - } - return CodeBlock(code: code, language: language); - } -} - -/// A fenced code block with a language header, copy button, and -/// `re_highlight` syntax highlighting. -class CodeBlock extends StatefulWidget { - const CodeBlock({required this.code, this.language, super.key}); - - /// Code text without the trailing fence newline. - final String code; - - /// Info-string language (e.g. `dart`), if one was provided. - final String? language; - - @override - State createState() => _CodeBlockState(); -} - -class _CodeBlockState extends State { - static const Duration _copyFeedback = Duration(milliseconds: 1500); - - bool _copied = false; - Timer? _copyTimer; - - @override - void dispose() { - _copyTimer?.cancel(); - super.dispose(); - } - - Future _copy() async { - await Clipboard.setData(ClipboardData(text: widget.code)); - if (!mounted) { - return; - } - setState(() => _copied = true); - _copyTimer?.cancel(); - _copyTimer = Timer(_copyFeedback, () { - if (mounted) { - setState(() => _copied = false); - } - }); - } - - @override - Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - final ColorScheme scheme = theme.colorScheme; - final Map syntaxTheme = - theme.brightness == Brightness.dark - ? atomOneDarkTheme - : atomOneLightTheme; - final TextStyle baseStyle = TextStyle( - fontFamily: _monoFamily, - fontSize: 12.5, - height: 1.55, - color: syntaxTheme['root']?.color ?? scheme.onSurface, - ); - final String label = switch (widget.language?.trim()) { - final String language when language.isNotEmpty => language, - _ => 'text', - }; - - return Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: scheme.outlineVariant, width: 1), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(theme, label), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), - child: Text.rich( - _highlightedSpan( - widget.code, - widget.language, - baseStyle, - syntaxTheme, - ), - softWrap: false, - ), - ), - ], - ), - ); - } - - Widget _buildHeader(ThemeData theme, String label) { - final ColorScheme scheme = theme.colorScheme; - return Container( - padding: const EdgeInsetsDirectional.only(start: 12, end: 4), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: scheme.outlineVariant, width: 1), - ), - ), - child: Row( - children: [ - Expanded( - child: SelectionContainer.disabled( - child: Text( - label, - style: (theme.textTheme.labelSmall ?? const TextStyle()) - .copyWith( - fontFamily: _monoFamily, - color: scheme.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - IconButton( - onPressed: _copy, - tooltip: _copied ? 'Copied' : 'Copy', - iconSize: 15, - visualDensity: VisualDensity.compact, - icon: Icon( - _copied ? Icons.check_rounded : Icons.copy_rounded, - color: _copied ? scheme.primary : scheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } -} - -/// Lazily constructed highlighter with the transcript's supported grammars. -final Highlight _highlighter = () { - final Highlight highlight = Highlight(); - highlight.registerLanguages({ - 'bash': langBash, - 'c': langC, - 'cpp': langCpp, - 'css': langCss, - 'dart': langDart, - 'go': langGo, - 'java': langJava, - 'javascript': langJavascript, - 'json': langJson, - 'kotlin': langKotlin, - 'markdown': langMarkdown, - 'python': langPython, - 'rust': langRust, - 'sql': langSql, - 'swift': langSwift, - 'typescript': langTypescript, - 'xml': langXml, - 'yaml': langYaml, - }); - return highlight; -}(); - -const Map _languageAliases = { - 'c++': 'cpp', - 'cc': 'cpp', - 'cjs': 'javascript', - 'golang': 'go', - 'htm': 'xml', - 'html': 'xml', - 'hpp': 'cpp', - 'js': 'javascript', - 'jsx': 'javascript', - 'kt': 'kotlin', - 'md': 'markdown', - 'mjs': 'javascript', - 'py': 'python', - 'rs': 'rust', - 'sh': 'bash', - 'shell': 'bash', - 'ts': 'typescript', - 'tsx': 'typescript', - 'yml': 'yaml', - 'zsh': 'bash', -}; - -TextSpan _highlightedSpan( - String code, - String? language, - TextStyle baseStyle, - Map syntaxTheme, -) { - final String? normalized = _normalizeLanguage(language); - if (normalized == null) { - return TextSpan(text: code, style: baseStyle); - } - final HighlightResult result = _highlighter.highlight( - code: code, - language: normalized, - ); - final TextSpanRenderer renderer = TextSpanRenderer(baseStyle, syntaxTheme); - result.render(renderer); - return renderer.span ?? TextSpan(text: code, style: baseStyle); -} - -String? _normalizeLanguage(String? language) { - final String? raw = language?.trim().toLowerCase(); - if (raw == null || raw.isEmpty) { - return null; - } - final String canonical = _languageAliases[raw] ?? raw; - return _highlighter.getLanguage(canonical) != null ? canonical : null; -} diff --git a/apps/flutter/lib/src/ui/transcript_search_pane.dart b/apps/flutter/lib/src/ui/transcript_search_pane.dart deleted file mode 100644 index a274df67..00000000 --- a/apps/flutter/lib/src/ui/transcript_search_pane.dart +++ /dev/null @@ -1,521 +0,0 @@ -part of 't4_app.dart'; - -final class _TranscriptSearchPane extends StatefulWidget { - const _TranscriptSearchPane({ - required this.actions, - required this.showHeader, - required this.onDone, - required this.onOpenSession, - }); - - final T4Actions actions; - final bool showHeader; - final VoidCallback onDone; - final Future Function(String sessionId) onOpenSession; - - @override - State<_TranscriptSearchPane> createState() => _TranscriptSearchPaneState(); -} - -final class _TranscriptSearchPaneState extends State<_TranscriptSearchPane> { - final TextEditingController _query = TextEditingController(); - final TextEditingController _project = TextEditingController(); - final Set _roles = {}; - final Map _contexts = - {}; - final Set _loadingContexts = {}; - TranscriptSearchResult? _result; - String _archived = 'include'; - String? _error; - bool _searching = false; - int _requestGeneration = 0; - - @override - void dispose() { - _query.dispose(); - _project.dispose(); - super.dispose(); - } - - Future _search({bool append = false}) async { - final query = _query.text.trim(); - if (query.isEmpty || _searching) return; - final generation = ++_requestGeneration; - setState(() { - _searching = true; - _error = null; - if (!append) { - _result = null; - _contexts.clear(); - _loadingContexts.clear(); - } - }); - try { - final next = await widget.actions.searchTranscripts( - query: query, - cursor: append ? _result?.nextCursor : null, - projectId: _project.text.trim().isEmpty ? null : _project.text.trim(), - roles: _roles.isEmpty ? null : _roles.toList(growable: false), - archived: _archived, - ); - if (!mounted || generation != _requestGeneration) return; - final current = _result; - setState(() { - if (append && current != null) { - _result = TranscriptSearchResult( - items: List.unmodifiable( - [...current.items, ...next.items], - ), - nextCursor: next.nextCursor, - incomplete: next.incomplete, - index: next.index, - ); - } else { - _result = next; - } - }); - } on Object catch (error) { - if (!mounted || generation != _requestGeneration) return; - setState(() => _error = 'Search failed: $error'); - } finally { - if (mounted && generation == _requestGeneration) { - setState(() => _searching = false); - } - } - } - - Future _loadContext(TranscriptSearchItem item) async { - if (_loadingContexts.contains(item.anchorId)) return; - setState(() { - _loadingContexts.add(item.anchorId); - _error = null; - }); - try { - final context = await widget.actions.loadTranscriptContext( - sessionId: item.sessionId, - anchorId: item.anchorId, - ); - if (!mounted) return; - setState(() => _contexts[item.anchorId] = context); - } on Object catch (error) { - if (!mounted) return; - setState(() => _error = 'Context failed: $error'); - } finally { - if (mounted) setState(() => _loadingContexts.remove(item.anchorId)); - } - } - - @override - Widget build(BuildContext context) { - final result = _result; - return Column( - children: [ - if (widget.showHeader) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.xl, - _T4Space.lg, - _T4Space.md, - _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Search transcripts', - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - IconButton( - onPressed: widget.onDone, - tooltip: 'Close search', - icon: const Icon(Icons.close), - ), - ], - ), - ), - Expanded( - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(_T4Space.lg), - sliver: SliverList.list( - children: [ - TextField( - controller: _query, - autofocus: true, - textInputAction: TextInputAction.search, - onSubmitted: (_) => unawaited(_search()), - decoration: InputDecoration( - labelText: 'Search transcripts', - hintText: 'Error text, decision, command…', - prefixIcon: const Icon(Icons.search), - suffixIcon: IconButton( - tooltip: 'Search', - onPressed: _searching - ? null - : () => unawaited(_search()), - icon: _searching - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.arrow_forward), - ), - ), - ), - const SizedBox(height: _T4Space.sm), - TextField( - controller: _project, - textInputAction: TextInputAction.done, - onSubmitted: (_) => unawaited(_search()), - decoration: const InputDecoration( - labelText: 'Project ID (optional)', - prefixIcon: Icon(Icons.folder_outlined), - ), - ), - const SizedBox(height: _T4Space.sm), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - for (final role in TranscriptSearchRole.values) - FilterChip( - label: Text(_roleLabel(role)), - selected: _roles.contains(role), - onSelected: (selected) => setState(() { - if (selected) { - _roles.add(role); - } else { - _roles.remove(role); - } - }), - ), - Tooltip( - message: 'Archived sessions', - child: DropdownButton( - value: _archived, - items: const [ - DropdownMenuItem( - value: 'include', - child: Text('All sessions'), - ), - DropdownMenuItem( - value: 'exclude', - child: Text('Current only'), - ), - DropdownMenuItem( - value: 'only', - child: Text('Archived only'), - ), - ], - onChanged: _searching - ? null - : (value) => setState(() => _archived = value!), - ), - ), - ], - ), - if (_error case final error?) ...[ - const SizedBox(height: _T4Space.sm), - Card( - color: Theme.of(context).colorScheme.errorContainer, - child: ListTile( - leading: const Icon(Icons.error_outline), - title: const Text('Search unavailable'), - subtitle: Text(error), - ), - ), - ], - if (result != null) ...[ - const SizedBox(height: _T4Space.md), - _SearchIndexStatus(status: result.index), - if (result.items.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: _T4Space.xl), - child: Column( - children: [ - Icon(Icons.search_off, size: _T4Size.emptyIcon), - SizedBox(height: _T4Space.sm), - Text('No matching transcripts'), - SizedBox(height: _T4Space.xxs), - Text('Try fewer filters or a different phrase.'), - ], - ), - ), - ], - ], - ), - ), - if (result != null) - SliverPadding( - padding: const EdgeInsets.fromLTRB( - _T4Space.lg, - 0, - _T4Space.lg, - _T4Space.xl, - ), - sliver: SliverList.separated( - itemCount: result.items.length, - separatorBuilder: (_, _) => - const SizedBox(height: _T4Space.sm), - itemBuilder: (context, index) { - final item = result.items[index]; - return _TranscriptSearchCard( - item: item, - contextResult: _contexts[item.anchorId], - loadingContext: _loadingContexts.contains( - item.anchorId, - ), - onLoadContext: () => unawaited(_loadContext(item)), - onOpenSession: item.archivedAt == null - ? () => unawaited( - widget.onOpenSession(item.sessionId), - ) - : null, - ); - }, - ), - ), - if (result?.nextCursor != null) - SliverPadding( - padding: const EdgeInsets.fromLTRB( - _T4Space.lg, - 0, - _T4Space.lg, - _T4Space.xl, - ), - sliver: SliverToBoxAdapter( - child: OutlinedButton.icon( - onPressed: _searching - ? null - : () => unawaited(_search(append: true)), - icon: const Icon(Icons.expand_more), - label: const Text('Load more'), - ), - ), - ), - ], - ), - ), - ], - ); - } -} - -final class _SearchIndexStatus extends StatelessWidget { - const _SearchIndexStatus({required this.status}); - - final TranscriptSearchIndexStatus status; - - @override - Widget build(BuildContext context) { - final indexed = status.indexedSessions; - final known = status.knownSessions; - final label = switch (status.state) { - TranscriptSearchIndexState.ready => '$known sessions indexed', - TranscriptSearchIndexState.building => - 'Indexing $indexed of $known sessions', - TranscriptSearchIndexState.stale => - 'Results may be incomplete while the index refreshes', - }; - return Row( - children: [ - Icon( - status.state == TranscriptSearchIndexState.ready - ? Icons.check_circle_outline - : Icons.sync, - size: _T4Size.indicator, - ), - const SizedBox(width: _T4Space.xs), - Expanded( - child: Text(label, style: Theme.of(context).textTheme.bodySmall), - ), - ], - ); - } -} - -final class _TranscriptSearchCard extends StatelessWidget { - const _TranscriptSearchCard({ - required this.item, - required this.contextResult, - required this.loadingContext, - required this.onLoadContext, - required this.onOpenSession, - }); - - final TranscriptSearchItem item; - final TranscriptContextResult? contextResult; - final bool loadingContext; - final VoidCallback onLoadContext; - final VoidCallback? onOpenSession; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - item.sessionTitle.isEmpty - ? 'Untitled session' - : item.sessionTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - ), - ), - if (item.archivedAt != null) - const Padding( - padding: EdgeInsets.only(left: _T4Space.xs), - child: Chip(label: Text('Archived')), - ), - ], - ), - const SizedBox(height: _T4Space.xxs), - Text( - '${_roleLabel(item.role)} · ${_formatTranscriptTime(item.timestamp)}', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: scheme.onSurfaceVariant), - ), - const SizedBox(height: _T4Space.sm), - SelectionArea(child: _HighlightedSnippet(item: item)), - const SizedBox(height: _T4Space.sm), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - children: [ - TextButton.icon( - onPressed: loadingContext || contextResult != null - ? null - : onLoadContext, - icon: loadingContext - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.history), - label: Text( - contextResult == null ? 'Show context' : 'Context loaded', - ), - ), - if (onOpenSession != null) - TextButton.icon( - onPressed: onOpenSession, - icon: const Icon(Icons.open_in_new), - label: const Text('Open session'), - ), - ], - ), - if (contextResult case final contextResult?) ...[ - const Divider(), - for (var index = 0; index < contextResult.rows.length; index++) - _TranscriptContextRowView( - row: contextResult.rows[index], - anchor: index == contextResult.anchorIndex, - ), - if (contextResult.hasBefore || contextResult.hasAfter) - Padding( - padding: const EdgeInsets.only(top: _T4Space.xs), - child: Text( - 'More transcript exists outside this context window.', - style: Theme.of(context).textTheme.labelSmall, - ), - ), - ], - ], - ), - ), - ); - } -} - -final class _HighlightedSnippet extends StatelessWidget { - const _HighlightedSnippet({required this.item}); - - final TranscriptSearchItem item; - - @override - Widget build(BuildContext context) { - final base = Theme.of(context).textTheme.bodyMedium; - final highlight = base?.copyWith( - fontWeight: FontWeight.w700, - backgroundColor: Theme.of(context).colorScheme.tertiaryContainer, - ); - final spans = []; - var offset = 0; - for (final range in item.highlights) { - if (range.end <= offset) continue; - final start = range.start < offset ? offset : range.start; - if (start > offset) { - spans.add(TextSpan(text: item.snippet.substring(offset, start))); - } - spans.add( - TextSpan( - text: item.snippet.substring(start, range.end), - style: highlight, - ), - ); - offset = range.end; - } - if (offset < item.snippet.length) { - spans.add(TextSpan(text: item.snippet.substring(offset))); - } - return Text.rich(TextSpan(style: base, children: spans)); - } -} - -final class _TranscriptContextRowView extends StatelessWidget { - const _TranscriptContextRowView({required this.row, required this.anchor}); - - final TranscriptContextRow row; - final bool anchor; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: _T4Space.xs), - padding: const EdgeInsets.all(_T4Space.sm), - decoration: BoxDecoration( - color: anchor ? scheme.secondaryContainer : scheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${_roleLabel(row.role)} · ${_formatTranscriptTime(row.timestamp)}', - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(height: _T4Space.xxs), - SelectionArea(child: Text(row.text)), - ], - ), - ); - } -} - -String _roleLabel(TranscriptSearchRole role) => switch (role) { - TranscriptSearchRole.user => 'User', - TranscriptSearchRole.assistant => 'Assistant', - TranscriptSearchRole.summary => 'Summary', -}; - -String _formatTranscriptTime(String value) { - final parsed = DateTime.tryParse(value); - return parsed == null ? value : _formatActivityTime(parsed); -} diff --git a/apps/flutter/lib/src/ui/usage_status_pane.dart b/apps/flutter/lib/src/ui/usage_status_pane.dart deleted file mode 100644 index c454e1d6..00000000 --- a/apps/flutter/lib/src/ui/usage_status_pane.dart +++ /dev/null @@ -1,483 +0,0 @@ -part of 't4_app.dart'; - -final class _UsageStatusPane extends StatefulWidget { - const _UsageStatusPane({ - required this.state, - required this.actions, - required this.showHeader, - required this.onDone, - }); - - final T4ViewState state; - final T4Actions actions; - final bool showHeader; - final VoidCallback onDone; - - @override - State<_UsageStatusPane> createState() => _UsageStatusPaneState(); -} - -final class _UsageStatusPaneState extends State<_UsageStatusPane> { - UsageReadResult? _usage; - BrokerStatusResult? _broker; - String? _usageError; - String? _brokerError; - bool _loadingUsage = false; - bool _loadingBroker = false; - - bool get _canReadUsage => - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('usage.read'); - - bool get _canReadBroker => - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('broker.read'); - - @override - void initState() { - super.initState(); - unawaited(_refresh()); - } - - Future _refresh() async { - if (_loadingUsage || _loadingBroker) return; - setState(() { - _loadingUsage = _canReadUsage; - _loadingBroker = _canReadBroker; - _usageError = null; - _brokerError = null; - }); - await Future.wait([ - if (_canReadUsage) _refreshUsage(), - if (_canReadBroker) _refreshBroker(), - ]); - } - - Future _refreshUsage() async { - try { - final usage = await widget.actions.readUsage(); - if (mounted) setState(() => _usage = usage); - } on Object { - if (mounted) setState(() => _usageError = 'Usage could not be loaded.'); - } finally { - if (mounted) setState(() => _loadingUsage = false); - } - } - - Future _refreshBroker() async { - try { - final broker = await widget.actions.readBrokerStatus(); - if (mounted) setState(() => _broker = broker); - } on Object { - if (mounted) { - setState( - () => _brokerError = 'Account broker status could not be loaded.', - ); - } - } finally { - if (mounted) setState(() => _loadingBroker = false); - } - } - - @override - Widget build(BuildContext context) { - final horizontal = widget.showHeader ? _T4Space.xl : _T4Space.md; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.showHeader) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.xl, - _T4Space.lg, - _T4Space.md, - _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Usage & accounts', - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - IconButton( - onPressed: widget.onDone, - tooltip: 'Close usage', - icon: const Icon(Icons.close), - ), - ], - ), - ), - if (_loadingUsage || _loadingBroker) - const LinearProgressIndicator( - semanticsLabel: 'Loading usage and account status', - ), - Expanded( - child: RefreshIndicator( - onRefresh: _refresh, - child: ListView( - padding: EdgeInsets.fromLTRB( - horizontal, - _T4Space.lg, - horizontal, - _T4Space.xl, - ), - children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildBroker(context), - const SizedBox(height: _T4Space.xl), - _buildUsage(context), - ], - ), - ), - ), - ], - ), - ), - ), - ], - ); - } - - Widget _buildBroker(BuildContext context) { - final broker = _broker; - return _StatusSection( - title: 'Account source', - trailing: IconButton( - onPressed: _loadingBroker ? null : () => unawaited(_refresh()), - tooltip: 'Refresh usage and account status', - icon: const Icon(Icons.refresh), - ), - child: !_canReadBroker - ? const _StatusNotice( - icon: Icons.lock_outline, - title: 'Broker status unavailable', - message: 'This host did not grant broker.read.', - ) - : _brokerError != null - ? _StatusNotice( - icon: Icons.error_outline, - title: 'Broker status unavailable', - message: _brokerError!, - ) - : broker == null - ? const _StatusNotice( - icon: Icons.hourglass_empty, - title: 'Loading account source', - message: 'Waiting for the connected host.', - ) - : _BrokerStatusCard(status: broker), - ); - } - - Widget _buildUsage(BuildContext context) { - final usage = _usage; - return _StatusSection( - title: 'Usage limits', - child: !_canReadUsage - ? const _StatusNotice( - icon: Icons.lock_outline, - title: 'Usage unavailable', - message: 'This host did not grant usage.read.', - ) - : _usageError != null - ? _StatusNotice( - icon: Icons.error_outline, - title: 'Usage unavailable', - message: _usageError!, - ) - : usage == null - ? const _StatusNotice( - icon: Icons.hourglass_empty, - title: 'Loading usage', - message: 'Waiting for provider reports.', - ) - : _UsageReportList(result: usage), - ); - } -} - -final class _StatusSection extends StatelessWidget { - const _StatusSection({ - required this.title, - required this.child, - this.trailing, - }); - - final String title; - final Widget child; - final Widget? trailing; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text(title, style: Theme.of(context).textTheme.titleLarge), - ), - ?trailing, - ], - ), - const SizedBox(height: _T4Space.sm), - child, - ], - ); -} - -final class _StatusNotice extends StatelessWidget { - const _StatusNotice({ - required this.icon, - required this.title, - required this.message, - }); - - final IconData icon; - final String title; - final String message; - - @override - Widget build(BuildContext context) => Card( - margin: EdgeInsets.zero, - child: ListTile( - leading: Icon(icon), - title: Text(title), - subtitle: Text(message), - ), - ); -} - -final class _BrokerStatusCard extends StatelessWidget { - const _BrokerStatusCard({required this.status}); - - final BrokerStatusResult status; - - @override - Widget build(BuildContext context) { - final (icon, title, message) = switch (status.state) { - BrokerState.local => ( - Icons.computer, - 'Local accounts', - 'Provider accounts are configured on this OMP host.', - ), - BrokerState.connected => ( - Icons.cloud_done_outlined, - 'Broker connected', - status.endpoint ?? 'The remote account broker is connected.', - ), - BrokerState.missingToken => ( - Icons.key_off_outlined, - 'Broker token missing', - status.endpoint ?? 'The host needs a broker token.', - ), - BrokerState.unreachable => ( - Icons.cloud_off_outlined, - 'Broker unreachable', - status.endpoint ?? 'The configured broker cannot be reached.', - ), - }; - return Card( - margin: EdgeInsets.zero, - child: ListTile( - leading: Icon(icon), - title: Text(title), - subtitle: Text(message), - ), - ); - } -} - -final class _UsageReportList extends StatelessWidget { - const _UsageReportList({required this.result}); - - final UsageReadResult result; - - @override - Widget build(BuildContext context) { - final generated = result.generatedAt > 0 - ? DateTime.fromMillisecondsSinceEpoch(result.generatedAt) - : null; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (generated != null) ...[ - Text( - 'Updated ${_formatActivityTime(generated)}', - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(height: _T4Space.sm), - ], - if (result.reports.isEmpty) - const _StatusNotice( - icon: Icons.data_usage, - title: 'No usage reports', - message: 'No provider returned a usage report.', - ) - else - for (final report in result.reports) ...[ - _UsageProviderCard(report: report), - const SizedBox(height: _T4Space.sm), - ], - if (result.accountsWithoutUsage.isNotEmpty) ...[ - const SizedBox(height: _T4Space.sm), - Text( - 'Accounts without usage data', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xs), - for (final account in result.accountsWithoutUsage) - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.account_circle_outlined), - title: Text(account.provider), - subtitle: Text( - account.email ?? account.orgName ?? account.type.name, - ), - ), - ], - ], - ); - } -} - -final class _UsageProviderCard extends StatelessWidget { - const _UsageProviderCard({required this.report}); - - final UsageReport report; - - @override - Widget build(BuildContext context) => Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - report.provider, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - if (report.availableResetCredits case final credits?) - Chip(label: Text('$credits reset credits')), - ], - ), - if (report.limits.isEmpty) - const Padding( - padding: EdgeInsets.only(top: _T4Space.sm), - child: Text('No limits reported.'), - ) - else - for (final limit in report.limits) ...[ - const SizedBox(height: _T4Space.md), - _UsageLimitMeter(limit: limit), - ], - for (final note in report.notes) - Padding( - padding: const EdgeInsets.only(top: _T4Space.xs), - child: Text(note, style: Theme.of(context).textTheme.bodySmall), - ), - ], - ), - ), - ); -} - -final class _UsageLimitMeter extends StatelessWidget { - const _UsageLimitMeter({required this.limit}); - - final UsageLimit limit; - - @override - Widget build(BuildContext context) { - final amount = limit.amount; - final fraction = _usageFraction(amount); - final detail = _usageAmountLabel(amount); - final scheme = Theme.of(context).colorScheme; - final color = switch (limit.status) { - UsageStatus.exhausted => scheme.error, - UsageStatus.warning => scheme.tertiary, - _ => scheme.primary, - }; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded(child: Text(limit.label)), - Text(detail, style: Theme.of(context).textTheme.labelMedium), - ], - ), - const SizedBox(height: _T4Space.xs), - LinearProgressIndicator( - value: fraction, - color: color, - semanticsLabel: '${limit.label}: $detail', - ), - if (limit.window case final window?) - Padding( - padding: const EdgeInsets.only(top: _T4Space.xxs), - child: Text( - window.resetsAt == null || window.resetsAt! <= 0 - ? window.label - : '${window.label} · resets ${_formatActivityTime(DateTime.fromMillisecondsSinceEpoch(window.resetsAt!))}', - style: Theme.of(context).textTheme.labelSmall, - ), - ), - for (final note in limit.notes) - Padding( - padding: const EdgeInsets.only(top: _T4Space.xxs), - child: Text(note, style: Theme.of(context).textTheme.bodySmall), - ), - ], - ); - } -} - -double? _usageFraction(UsageAmount amount) { - final explicit = - amount.usedFraction ?? - (amount.remainingFraction == null ? null : 1 - amount.remainingFraction!); - final calculated = - amount.used != null && amount.limit != null && amount.limit != 0 - ? amount.used! / amount.limit! - : null; - final value = explicit ?? calculated; - return value?.clamp(0, 1).toDouble(); -} - -String _usageAmountLabel(UsageAmount amount) { - final unit = switch (amount.unit) { - UsageUnit.percent => '%', - UsageUnit.usd => ' USD', - UsageUnit.tokens => ' tokens', - UsageUnit.requests => ' requests', - UsageUnit.minutes => ' min', - UsageUnit.bytes => ' bytes', - UsageUnit.unknown => '', - }; - if (amount.used != null && amount.limit != null) { - return '${_compactNumber(amount.used!)} / ${_compactNumber(amount.limit!)}$unit'; - } - if (amount.remaining != null) { - return '${_compactNumber(amount.remaining!)}$unit remaining'; - } - final fraction = _usageFraction(amount); - return fraction == null ? 'Reported' : '${(fraction * 100).round()}% used'; -} - -String _compactNumber(double value) => value == value.roundToDouble() - ? value.toInt().toString() - : value.toStringAsFixed(2); diff --git a/apps/flutter/linux/.gitignore b/apps/flutter/linux/.gitignore deleted file mode 100644 index d3896c98..00000000 --- a/apps/flutter/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/apps/flutter/linux/CMakeLists.txt b/apps/flutter/linux/CMakeLists.txt deleted file mode 100644 index 4f6a7733..00000000 --- a/apps/flutter/linux/CMakeLists.txt +++ /dev/null @@ -1,128 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "t4code") -# The unique GTK application identifier for this application. See: -# https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.lycaonsolutions.t4code") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/apps/flutter/linux/flutter/CMakeLists.txt b/apps/flutter/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd0164..00000000 --- a/apps/flutter/linux/flutter/CMakeLists.txt +++ /dev/null @@ -1,88 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) diff --git a/apps/flutter/linux/flutter/generated_plugin_registrant.cc b/apps/flutter/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 85a24130..00000000 --- a/apps/flutter/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,19 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); - file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); - flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); -} diff --git a/apps/flutter/linux/flutter/generated_plugin_registrant.h b/apps/flutter/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47b..00000000 --- a/apps/flutter/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/flutter/linux/flutter/generated_plugins.cmake b/apps/flutter/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 7aea3ec1..00000000 --- a/apps/flutter/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,26 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - file_selector_linux - flutter_secure_storage_linux -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/apps/flutter/linux/runner/CMakeLists.txt b/apps/flutter/linux/runner/CMakeLists.txt deleted file mode 100644 index e97dabc7..00000000 --- a/apps/flutter/linux/runner/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the application ID. -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/apps/flutter/linux/runner/main.cc b/apps/flutter/linux/runner/main.cc deleted file mode 100644 index e7c5c543..00000000 --- a/apps/flutter/linux/runner/main.cc +++ /dev/null @@ -1,6 +0,0 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} diff --git a/apps/flutter/linux/runner/my_application.cc b/apps/flutter/linux/runner/my_application.cc deleted file mode 100644 index e753ec73..00000000 --- a/apps/flutter/linux/runner/my_application.cc +++ /dev/null @@ -1,148 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView* view) { - gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); -} - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "t4code"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "t4code"); - } - - gtk_window_set_default_size(window, 1280, 720); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments( - project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - // Background defaults to black, override it here if necessary, e.g. #00000000 - // for transparent. - gdk_rgba_parse(&background_color, "#000000"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - // Show the window when Flutter renders. - // Requires the view to be realized so we can start rendering. - g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), - self); - gtk_widget_realize(GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GApplication::startup. -static void my_application_startup(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application startup. - - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -// Implements GApplication::shutdown. -static void my_application_shutdown(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application shutdown. - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); -} diff --git a/apps/flutter/linux/runner/my_application.h b/apps/flutter/linux/runner/my_application.h deleted file mode 100644 index db16367a..00000000 --- a/apps/flutter/linux/runner/my_application.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, - my_application, - MY, - APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/apps/flutter/macos/.gitignore b/apps/flutter/macos/.gitignore deleted file mode 100644 index 746adbb6..00000000 --- a/apps/flutter/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/apps/flutter/macos/Flutter/Flutter-Debug.xcconfig b/apps/flutter/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index c2efd0b6..00000000 --- a/apps/flutter/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/flutter/macos/Flutter/Flutter-Release.xcconfig b/apps/flutter/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index c2efd0b6..00000000 --- a/apps/flutter/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/flutter/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 5d648153..00000000 --- a/apps/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import file_selector_macos -import flutter_secure_storage_darwin -import shared_preferences_foundation - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) -} diff --git a/apps/flutter/macos/Runner.xcodeproj/project.pbxproj b/apps/flutter/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index f3cfcf83..00000000 --- a/apps/flutter/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,743 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; - A4C1F1012E2C000100000001 /* PlatformLifecycleChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4C1F1002E2C000100000001 /* PlatformLifecycleChannel.swift */; }; - A4C1F1032E2C000100000003 /* t4-host in Embed T4 Host */ = {isa = PBXBuildFile; fileRef = A4C1F1022E2C000100000002 /* t4-host */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; - A4C1F1042E2C000100000004 /* Embed T4 Host */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = runtime; - dstSubfolderSpec = 7; - files = ( - A4C1F1032E2C000100000003 /* t4-host in Embed T4 Host */, - ); - name = "Embed T4 Host"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* t4code.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "t4code.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - A4C1F1002E2C000100000001 /* PlatformLifecycleChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformLifecycleChannel.swift; sourceTree = ""; }; - A4C1F1022E2C000100000002 /* t4-host */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = ../../../packages/host-daemon/dist/t4-host; sourceTree = SOURCE_ROOT; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* t4code.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - A4C1F1022E2C000100000002 /* t4-host */, - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - A4C1F1002E2C000100000001 /* PlatformLifecycleChannel.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - A4C1F1042E2C000100000004 /* Embed T4 Host */, - 3399D490228B24CF009A79C7 /* ShellScript */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* t4code.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - A4C1F1012E2C000100000001 /* PlatformLifecycleChannel.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/t4code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/t4code"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/t4code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/t4code"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/t4code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/t4code"; - }; - name = Profile; - }; - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Profile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/apps/flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/apps/flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index 0cc27e0a..00000000 --- a/apps/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata b/apps/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16..00000000 --- a/apps/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/apps/flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/apps/flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/flutter/macos/Runner/AppDelegate.swift b/apps/flutter/macos/Runner/AppDelegate.swift deleted file mode 100644 index d7e1a26a..00000000 --- a/apps/flutter/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Cocoa -import FlutterMacOS - -@main -class AppDelegate: FlutterAppDelegate { - private var platformLifecycleChannel: PlatformLifecycleChannel? - - override func applicationDidFinishLaunching(_ notification: Notification) { - super.applicationDidFinishLaunching(notification) - guard let flutterViewController = mainFlutterWindow?.contentViewController as? FlutterViewController else { - return - } - platformLifecycleChannel = PlatformLifecycleChannel( - messenger: flutterViewController.engine.binaryMessenger - ) - } - - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true - } -} diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f1..00000000 --- a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 7bbdb63f..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index b04a4e29..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 9a5cd0fd..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index 83a68d86..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index e2df182c..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 225c077d..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index b31a93bc..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Base.lproj/MainMenu.xib b/apps/flutter/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index 80e867a4..00000000 --- a/apps/flutter/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/macos/Runner/Configs/AppInfo.xcconfig b/apps/flutter/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index c280418e..00000000 --- a/apps/flutter/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = t4code - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 com.lycaonsolutions. All rights reserved. diff --git a/apps/flutter/macos/Runner/Configs/Debug.xcconfig b/apps/flutter/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd94..00000000 --- a/apps/flutter/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/apps/flutter/macos/Runner/Configs/Release.xcconfig b/apps/flutter/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f495..00000000 --- a/apps/flutter/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/apps/flutter/macos/Runner/Configs/Warnings.xcconfig b/apps/flutter/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf47..00000000 --- a/apps/flutter/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/apps/flutter/macos/Runner/DebugProfile.entitlements b/apps/flutter/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index 6dd0d082..00000000 --- a/apps/flutter/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - com.apple.security.network.client - - com.apple.security.files.user-selected.read-only - - - diff --git a/apps/flutter/macos/Runner/Info.plist b/apps/flutter/macos/Runner/Info.plist deleted file mode 100644 index 4789daa6..00000000 --- a/apps/flutter/macos/Runner/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - diff --git a/apps/flutter/macos/Runner/MainFlutterWindow.swift b/apps/flutter/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 3cc05eb2..00000000 --- a/apps/flutter/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/apps/flutter/macos/Runner/PlatformLifecycleChannel.swift b/apps/flutter/macos/Runner/PlatformLifecycleChannel.swift deleted file mode 100644 index b8d02291..00000000 --- a/apps/flutter/macos/Runner/PlatformLifecycleChannel.swift +++ /dev/null @@ -1,1304 +0,0 @@ -import CoreFoundation -import Darwin -import FlutterMacOS -import Foundation - -private let runtimeOutputLimit = 16 * 1024 -private let runtimeProbeTimeout: TimeInterval = 1.5 -private let runtimeCommandTimeout: TimeInterval = 8 -private let runtimeLabel = "dev.oh-my-pi.appserver" -private let authorityBridgeHelpMarkers = [ - "Expose the private OMP authority bridge used by T4 Code", - "--stdio", -] - -struct RuntimeProcessResult { - let exitCode: Int32? - let output: String - let timedOut: Bool - let overflowed: Bool -} - -protocol RuntimeProcessRunning { - func run( - executableURL: URL, - arguments: [String], - environment: [String: String], - timeout: TimeInterval, - maxOutputBytes: Int - ) throws -> RuntimeProcessResult -} - -final class BoundedRuntimeProcessRunner: RuntimeProcessRunning { - func run( - executableURL: URL, - arguments: [String], - environment: [String: String], - timeout: TimeInterval, - maxOutputBytes: Int - ) throws -> RuntimeProcessResult { - let process = Process() - process.executableURL = executableURL - process.arguments = arguments - process.environment = environment - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - let lock = NSLock() - var output = Data() - var overflowed = false - pipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - lock.lock() - if output.count < maxOutputBytes + 1 { - output.append(data.prefix(maxOutputBytes + 1 - output.count)) - } - if output.count > maxOutputBytes { - overflowed = true - } - lock.unlock() - if overflowed, process.isRunning { - process.terminate() - } - } - - let terminated = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in terminated.signal() } - do { - try process.run() - } catch { - pipe.fileHandleForReading.readabilityHandler = nil - throw error - } - - var timedOut = false - if terminated.wait(timeout: .now() + timeout) == .timedOut { - timedOut = true - if process.isRunning { process.terminate() } - if terminated.wait(timeout: .now() + 0.2) == .timedOut, process.isRunning { - kill(process.processIdentifier, SIGKILL) - _ = terminated.wait(timeout: .now() + 0.2) - } - } - - pipe.fileHandleForReading.readabilityHandler = nil - let tail = pipe.fileHandleForReading.readDataToEndOfFile() - lock.lock() - if output.count < maxOutputBytes + 1 { - output.append(tail.prefix(maxOutputBytes + 1 - output.count)) - } - overflowed = overflowed || output.count > maxOutputBytes - let bounded = output.prefix(maxOutputBytes) - lock.unlock() - - return RuntimeProcessResult( - exitCode: process.isRunning ? nil : process.terminationStatus, - output: String(decoding: bounded, as: UTF8.self), - timedOut: timedOut, - overflowed: overflowed - ) - } -} - -enum RuntimeDiscovery: Equatable { - case found(String) - case incompatible - case missing -} - -final class OmpRuntimeDiscovery { - private let environment: [String: String] - private let homeDirectory: String - private let runner: RuntimeProcessRunning - - init( - environment: [String: String] = ProcessInfo.processInfo.environment, - homeDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path, - runner: RuntimeProcessRunning = BoundedRuntimeProcessRunner() - ) { - self.environment = environment - self.homeDirectory = homeDirectory - self.runner = runner - } - - func discover() -> RuntimeDiscovery { - var candidates: [String] = [] - if let explicit = environment["OMP_EXECUTABLE"], !explicit.isEmpty { - candidates.append(explicit) - } - let pathEntries = (environment["PATH"] ?? "") - .split(separator: ":", omittingEmptySubsequences: true) - .prefix(64) - candidates.append(contentsOf: pathEntries.map { "\($0)/omp" }) - candidates.append(contentsOf: [ - "\(homeDirectory)/.local/bin/omp", - "\(homeDirectory)/bin/omp", - "/usr/local/bin/omp", - "/usr/bin/omp", - "/opt/omp/bin/omp", - ]) - - var seen = Set() - var foundIncompatible = false - for candidate in candidates { - guard isExecutableCandidate(candidate), seen.insert(candidate).inserted else { continue } - switch probe(candidate) { - case .running, .stopped: - return .found(candidate) - case .incompatible: - foundIncompatible = true - case .invalid: - continue - } - } - return foundIncompatible ? .incompatible : .missing - } - - private func isExecutableCandidate(_ path: String) -> Bool { - guard path.utf8.count <= 4096, - path.first == "/", - !path.utf8.contains(0), - URL(fileURLWithPath: path).lastPathComponent == "omp" - else { return false } - guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), - attributes[.type] as? FileAttributeType == .typeRegular - else { return false } - return Darwin.access(path, X_OK) == 0 - } - - private enum ProbeResult { case running, stopped, incompatible, invalid } - - private func probe(_ executable: String) -> ProbeResult { - var safeEnvironment: [String: String] = [:] - for key in ["HOME", "PATH", "TMPDIR"] { - if let value = environment[key] { safeEnvironment[key] = value } - } - safeEnvironment["OMP_PROFILE"] = "default" - - guard - let bridge = try? runner.run( - executableURL: URL(fileURLWithPath: executable), - arguments: ["bridge", "--help"], - environment: safeEnvironment, - timeout: runtimeProbeTimeout, - maxOutputBytes: runtimeOutputLimit - ), bridge.exitCode == 0, !bridge.timedOut, !bridge.overflowed, - authorityBridgeHelpMarkers.allSatisfy({ bridge.output.contains($0) }), - let result = try? runner.run( - executableURL: URL(fileURLWithPath: executable), - arguments: ["appserver", "status", "--json"], - environment: safeEnvironment, - timeout: runtimeProbeTimeout, - maxOutputBytes: runtimeOutputLimit - ), !result.timedOut, !result.overflowed - else { return .invalid } - - if isUnsupportedJSONDiagnostic(result.output) { return .incompatible } - guard result.exitCode == 0 || result.exitCode == 1, - let data = result.output.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data), - let status = object as? [String: Any], - let state = status["state"] as? String - else { return .invalid } - - if state == "running", - let health = status["health"] as? [String: Any], - health["ok"] as? Bool == true, - let hostID = health["hostId"] as? String, !hostID.isEmpty, - let epoch = health["epoch"] as? String, !epoch.isEmpty - { - return .running - } - if state == "stopped", - let reason = status["reason"] as? String, - ["unreachable", "malformed", "failed"].contains(reason) - { - return .stopped - } - return .invalid - } - - private func isUnsupportedJSONDiagnostic(_ output: String) -> Bool { - let value = output.lowercased() - guard value.contains("--json") || value.contains("-json") else { return false } - return value.contains("unknown flag") - || value.contains("unknown option") - || value.contains("unrecognized flag") - || value.contains("unrecognized option") - || value.contains("flag provided but not defined") - } -} - -final class T4HostRuntimeDiscovery { - private let environment: [String: String] - private let homeDirectory: String - private let packagedExecutable: String? - - init( - environment: [String: String] = ProcessInfo.processInfo.environment, - homeDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path, - packagedExecutable: String? = Bundle.main.resourceURL? - .appendingPathComponent("runtime/t4-host").path - ) { - self.environment = environment - self.homeDirectory = homeDirectory - self.packagedExecutable = packagedExecutable - } - - func discover() -> String? { - var candidates: [String] = [] - if let explicit = environment["T4_HOST_EXECUTABLE"], !explicit.isEmpty { - candidates.append(explicit) - } - if let packagedExecutable, !packagedExecutable.isEmpty { - candidates.append(packagedExecutable) - } - let pathEntries = (environment["PATH"] ?? "") - .split(separator: ":", omittingEmptySubsequences: true) - .prefix(64) - candidates.append(contentsOf: pathEntries.map { "\($0)/t4-host" }) - candidates.append(contentsOf: [ - "\(homeDirectory)/.local/bin/t4-host", - "\(homeDirectory)/bin/t4-host", - "/usr/local/bin/t4-host", - "/usr/bin/t4-host", - ]) - - var seen = Set() - for candidate in candidates where seen.insert(candidate).inserted { - guard candidate.utf8.count <= 4096, - candidate.first == "/", - !candidate.utf8.contains(0), - URL(fileURLWithPath: candidate).lastPathComponent == "t4-host", - let attributes = try? FileManager.default.attributesOfItem(atPath: candidate), - attributes[.type] as? FileAttributeType == .typeRegular, - Darwin.access(candidate, X_OK) == 0 - else { continue } - return candidate - } - return nil - } -} - -enum SecureRuntimeFileError: Error { - case invalidPath, notFound, unsafePath - case io(String) -} - -struct RuntimeFileSnapshot { - let content: String? - let mode: mode_t -} - -protocol RuntimeFileStoring { - func read(_ path: String, maxBytes: Int) throws -> RuntimeFileSnapshot - func ensureDirectory(_ path: String) throws - func writeAtomically(_ path: String, content: String, mode: mode_t) throws - func remove(_ path: String) throws -} - -final class SecureRuntimeFileStore: RuntimeFileStoring { - private func components(_ absolutePath: String) throws -> [String] { - guard absolutePath.first == "/", !absolutePath.utf8.contains(0), absolutePath.utf8.count <= 4096 - else { - throw SecureRuntimeFileError.invalidPath - } - let parts = absolutePath.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - guard !parts.isEmpty, !parts.contains("."), !parts.contains("..") else { - throw SecureRuntimeFileError.invalidPath - } - return parts - } - - private func directoryDescriptor(_ parts: ArraySlice, create: Bool) throws -> Int32 { - var descriptor = Darwin.open("/", O_RDONLY | O_DIRECTORY) - guard descriptor >= 0 else { throw SecureRuntimeFileError.io("open root") } - do { - for component in parts { - if create, mkdirat(descriptor, component, 0o700) != 0, errno != EEXIST { - throw SecureRuntimeFileError.io("create directory") - } - let next = openat(descriptor, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW) - if next < 0 { - if errno == ENOENT { throw SecureRuntimeFileError.notFound } - if errno == ELOOP || errno == ENOTDIR { throw SecureRuntimeFileError.unsafePath } - throw SecureRuntimeFileError.io("open directory") - } - Darwin.close(descriptor) - descriptor = next - } - return descriptor - } catch { - Darwin.close(descriptor) - throw error - } - } - - func ensureDirectory(_ path: String) throws { - let parts = try components(path) - let descriptor = try directoryDescriptor(parts[...], create: true) - Darwin.close(descriptor) - } - - func read(_ path: String, maxBytes: Int = 64 * 1024) throws -> RuntimeFileSnapshot { - let parts = try components(path) - guard let name = parts.last else { throw SecureRuntimeFileError.invalidPath } - let descriptor: Int32 - do { - descriptor = try directoryDescriptor(parts.dropLast(), create: false) - } catch SecureRuntimeFileError.notFound { - return RuntimeFileSnapshot(content: nil, mode: 0o600) - } - defer { Darwin.close(descriptor) } - - let file = openat(descriptor, name, O_RDONLY | O_NOFOLLOW) - if file < 0 { - if errno == ENOENT { return RuntimeFileSnapshot(content: nil, mode: 0o600) } - if errno == ELOOP { throw SecureRuntimeFileError.unsafePath } - throw SecureRuntimeFileError.io("read definition") - } - defer { Darwin.close(file) } - var info = stat() - guard fstat(file, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { - throw SecureRuntimeFileError.unsafePath - } - var data = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - while data.count <= maxBytes { - let count = Darwin.read(file, &buffer, min(buffer.count, maxBytes + 1 - data.count)) - if count < 0 { throw SecureRuntimeFileError.io("read definition") } - if count == 0 { break } - data.append(buffer, count: count) - } - guard data.count <= maxBytes else { throw SecureRuntimeFileError.io("definition too large") } - return RuntimeFileSnapshot( - content: String(decoding: data, as: UTF8.self), - mode: info.st_mode & 0o777 - ) - } - - func writeAtomically(_ path: String, content: String, mode: mode_t) throws { - let parts = try components(path) - guard let name = parts.last, let data = content.data(using: .utf8) else { - throw SecureRuntimeFileError.invalidPath - } - let descriptor = try directoryDescriptor(parts.dropLast(), create: true) - defer { Darwin.close(descriptor) } - - let temporary = ".\(name).tmp-\(UUID().uuidString)" - let file = openat(descriptor, temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode) - guard file >= 0 else { throw SecureRuntimeFileError.io("create temporary definition") } - var shouldRemoveTemporary = true - defer { - Darwin.close(file) - if shouldRemoveTemporary { unlinkat(descriptor, temporary, 0) } - } - try data.withUnsafeBytes { rawBuffer in - guard var pointer = rawBuffer.baseAddress else { return } - var remaining = rawBuffer.count - while remaining > 0 { - let count = Darwin.write(file, pointer, remaining) - if count <= 0 { throw SecureRuntimeFileError.io("write definition") } - remaining -= count - pointer = pointer.advanced(by: count) - } - } - guard fchmod(file, mode) == 0, fsync(file) == 0 else { - throw SecureRuntimeFileError.io("sync definition") - } - guard renameat(descriptor, temporary, descriptor, name) == 0 else { - throw SecureRuntimeFileError.io("replace definition") - } - shouldRemoveTemporary = false - guard fsync(descriptor) == 0 else { - throw SecureRuntimeFileError.io("sync definition directory") - } - } - - func remove(_ path: String) throws { - let parts = try components(path) - guard let name = parts.last else { throw SecureRuntimeFileError.invalidPath } - let descriptor: Int32 - do { - descriptor = try directoryDescriptor(parts.dropLast(), create: false) - } catch SecureRuntimeFileError.notFound { - return - } - defer { Darwin.close(descriptor) } - - var info = stat() - if fstatat(descriptor, name, &info, AT_SYMLINK_NOFOLLOW) != 0 { - if errno == ENOENT { return } - throw SecureRuntimeFileError.io("inspect definition") - } - guard (info.st_mode & S_IFMT) == S_IFREG else { throw SecureRuntimeFileError.unsafePath } - guard unlinkat(descriptor, name, 0) == 0, fsync(descriptor) == 0 else { - throw SecureRuntimeFileError.io("remove definition") - } - } -} - -struct RuntimeBridgeFailure: Error { - let code: String - let message: String -} - -final class MacRuntimeLifecycle { - private let environment: [String: String] - private let homeDirectory: String - private let uid: uid_t - private let runner: RuntimeProcessRunning - private let files: RuntimeFileStoring - private let packagedHostExecutable: String? - - init( - environment: [String: String] = ProcessInfo.processInfo.environment, - homeDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path, - uid: uid_t = getuid(), - runner: RuntimeProcessRunning = BoundedRuntimeProcessRunner(), - files: RuntimeFileStoring = SecureRuntimeFileStore(), - packagedHostExecutable: String? = Bundle.main.resourceURL? - .appendingPathComponent("runtime/t4-host").path - ) { - self.environment = environment - self.homeDirectory = homeDirectory - self.uid = uid - self.runner = runner - self.files = files - self.packagedHostExecutable = packagedHostExecutable - } - - private var definitionPath: String { - "\(homeDirectory)/Library/LaunchAgents/\(runtimeLabel).plist" - } - - private var logsDirectory: String { - "\(homeDirectory)/Library/Logs/T4 Code/appserver" - } - - private var domain: String { "gui/\(uid)" } - private var target: String { "\(domain)/\(runtimeLabel)" } - - func inspect() -> [String: Any] { - inspect(discovery: discovery()) - } - - func install() throws -> [String: Any] { - let discovery = discovery() - let ompExecutable = try requireExecutable(discovery) - let hostExecutable = try requireHostExecutable() - let definition = try renderDefinition( - hostExecutable: hostExecutable, - ompExecutable: ompExecutable - ) - let previous = try fileSnapshotForMutation() - let status = try launchctl(["print", target], allowMissing: true) - let registered = !isMissingService(status) - let changed = previous.content != definition || previous.mode != 0o600 - - do { - if changed { - try files.ensureDirectory(logsDirectory) - try files.writeAtomically(definitionPath, content: definition, mode: 0o600) - } - if registered && changed { _ = try launchctl(["bootout", target]) } - if !registered || changed { _ = try launchctl(["bootstrap", domain, definitionPath]) } - _ = try launchctl(["kickstart", "-k", target]) - } catch { - if changed { - if let old = previous.content { - try? files.writeAtomically(definitionPath, content: old, mode: previous.mode) - } else { - try? files.remove(definitionPath) - } - _ = try? launchctl(["bootout", target], allowMissing: true) - if registered, previous.content != nil { - _ = try? launchctl(["bootstrap", domain, definitionPath]) - if serviceState(status) == "running" { - _ = try? launchctl(["kickstart", "-k", target]) - } - } - } - throw bridgeFailure(error, fallbackCode: "runtime_install_failed") - } - return inspect(discovery: discovery) - } - - func start() throws -> [String: Any] { - let discovery = discovery() - let ompExecutable = try requireExecutable(discovery) - let hostExecutable = try requireHostExecutable() - try requireCurrentDefinition( - hostExecutable: hostExecutable, - ompExecutable: ompExecutable - ) - let status = try launchctl(["print", target], allowMissing: true) - if isMissingService(status) { _ = try launchctl(["bootstrap", domain, definitionPath]) } - _ = try launchctl(["kickstart", "-k", target]) - return inspect(discovery: discovery) - } - - func stop() throws -> [String: Any] { - _ = try launchctl(["bootout", target]) - return inspect(discovery: discovery()) - } - - func restart() throws -> [String: Any] { - let discovery = discovery() - let ompExecutable = try requireExecutable(discovery) - let hostExecutable = try requireHostExecutable() - try requireCurrentDefinition( - hostExecutable: hostExecutable, - ompExecutable: ompExecutable - ) - let status = try launchctl(["print", target], allowMissing: true) - if isMissingService(status) { _ = try launchctl(["bootstrap", domain, definitionPath]) } - _ = try launchctl(["kickstart", "-k", target]) - return inspect(discovery: discovery) - } - - func uninstall() throws -> [String: Any] { - let previous = try fileSnapshotForMutation() - let status = try launchctl(["print", target], allowMissing: true) - if !isMissingService(status) { _ = try launchctl(["bootout", target]) } - do { - try files.remove(definitionPath) - } catch { - if !isMissingService(status), previous.content != nil { - _ = try? launchctl(["bootstrap", domain, definitionPath]) - if serviceState(status) == "running" { - _ = try? launchctl(["kickstart", "-k", target]) - } - } - throw bridgeFailure(error, fallbackCode: "runtime_uninstall_failed") - } - return inspect(discovery: discovery()) - } - - private func discovery() -> RuntimeDiscovery { - OmpRuntimeDiscovery( - environment: environment, - homeDirectory: homeDirectory, - runner: runner - ).discover() - } - - private func inspect(discovery: RuntimeDiscovery) -> [String: Any] { - var map: [String: Any] = [ - "available": false, - "definition": "missing", - "service": "unknown", - "diagnostics": "", - ] - - var ompExecutable: String? - var hostExecutable: String? - switch discovery { - case .found(let path): - ompExecutable = path - map["executable"] = String(path.prefix(4096)) - case .incompatible: - map["issueCode"] = "omp_authority_bridge_required" - map["message"] = boundedDiagnostic( - "Installed OMP is incompatible with this T4 Code build. T4 Code requires the versioned `omp bridge --stdio` authority bridge. Update OMP, then choose Check again." - ) - case .missing: - map["issueCode"] = "omp_not_found" - map["message"] = "A compatible system OMP executable was not found." - } - - if ompExecutable != nil { - hostExecutable = hostDiscovery().discover() - if let hostExecutable { - map["available"] = true - map["hostExecutable"] = String(hostExecutable.prefix(4096)) - } else { - map["issueCode"] = "t4_host_not_found" - map["message"] = "The standalone T4 host executable is missing from this build." - } - } - - do { - let snapshot = try files.read(definitionPath, maxBytes: 64 * 1024) - if let ompExecutable, let hostExecutable, - let expected = try? renderDefinition( - hostExecutable: hostExecutable, - ompExecutable: ompExecutable - ), - snapshot.content == expected, - snapshot.mode == 0o600 - { - map["definition"] = "current" - } else if snapshot.content == nil { - map["definition"] = "missing" - } else { - map["definition"] = "drifted" - } - } catch { - map["definition"] = "drifted" - map["diagnostics"] = boundedDiagnostic("Unable to safely inspect the LaunchAgent definition.") - if map["issueCode"] == nil { - map["issueCode"] = "runtime_definition_unsafe" - map["message"] = "The LaunchAgent definition path is unsafe or unreadable." - } - } - - do { - let status = try launchctl(["print", target], allowMissing: true) - map["service"] = serviceState(status) - let diagnostic = boundedDiagnostic(status.output) - if !diagnostic.isEmpty { map["diagnostics"] = diagnostic } - } catch { - map["service"] = "unknown" - map["diagnostics"] = boundedDiagnostic("Unable to inspect the per-user LaunchAgent.") - if map["issueCode"] == nil { - map["issueCode"] = "runtime_status_failed" - map["message"] = "The per-user LaunchAgent status could not be read." - } - } - return map - } - - private func fileSnapshotForMutation() throws -> RuntimeFileSnapshot { - do { - return try files.read(definitionPath, maxBytes: 64 * 1024) - } catch { - throw RuntimeBridgeFailure( - code: "runtime_definition_unsafe", - message: "The LaunchAgent definition path is unsafe or unreadable." - ) - } - } - - private func requireExecutable(_ discovery: RuntimeDiscovery) throws -> String { - switch discovery { - case .found(let path): return path - case .incompatible: - throw RuntimeBridgeFailure( - code: "omp_authority_bridge_required", - message: - "Installed OMP is incompatible with this T4 Code build. T4 Code requires the versioned `omp bridge --stdio` authority bridge." - ) - case .missing: - throw RuntimeBridgeFailure( - code: "omp_not_found", message: "A compatible system OMP executable was not found.") - } - } - - private func hostDiscovery() -> T4HostRuntimeDiscovery { - T4HostRuntimeDiscovery( - environment: environment, - homeDirectory: homeDirectory, - packagedExecutable: packagedHostExecutable - ) - } - - private func requireHostExecutable() throws -> String { - guard let executable = hostDiscovery().discover() else { - throw RuntimeBridgeFailure( - code: "t4_host_not_found", - message: "The standalone T4 host executable is missing from this build." - ) - } - return executable - } - - private func requireCurrentDefinition(hostExecutable: String, ompExecutable: String) throws { - let snapshot = try fileSnapshotForMutation() - let expected = try renderDefinition( - hostExecutable: hostExecutable, - ompExecutable: ompExecutable - ) - guard snapshot.content == expected, snapshot.mode == 0o600 else { - throw RuntimeBridgeFailure( - code: "runtime_definition_not_current", - message: "Install the current LaunchAgent definition before starting the runtime." - ) - } - } - - private func renderDefinition(hostExecutable: String, ompExecutable: String) throws -> String { - let values = [hostExecutable, ompExecutable, logsDirectory] - guard - values.allSatisfy({ value in - !value.isEmpty && value.utf8.count <= 4096 - && !value.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7f }) - }) - else { - throw RuntimeBridgeFailure( - code: "runtime_path_invalid", message: "The runtime path is invalid.") - } - let hostExecutableXML = escapeXML(hostExecutable) - let ompExecutableXML = escapeXML(ompExecutable) - let logsXML = escapeXML(logsDirectory) - return [ - "", - "", - "", - " ", - " Label\(runtimeLabel)", - " ProgramArguments", - " ", - " \(hostExecutableXML)", - " serve", - " --omp", - " \(ompExecutableXML)", - " --profile", - " default", - " ", - " RunAtLoad", - " KeepAlive", - " SuccessfulExit", - " Umask63", - " StandardOutPath\(logsXML)/appserver.log", - " StandardErrorPath\(logsXML)/appserver.error.log", - " EnvironmentVariables", - " ", - " OMP_PROFILE", - " default", - " ", - " ", - "", - "", - ].joined(separator: "\n") - } - - private func escapeXML(_ value: String) -> String { - value - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "\"", with: """) - .replacingOccurrences(of: "'", with: "'") - } - - private func launchctl(_ arguments: [String], allowMissing: Bool = false) throws - -> RuntimeProcessResult - { - let result = try runLaunchctl(arguments) - if result.timedOut { - throw RuntimeBridgeFailure( - code: "runtime_command_timeout", message: "The LaunchAgent command timed out.") - } - if result.overflowed { - throw RuntimeBridgeFailure( - code: "runtime_command_output_limit", - message: "The LaunchAgent command produced too much output.") - } - guard result.exitCode == 0 || allowMissing && isMissingService(result) else { - let message = result.output.isEmpty ? "The LaunchAgent command failed." : result.output - throw RuntimeBridgeFailure( - code: "runtime_command_failed", - message: boundedDiagnostic(message) - ) - } - return result - } - private func runLaunchctl(_ arguments: [String]) throws -> RuntimeProcessResult { - do { - return try runner.run( - executableURL: URL(fileURLWithPath: "/bin/launchctl"), - arguments: arguments, - environment: safeEnvironment(), - timeout: runtimeCommandTimeout, - maxOutputBytes: runtimeOutputLimit - ) - } catch { - throw RuntimeBridgeFailure( - code: "runtime_command_failed", message: "The LaunchAgent command could not be executed.") - } - } - - private func safeEnvironment() -> [String: String] { - var safe: [String: String] = [:] - for key in ["HOME", "PATH", "TMPDIR"] { - if let value = environment[key] { safe[key] = value } - } - return safe - } - - private func isMissingService(_ result: RuntimeProcessResult) -> Bool { - let output = result.output.lowercased() - return output.contains("could not find") - || output.contains("not loaded") - || output.contains("no such process") - } - - private func serviceState(_ result: RuntimeProcessResult) -> String { - let output = result.output.lowercased() - if output.contains("state = running") || output.contains("state=running") { return "running" } - if output.contains("state = starting") || output.contains("state=starting") { - return "starting" - } - if output.contains("state = exited") || output.contains("state=exited") - || isMissingService(result) - { - return "stopped" - } - if output.contains("failed") { return "failed" } - return result.exitCode == 0 ? "running" : "unknown" - } - - private func bridgeFailure(_ error: Error, fallbackCode: String) -> RuntimeBridgeFailure { - if let failure = error as? RuntimeBridgeFailure { return failure } - return RuntimeBridgeFailure( - code: fallbackCode, message: "The runtime lifecycle operation failed safely.") - } -} - -func boundedDiagnostic(_ input: String) -> String { - var value = input - let patterns = [ - "(?i)(Bearer\\s+)[^\\s]+", - "(?i)([A-Za-z0-9_-]*(?:token|secret|password|credential|authorization|api[_-]?key|private[_-]?key)[A-Za-z0-9_-]*\\s*[=:]\\s*)[^\\s,;]+", - ] - for (index, pattern) in patterns.enumerated() { - guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } - let replacement = index == 0 ? "$1[redacted]" : "$1[redacted]" - value = regex.stringByReplacingMatches( - in: value, - range: NSRange(value.startIndex..., in: value), - withTemplate: replacement - ) - } - value = String( - value.unicodeScalars.map { scalar in - scalar.value < 0x20 || scalar.value == 0x7f ? " " : Character(String(scalar)) - }) - return String(value.trimmingCharacters(in: .whitespacesAndNewlines).prefix(512)) -} - -private let updateManifestURL = URL(string: "https://t4code.net/releases/latest.json")! -private let updateManifestLimit = 256 * 1024 - -protocol UpdateManifestFetching { - func fetch() throws -> Data -} - -final class PinnedUpdateManifestFetcher: NSObject, UpdateManifestFetching, URLSessionDataDelegate, - URLSessionTaskDelegate -{ - private let lock = NSLock() - private var received = Data() - private var responseError: Error? - private var completed = DispatchSemaphore(value: 0) - private var responseAccepted = false - - func fetch() throws -> Data { - lock.lock() - received = Data() - responseError = nil - completed = DispatchSemaphore(value: 0) - responseAccepted = false - lock.unlock() - - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 10 - configuration.timeoutIntervalForResource = 10 - configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData - configuration.httpCookieAcceptPolicy = .never - configuration.httpCookieStorage = nil - configuration.urlCredentialStorage = nil - configuration.urlCache = nil - let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) - var request = URLRequest(url: updateManifestURL) - request.timeoutInterval = 10 - request.setValue("application/json", forHTTPHeaderField: "Accept") - let task = session.dataTask(with: request) - task.resume() - if completed.wait(timeout: .now() + 10.5) == .timedOut { - task.cancel() - session.invalidateAndCancel() - throw RuntimeBridgeFailure(code: "update_timeout", message: "The update check timed out.") - } - session.finishTasksAndInvalidate() - - lock.lock() - defer { lock.unlock() } - if let error = responseError { throw error } - guard responseAccepted, received.count <= updateManifestLimit else { - throw RuntimeBridgeFailure( - code: "update_invalid_response", message: "The update service response was invalid.") - } - return received - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, - newRequest request: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void - ) { - completionHandler(nil) - } - - func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void - ) { - guard let http = response as? HTTPURLResponse, - http.statusCode == 200, - http.url == updateManifestURL, - http.url?.scheme == "https", - http.url?.host == "t4code.net", - http.expectedContentLength <= Int64(updateManifestLimit) - || http.expectedContentLength == -1 - else { - lock.lock() - responseError = RuntimeBridgeFailure( - code: "update_invalid_response", - message: "The update service response was invalid." - ) - lock.unlock() - completionHandler(.cancel) - return - } - lock.lock() - responseAccepted = true - lock.unlock() - completionHandler(.allow) - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - lock.lock() - if data.count > updateManifestLimit - received.count { - responseError = RuntimeBridgeFailure( - code: "update_manifest_too_large", - message: "The update manifest was too large." - ) - lock.unlock() - dataTask.cancel() - return - } - received.append(data) - lock.unlock() - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: Error? - ) { - lock.lock() - if responseError == nil, let error = error { - responseError = RuntimeBridgeFailure( - code: "update_network_error", - message: error is URLError && (error as? URLError)?.code == .timedOut - ? "The update check timed out." - : "The update service could not be reached." - ) - } - lock.unlock() - completed.signal() - } -} - -struct MacReleaseManifest { - let version: String - let downloadURL: URL -} - -final class MacUpdateLifecycle { - private let currentVersion: String - private let fetcher: UpdateManifestFetching - private let openURL: (URL) -> Bool - private var state: [String: Any] - private var selectedDownloadURL: URL? - - init( - currentVersion: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") - as? String - ?? "0.0.0", - fetcher: UpdateManifestFetching = PinnedUpdateManifestFetcher(), - openURL: @escaping (URL) -> Bool = { url in - if Thread.isMainThread { return NSWorkspace.shared.open(url) } - return DispatchQueue.main.sync { NSWorkspace.shared.open(url) } - } - ) { - let safeCurrentVersion = MacUpdateLifecycle.isVersion(currentVersion) ? currentVersion : "0.0.0" - self.currentVersion = safeCurrentVersion - self.fetcher = fetcher - self.openURL = openURL - state = ["currentVersion": safeCurrentVersion, "phase": "idle"] - } - - func getState() -> [String: Any] { state } - - func check() -> [String: Any] { - state = ["currentVersion": currentVersion, "phase": "checking"] - selectedDownloadURL = nil - let checkedAt = Int(Date().timeIntervalSince1970 * 1000) - do { - let manifest = try decodeManifest(fetcher.fetch()) - if compareVersions(manifest.version, currentVersion) <= 0 { - state = [ - "currentVersion": currentVersion, - "phase": "current", - "checkedAt": checkedAt, - "message": "T4 Code is up to date.", - ] - } else { - selectedDownloadURL = manifest.downloadURL - state = [ - "currentVersion": currentVersion, - "phase": "manual", - "latestVersion": manifest.version, - "checkedAt": checkedAt, - "message": "T4 Code \(manifest.version) is available from the official release.", - ] - } - } catch let failure as RuntimeBridgeFailure { - state = [ - "currentVersion": currentVersion, - "phase": "error", - "checkedAt": checkedAt, - "error": String(failure.code.prefix(128)), - "message": boundedDiagnostic(failure.message), - ] - } catch { - state = [ - "currentVersion": currentVersion, - "phase": "error", - "checkedAt": checkedAt, - "error": "update_manifest_invalid", - "message": "The update manifest was invalid.", - ] - } - return state - } - - func download() -> [String: Any] { - guard state["phase"] as? String == "manual", let url = selectedDownloadURL else { return state } - guard isPinnedReleaseURL(url), openURL(url) else { - state = [ - "currentVersion": currentVersion, - "phase": "error", - "checkedAt": state["checkedAt"] as? Int ?? Int(Date().timeIntervalSince1970 * 1000), - "error": "update_open_failed", - "message": "The official release could not be opened.", - ] - return state - } - return state - } - - func install() -> [String: Any] { - guard state["phase"] as? String == "manual" else { return state } - var manual = state - manual["message"] = "Install the downloaded signed DMG manually from the official release." - state = manual - return state - } - - private func decodeManifest(_ data: Data) throws -> MacReleaseManifest { - guard data.count <= updateManifestLimit, - String(data: data, encoding: .utf8) != nil, - let object = try? JSONSerialization.jsonObject(with: data), - let root = object as? [String: Any], - Set(root.keys) - == Set([ - "schemaVersion", "channel", "version", "tag", "publishedAt", "releaseUrl", "assets", - ]), - (root["schemaVersion"] as? NSNumber)?.intValue == 1, - !Self.isJSONBoolean(root["schemaVersion"]), - root["channel"] as? String == "stable", - let version = root["version"] as? String, - Self.isVersion(version), - root["tag"] as? String == "v\(version)", - root["releaseUrl"] as? String - == "https://github.com/LycaonLLC/t4-code/releases/tag/v\(version)", - let publishedAt = root["publishedAt"] as? String, - publishedAt.utf8.count <= 64, - Self.isTimestamp(publishedAt), - let assets = root["assets"] as? [Any], - assets.count == 5 - else { - throw RuntimeBridgeFailure( - code: "update_manifest_invalid", message: "The update manifest was invalid.") - } - - let canonical: [(String, String, String, String)] = [ - ("android", "apk", "universal", "T4-Code-\(version)-android.apk"), - ("linux", "deb", "x86_64", "T4-Code-\(version)-linux-amd64.deb"), - ("linux", "appimage", "x86_64", "T4-Code-\(version)-linux-x86_64.AppImage"), - ("mac", "dmg", "arm64", "T4-Code-\(version)-mac-arm64.dmg"), - ("mac", "zip", "arm64", "T4-Code-\(version)-mac-arm64.zip"), - ] - var decoded: [(String, String, String, String, URL)] = [] - var names = Set() - for rawAsset in assets { - guard let asset = rawAsset as? [String: Any], - Set(asset.keys) == Set(["platform", "kind", "arch", "name", "url", "size", "sha256"]), - let platform = asset["platform"] as? String, - ["android", "linux", "mac"].contains(platform), - let kind = asset["kind"] as? String, - ["apk", "deb", "appimage", "dmg", "zip"].contains(kind), - let arch = asset["arch"] as? String, - ["universal", "x86_64", "arm64"].contains(arch), - let name = asset["name"] as? String, - !name.isEmpty, name.utf8.count <= 160, - names.insert(name).inserted, - let urlString = asset["url"] as? String, - urlString - == "https://github.com/LycaonLLC/t4-code/releases/download/v\(version)/\(name)", - let url = URL(string: urlString), - isPinnedReleaseURL(url), - let size = asset["size"] as? NSNumber, - !Self.isJSONBoolean(size), - size.doubleValue.rounded() == size.doubleValue, - size.int64Value > 0, size.int64Value <= 2 * 1024 * 1024 * 1024, - let digest = asset["sha256"] as? String, - digest.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil - else { - throw RuntimeBridgeFailure( - code: "update_manifest_invalid", message: "The update manifest was invalid.") - } - decoded.append((platform, kind, arch, name, url)) - } - for expected in canonical { - guard - decoded.filter({ - $0.0 == expected.0 && $0.1 == expected.1 && $0.2 == expected.2 && $0.3 == expected.3 - }).count == 1 - else { - throw RuntimeBridgeFailure( - code: "update_manifest_invalid", message: "The update manifest was invalid.") - } - } - guard - let dmg = decoded.first(where: { - $0.0 == "mac" && $0.1 == "dmg" && $0.2 == "arm64" - && $0.3 == "T4-Code-\(version)-mac-arm64.dmg" - }) - else { - throw RuntimeBridgeFailure( - code: "update_manifest_invalid", message: "The update manifest was invalid.") - } - return MacReleaseManifest(version: version, downloadURL: dmg.4) - } - - private static func isVersion(_ value: String) -> Bool { - value.range( - of: #"^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z](?:[0-9A-Za-z.-]{0,62}[0-9A-Za-z])?)?$"#, - options: .regularExpression - ) != nil - } - - private func compareVersions(_ left: String, _ right: String) -> Int { - func parsed(_ version: String) -> ([Int], [String]) { - let pieces = version.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) - let core = pieces[0].split(separator: ".").map { Int($0) ?? 0 } - let prerelease = pieces.count == 2 ? pieces[1].split(separator: ".").map(String.init) : [] - return (core, prerelease) - } - let a = parsed(left) - let b = parsed(right) - for index in 0..<3 { - if a.0[index] != b.0[index] { return a.0[index] < b.0[index] ? -1 : 1 } - } - if a.1.isEmpty || b.1.isEmpty { - if a.1.isEmpty == b.1.isEmpty { return 0 } - return a.1.isEmpty ? 1 : -1 - } - for index in 0..= a.1.count { return -1 } - if index >= b.1.count { return 1 } - if a.1[index] == b.1[index] { continue } - let leftNumber = Int(a.1[index]) - let rightNumber = Int(b.1[index]) - if let leftNumber = leftNumber, let rightNumber = rightNumber { - return leftNumber < rightNumber ? -1 : 1 - } - if (leftNumber != nil) != (rightNumber != nil) { return leftNumber != nil ? -1 : 1 } - return a.1[index] < b.1[index] ? -1 : 1 - } - return 0 - } - - private static func isTimestamp(_ value: String) -> Bool { - let formatter = ISO8601DateFormatter() - if formatter.date(from: value) != nil { return true } - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.date(from: value) != nil - } - - private static func isJSONBoolean(_ value: Any?) -> Bool { - guard let number = value as? NSNumber else { return false } - return CFGetTypeID(number) == CFBooleanGetTypeID() - } - - private func isPinnedReleaseURL(_ url: URL) -> Bool { - guard url.scheme == "https", url.host == "github.com", url.user == nil, url.password == nil, - url.port == nil, url.query == nil, url.fragment == nil - else { return false } - return url.path.hasPrefix("/LycaonLLC/t4-code/releases/download/v") - } -} - -final class PlatformLifecycleChannel { - static let name = "com.lycaonsolutions.t4code/platform_lifecycle" - - private let channel: FlutterMethodChannel - private let lifecycle: MacRuntimeLifecycle - private let updates: MacUpdateLifecycle - private let executor = DispatchQueue( - label: "com.lycaonsolutions.t4code.platform-lifecycle", qos: .userInitiated) - - init( - messenger: FlutterBinaryMessenger, - lifecycle: MacRuntimeLifecycle = MacRuntimeLifecycle(), - updates: MacUpdateLifecycle = MacUpdateLifecycle() - ) { - channel = FlutterMethodChannel(name: Self.name, binaryMessenger: messenger) - self.lifecycle = lifecycle - self.updates = updates - channel.setMethodCallHandler { [weak self] call, result in - self?.handle(call, result: result) - } - } - - private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - let operation: (() throws -> [String: Any])? - switch call.method { - case "runtime.inspect": operation = { self.lifecycle.inspect() } - case "runtime.install": operation = { try self.lifecycle.install() } - case "runtime.start": operation = { try self.lifecycle.start() } - case "runtime.stop": operation = { try self.lifecycle.stop() } - case "runtime.restart": operation = { try self.lifecycle.restart() } - case "runtime.uninstall": operation = { try self.lifecycle.uninstall() } - case "update.getState": operation = { self.updates.getState() } - case "update.check": operation = { self.updates.check() } - case "update.download": operation = { self.updates.download() } - case "update.install": operation = { self.updates.install() } - default: - result(FlutterMethodNotImplemented) - return - } - - executor.async { - do { - let response = try operation?() ?? [:] - DispatchQueue.main.async { result(response) } - } catch let failure as RuntimeBridgeFailure { - DispatchQueue.main.async { - result( - FlutterError( - code: failure.code, message: boundedDiagnostic(failure.message), details: nil)) - } - } catch { - DispatchQueue.main.async { - result( - FlutterError( - code: "platform_internal", message: "The platform lifecycle operation failed safely.", - details: nil)) - } - } - } - } -} diff --git a/apps/flutter/macos/Runner/Profile.entitlements b/apps/flutter/macos/Runner/Profile.entitlements deleted file mode 100644 index 0fa6d7d2..00000000 --- a/apps/flutter/macos/Runner/Profile.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - com.apple.security.network.client - - keychain-access-groups - - - diff --git a/apps/flutter/macos/Runner/Release.entitlements b/apps/flutter/macos/Runner/Release.entitlements deleted file mode 100644 index 8f14e295..00000000 --- a/apps/flutter/macos/Runner/Release.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.network.client - - com.apple.security.files.user-selected.read-only - - keychain-access-groups - - - diff --git a/apps/flutter/macos/RunnerTests/RunnerTests.swift b/apps/flutter/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 2e1e2981..00000000 --- a/apps/flutter/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,334 +0,0 @@ -import Darwin -import Foundation -import XCTest -@testable import t4code - -private final class StubRuntimeRunner: RuntimeProcessRunning { - struct Invocation { - let executable: String - let arguments: [String] - let environment: [String: String] - } - - var probeOutput: String - var probeExitCode: Int32 - var bridgeOutput = "Expose the private OMP authority bridge used by T4 Code\n--stdio\n" - var registered = false - var invocations: [Invocation] = [] - - init(probeOutput: String, probeExitCode: Int32 = 0) { - self.probeOutput = probeOutput - self.probeExitCode = probeExitCode - } - - func run( - executableURL: URL, - arguments: [String], - environment: [String: String], - timeout: TimeInterval, - maxOutputBytes: Int - ) throws -> RuntimeProcessResult { - invocations.append(Invocation( - executable: executableURL.path, - arguments: arguments, - environment: environment - )) - if executableURL.path != "/bin/launchctl" { - if arguments == ["bridge", "--help"] { - return RuntimeProcessResult( - exitCode: 0, - output: bridgeOutput, - timedOut: false, - overflowed: false - ) - } - return RuntimeProcessResult( - exitCode: probeExitCode, - output: probeOutput, - timedOut: false, - overflowed: false - ) - } - switch arguments.first { - case "print": - return RuntimeProcessResult( - exitCode: registered ? 0 : 113, - output: registered ? "state = running" : "Could not find service", - timedOut: false, - overflowed: false - ) - case "bootstrap": - registered = true - case "bootout": - registered = false - case "kickstart": - registered = true - default: - XCTFail("unexpected launchctl command: \(arguments)") - } - return RuntimeProcessResult(exitCode: 0, output: "", timedOut: false, overflowed: false) - } -} - -private struct StubManifestFetcher: UpdateManifestFetching { - let data: Data - func fetch() throws -> Data { data } -} - -final class RunnerTests: XCTestCase { - private var temporaryDirectories: [URL] = [] - - override func tearDownWithError() throws { - for directory in temporaryDirectories { - try? FileManager.default.removeItem(at: directory) - } - temporaryDirectories.removeAll() - } - - func testDiscoveryAcceptsExactRunningStatusWithCredentialFreeEnvironment() throws { - let home = try temporaryDirectory() - let executable = try makeExecutable(home: home) - let runner = StubRuntimeRunner( - probeOutput: #"{"state":"running","health":{"ok":true,"hostId":"host-a","epoch":"epoch-a"}}"# - ) - let discovery = OmpRuntimeDiscovery( - environment: [ - "HOME": home.path, - "PATH": "\(home.path)/bin", - "TMPDIR": home.path, - "OMP_EXECUTABLE": executable, - "OPENAI_API_KEY": "must-not-leak", - ], - homeDirectory: home.path, - runner: runner - ).discover() - - XCTAssertEqual(discovery, .found(executable)) - XCTAssertEqual(runner.invocations.count, 2) - XCTAssertEqual( - Set(runner.invocations[0].environment.keys), - Set(["HOME", "PATH", "TMPDIR", "OMP_PROFILE"]) - ) - XCTAssertEqual(runner.invocations[0].environment["OMP_PROFILE"], "default") - XCTAssertNil(runner.invocations[0].environment["OPENAI_API_KEY"]) - XCTAssertEqual(runner.invocations[0].arguments, ["bridge", "--help"]) - XCTAssertEqual(runner.invocations[1].arguments, ["appserver", "status", "--json"]) - } - - func testDiscoveryAcceptsExactStoppedStatus() throws { - let home = try temporaryDirectory() - let executable = try makeExecutable(home: home) - let runner = StubRuntimeRunner( - probeOutput: #"{"state":"stopped","reason":"unreachable"}"#, - probeExitCode: 1 - ) - let result = OmpRuntimeDiscovery( - environment: ["OMP_EXECUTABLE": executable], - homeDirectory: home.path, - runner: runner - ).discover() - XCTAssertEqual(result, .found(executable)) - } - - func testDiscoveryReportsUnsupportedJSONDistinctly() throws { - let home = try temporaryDirectory() - let executable = try makeExecutable(home: home) - let runner = StubRuntimeRunner(probeOutput: "unknown flag: --json", probeExitCode: 2) - let result = OmpRuntimeDiscovery( - environment: ["OMP_EXECUTABLE": executable], - homeDirectory: home.path, - runner: runner - ).discover() - XCTAssertEqual(result, .incompatible) - } - - func testDiscoveryRejectsMalformedAndMissingCandidates() throws { - let home = try temporaryDirectory() - let executable = try makeExecutable(home: home) - let runner = StubRuntimeRunner(probeOutput: #"{"state":"stopped","reason":"other"}"#) - let malformed = OmpRuntimeDiscovery( - environment: ["OMP_EXECUTABLE": executable], - homeDirectory: home.path, - runner: runner - ).discover() - XCTAssertEqual(malformed, .missing) - - let missing = OmpRuntimeDiscovery( - environment: ["OMP_EXECUTABLE": "\(home.path)/missing/omp", "PATH": ""], - homeDirectory: home.path, - runner: runner - ).discover() - XCTAssertEqual(missing, .missing) - } - - func testT4HostDiscoveryRequiresAnExecutableWithTheExactName() throws { - let home = try temporaryDirectory() - let hostExecutable = try makeExecutable(home: home, name: "t4-host") - XCTAssertEqual( - T4HostRuntimeDiscovery( - environment: ["T4_HOST_EXECUTABLE": hostExecutable, "PATH": ""], - homeDirectory: home.path, - packagedExecutable: nil - ).discover(), - hostExecutable - ) - - let wrongHome = try temporaryDirectory() - let wrongExecutable = try makeExecutable(home: wrongHome, name: "not-t4-host") - XCTAssertNil( - T4HostRuntimeDiscovery( - environment: ["T4_HOST_EXECUTABLE": wrongExecutable, "PATH": ""], - homeDirectory: wrongHome.path, - packagedExecutable: nil - ).discover() - ) - } - - func testLaunchAgentInstallInspectAndUninstallLifecycle() throws { - let home = try temporaryDirectory() - let executable = try makeExecutable(home: home) - let hostExecutable = try makeExecutable(home: home, name: "t4-host") - let runner = StubRuntimeRunner(probeOutput: #"{"state":"stopped","reason":"unreachable"}"#) - let files = SecureRuntimeFileStore() - let lifecycle = MacRuntimeLifecycle( - environment: [ - "HOME": home.path, - "OMP_EXECUTABLE": executable, - "T4_HOST_EXECUTABLE": hostExecutable, - ], - homeDirectory: home.path, - uid: 501, - runner: runner, - files: files - ) - - let installed = try lifecycle.install() - XCTAssertEqual(installed["definition"] as? String, "current") - XCTAssertEqual(installed["service"] as? String, "running") - let definitionPath = "\(home.path)/Library/LaunchAgents/dev.oh-my-pi.appserver.plist" - let snapshot = try files.read(definitionPath, maxBytes: 64 * 1024) - XCTAssertEqual(snapshot.mode, 0o600) - XCTAssertTrue(snapshot.content?.contains("\(hostExecutable)") == true) - XCTAssertTrue(snapshot.content?.contains("\(executable)") == true) - XCTAssertTrue(snapshot.content?.contains("serve") == true) - XCTAssertTrue(snapshot.content?.contains("--omp") == true) - XCTAssertTrue(snapshot.content?.contains("--profile") == true) - XCTAssertTrue(snapshot.content?.contains("OMP_PROFILE") == true) - XCTAssertTrue(snapshot.content?.contains("default") == true) - XCTAssertTrue(snapshot.content?.contains("Library/Logs/T4 Code/appserver/appserver.log") == true) - XCTAssertFalse(snapshot.content?.contains("must-not-leak") == true) - XCTAssertTrue(runner.invocations.contains(where: { - $0.executable == "/bin/launchctl" - && $0.arguments == ["bootstrap", "gui/501", definitionPath] - })) - XCTAssertTrue(runner.invocations.contains(where: { - $0.executable == "/bin/launchctl" - && $0.arguments == ["kickstart", "-k", "gui/501/dev.oh-my-pi.appserver"] - })) - - let uninstalled = try lifecycle.uninstall() - XCTAssertEqual(uninstalled["definition"] as? String, "missing") - XCTAssertEqual(uninstalled["service"] as? String, "stopped") - XCTAssertNil(try files.read(definitionPath, maxBytes: 1024).content) - } - - func testSecureStoreRefusesSymlinkDefinition() throws { - let home = try temporaryDirectory() - let launchAgents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true) - try FileManager.default.createDirectory(at: launchAgents, withIntermediateDirectories: true) - let target = home.appendingPathComponent("elsewhere.plist") - try Data("safe".utf8).write(to: target) - let definition = launchAgents.appendingPathComponent("dev.oh-my-pi.appserver.plist") - try FileManager.default.createSymbolicLink(at: definition, withDestinationURL: target) - - XCTAssertThrowsError(try SecureRuntimeFileStore().read(definition.path, maxBytes: 1024)) - XCTAssertEqual(try String(contentsOf: target, encoding: .utf8), "safe") - } - - func testMacUpdateCheckSelectsCanonicalDMGAndOnlyOpensValidatedURL() throws { - let manifest = try releaseManifest(version: "0.1.25") - var opened: URL? - let updates = MacUpdateLifecycle( - currentVersion: "0.1.24", - fetcher: StubManifestFetcher(data: manifest), - openURL: { url in opened = url; return true } - ) - - let checked = updates.check() - XCTAssertEqual(checked["phase"] as? String, "manual") - XCTAssertEqual(checked["latestVersion"] as? String, "0.1.25") - XCTAssertNil(checked["revision"]) - let downloaded = updates.download() - XCTAssertEqual(downloaded["phase"] as? String, "manual") - XCTAssertEqual( - opened?.absoluteString, - "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.25/T4-Code-0.1.25-mac-arm64.dmg" - ) - let installed = updates.install() - XCTAssertEqual(installed["phase"] as? String, "manual") - XCTAssertTrue((installed["message"] as? String)?.contains("signed DMG") == true) - } - - func testMacUpdateRejectsNonCanonicalReleaseURL() throws { - var object = try XCTUnwrap( - JSONSerialization.jsonObject(with: releaseManifest(version: "0.1.25")) as? [String: Any] - ) - var assets = try XCTUnwrap(object["assets"] as? [[String: Any]]) - assets[3]["url"] = "https://evil.example/T4-Code-0.1.25-mac-arm64.dmg" - object["assets"] = assets - let updates = MacUpdateLifecycle( - currentVersion: "0.1.24", - fetcher: StubManifestFetcher(data: try JSONSerialization.data(withJSONObject: object)), - openURL: { _ in XCTFail("invalid URL must not open"); return true } - ) - let result = updates.check() - XCTAssertEqual(result["phase"] as? String, "error") - XCTAssertEqual(result["error"] as? String, "update_manifest_invalid") - } - - private func temporaryDirectory() throws -> URL { - let base = FileManager.default.homeDirectoryForCurrentUser - let directory = base.appendingPathComponent(".t4-macos-tests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) - temporaryDirectories.append(directory) - return directory - } - - private func makeExecutable(home: URL, name: String = "omp") throws -> String { - let directory = home.appendingPathComponent("bin", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let executable = directory.appendingPathComponent(name) - XCTAssertTrue(FileManager.default.createFile(atPath: executable.path, contents: Data())) - XCTAssertEqual(chmod(executable.path, 0o700), 0) - return executable.path - } - - private func releaseManifest(version: String) throws -> Data { - func asset(_ platform: String, _ kind: String, _ arch: String, _ name: String) -> [String: Any] { - [ - "platform": platform, - "kind": kind, - "arch": arch, - "name": name, - "url": "https://github.com/LycaonLLC/t4-code/releases/download/v\(version)/\(name)", - "size": 1024, - "sha256": String(repeating: "a", count: 64), - ] - } - return try JSONSerialization.data(withJSONObject: [ - "schemaVersion": 1, - "channel": "stable", - "version": version, - "tag": "v\(version)", - "publishedAt": "2026-07-19T12:00:00.000Z", - "releaseUrl": "https://github.com/LycaonLLC/t4-code/releases/tag/v\(version)", - "assets": [ - asset("android", "apk", "universal", "T4-Code-\(version)-android.apk"), - asset("linux", "deb", "x86_64", "T4-Code-\(version)-linux-amd64.deb"), - asset("linux", "appimage", "x86_64", "T4-Code-\(version)-linux-x86_64.AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-\(version)-mac-arm64.dmg"), - asset("mac", "zip", "arm64", "T4-Code-\(version)-mac-arm64.zip"), - ], - ]) - } -} diff --git a/apps/flutter/package.json b/apps/flutter/package.json deleted file mode 100644 index 635bab51..00000000 --- a/apps/flutter/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@t4-code/flutter", - "version": "0.1.30", - "private": true, - "scripts": { - "dev": "flutter run", - "typecheck": "flutter analyze", - "test": "flutter test", - "test:coverage": "flutter test --coverage && node ../../scripts/check-flutter-coverage.mjs", - "test:device": "flutter test integration_test/app_smoke_test.dart", - "build:web": "flutter build web", - "build:macos": "flutter build macos", - "build:android": "flutter build apk --debug", - "build:ios": "flutter build ios --simulator" - } -} diff --git a/apps/flutter/pubspec.lock b/apps/flutter/pubspec.lock deleted file mode 100644 index 5fdfd177..00000000 --- a/apps/flutter/pubspec.lock +++ /dev/null @@ -1,729 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" - url: "https://pub.dev" - source: hosted - version: "0.3.5+4" - crypto: - dependency: "direct main" - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - equatable: - dependency: transitive - description: - name: equatable - sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - file_selector: - dependency: "direct main" - description: - name: file_selector - sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a - url: "https://pub.dev" - source: hosted - version: "1.1.0" - file_selector_android: - dependency: transitive - description: - name: file_selector_android - sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b" - url: "https://pub.dev" - source: hosted - version: "0.5.2+8" - file_selector_ios: - dependency: transitive - description: - name: file_selector_ios - sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca - url: "https://pub.dev" - source: hosted - version: "0.5.3+5" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" - url: "https://pub.dev" - source: hosted - version: "0.9.4" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" - url: "https://pub.dev" - source: hosted - version: "0.9.5" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.dev" - source: hosted - version: "2.7.0" - file_selector_web: - dependency: transitive - description: - name: file_selector_web - sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" - url: "https://pub.dev" - source: hosted - version: "0.9.5" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" - url: "https://pub.dev" - source: hosted - version: "0.9.3+5" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_markdown_plus: - dependency: "direct main" - description: - name: flutter_markdown_plus - sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935 - url: "https://pub.dev" - source: hosted - version: "1.0.12" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" - url: "https://pub.dev" - source: hosted - version: "10.3.1" - flutter_secure_storage_darwin: - dependency: transitive - description: - name: flutter_secure_storage_darwin - sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" - url: "https://pub.dev" - source: hosted - version: "0.3.2" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 - url: "https://pub.dev" - source: hosted - version: "3.0.1" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" - url: "https://pub.dev" - source: hosted - version: "4.1.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - hooks: - dependency: transitive - description: - name: hooks - sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - jni: - dependency: transitive - description: - name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - markdown: - dependency: transitive - description: - name: markdown - sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 - url: "https://pub.dev" - source: hosted - version: "7.3.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" - source: hosted - version: "1.18.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" - url: "https://pub.dev" - source: hosted - version: "9.4.1" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: transitive - description: - name: path_provider - sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 - url: "https://pub.dev" - source: hosted - version: "2.1.6" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - process: - dependency: transitive - description: - name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 - url: "https://pub.dev" - source: hosted - version: "5.0.5" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - quiver: - dependency: transitive - description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - re_highlight: - dependency: "direct main" - description: - name: re_highlight - sha256: "6c4ac3f76f939fb7ca9df013df98526634e17d8f7460e028bd23a035870024f2" - url: "https://pub.dev" - source: hosted - version: "0.0.3" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.dev" - source: hosted - version: "2.5.5" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" - url: "https://pub.dev" - source: hosted - version: "2.4.27" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - sync_http: - dependency: transitive - description: - name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" - source: hosted - version: "0.3.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" - source: hosted - version: "15.2.0" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: "direct main" - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webdriver: - dependency: transitive - description: - name: webdriver - sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xterm: - dependency: "direct main" - description: - name: xterm - sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" - zmodem: - dependency: transitive - description: - name: zmodem - sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" - url: "https://pub.dev" - source: hosted - version: "0.0.6" -sdks: - dart: ">=3.12.2 <4.0.0" - flutter: ">=3.44.0" diff --git a/apps/flutter/pubspec.yaml b/apps/flutter/pubspec.yaml deleted file mode 100644 index e3456461..00000000 --- a/apps/flutter/pubspec.yaml +++ /dev/null @@ -1,36 +0,0 @@ -name: t4code -description: "T4 Code Flutter client" -publish_to: 'none' -version: 0.1.30+1 - -environment: - sdk: ^3.12.2 - -dependencies: - flutter: - sdk: flutter - web_socket_channel: ^3.0.3 - flutter_secure_storage: 10.3.1 - shared_preferences: 2.5.5 - crypto: ^3.0.7 - flutter_markdown_plus: ^1.0.12 - file_selector: ^1.1.0 - xterm: ^4.0.0 - re_highlight: ^0.0.3 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - integration_test: - sdk: flutter - -flutter: - uses-material-design: true - fonts: - - family: DM Sans - fonts: - - asset: assets/fonts/DMSans-Variable.ttf - - family: JetBrains Mono - fonts: - - asset: assets/fonts/JetBrainsMono-Variable.ttf diff --git a/apps/flutter/test/client/model_labels_test.dart b/apps/flutter/test/client/model_labels_test.dart deleted file mode 100644 index 7cdf87fc..00000000 --- a/apps/flutter/test/client/model_labels_test.dart +++ /dev/null @@ -1,385 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/model_labels.dart'; -import 'package:t4code/src/protocol/models.dart'; - -void main() { - group('humanizeIdentifier', () { - test('splits camelCase, hyphens, underscores and title-cases words', () { - expect(humanizeIdentifier('gpt-engineer'), 'GPT Engineer'); - expect(humanizeIdentifier('claude-fable-5'), 'Claude Fable 5'); - expect(humanizeIdentifier('quickTask'), 'Quick Task'); - expect(humanizeIdentifier('gemini_3_flash'), 'Gemini 3 Flash'); - }); - - test('keeps known acronyms uppercase', () { - expect(humanizeIdentifier('gpt-5-6-sol'), 'GPT 5 6 Sol'); - expect(humanizeIdentifier('glm-4-api'), 'GLM 4 API'); - }); - - test('passes through unimprovable tokens capitalized', () { - expect(humanizeIdentifier('ollama'), 'Ollama'); - expect(humanizeIdentifier('mistral'), 'Mistral'); - }); - }); - - group('providerDisplayName', () { - test('maps known providers to friendly names', () { - expect(providerDisplayName('anthropic'), 'Anthropic'); - expect(providerDisplayName('openai'), 'OpenAI'); - expect(providerDisplayName('openai-codex'), 'OpenAI'); - expect(providerDisplayName('xai-oauth'), 'xAI'); - expect(providerDisplayName('google-vertex'), 'Google'); - expect(providerDisplayName('github-copilot'), 'GitHub Copilot'); - }); - - test('humanizes unknown providers deterministically', () { - expect(providerDisplayName('ollama'), 'Ollama'); - expect(providerDisplayName('together-ai'), 'Together AI'); - }); - }); - - group('modelItemSelector', () { - test('builds provider/modelId from metadata', () { - final item = _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ); - expect(modelItemSelector(item), 'openai-codex/gpt-5.6-sol'); - }); - - test('falls back to name when it contains a slash', () { - final item = _catalogItem( - kind: 'model', - name: 'anthropic/claude-fable-5', - metadata: null, - ); - expect(modelItemSelector(item), 'anthropic/claude-fable-5'); - }); - - test('returns null when no provider/modelId and name has no slash', () { - final item = _catalogItem( - kind: 'model', - name: 'claude-fable-5', - metadata: null, - ); - expect(modelItemSelector(item), isNull); - }); - }); - - group('modelLabelFor', () { - test('uses catalog name when it is a human label', () { - final item = _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ); - final label = modelLabelFor(item); - expect(label.selector, 'openai-codex/gpt-5.6-sol'); - expect(label.label, 'GPT-5.6 Sol'); - expect(label.provider, 'openai-codex'); - expect(label.providerLabel, 'OpenAI'); - expect(label.inCatalog, isTrue); - }); - - test('humanizes the model id when the catalog name equals the raw id', () { - final item = _catalogItem( - kind: 'model', - name: 'openai-codex/gpt-5.6-sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ); - final label = modelLabelFor(item); - expect(label.selector, 'openai-codex/gpt-5.6-sol'); - expect(label.label, 'GPT 5.6 Sol'); - expect(label.providerLabel, 'OpenAI'); - }); - - test('raw protocol id never leaks as the primary known-model label', () { - final item = _catalogItem( - kind: 'model', - name: 'openai-codex/gpt-5.6-sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ); - final label = modelLabelFor(item); - expect(label.label, isNot(equals(label.selector))); - expect(label.label, isNot(contains('/'))); - }); - }); - - group('groupModelChoices', () { - test('groups models by provider with friendly provider labels', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - _catalogItem( - kind: 'model', - name: 'GPT-5.6', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6', - }, - ), - _catalogItem( - kind: 'model', - name: 'Claude Fable 5', - metadata: { - 'provider': 'anthropic', - 'modelId': 'claude-fable-5', - }, - ), - ]; - final groups = groupModelChoices(catalog); - expect(groups, hasLength(2)); - // Known providers sort alphabetically by friendly label. - expect(groups.first.label, 'Anthropic'); - expect(groups.first.choices, hasLength(1)); - expect(groups.first.choices.single.label, 'Claude Fable 5'); - expect(groups.last.label, 'OpenAI'); - expect(groups.last.choices, hasLength(2)); - // Within a group, choices sort by label. - expect(groups.last.choices.first.label, 'GPT-5.6'); - expect(groups.last.choices.last.label, 'GPT-5.6 Sol'); - }); - - test('collapses duplicate selectors to the first occurrence', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - _catalogItem( - kind: 'model', - name: 'Duplicate', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - ]; - final groups = groupModelChoices(catalog); - expect(groups, hasLength(1)); - expect(groups.single.choices, hasLength(1)); - expect(groups.single.choices.single.label, 'GPT-5.6 Sol'); - }); - - test('unknown providers sort after known providers', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'Claude Fable 5', - metadata: { - 'provider': 'anthropic', - 'modelId': 'claude-fable-5', - }, - ), - _catalogItem( - kind: 'model', - name: 'Mistral Large', - metadata: { - 'provider': 'mistral', - 'modelId': 'large', - }, - ), - ]; - final groups = groupModelChoices(catalog); - expect(groups, hasLength(2)); - expect(groups.first.label, 'Anthropic'); - expect(groups.last.label, 'Mistral'); - expect(groups.last.choices.single.selector, 'mistral/large'); - }); - - test('models that cannot resolve to a selector are dropped safely', () { - final catalog = [ - _catalogItem(kind: 'model', name: 'local-model', metadata: null), - _catalogItem( - kind: 'model', - name: 'anthropic/claude-fable-5', - metadata: null, - ), - ]; - final groups = groupModelChoices(catalog); - // The slash-less name with no metadata cannot resolve to a switchable - // selector, so it is dropped rather than offered as a non-functional - // choice. The slash-named model still resolves and groups under Anthropic. - expect(groups, hasLength(1)); - expect(groups.single.label, 'Anthropic'); - expect(groups.single.choices.single.selector, 'anthropic/claude-fable-5'); - }); - - test('ignores non-model catalog items', () { - final catalog = [ - _catalogItem(kind: 'command', name: '/model', metadata: null), - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - ]; - final groups = groupModelChoices(catalog); - expect(groups, hasLength(1)); - expect(groups.single.choices, hasLength(1)); - }); - - test('retains unsupported models as selectable but marked', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - supported: false, - reason: 'No API key', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - ]; - final groups = groupModelChoices(catalog); - expect(groups.single.choices.single.supported, isFalse); - expect(groups.single.choices.single.reason, 'No API key'); - // Selector is still present so the model stays selectable. - expect(groups.single.choices.single.selector, 'openai-codex/gpt-5.6-sol'); - }); - }); - - group('sessionModelLabel', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - ]; - - test('prefers the host-reported display name', () { - expect( - sessionModelLabel('openai-codex/gpt-5.6-sol', 'GPT-5.6 Sol', catalog), - 'GPT-5.6 Sol', - ); - }); - - test('falls back to the catalog label when display name is absent', () { - expect( - sessionModelLabel('openai-codex/gpt-5.6-sol', null, catalog), - 'GPT-5.6 Sol', - ); - }); - - test('humanizes an unknown selector so the raw id never shows', () { - expect( - sessionModelLabel('unknown-vendor/some-model-7', null, catalog), - 'Some Model 7', - ); - }); - - test('returns the display name when selector is null', () { - expect(sessionModelLabel(null, 'Host model', catalog), 'Host model'); - }); - - test('returns null when both selector and display name are absent', () { - expect(sessionModelLabel(null, null, catalog), isNull); - }); - - test('strips a trailing thinking-level suffix before matching', () { - expect( - sessionModelLabel('openai-codex/gpt-5.6-sol:high', null, catalog), - 'GPT-5.6 Sol', - ); - }); - }); - - group('submission of the unchanged raw id', () { - test('groupModelChoices carries the exact provider/modelId selector', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - ]; - final groups = groupModelChoices(catalog); - // The submitted value is the raw protocol id, not the humanized label. - expect(groups.single.choices.single.selector, 'openai-codex/gpt-5.6-sol'); - expect( - groups.single.choices.single.selector, - isNot(equals(groups.single.choices.single.label)), - ); - }); - - test('modelLabelFor never rewrites the selector', () { - final item = _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ); - final label = modelLabelFor(item); - expect(label.selector, 'openai-codex/gpt-5.6-sol'); - expect(label.label, 'GPT-5.6 Sol'); - // The human label and the submitted selector are distinct values. - expect(label.label, isNot(equals(label.selector))); - }); - }); -} - -CatalogItem _catalogItem({ - required String kind, - required String name, - Map? metadata, - bool? supported, - String? reason, -}) { - final raw = { - 'id': '$kind-$name', - 'kind': kind, - 'name': name, - 'description': null, - 'capabilities': null, - 'supported': supported, - 'reason': reason, - 'metadata': metadata, - }; - return CatalogItem( - id: '$kind-$name', - kind: kind, - name: name, - description: null, - capabilities: null, - supported: supported, - reason: reason, - metadata: metadata, - raw: raw, - ); -} diff --git a/apps/flutter/test/client/t4_client_controller_fixture_test.dart b/apps/flutter/test/client/t4_client_controller_fixture_test.dart deleted file mode 100644 index 1b34d951..00000000 --- a/apps/flutter/test/client/t4_client_controller_fixture_test.dart +++ /dev/null @@ -1,667 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/t4_client_controller.dart'; -import 'package:t4code/src/client/app_state.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/protocol/protocol.dart'; - -const _startupTimeout = Duration(seconds: 10); -const _operationTimeout = Duration(seconds: 5); - -void main() { - test( - 'streams, settles, and resumes against the real stream-v1 fixture', - () async { - final fixture = await _FixtureProcess.start(); - final controller = T4ClientController( - hostDirectoryStore: _MemoryDirectoryStore(), - hostCredentialStore: _MemoryCredentialStore(), - developmentEndpoint: fixture.wsUrl, - ); - addTearDown(() async { - controller.dispose(); - await fixture.stop(); - }); - - await controller.initialize().timeout(_operationTimeout); - final initiallyReady = await _waitForState( - controller, - (state) => state.connectionPhase == ConnectionPhase.ready, - description: 'the initial snapshot to become ready', - ); - - expect(initiallyReady.errorMessage, isNull); - expect(initiallyReady.submitting, isFalse); - expect(initiallyReady.sessions, hasLength(1)); - expect(initiallyReady.selectedSessionId, 'session-stream'); - expect(initiallyReady.messages, hasLength(1)); - final snapshotMessage = initiallyReady.messages.single; - expect(snapshotMessage.role, MessageRole.assistant); - expect(snapshotMessage.text, 'Hello world'); - expect(snapshotMessage.streaming, isFalse); - final session = initiallyReady.selectedSession; - expect(session, isNotNull); - expect(session!.hostId, 'host-stream'); - expect(session.sessionId, 'session-stream'); - expect(session.title, 'stream-v1 fixture'); - expect(session.revision, 'rev-stream-1'); - - const prompt = 'Flutter fixture prompt'; - final promptAccepted = _waitForState( - controller, - (state) => - state.submitting && - state.messages.any( - (message) => - message.role == MessageRole.user && message.text == prompt, - ), - description: 'the fixture to accept the prompt', - ); - await controller.submitPrompt(prompt).timeout(_operationTimeout); - final submitting = await promptAccepted; - expect(submitting.errorMessage, isNull); - expect( - submitting.messages.where((message) => message.text == prompt), - hasLength(1), - ); - - final settledFuture = _waitForState( - controller, - (state) => - !state.submitting && - state.messages.any( - (message) => - message.id != snapshotMessage.id && - message.role == MessageRole.assistant && - message.text == 'Hello world' && - !message.streaming, - ), - description: 'the streamed response to settle durably', - ); - final advance = await fixture.advanceBy(30); - expect(advance, containsPair('ok', true)); - expect(advance, containsPair('nowMs', 30)); - final settled = await settledFuture; - - expect(settled.connectionPhase, ConnectionPhase.ready); - expect(settled.errorMessage, isNull); - expect( - settled.messages, - hasLength(2), - reason: settled.messages - .map( - (message) => - '${message.id}|${message.role.name}|${message.text}|${message.streaming}', - ) - .join(', '), - ); - expect( - settled.messages - .where( - (message) => - message.role == MessageRole.assistant && - message.text == 'Hello world', - ) - .length, - 2, - ); - expect(settled.messages.where((message) => message.streaming), isEmpty); - expect( - settled.messages.map((message) => message.id).toSet(), - hasLength(settled.messages.length), - ); - - final retainedTranscript = settled.messages - .map( - (message) => ( - id: message.id, - role: message.role, - text: message.text, - streaming: message.streaming, - ), - ) - .toList(growable: false); - - final retryingFuture = _waitForState( - controller, - (state) => state.connectionPhase == ConnectionPhase.retrying, - description: 'the forced disconnect to enter retrying', - ); - await fixture.disconnectClients(); - final retrying = await retryingFuture; - expect( - retrying.messages.map((message) => message.id), - retainedTranscript.map((message) => message.id), - ); - - final reconnected = await _waitForState( - controller, - (state) => state.connectionPhase == ConnectionPhase.ready, - description: 'the controller to reconnect automatically', - ); - expect(reconnected.errorMessage, isNull); - expect(reconnected.submitting, isFalse); - expect( - reconnected.messages - .map( - (message) => ( - id: message.id, - role: message.role, - text: message.text, - streaming: message.streaming, - ), - ) - .toList(growable: false), - retainedTranscript, - ); - expect( - reconnected.messages.where((message) => message.streaming), - isEmpty, - ); - expect( - reconnected.messages.where((message) => message.text == 'Hello world'), - hasLength(2), - ); - - final fixtureState = await fixture.state(); - expect(fixtureState, containsPair('scenario', 'stream-v1')); - expect(fixtureState, containsPair('clients', 1)); - expect(fixtureState, containsPair('connections', 2)); - }, - ); - test( - 'reads and explicitly confirms settings writes against the real fixture', - () async { - final fixture = await _FixtureProcess.start(); - final controller = T4ClientController( - hostDirectoryStore: _MemoryDirectoryStore(), - hostCredentialStore: _MemoryCredentialStore(), - developmentEndpoint: fixture.wsUrl, - ); - addTearDown(() async { - controller.dispose(); - await fixture.stop(); - }); - - await controller.initialize().timeout(_operationTimeout); - final settingsReady = await _waitForState( - controller, - (state) => - state.connectionPhase == ConnectionPhase.ready && - !state.settings.loading && - state.settings.entries.any( - (entry) => entry.path == 'appearance.mode', - ), - description: 'fixture settings metadata to load', - ); - final appearance = settingsReady.settings.entries.singleWhere( - (entry) => entry.path == 'appearance.mode', - ); - expect(appearance.effectiveValue, 'system'); - expect(appearance.restartRequired, isTrue); - expect(appearance.writableScopes, contains('global')); - final secret = settingsReady.settings.entries.singleWhere( - (entry) => entry.path == 'provider.apiKey', - ); - expect(secret.sensitive, isTrue); - expect(secret.effectiveValue, isNull); - - final write = controller.writeSetting( - 'appearance.mode', - 'global', - value: 'dark', - ); - final challenged = await _waitForState( - controller, - (state) => state.attentionItems.any( - (item) => - item.kind == AttentionKind.confirmation && - item.sessionId.isEmpty && - item.needsResponse, - ), - description: 'fixture settings confirmation', - ); - final confirmation = challenged.attentionItems.singleWhere( - (item) => - item.kind == AttentionKind.confirmation && - item.sessionId.isEmpty && - item.needsResponse, - ); - await controller.respondToAttention( - confirmation, - const AttentionResponse(decision: AttentionDecision.approve), - ); - await write.timeout(_operationTimeout); - - final applied = controller.state.settings.entries.singleWhere( - (entry) => entry.path == 'appearance.mode', - ); - expect(applied.effectiveValue, 'dark'); - expect(applied.effectiveSource, 'global'); - expect(controller.state.settings.error, isNull); - expect(controller.state.settingsOperationPending, isFalse); - - final resumeWrite = controller.writeSetting( - 'session.autoResume', - 'global', - value: false, - ); - final resumeChallenge = await _waitForState( - controller, - (state) => state.attentionItems.any( - (item) => - item.kind == AttentionKind.confirmation && - item.sessionId.isEmpty && - item.needsResponse, - ), - description: 'fixture resume setting confirmation', - ); - final resumeConfirmation = resumeChallenge.attentionItems.singleWhere( - (item) => - item.kind == AttentionKind.confirmation && - item.sessionId.isEmpty && - item.needsResponse, - ); - await controller.respondToAttention( - resumeConfirmation, - const AttentionResponse(decision: AttentionDecision.approve), - ); - await resumeWrite.timeout(_operationTimeout); - expect( - controller.state.settings.entries - .singleWhere((entry) => entry.path == 'session.autoResume') - .effectiveValue, - false, - ); - }, - ); - test( - 'runs implemented session and developer workflows against the real fixture', - () async { - final fixture = await _FixtureProcess.start(); - final controller = T4ClientController( - hostDirectoryStore: _MemoryDirectoryStore(), - hostCredentialStore: _MemoryCredentialStore(), - developmentEndpoint: fixture.wsUrl, - ); - addTearDown(() async { - controller.dispose(); - await fixture.stop(); - }); - - await controller.initialize().timeout(_operationTimeout); - await _waitForState( - controller, - (state) => state.connectionPhase == ConnectionPhase.ready, - description: 'fixture session to become ready', - ); - const sessionId = 'session-stream'; - - final search = await controller - .searchTranscripts( - query: 'fixture', - roles: const [TranscriptSearchRole.assistant], - ) - .timeout(_operationTimeout); - expect(search.items.single.sessionId, sessionId); - expect(search.items.single.snippet, contains('transcript search')); - expect(search.index.state, TranscriptSearchIndexState.ready); - final context = await controller - .loadTranscriptContext( - sessionId: sessionId, - anchorId: search.items.single.anchorId, - ) - .timeout(_operationTimeout); - expect(context.rows, hasLength(2)); - expect(context.rows[context.anchorIndex].anchorId, context.anchorId); - - final usage = await controller.readUsage().timeout(_operationTimeout); - expect(usage.reports.single.provider, 'fixture-provider'); - expect(usage.reports.single.limits.single.amount.used, 4); - final broker = await controller.readBrokerStatus().timeout( - _operationTimeout, - ); - expect(broker.state, BrokerState.connected); - expect(broker.endpoint, 'https://broker.fixture.invalid'); - - await _waitForState( - controller, - (state) => state.agentActivities.any( - (activity) => - activity.agentId == 'agent-fixture' && - activity.parentAgentId == 'agent-parent' && - activity.currentTool == 'read', - ), - description: 'fixture agent hierarchy projection', - ); - await controller.cancelAgent('agent-fixture').timeout(_operationTimeout); - await _waitForState( - controller, - (state) => state.agentActivities.any( - (activity) => - activity.agentId == 'agent-fixture' && - activity.status == 'cancelled', - ), - description: 'cancelled fixture child agent', - ); - - await controller - .renameSession(sessionId, 'Renamed fixture') - .timeout(_operationTimeout); - await _waitForState( - controller, - (state) => - state.sessions - .where((session) => session.sessionId == sessionId) - .single - .title == - 'Renamed fixture', - description: 'renamed session projection', - ); - - await controller.refreshActivity().timeout(_operationTimeout); - await controller.listFiles().timeout(_operationTimeout); - expect(controller.state.fileWorkspace.entries, isEmpty); - await controller.readFile('README.md').timeout(_operationTimeout); - expect(controller.state.fileWorkspace.path, 'README.md'); - expect(controller.state.fileWorkspace.content, ''); - await controller.loadSessionDiff().timeout(_operationTimeout); - expect(controller.state.fileWorkspace.diff, ''); - expect(controller.state.fileWorkspace.revision, isNotNull); - expect(controller.state.reviews.single.reviewId, 'review-fixture'); - await controller - .writeFile('README.md', 'fixture edit') - .timeout(_operationTimeout); - expect(controller.state.fileWorkspace.content, 'fixture edit'); - await controller.applyReview('review-fixture').timeout(_operationTimeout); - await _waitForState( - controller, - (state) => state.reviews.single.status == 'applied', - description: 'applied fixture review projection', - ); - - final terminalId = await controller - .openTerminal(cwd: 'src') - .timeout(_operationTimeout); - expect(terminalId, 'terminal-fixture'); - expect(controller.state.activeTerminalId, terminalId); - expect(controller.state.terminals.single.running, isTrue); - controller.sendTerminalInput(terminalId, 'pwd\n'); - controller.resizeTerminal(terminalId, 100, 30); - controller.closeTerminal(terminalId); - expect(controller.state.terminals, isEmpty); - - final previewId = await controller - .launchPreview('http://127.0.0.1/fixture') - .timeout(_operationTimeout); - expect(controller.state.activePreviewId, previewId); - await controller.capturePreview(previewId).timeout(_operationTimeout); - expect(controller.state.activePreview?.capture, isNotEmpty); - await controller - .runPreviewInteraction(previewId, 'press', { - 'key': 'Enter', - }) - .timeout(_operationTimeout); - expect(controller.state.activePreviewId, previewId); - await controller - .navigatePreview(previewId, 'http://127.0.0.1/next') - .timeout(_operationTimeout); - expect(controller.state.activePreview?.url, 'http://127.0.0.1/next'); - await controller - .runPreviewAction(previewId, 'close') - .timeout(_operationTimeout); - expect(controller.state.previews, isEmpty); - - await controller.archiveSession(sessionId).timeout(_operationTimeout); - await _waitForState( - controller, - (state) => state.sessions - .where((session) => session.sessionId == sessionId) - .single - .archived, - description: 'archived session projection', - ); - await controller.restoreSession(sessionId).timeout(_operationTimeout); - await _waitForState( - controller, - (state) => !state.sessions - .where((session) => session.sessionId == sessionId) - .single - .archived, - description: 'restored session projection', - ); - - await controller.deleteSession(sessionId).timeout(_operationTimeout); - await _waitForState( - controller, - (state) => - state.sessions.every((session) => session.sessionId != sessionId), - description: 'deleted session projection', - ); - }, - ); -} - -Future _waitForState( - T4ClientController controller, - bool Function(T4ViewState state) predicate, { - required String description, -}) { - final completer = Completer(); - late void Function() listener; - - listener = () { - if (completer.isCompleted) return; - final state = controller.state; - if (!predicate(state)) return; - controller.removeListener(listener); - completer.complete(state); - }; - - controller.addListener(listener); - listener(); - return completer.future.timeout( - _operationTimeout, - onTimeout: () { - controller.removeListener(listener); - final state = controller.state; - throw TimeoutException( - 'Timed out waiting for $description; ' - 'phase=${state.connectionPhase.name}, ' - 'messages=${state.messages.map((message) => '${message.id}:${message.text}:${message.streaming}').toList()}, ' - 'error=${state.errorMessage}', - ); - }, - ); -} - -final class _MemoryDirectoryStore implements HostDirectoryStore { - HostDirectory directory = const HostDirectory.empty(); - - @override - Future load() async => directory; - - @override - Future save(HostDirectory directory) async { - this.directory = directory; - } -} - -final class _MemoryCredentialStore implements HostCredentialStore { - @override - Future delete(HostProfile profile) async {} - - @override - Future read(HostProfile profile) async => null; - - @override - Future write( - HostProfile profile, - DeviceCredentials credentials, - ) async {} -} - -final class _FixtureProcess { - _FixtureProcess._({ - required this._process, - required this.wsUrl, - required this._controlUrl, - required this._stdoutSubscription, - required this._stderrSubscription, - }); - - final Process _process; - final Uri wsUrl; - final Uri _controlUrl; - final StreamSubscription _stdoutSubscription; - final StreamSubscription _stderrSubscription; - final HttpClient _httpClient = HttpClient(); - bool _stopped = false; - - static Future<_FixtureProcess> start() async { - final process = await Process.start( - '../../node_modules/.bin/jiti', - const ['../../e2e/fixture-process.ts'], - environment: const {'T4_FIXTURE_SCENARIO': 'stream-v1'}, - ).timeout(_operationTimeout); - final stderr = StringBuffer(); - final ready = Completer<({Uri wsUrl, Uri controlUrl})>(); - - final stdoutSubscription = process.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen( - (line) { - const prefix = 'T4_FIXTURE_READY '; - if (ready.isCompleted || !line.startsWith(prefix)) return; - try { - final decoded = jsonDecode(line.substring(prefix.length)); - if (decoded is! Map) { - throw const FormatException( - 'fixture ready payload is not an object', - ); - } - final wsUrl = Uri.parse(decoded['wsUrl'] as String); - final controlUrl = Uri.parse(decoded['controlUrl'] as String); - if (wsUrl.scheme != 'ws' || wsUrl.host.isEmpty) { - throw FormatException('invalid fixture WebSocket URL: $wsUrl'); - } - if (controlUrl.scheme != 'http' || controlUrl.host.isEmpty) { - throw FormatException( - 'invalid fixture control URL: $controlUrl', - ); - } - ready.complete((wsUrl: wsUrl, controlUrl: controlUrl)); - } on Object catch (error, stackTrace) { - ready.completeError(error, stackTrace); - } - }, - onError: (Object error, StackTrace stackTrace) { - if (!ready.isCompleted) ready.completeError(error, stackTrace); - }, - ); - final stderrSubscription = process.stderr - .transform(utf8.decoder) - .listen(stderr.write); - - unawaited( - process.exitCode.then((exitCode) { - if (!ready.isCompleted) { - ready.completeError( - StateError( - 'Fixture process exited before ready with code $exitCode. ' - 'stderr: $stderr', - ), - ); - } - }), - ); - - try { - final endpoints = await ready.future.timeout( - _startupTimeout, - onTimeout: () => throw TimeoutException( - 'Fixture process did not become ready. stderr: $stderr', - _startupTimeout, - ), - ); - return _FixtureProcess._( - process: process, - wsUrl: endpoints.wsUrl, - controlUrl: endpoints.controlUrl, - stdoutSubscription: stdoutSubscription, - stderrSubscription: stderrSubscription, - ); - } on Object { - await _terminate(process); - await Future.wait([ - stdoutSubscription.cancel(), - stderrSubscription.cancel(), - ]).timeout(_operationTimeout); - rethrow; - } - } - - Future> advanceBy(int milliseconds) => _request( - 'POST', - _controlUrl - .resolve('/advance') - .replace(queryParameters: {'ms': '$milliseconds'}), - ); - - Future> disconnectClients() => - _request('POST', _controlUrl.resolve('/disconnect')); - - Future> state() => - _request('GET', _controlUrl.resolve('/state')); - - Future> _request(String method, Uri uri) async { - final request = await _httpClient - .openUrl(method, uri) - .timeout(_operationTimeout); - final response = await request.close().timeout(_operationTimeout); - final body = await response - .transform(utf8.decoder) - .join() - .timeout(_operationTimeout); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw HttpException( - 'Fixture control $method $uri failed with ${response.statusCode}: $body', - uri: uri, - ); - } - final decoded = jsonDecode(body); - if (decoded is! Map) { - throw FormatException('Fixture control response is not an object: $body'); - } - return decoded; - } - - Future stop() async { - if (_stopped) return; - _stopped = true; - _httpClient.close(force: true); - try { - await _terminate(_process); - } finally { - await Future.wait([ - _stdoutSubscription.cancel(), - _stderrSubscription.cancel(), - ]).timeout(_operationTimeout); - } - } - - static Future _terminate(Process process) async { - process.kill(ProcessSignal.sigterm); - await process.exitCode.timeout( - _operationTimeout, - onTimeout: () async { - process.kill(ProcessSignal.sigkill); - return process.exitCode.timeout(_operationTimeout); - }, - ); - } -} diff --git a/apps/flutter/test/client/t4_client_controller_test.dart b/apps/flutter/test/client/t4_client_controller_test.dart deleted file mode 100644 index 8acabb4a..00000000 --- a/apps/flutter/test/client/t4_client_controller_test.dart +++ /dev/null @@ -1,3267 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/app_state.dart'; -import 'package:t4code/src/client/t4_client_controller.dart'; -import 'package:t4code/src/client/transcript_tail_store.dart'; -import 'package:t4code/src/client/web_socket_connector.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/host/app_preferences.dart'; -import 'package:t4code/src/protocol/protocol.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -void main() { - test( - 'loads credentials before connecting and sends authenticated hello', - () async { - final profile = _profile('alpha'); - final events = []; - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - events: events, - ); - final credentials = _MemoryCredentialStore(events: events) - ..values[profile.endpointKey] = DeviceCredentials( - deviceId: 'device-alpha', - deviceToken: _token, - ); - final connector = _FakeConnector(events: events); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - - await controller.initialize(); - await _flush(); - - expect(events.take(3), [ - 'load', - 'read:${profile.endpointKey}', - 'connect:${profile.webSocketUrl}', - ]); - final hello = connector.channels.single.sentJson.single; - expect(hello['type'], 'hello'); - expect(hello['capabilities'], { - 'client': t4RequestedCapabilities, - }); - expect(hello['authentication'], { - 'deviceId': 'device-alpha', - 'deviceToken': _token, - }); - }, - ); - - test( - 'probes before saving a host and then connects to the saved profile', - () async { - final events = []; - final directory = _MemoryDirectoryStore(events: events); - final credentials = _MemoryCredentialStore(events: events); - final connector = _FakeConnector(events: events); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - await controller.initialize(); - - await controller.addHost('alpha.example.ts.net'); - - final saveIndex = events.indexWhere((event) => event.startsWith('save:')); - final probeIndex = events.indexOf( - 'connect:wss://alpha.example.ts.net/v1/ws', - ); - final probeCloseIndex = events.indexOf('close:0'); - expect(probeIndex, greaterThanOrEqualTo(0)); - expect(probeCloseIndex, greaterThan(probeIndex)); - expect(saveIndex, greaterThan(probeCloseIndex)); - expect(connector.uris, hasLength(2)); - expect( - directory.directory.activeProfile?.webSocketUrl, - connector.uris.last, - ); - }, - ); - - test( - 'welcome bootstraps session.list then host.watch with index cursor', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final credentials = _MemoryCredentialStore(); - final connector = _FakeConnector(); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - final hello = channel.sentJson.single; - final requestedFeatures = (hello['requestedFeatures']! as List) - .cast(); - expect(requestedFeatures, t4RequestedFeatures); - channel.emit(_welcome('host-alpha', features: requestedFeatures)); - await _flush(); - expect( - controller.state.grantedCapabilities, - containsAll(['sessions.read']), - ); - expect(controller.state.grantedFeatures, containsAll(requestedFeatures)); - final list = channel.sentJson.last; - expect(list, containsPair('command', 'session.list')); - - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult( - 'host-alpha', - epoch: 'index-epoch', - seq: 7, - ), - ), - ); - await _flush(); - - final watch = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'host.watch', - ); - expect(watch, containsPair('command', 'host.watch')); - expect(watch['args'], { - 'cursor': {'epoch': 'index-epoch', 'seq': 7}, - }); - expect(controller.state.sessions.single.sessionId, 'session-alpha'); - }, - ); - - test( - 'pages a cold transcript before attach and prepends older history separately', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final cache = InMemoryTranscriptTailStore(); - await cache.save( - hostId: 'host-alpha', - sessionId: 'session-alpha', - generation: 'cached-generation', - entries: [ - _durableMessage( - 'cached-entry', - hostId: 'host-alpha', - sessionId: 'session-alpha', - text: 'saved recent answer', - ), - ], - ); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - transcriptTailStore: cache, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - Map entry(String id, String text) => { - 'id': id, - 'parentId': null, - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'kind': 'message', - 'timestamp': '2026-07-20T09:00:00.000Z', - 'data': {'role': 'assistant', 'text': text}, - }; - - channel.emit( - _welcome('host-alpha', features: const ['transcript.page']), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - channel.emit(_sessions('host-alpha')); - await _until( - () => channel.sentJson.any( - (frame) => frame['command'] == 'transcript.page', - ), - ); - - expect(controller.state.messages.single.id, 'cached-entry'); - expect(controller.state.transcriptTailFromCache, isTrue); - expect( - channel.sentJson.where( - (frame) => frame['command'] == 'transcript.page', - ), - hasLength(1), - ); - - final page = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'transcript.page', - ); - expect( - channel.sentJson.where( - (frame) => frame['command'] == 'transcript.page', - ), - hasLength(1), - ); - expect( - channel.sentJson.where((frame) => frame['command'] == 'session.attach'), - isEmpty, - ); - expect(page['command'], 'transcript.page'); - expect(page['args'], { - 'limit': 64, - 'maxBytes': 256 * 1024, - }); - expect( - channel.sentJson.where((frame) => frame['command'] == 'session.attach'), - isEmpty, - ); - channel.emit( - _response( - page, - command: 'transcript.page', - result: { - 'entries': [ - entry('page-1', 'paged one'), - entry('page-2', 'paged two'), - ], - 'nextCursor': 'older-page', - 'hasMore': true, - 'generation': 'page-generation', - }, - ), - ); - await _flush(); - - final attach = channel.sentJson.last; - expect(attach['command'], 'session.attach'); - expect(controller.state.messages.map((message) => message.id), [ - 'page-1', - 'page-2', - ]); - expect(controller.state.transcriptHistoryHasMore, isTrue); - expect(controller.state.transcriptTailFromCache, isFalse); - - channel.emit({ - ..._snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha', - ), - 'entries': [ - entry('page-2', 'paged two'), - entry('live-3', 'live three'), - ], - }); - await _flush(); - expect(controller.state.messages.map((message) => message.id), [ - 'page-1', - 'page-2', - 'live-3', - ]); - - final loadOlder = controller.loadEarlierTranscript(); - await _flush(); - final older = channel.sentJson.last; - expect(older['command'], 'transcript.page'); - expect(older['args'], { - 'before': 'older-page', - 'limit': 128, - 'maxBytes': 512 * 1024, - }); - channel.emit( - _response( - older, - command: 'transcript.page', - result: { - 'entries': [entry('older-0', 'older zero')], - 'hasMore': false, - 'generation': 'page-generation', - }, - ), - ); - await loadOlder; - - expect(controller.state.messages.map((message) => message.id), [ - 'older-0', - 'page-1', - 'page-2', - 'live-3', - ]); - expect(controller.state.transcriptHistoryHasMore, isFalse); - expect(controller.state.transcriptHistoryLoading, isFalse); - }, - ); - - test( - 'hydrates Fast availability after attaching an existing session', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - await _flush(); - final attach = channel.sentJson.singleWhere( - (frame) => frame['command'] == 'session.attach', - ); - channel.emit( - _response( - attach, - command: 'session.attach', - result: const {}, - ), - ); - await _flush(); - - final stateGet = channel.sentJson.last; - expect(stateGet['command'], 'session.state.get'); - expect(stateGet['sessionId'], 'session-alpha'); - channel.emit( - _response( - stateGet, - command: 'session.state.get', - result: { - 'isStreaming': false, - 'isCompacting': false, - 'isPaused': false, - 'messageCount': 99, - 'queuedMessageCount': 0, - 'steeringMode': 'one-at-a-time', - 'followUpMode': 'one-at-a-time', - 'interruptMode': 'immediate', - 'model': { - 'id': 'gpt-5.6-sol', - 'provider': 'openai-codex', - 'displayName': 'GPT-5.6-Sol', - 'selector': 'openai-codex/gpt-5.6-sol:high', - 'role': 'default', - }, - 'thinking': 'high', - 'thinkingLevels': [ - 'low', - 'medium', - 'high', - 'xhigh', - 'max', - ], - 'thinkingSupported': true, - 'fast': false, - 'fastAvailable': true, - 'fastActive': false, - }, - ), - ); - await _flush(); - - expect(controller.state.composer.fastAvailable, isTrue); - expect(controller.state.composer.fastEnabled, isFalse); - expect(controller.state.composer.modelLabel, 'GPT-5.6-Sol'); - expect(controller.state.composer.thinking, 'high'); - }, - ); - - test( - 'keeps observer sessions ready when state hydration is locked', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - await _flush(); - final attach = channel.sentJson.singleWhere( - (frame) => frame['command'] == 'session.attach', - ); - channel.emit( - _response( - attach, - command: 'session.attach', - result: const {}, - ), - ); - await _flush(); - final stateGet = channel.sentJson.last; - expect(stateGet['command'], 'session.state.get'); - - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha', - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'response', - 'requestId': stateGet['requestId'], - 'commandId': stateGet['commandId'], - 'hostId': stateGet['hostId'], - 'sessionId': stateGet['sessionId'], - 'command': 'session.state.get', - 'ok': false, - 'error': { - 'code': 'session_locked', - 'message': 'session is locked by another process', - }, - }); - await _flush(); - - expect(controller.state.connectionPhase, ConnectionPhase.ready); - expect(controller.state.errorMessage, isNull); - expect(controller.state.composer.fastAvailable, isFalse); - }, - ); - - test( - 'composer uses official OMP operation capabilities and keeps terminal-only commands disabled', - () async { - final profile = _profile('alpha'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit( - _welcome( - 'host-alpha', - capabilities: const [ - 'sessions.read', - 'sessions.prompt', - 'catalog.read', - ], - features: const ['catalog.metadata'], - ), - ); - await _flush(); - final list = channel.sentJson.firstWhere( - (frame) => frame['command'] == 'session.list', - ); - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'catalog', - 'hostId': 'host-alpha', - 'revision': 'catalog-operation-capabilities', - 'items': [ - { - 'id': 'command:session.cancel', - 'kind': 'command', - 'name': 'session.cancel', - }, - ], - 'operations': [ - { - 'operationId': 'session.prompt', - 'label': 'Prompt', - 'execution': 'typed', - 'supported': true, - }, - { - 'operationId': 'slash.compact', - 'label': '/compact', - 'description': 'Compact the active conversation', - 'execution': 'headless', - 'supported': true, - 'metadata': { - 'aliases': ['compress'], - }, - }, - { - 'operationId': 'slash.plan', - 'label': '/plan', - 'description': 'Toggle plan mode', - 'execution': 'terminal-only', - 'supported': false, - 'disabledReason': { - 'code': 'terminal_only', - 'message': '/plan requires the OMP terminal interface.', - }, - }, - ], - }); - await _flush(); - - final commands = controller.state.composer.slashCommands; - expect(commands.map((command) => command.name), [ - '/compact', - '/plan', - ]); - expect(commands.first.aliases, ['/compress']); - expect(commands.first.disabledReason, isNull); - expect( - commands.last.disabledReason, - '/plan requires the OMP terminal interface.', - ); - - channel.emit({ - 'v': 'omp-app/1', - 'type': 'catalog', - 'hostId': 'host-alpha', - 'revision': 'catalog-authoritative-empty', - 'items': [ - { - 'id': 'command:legacy-compact', - 'kind': 'command', - 'name': '/compact', - }, - { - 'id': 'command:session.cancel', - 'kind': 'command', - 'name': 'session.cancel', - }, - ], - 'operations': [], - }); - await _flush(); - expect(controller.state.composer.slashCommands, isEmpty); - }, - ); - - test( - 'read-only catalog clients see official headless commands disabled', - () async { - final profile = _profile('alpha'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit( - _welcome( - 'host-alpha', - capabilities: const ['sessions.read', 'catalog.read'], - features: const ['catalog.metadata'], - ), - ); - await _flush(); - final list = channel.sentJson.firstWhere( - (frame) => frame['command'] == 'session.list', - ); - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'catalog', - 'hostId': 'host-alpha', - 'revision': 'catalog-read-only', - 'items': [], - 'operations': [ - { - 'operationId': 'slash.compact', - 'label': '/compact', - 'execution': 'headless', - 'supported': true, - }, - ], - }); - await _flush(); - - expect( - controller.state.composer.slashCommands.single.disabledReason, - 'Not granted on this host', - ); - }, - ); - - test('command ids are unique across controller restarts', () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final firstConnector = _FakeConnector(); - final secondConnector = _FakeConnector(); - final first = _controller( - directory, - _MemoryCredentialStore(), - firstConnector, - ); - final second = _controller( - directory, - _MemoryCredentialStore(), - secondConnector, - ); - addTearDown(first.dispose); - addTearDown(second.dispose); - - await first.initialize(); - await second.initialize(); - final firstChannel = firstConnector.channels.single; - final secondChannel = secondConnector.channels.single; - firstChannel.emit(_welcome('host-alpha')); - secondChannel.emit(_welcome('host-alpha')); - await _flush(); - - expect( - firstChannel.sentJson.last['commandId'], - isNot(secondChannel.sentJson.last['commandId']), - ); - }); - - test( - 'duplicate inventories share an attach while a new selection stays isolated', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - final list = channel.sentJson.last; - final inventory = _sessionListResultFor('host-alpha', const [ - 'session-alpha', - 'session-beta', - ]); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-alpha', - ...inventory, - }); - await _flush(); - final firstAttach = channel.sentJson.singleWhere( - (frame) => - frame['command'] == 'session.attach' && - frame['sessionId'] == 'session-alpha', - ); - - channel.emit(_response(list, command: 'session.list', result: inventory)); - await _flush(); - expect( - channel.sentJson.where( - (frame) => - frame['command'] == 'session.attach' && - frame['sessionId'] == 'session-alpha', - ), - hasLength(1), - ); - - await controller.selectSession('session-beta'); - expect( - channel.sentJson.where((frame) => frame['command'] == 'session.attach'), - hasLength(2), - ); - expect(channel.sentJson.last['sessionId'], 'session-beta'); - expect(controller.state.connectionPhase, ConnectionPhase.synchronizing); - - channel.emit( - _response( - firstAttach, - command: 'session.attach', - result: const {}, - ), - ); - await _flush(); - expect(controller.state.selectedSessionId, 'session-beta'); - expect(controller.state.connectionPhase, ConnectionPhase.synchronizing); - }, - ); - - test( - 'ignores transcript frames from a previously selected session', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-alpha', - ..._sessionListResultFor('host-alpha', const [ - 'session-alpha', - 'session-beta', - ]), - }); - await _flush(); - channel.emit( - _snapshot('host-alpha', 'session-alpha', revision: 'revision-alpha'), - ); - await _flush(); - - await controller.selectSession('session-beta'); - channel.emit( - _snapshot('host-alpha', 'session-beta', revision: 'revision-beta'), - ); - channel.emit( - _transcriptEvent( - 'host-alpha', - 'session-beta', - seq: 1, - event: const { - 'type': 'message.update', - 'entryId': 'live-message', - 'role': 'assistant', - 'text': 'Beta response', - }, - ), - ); - await _flush(); - expect(controller.state.messages.single.text, 'Beta response'); - - channel.emit( - _transcriptMessageEntry( - 'host-alpha', - 'session-alpha', - seq: 1, - entryId: 'stale-durable-system', - role: 'system', - text: 'Stale durable system prompt', - ), - ); - channel.emit( - _transcriptEvent( - 'host-alpha', - 'session-alpha', - seq: 2, - event: const { - 'type': 'message.update', - 'entryId': 'live-message', - 'role': 'system', - 'text': 'Stale system prompt', - }, - ), - ); - channel.emit( - _transcriptEvent( - 'host-alpha', - 'session-alpha', - seq: 3, - event: const { - 'type': 'tool.start', - 'callId': 'alpha-tool', - 'tool': 'bash', - }, - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'gap', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'from': {'epoch': 'transcript', 'seq': 4}, - 'to': {'epoch': 'transcript', 'seq': 5}, - 'reason': 'stale alpha gap', - }); - await _flush(); - - expect(controller.state.selectedSessionId, 'session-beta'); - expect(controller.state.connectionPhase, ConnectionPhase.ready); - expect(controller.state.messages, hasLength(1)); - expect(controller.state.messages.single.role, MessageRole.assistant); - expect(controller.state.messages.single.text, 'Beta response'); - }, - ); - - test( - 'gap recovery requests a cursorless snapshot before returning ready', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - await _flush(); - final initialAttach = channel.sentJson.singleWhere( - (frame) => frame['command'] == 'session.attach', - ); - channel.emit( - _response( - initialAttach, - command: 'session.attach', - result: const {}, - ), - ); - channel.emit( - _snapshot('host-alpha', 'session-alpha', revision: 'revision-initial'), - ); - await _flush(); - expect(controller.state.connectionPhase, ConnectionPhase.ready); - - channel.emit({ - 'v': 'omp-app/1', - 'type': 'gap', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'from': {'epoch': 'transcript', 'seq': 1}, - 'to': {'epoch': 'transcript', 'seq': 5}, - 'reason': 'rebase_budget_exceeded', - }); - await _flush(); - - final recoveryAttach = channel.sentJson.lastWhere( - (frame) => - frame['command'] == 'session.attach' && - frame['requestId'] != initialAttach['requestId'], - ); - expect(recoveryAttach['args'], isEmpty); - expect(controller.state.connectionPhase, ConnectionPhase.synchronizing); - expect( - controller.state.errorMessage, - 'Recovering transcript continuity…', - ); - - channel.emit( - _response( - recoveryAttach, - command: 'session.attach', - result: const {}, - ), - ); - await _flush(); - expect(controller.state.connectionPhase, ConnectionPhase.synchronizing); - channel.emit({ - ..._snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-recovered', - ), - 'cursor': {'epoch': 'transcript', 'seq': 5}, - }); - await _flush(); - - expect(controller.state.connectionPhase, ConnectionPhase.ready); - expect(controller.state.errorMessage, isNull); - }, - ); - - test('returning to a session requests its complete transcript', () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-alpha', - ..._sessionListResultFor('host-alpha', const [ - 'session-alpha', - 'session-beta', - ]), - }); - await _flush(); - final firstSessionId = controller.state.selectedSessionId!; - final secondSessionId = controller.state.sessions - .firstWhere((session) => session.sessionId != firstSessionId) - .sessionId; - final firstAttach = channel.sentJson.last; - channel.emit( - _response( - firstAttach, - command: 'session.attach', - result: const {}, - ), - ); - channel.emit( - _snapshot('host-alpha', firstSessionId, revision: 'revision-first'), - ); - await _flush(); - - await controller.selectSession(secondSessionId); - expect(controller.state.selectedSessionId, secondSessionId); - final secondAttach = channel.sentJson.last; - channel.emit( - _response( - secondAttach, - command: 'session.attach', - result: const {}, - ), - ); - channel.emit( - _snapshot('host-alpha', secondSessionId, revision: 'revision-second'), - ); - await _flush(); - expect(controller.state.selectedSessionId, secondSessionId); - expect( - controller.state.sessions.map((session) => session.sessionId), - contains(firstSessionId), - ); - await controller.selectSession(firstSessionId); - expect(controller.state.selectedSessionId, firstSessionId); - - final attach = channel.sentJson.last; - expect(attach, containsPair('command', 'session.attach')); - expect(attach, containsPair('sessionId', firstSessionId)); - expect(attach['args'], isEmpty); - }); - - test( - 'switching hosts clears projections without deleting credentials', - () async { - final alpha = _profile('alpha'); - final beta = _profile('beta'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(beta).upsert(alpha), - ); - final credentials = _MemoryCredentialStore(); - final connector = _FakeConnector(); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - await controller.initialize(); - final first = connector.channels.single; - first.emit(_welcome('host-alpha')); - first.emit(_sessions('host-alpha')); - await _flush(); - expect(controller.state.sessions, isNotEmpty); - - await controller.activateHost(beta.endpointKey); - - expect( - controller.state.hostDirectory.activeEndpointKey, - beta.endpointKey, - ); - expect(controller.state.sessions, isEmpty); - expect(controller.state.messages, isEmpty); - expect(controller.state.selectedSessionId, isNull); - expect(credentials.deleted, isEmpty); - expect(connector.uris.last, beta.webSocketUrl); - }, - ); - - testWidgets('failed handshake close cannot stall automatic reconnect', ( - tester, - ) async { - final readyGate = Completer(); - final connector = _FakeConnector( - readyGate: readyGate, - hangFirstClose: true, - ); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(_profile('alpha')), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - final initialization = controller.initialize(); - await tester.pump(); - expect(connector.channels, hasLength(1)); - - readyGate.completeError(StateError('server unavailable')); - await initialization; - expect(controller.state.connectionPhase, ConnectionPhase.retrying); - await tester.pump(const Duration(milliseconds: 600)); - - expect(connector.channels.length, greaterThan(1)); - await controller.disconnect(); - await tester.pump(const Duration(seconds: 1)); - }); - - test('deliberate disconnect cancels an automatic reconnect', () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final first = connector.channels.single; - first.fail(StateError('network lost')); - await _flush(); - expect(controller.state.connectionPhase, ConnectionPhase.retrying); - - await controller.disconnect(); - await Future.delayed(const Duration(milliseconds: 600)); - - expect(controller.state.connectionPhase, ConnectionPhase.disconnected); - expect(connector.channels, hasLength(1)); - await controller.connect(); - expect(connector.channels, hasLength(2)); - }); - - test('cancelling a pending host probe never saves it', () async { - final readyGate = Completer(); - final directory = _MemoryDirectoryStore(); - final connector = _FakeConnector(readyGate: readyGate); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - - final adding = controller.addHost('alpha.example.ts.net'); - await _until(() => connector.channels.isNotEmpty); - expect(controller.state.hostOperationPending, isTrue); - controller.cancelHostProbe(); - readyGate.complete(); - await adding; - - expect(controller.state.hostOperationPending, isFalse); - expect(directory.saved, isEmpty); - expect(directory.directory.profiles, isEmpty); - }); - - test('credential deletion failure rolls host metadata back', () async { - final alpha = _profile('alpha'); - final beta = _profile('beta'); - final original = const HostDirectory.empty().upsert(beta).upsert(alpha); - final events = []; - final directory = _MemoryDirectoryStore( - directory: original, - events: events, - ); - final credentials = _MemoryCredentialStore(events: events) - ..deleteError = StateError('vault unavailable'); - final connector = _FakeConnector(); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - await controller.initialize(); - - await expectLater( - controller.removeHost(beta.endpointKey), - throwsA(isA()), - ); - - expect(directory.saved, hasLength(2)); - expect(directory.saved.first.profiles, isNot(contains(beta))); - expect(directory.saved.last.activeEndpointKey, original.activeEndpointKey); - final firstSave = events.indexWhere((event) => event.startsWith('save:')); - final deletion = events.indexOf('delete:${beta.endpointKey}'); - final rollbackSave = events.lastIndexWhere( - (event) => event.startsWith('save:'), - ); - expect(deletion, greaterThan(firstSave)); - expect(rollbackSave, greaterThan(deletion)); - expect( - directory.directory.profiles.map((item) => item.endpointKey), - original.profiles.map((item) => item.endpointKey), - ); - expect(controller.state.hostDirectory.profiles, original.profiles); - expect(controller.state.errorMessage, contains('host was restored')); - }); - - test( - 'pairing persists scoped credentials and reconnects authenticated', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final credentials = _MemoryCredentialStore(); - final connector = _FakeConnector(); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - await controller.initialize(); - final first = connector.channels.single; - first.emit( - _welcome( - 'host-alpha', - authentication: 'pairing-required', - capabilities: const [], - ), - ); - await _flush(); - - await controller.pairHost('12345'); - expect(controller.state.errorMessage, contains('six-digit')); - final sentBeforePair = first.sent.length; - await controller.pairHost('123456'); - final pair = first.sentJson.last; - expect(first.sent, hasLength(sentBeforePair + 1)); - expect(pair['type'], 'pair.start'); - expect(pair['code'], '123456'); - expect(pair['deviceId'], matches(RegExp(r'^[A-Za-z0-9_-]{32}$'))); - - first.emit({ - 'v': 'omp-app/1', - 'type': 'pair.ok', - 'requestId': pair['requestId'], - 'pairingId': 'pair-alpha', - 'deviceId': pair['deviceId'], - 'deviceName': pair['deviceName'], - 'platform': pair['platform'], - 'requestedCapabilities': pair['requestedCapabilities'], - 'grantedCapabilities': pair['requestedCapabilities'], - 'deviceToken': _token, - 'expiresAt': DateTime.now() - .toUtc() - .add(const Duration(hours: 1)) - .toIso8601String(), - }); - await _until(() => connector.channels.length == 2); - - final saved = credentials.values[profile.endpointKey]; - expect(saved?.deviceId, pair['deviceId']); - expect(saved?.deviceToken, _token); - final authenticatedHello = connector.channels.last.sentJson.single; - expect(authenticatedHello['authentication'], { - 'deviceId': pair['deviceId'], - 'deviceToken': _token, - }); - }, - ); - - test( - 'stale credential read cannot connect the previous active host', - () async { - final alpha = _profile('alpha'); - final beta = _profile('beta'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(beta).upsert(alpha), - ); - final delayedRead = Completer(); - final credentials = _MemoryCredentialStore() - ..delayedReads[alpha.endpointKey] = delayedRead; - final connector = _FakeConnector(); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - final initializing = controller.initialize(); - await _until(() => credentials.readProfiles.contains(alpha.endpointKey)); - - await controller.activateHost(beta.endpointKey); - delayedRead.complete(null); - await initializing; - await _flush(); - - expect(connector.uris, [beta.webSocketUrl]); - expect( - controller.state.hostDirectory.activeEndpointKey, - beta.endpointKey, - ); - }, - ); - test('uploads prompt images and reads transcript image chunks', () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit( - _welcome( - 'host-alpha', - capabilities: const [ - 'sessions.read', - 'sessions.prompt', - 'sessions.control', - 'sessions.manage', - ], - ), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResultFor('host-alpha', const [ - 'session-alpha', - ]), - ), - ); - await _flush(); - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha-2', - ), - ); - await _flush(); - expect(controller.state.connectionPhase, ConnectionPhase.ready); - - final sending = controller.submitPrompt( - 'Inspect this image', - images: [ - PromptImageAttachment( - id: 'local-image', - name: 'fixture.png', - mimeType: 'image/png', - bytes: Uint8List.fromList([1, 2, 3]), - ), - ], - ); - await _flush(); - final begin = channel.sentJson.last; - expect(begin['command'], 'session.image.begin'); - expect(begin['args'], { - 'mimeType': 'image/png', - 'size': 3, - 'sha256': isA(), - }); - channel.emit( - _response( - begin, - command: 'session.image.begin', - result: { - 'imageId': 'uploaded-image', - 'chunkBytes': 2, - 'expiresAt': '2030-01-01T00:00:00.000Z', - }, - ), - ); - await _flush(); - - final firstChunk = channel.sentJson.last; - expect(firstChunk['command'], 'session.image.chunk'); - expect(firstChunk['args'], { - 'imageId': 'uploaded-image', - 'offset': 0, - 'content': 'AQI=', - }); - channel.emit( - _response( - firstChunk, - command: 'session.image.chunk', - result: { - 'imageId': 'uploaded-image', - 'received': 2, - 'complete': false, - }, - ), - ); - await _flush(); - - final finalChunk = channel.sentJson.last; - expect(finalChunk['command'], 'session.image.chunk'); - expect(finalChunk['args'], { - 'imageId': 'uploaded-image', - 'offset': 2, - 'content': 'Aw==', - }); - channel.emit( - _response( - finalChunk, - command: 'session.image.chunk', - result: { - 'imageId': 'uploaded-image', - 'received': 3, - 'complete': true, - }, - ), - ); - await _flush(); - - final prompt = channel.sentJson.last; - expect(prompt['command'], 'session.prompt'); - expect(prompt['expectedRevision'], 'revision-session-alpha-2'); - expect(prompt['args'], { - 'message': 'Inspect this image', - 'images': [ - {'imageId': 'uploaded-image'}, - ], - }); - channel.emit( - _response( - prompt, - command: 'session.prompt', - result: {'accepted': true}, - ), - ); - expect(await sending, isTrue); - - const contentSha256 = - '039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81'; - final reading = controller.readTranscriptImage( - 'entry-image', - TranscriptImageMetadata(sha256: contentSha256, mimeType: 'image/png'), - ); - await _flush(); - final firstRead = channel.sentJson.last; - expect(firstRead['command'], 'session.image.read'); - expect(firstRead['args'], { - 'entryId': 'entry-image', - 'sha256': contentSha256, - 'offset': 0, - }); - channel.emit( - _response( - firstRead, - command: 'session.image.read', - result: { - 'sha256': contentSha256, - 'mimeType': 'image/png', - 'size': 3, - 'offset': 0, - 'nextOffset': 2, - 'complete': false, - 'content': 'AQI=', - }, - ), - ); - await _flush(); - final finalRead = channel.sentJson.last; - expect(finalRead['args'], { - 'entryId': 'entry-image', - 'sha256': contentSha256, - 'offset': 2, - }); - channel.emit( - _response( - finalRead, - command: 'session.image.read', - result: { - 'sha256': contentSha256, - 'mimeType': 'image/png', - 'size': 3, - 'offset': 2, - 'nextOffset': 3, - 'complete': true, - 'content': 'Aw==', - }, - ), - ); - expect(await reading, [1, 2, 3]); - - final queueing = controller.queuePrompt('Follow up later'); - await _flush(); - final followUp = channel.sentJson.last; - expect(followUp['command'], 'session.followUp'); - expect(followUp['args'], {'message': 'Follow up later'}); - channel.emit( - _response( - followUp, - command: 'session.followUp', - result: {'accepted': true}, - ), - ); - expect(await queueing, isTrue); - - final settingModel = controller.setSessionModel('fixture/model-pro'); - await _flush(); - final model = channel.sentJson.last; - expect(model['command'], 'session.model.set'); - expect(model['args'], { - 'selector': 'fixture/model-pro', - 'persistence': 'session', - }); - channel.emit( - _response( - model, - command: 'session.model.set', - result: {'updated': true}, - ), - ); - await settingModel; - - final settingThinking = controller.setSessionThinking('high'); - await _flush(); - final thinking = channel.sentJson.last; - expect(thinking['command'], 'session.thinking.set'); - expect(thinking['args'], {'level': 'high'}); - channel.emit( - _response( - thinking, - command: 'session.thinking.set', - result: {'updated': true}, - ), - ); - await settingThinking; - - final settingFast = controller.setSessionFast(true); - await _flush(); - final fast = channel.sentJson.last; - expect(fast['command'], 'session.fast.set'); - expect(fast['args'], {'enabled': true}); - channel.emit( - _response( - fast, - command: 'session.fast.set', - result: {'updated': true}, - ), - ); - await settingFast; - }); - - test( - 'creates and manages sessions through index deltas and confirmations', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit( - _welcome( - 'host-alpha', - capabilities: const [ - 'sessions.read', - 'sessions.control', - 'sessions.manage', - ], - features: const ['host.watch'], - ), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResultFor('host-alpha', const [ - 'session-alpha', - 'session-beta', - ]), - ), - ); - await _flush(); - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha-2', - ), - ); - await _flush(); - expect(controller.state.connectionPhase, ConnectionPhase.ready); - expect(controller.state.sessions.first.projectName, 'Project Alpha'); - - final creating = controller.createSession( - 'project-session-alpha', - title: 'New investigation', - ); - await _flush(); - final create = channel.sentJson.last; - expect(create['command'], 'session.create'); - expect(create['args'], { - 'projectId': 'project-session-alpha', - 'title': 'New investigation', - }); - final createdRef = _sessionRef( - 'host-alpha', - 'session-created', - projectId: 'project-session-alpha', - projectName: 'Project Alpha', - revision: 'revision-created', - title: 'New investigation', - ); - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created', - upsert: createdRef, - seq: 2, - ), - ); - channel.emit( - _response( - create, - command: 'session.create', - result: {'session': createdRef}, - ), - ); - await creating; - expect(controller.state.selectedSessionId, 'session-created'); - expect(controller.state.selectedSession?.title, 'New investigation'); - expect(channel.sentJson.last, containsPair('command', 'session.attach')); - channel.emit( - _snapshot( - 'host-alpha', - 'session-created', - revision: 'revision-created-2', - ), - ); - await _flush(); - - final renaming = controller.renameSession( - 'session-created', - 'Renamed investigation', - ); - await _flush(); - final rename = channel.sentJson.last; - expect(rename['command'], 'session.rename'); - expect(rename['expectedRevision'], 'revision-created-2'); - final renamedRef = _sessionRef( - 'host-alpha', - 'session-created', - projectId: 'project-session-alpha', - projectName: 'Project Alpha', - revision: 'revision-created-3', - title: 'Renamed investigation', - ); - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created-3', - upsert: renamedRef, - seq: 3, - ), - ); - channel.emit( - _response( - rename, - command: 'session.rename', - result: {'renamed': true}, - ), - ); - await renaming; - expect(controller.state.selectedSession?.title, 'Renamed investigation'); - - final archiving = controller.archiveSession('session-created'); - await _flush(); - final archive = channel.sentJson.last; - final archivedRef = { - ...renamedRef, - 'revision': 'revision-created-4', - 'archivedAt': '2026-07-19T01:00:00.000Z', - }; - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created-4', - upsert: archivedRef, - seq: 4, - ), - ); - channel.emit( - _response( - archive, - command: 'session.archive', - result: {'archived': true}, - ), - ); - await archiving; - expect( - controller.state.sessions - .singleWhere((session) => session.sessionId == 'session-created') - .archived, - isTrue, - ); - expect(controller.state.selectedSessionId, isNot('session-created')); - channel.emit( - _snapshot( - 'host-alpha', - controller.state.selectedSessionId!, - revision: 'revision-replacement', - ), - ); - await _flush(); - - final restoring = controller.restoreSession('session-created'); - await _flush(); - final restore = channel.sentJson.last; - final restoredRef = { - ...renamedRef, - 'revision': 'revision-created-5', - }; - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created-5', - upsert: restoredRef, - seq: 5, - ), - ); - channel.emit( - _response( - restore, - command: 'session.restore', - result: {'restored': true}, - ), - ); - await restoring; - - final terminating = controller.terminateSession('session-created'); - await _flush(); - final terminate = channel.sentJson.last; - expect(terminate['command'], 'session.close'); - channel.emit(_confirmation(terminate, 'confirmation-close')); - await _flush(); - final closeConfirmation = channel.sentJson.last; - expect(closeConfirmation, containsPair('type', 'confirm')); - expect(closeConfirmation, containsPair('decision', 'approve')); - final closedRef = { - ...restoredRef, - 'revision': 'revision-created-6', - 'status': 'closed', - }; - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created-6', - upsert: closedRef, - seq: 6, - ), - ); - channel.emit( - _response( - terminate, - command: 'session.close', - result: { - 'closed': true, - 'sessionId': 'session-created', - }, - ), - ); - await terminating; - - final deleting = controller.deleteSession('session-created'); - await _flush(); - final delete = channel.sentJson.last; - expect(delete['command'], 'session.delete'); - channel.emit(_confirmation(delete, 'confirmation-delete')); - await _flush(); - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created-7', - remove: 'session-created', - seq: 7, - ), - ); - channel.emit( - _response( - delete, - command: 'session.delete', - result: {'deleted': true}, - ), - ); - await deleting; - expect( - controller.state.sessions.any( - (session) => session.sessionId == 'session-created', - ), - isFalse, - ); - expect(controller.state.sessionOperationPending, isFalse); - }, - ); - - test('projects attention, responds, retries, and tracks agents', () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit( - _welcome( - 'host-alpha', - capabilities: const [ - 'sessions.read', - 'sessions.prompt', - 'sessions.control', - ], - features: const ['prompt.lease'], - ), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResultFor( - 'host-alpha', - const ['session-alpha'], - attention: { - 'pending': [ - { - 'kind': 'question', - 'id': 'question-1', - 'question': 'Which environment?', - 'options': [ - {'id': 'staging', 'label': 'Staging'}, - {'id': 'production', 'label': 'Production'}, - ], - 'allowText': true, - 'requestedAt': '2026-07-19T00:00:00.000Z', - }, - ], - 'pendingCount': 1, - 'truncated': false, - 'latestOutcome': { - 'id': 'outcome-1', - 'kind': 'failed', - 'at': '2026-07-19T00:01:00.000Z', - 'summary': 'The last turn failed.', - }, - }, - ), - ), - ); - await _flush(); - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha', - ), - ); - await _flush(); - - expect(controller.state.urgentAttentionCount, 1); - expect(controller.state.attentionItems, hasLength(2)); - final question = controller.state.attentionItems.firstWhere( - (item) => item.kind == AttentionKind.question, - ); - expect(question.choices.map((choice) => choice.id), [ - 'staging', - 'production', - ]); - - final responding = controller.respondToAttention( - question, - const AttentionResponse( - decision: AttentionDecision.approve, - optionIds: ['staging'], - ), - ); - await _flush(); - final acquisition = channel.sentJson.last; - expect(acquisition['command'], 'prompt.lease.acquire'); - expect(acquisition['args'], { - 'ownerId': 't4-code-flutter', - }); - channel.emit( - _response( - acquisition, - command: 'prompt.lease.acquire', - result: { - 'accepted': true, - 'leaseId': 'prompt-lease-1', - 'expiresAt': '2030-01-01T00:00:00.000Z', - }, - ), - ); - await _flush(); - final responseCommand = channel.sentJson.last; - expect(responseCommand['command'], 'session.ui.respond'); - expect(responseCommand['expectedRevision'], 'revision-session-alpha'); - expect(responseCommand['args'], { - 'requestId': 'question-1', - 'value': 'staging', - 'leaseId': 'prompt-lease-1', - }); - channel.emit( - _response( - responseCommand, - command: 'session.ui.respond', - result: {'accepted': true}, - ), - ); - expect(await responding, isTrue); - - final retrying = controller.retrySession('session-alpha'); - await _flush(); - final retry = channel.sentJson.last; - expect(retry['command'], 'session.retry'); - channel.emit( - _response( - retry, - command: 'session.retry', - result: {'retried': true}, - ), - ); - await retrying; - - channel.emit({ - 'v': 'omp-app/1', - 'type': 'agent.progress', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'agentId': 'agent-1', - 'cursor': {'epoch': 'agent', 'seq': 1}, - 'revision': 'revision-session-alpha', - 'progress': 0.5, - 'detail': {'title': 'Reviewing changes'}, - }); - await _flush(); - expect(controller.state.agentActivities.single.label, 'Reviewing changes'); - expect(controller.state.agentActivities.single.progress, 0.5); - - channel.emit( - _confirmation({ - 'commandId': 'remote-command', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'expectedRevision': 'revision-session-alpha', - 'command': 'files.write', - }, 'confirmation-remote'), - ); - await _flush(); - final confirmation = controller.state.attentionItems.firstWhere( - (item) => item.kind == AttentionKind.confirmation, - ); - expect(controller.state.urgentAttentionCount, 2); - expect( - await controller.respondToAttention( - confirmation, - const AttentionResponse(decision: AttentionDecision.deny), - ), - isTrue, - ); - expect(channel.sentJson.last, containsPair('type', 'confirm')); - expect(channel.sentJson.last, containsPair('decision', 'deny')); - }); - - test( - 'searches project files and runs pause resume and compact controls', - () async { - final profile = _profile('alpha'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome( - 'host-alpha', - capabilities: t4RequestedCapabilities, - features: const ['files.search'], - ), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - await _flush(); - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha', - ), - ); - await _flush(); - - final searching = controller.searchProjectFiles(' main ', limit: 5); - await _flush(); - final search = channel.sentJson.last; - expect(search['command'], 'files.search'); - expect(search['args'], {'query': 'main', 'limit': 5}); - channel.emit( - _response( - search, - command: 'files.search', - result: { - 'matches': [ - {'path': 'lib/main.dart'}, - {'path': 'test/main_test.dart'}, - ], - 'truncated': true, - }, - ), - ); - final searchResult = await searching; - expect(searchResult.paths, [ - 'lib/main.dart', - 'test/main_test.dart', - ]); - expect(searchResult.truncated, isTrue); - - final pausing = controller.pauseSession(); - await _flush(); - final pause = channel.sentJson.last; - expect(pause['command'], 'session.pause'); - channel.emit( - _response( - pause, - command: 'session.pause', - result: {'paused': true, 'changed': true}, - ), - ); - await pausing; - expect(controller.state.composer.isPaused, isTrue); - - final resuming = controller.resumeSession(); - await _flush(); - final resume = channel.sentJson.last; - expect(resume['command'], 'session.resume'); - channel.emit( - _response( - resume, - command: 'session.resume', - result: {'resumed': true, 'paused': false}, - ), - ); - await resuming; - expect(controller.state.composer.isPaused, isFalse); - - final compacting = controller.compactSession( - instructions: 'Keep the current implementation plan.', - ); - await _flush(); - final compact = channel.sentJson.last; - expect(compact['command'], 'session.compact'); - expect(compact['args'], { - 'instructions': 'Keep the current implementation plan.', - }); - channel.emit( - _response( - compact, - command: 'session.compact', - result: {'compacted': true}, - ), - ); - await compacting; - }, - ); - - test( - 'projects terminal, files, audit, and preview developer state', - () async { - final profile = _profile('alpha'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome('host-alpha', capabilities: t4RequestedCapabilities), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResultFor('host-alpha', const [ - 'session-alpha', - ]), - ), - ); - await _flush(); - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha', - ), - ); - await _flush(); - - final opening = controller.openTerminal(cwd: 'packages/client'); - await _flush(); - final open = channel.sentJson.last; - expect(open['command'], 'term.open'); - expect(open['args'], containsPair('cwd', 'packages/client')); - channel.emit(_confirmation(open, 'confirmation-terminal')); - await _flush(); - expect(channel.sentJson.last, containsPair('decision', 'approve')); - channel.emit( - _response( - open, - command: 'term.open', - result: {'terminalId': 'terminal-alpha'}, - ), - ); - expect(await opening, 'terminal-alpha'); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'terminal.output', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'terminalId': 'terminal-alpha', - 'cursor': {'epoch': 'terminal', 'seq': 1}, - 'stream': 'stdout', - 'data': r'$ ready', - }); - await _flush(); - expect(controller.state.activeTerminal?.output, r'$ ready'); - controller.sendTerminalInput('terminal-alpha', 'pwd\n'); - expect(channel.sentJson.last, containsPair('type', 'terminal.input')); - expect(channel.sentJson.last, containsPair('data', 'pwd\n')); - controller.resizeTerminal('terminal-alpha', 120, 40); - expect(channel.sentJson.last, containsPair('type', 'terminal.resize')); - expect(channel.sentJson.last, containsPair('cols', 120)); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'terminal.exit', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'terminalId': 'terminal-alpha', - 'cursor': {'epoch': 'terminal', 'seq': 2}, - 'exitCode': 0, - }); - await _flush(); - expect(controller.state.activeTerminal?.running, isFalse); - controller.closeTerminal('terminal-alpha'); - expect(channel.sentJson.last, containsPair('type', 'terminal.close')); - expect(controller.state.activeTerminal, isNull); - - final listing = controller.listFiles(); - await _flush(); - final filesCommand = channel.sentJson.last; - expect(filesCommand['command'], 'files.list'); - channel.emit( - _response( - filesCommand, - command: 'files.list', - result: { - 'entries': [ - { - 'path': 'lib/main.dart', - 'kind': 'file', - 'size': 42, - 'revision': 'revision-file', - }, - ], - 'revision': 'revision-files', - }, - ), - ); - await listing; - expect( - controller.state.fileWorkspace.entries.single.path, - 'lib/main.dart', - ); - - final refreshing = controller.refreshActivity(); - await _flush(); - final auditCommand = channel.sentJson.last; - expect(auditCommand['command'], 'audit.read'); - channel.emit( - _response( - auditCommand, - command: 'audit.read', - result: { - 'events': [ - { - 'eventId': 'operation-audit-1', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'action': 'files.read', - 'actor': 'fixture', - 'timestamp': '2026-07-19T00:00:00.000Z', - 'detail': {'token': 'must-not-render'}, - }, - ], - }, - ), - ); - await refreshing; - final audit = controller.state.activities.firstWhere( - (activity) => activity.id == 'operation-audit-1', - ); - expect(audit.raw, contains('')); - expect(audit.raw, isNot(contains('must-not-render'))); - - final launching = controller.launchPreview('https://example.test'); - await _flush(); - final launch = channel.sentJson.last; - expect(launch['command'], 'preview.launch'); - channel.emit(_confirmation(launch, 'confirmation-preview')); - await _flush(); - channel.emit( - _response( - launch, - command: 'preview.launch', - result: { - 'preview': { - 'previewId': 'preview-alpha', - 'state': 'ready', - 'url': 'https://example.test/', - 'revision': 'revision-preview', - 'cursor': {'epoch': 'preview', 'seq': 1}, - 'title': 'Fixture preview', - 'canGoBack': false, - 'canGoForward': false, - }, - }, - ), - ); - expect(await launching, 'preview-alpha'); - expect(controller.state.activePreview?.title, 'Fixture preview'); - expect(controller.state.developerOperationPending, isFalse); - }, - ); - - test('IO transport sends the exact native Origin', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final origin = Completer(); - final accepted = Completer(); - server.listen((request) async { - if (!origin.isCompleted) { - origin.complete(request.headers.value('origin')); - } - final socket = await WebSocketTransformer.upgrade(request); - accepted.complete(socket); - }); - addTearDown(() async { - if (accepted.isCompleted) (await accepted.future).close(); - await server.close(force: true); - }); - - final channel = await connectPlatformWebSocket( - Uri.parse('ws://127.0.0.1:${server.port}/v1/ws'), - ); - await channel.ready; - expect(await origin.future, 'https://localhost'); - await channel.sink.close(); - }); - test('loads and saves the injected theme preference', () async { - final preferences = InMemoryAppPreferenceStore(themePreference: 'dark'); - final controller = T4ClientController( - hostDirectoryStore: _MemoryDirectoryStore(), - hostCredentialStore: _MemoryCredentialStore(), - appPreferenceStore: preferences, - webSocketConnector: _FakeConnector().call, - ); - addTearDown(controller.dispose); - - await controller.initialize(); - expect(controller.state.themePreference, T4ThemePreference.dark); - - await controller.setThemePreference(T4ThemePreference.light); - expect(controller.state.themePreference, T4ThemePreference.light); - expect(preferences.themePreference, 'light'); - }); - - test( - 'projects live settings defensively and explicitly confirms exact writes', - () async { - final profile = _profile('settings'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome( - 'host-settings', - capabilities: const [ - 'sessions.read', - 'catalog.read', - 'config.read', - 'config.write', - ], - features: const ['catalog.metadata', 'settings.metadata'], - ), - ); - await _flush(); - expect( - channel.sentJson.map((frame) => frame['command']), - containsAll(['catalog.get', 'settings.read']), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-settings', - ..._sessionListResultFor('host-settings', const []), - }); - channel.emit(_settingsCatalog()); - channel.emit(_settingsValues()); - await _flush(); - - final compact = controller.state.settings.entries.singleWhere( - (entry) => entry.path == 'appearance.compact', - ); - expect(compact.control, HostSettingControlKind.boolean); - expect(compact.effectiveValue, false); - final secret = controller.state.settings.entries.singleWhere( - (entry) => entry.path == 'provider.token', - ); - expect(secret.sensitive, isTrue); - expect(secret.configured, isTrue); - expect(secret.effectiveValue, isNull); - expect(secret.control, HostSettingControlKind.unsupported); - expect( - controller.state.settings.issues, - contains('provider.token: sensitive setting arrived with a value'), - ); - expect( - controller.state.settings.entries - .singleWhere((entry) => entry.path == 'future.setting') - .control, - HostSettingControlKind.unsupported, - ); - - final write = controller.writeSetting( - 'appearance.compact', - 'global', - value: true, - ); - await _flush(); - final writeFrame = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'settings.write', - ); - expect(writeFrame['expectedRevision'], 'settings-rev-1'); - expect(writeFrame.containsKey('sessionId'), isFalse); - expect(writeFrame['args'], { - 'edits': [ - { - 'path': 'appearance.compact', - 'scope': 'global', - 'value': true, - }, - ], - 'expectedRevision': 'settings-rev-1', - }); - - channel.emit(_confirmation(writeFrame, 'confirm-settings')); - await _flush(); - expect( - channel.sentJson.where((frame) => frame['type'] == 'confirm'), - isEmpty, - ); - final confirmation = controller.state.attentionItems.singleWhere( - (item) => item.confirmationId == 'confirm-settings', - ); - expect(confirmation.sessionId, isEmpty); - await controller.respondToAttention( - confirmation, - const AttentionResponse(decision: AttentionDecision.approve), - ); - final confirmFrame = channel.sentJson.last; - expect(confirmFrame['type'], 'confirm'); - expect(confirmFrame.containsKey('sessionId'), isFalse); - expect(confirmFrame['decision'], 'approve'); - - channel.emit( - _response( - writeFrame, - command: 'settings.write', - result: const { - 'accepted': true, - 'revision': 'settings-rev-2', - }, - ), - ); - await _flush(); - expect( - channel.sentJson - .where( - (frame) => - frame['command'] == 'catalog.get' || - frame['command'] == 'settings.read', - ) - .length, - 4, - ); - channel.emit(_settingsCatalog(revision: 'catalog-rev-2')); - channel.emit(_settingsValues(revision: 'settings-rev-2')); - await write; - expect(controller.state.settings.revision, 'settings-rev-2'); - expect(controller.state.settingsOperationPending, isFalse); - - final conflict = controller.writeSetting( - 'appearance.compact', - 'global', - value: false, - ); - await _flush(); - final conflictFrame = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'settings.write', - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'response', - 'requestId': conflictFrame['requestId'], - 'commandId': conflictFrame['commandId'], - 'hostId': 'host-settings', - 'command': 'settings.write', - 'ok': false, - 'error': { - 'code': 'revision_conflict', - 'message': 'settings changed on the host', - }, - }); - await expectLater(conflict, throwsStateError); - expect( - controller.state.settings.error, - contains('settings changed on the host'), - ); - - final reset = controller.writeSetting( - 'appearance.compact', - 'session', - reset: true, - ); - await _flush(); - final resetFrame = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'settings.write', - ); - expect(resetFrame['args'], { - 'edits': [ - { - 'path': 'appearance.compact', - 'scope': 'session', - 'reset': true, - }, - ], - 'expectedRevision': 'settings-rev-2', - }); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'response', - 'requestId': resetFrame['requestId'], - 'commandId': resetFrame['commandId'], - 'hostId': 'host-settings', - 'command': 'settings.write', - 'ok': false, - 'error': {'code': 'denied', 'message': 'reset denied'}, - }); - await expectLater(reset, throwsStateError); - }, - ); - - test('settings writes require granted permission', () async { - final profile = _profile('readonly'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome( - 'host-readonly', - capabilities: const [ - 'sessions.read', - 'catalog.read', - 'config.read', - ], - features: const ['catalog.metadata', 'settings.metadata'], - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-readonly', - ..._sessionListResultFor('host-readonly', const []), - }); - channel.emit(_settingsCatalog(hostId: 'host-readonly')); - channel.emit(_settingsValues(hostId: 'host-readonly')); - await _flush(); - - await expectLater( - controller.writeSetting('appearance.compact', 'global', value: true), - throwsStateError, - ); - }); - - testWidgets('settings refresh settles with a bounded timeout', ( - tester, - ) async { - final profile = _profile('refresh-timeout'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome( - 'host-timeout', - capabilities: const [ - 'sessions.read', - 'catalog.read', - 'config.read', - ], - features: const ['catalog.metadata', 'settings.metadata'], - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-timeout', - ..._sessionListResultFor('host-timeout', const []), - }); - channel.emit(_settingsCatalog(hostId: 'host-timeout')); - channel.emit(_settingsValues(hostId: 'host-timeout')); - await tester.pump(); - expect(controller.state.settings.loading, isFalse); - - final expectation = expectLater( - controller.refreshSettings(), - throwsA(isA()), - ); - expect(controller.state.settings.loading, isTrue); - await tester.pump(const Duration(seconds: 11)); - await expectation; - expect(controller.state.settings.loading, isFalse); - expect(controller.state.settings.error, contains('timed out')); - }); - - test('background resume reuses the loaded settings projection', () async { - final profile = _profile('settings-resume'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final initialChannel = connector.channels.single; - const capabilities = [ - 'sessions.read', - 'catalog.read', - 'config.read', - ]; - const features = ['catalog.metadata', 'settings.metadata']; - initialChannel.emit( - _welcome( - 'host-settings-resume', - capabilities: capabilities, - features: features, - ), - ); - await _flush(); - initialChannel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-settings-resume', - ..._sessionListResultFor('host-settings-resume', const []), - }); - initialChannel.emit(_settingsCatalog(hostId: 'host-settings-resume')); - initialChannel.emit(_settingsValues(hostId: 'host-settings-resume')); - await _flush(); - expect(controller.state.settings.entries, isNotEmpty); - expect(controller.state.settings.loading, isFalse); - - await controller.handleLifecyclePhase(T4LifecyclePhase.background); - await controller.handleLifecyclePhase(T4LifecyclePhase.resumed); - final resumedChannel = connector.channels.last; - resumedChannel.emit( - _welcome( - 'host-settings-resume', - capabilities: capabilities, - features: features, - ), - ); - await _flush(); - - expect( - resumedChannel.sentJson.where( - (frame) => - frame['command'] == 'catalog.get' || - frame['command'] == 'settings.read', - ), - isEmpty, - ); - expect(controller.state.settings.loading, isFalse); - }); - - test( - 'background resume reconnects once but deliberate disconnect stays closed', - () async { - final profile = _profile('lifecycle'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - expect(connector.channels, hasLength(1)); - - await controller.handleLifecyclePhase(T4LifecyclePhase.background); - expect(controller.state.lifecyclePhase, T4LifecyclePhase.background); - expect(controller.state.connectionPhase, ConnectionPhase.disconnected); - await controller.handleLifecyclePhase(T4LifecyclePhase.resumed); - expect(connector.channels, hasLength(2)); - await controller.handleLifecyclePhase(T4LifecyclePhase.resumed); - expect(connector.channels, hasLength(2)); - - await controller.handleLifecyclePhase(T4LifecyclePhase.background); - await controller.disconnect(); - await controller.handleLifecyclePhase(T4LifecyclePhase.resumed); - expect(connector.channels, hasLength(2)); - expect( - controller.state.hostDirectory.activeEndpointKey, - profile.endpointKey, - ); - }, - ); - test( - 'thinking menu exposes advertised levels and keeps a valid current value', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome('host-alpha', capabilities: t4RequestedCapabilities), - ); - await _flush(); - - Map sessionFrame({ - required String? thinking, - required bool? thinkingSupported, - required List thinkingLevels, - }) => { - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-alpha', - 'cursor': {'epoch': 'index', 'seq': 1}, - 'sessions': [ - { - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'project': { - 'projectId': 'project-alpha', - 'name': 'Project Alpha', - }, - 'revision': 'revision-alpha', - 'title': 'session-alpha title', - 'status': 'idle', - 'updatedAt': '2026-07-19T00:00:00.000Z', - 'liveState': { - 'thinking': ?thinking, - 'thinkingSupported': ?thinkingSupported, - if (thinkingLevels.isNotEmpty) 'thinkingLevels': thinkingLevels, - }, - }, - ], - 'totalCount': 1, - 'truncated': false, - }; - - // Model advertises off/low/medium/high/auto; current value is high. - channel.emit( - sessionFrame( - thinking: 'high', - thinkingSupported: true, - thinkingLevels: const ['low', 'medium', 'high'], - ), - ); - await _flush(); - await controller.selectSession('session-alpha'); - await _flush(); - channel.emit( - _snapshot('host-alpha', 'session-alpha', revision: 'revision-alpha'), - ); - await _flush(); - - var composer = controller.state.composer; - expect(composer.thinking, 'high'); - expect(composer.thinkingLevels, [ - 'off', - 'auto', - 'low', - 'medium', - 'high', - ]); - - // Capability refresh arrives without concrete efforts yet: the still-valid - // current value (high) must remain selectable rather than collapsing to - // off/auto only. - channel.emit( - sessionFrame( - thinking: 'high', - thinkingSupported: true, - thinkingLevels: const [], - ), - ); - await _flush(); - composer = controller.state.composer; - expect(composer.thinking, 'high'); - expect(composer.thinkingLevels, contains('off')); - expect(composer.thinkingLevels, contains('auto')); - expect(composer.thinkingLevels, contains('high')); - expect(composer.thinkingLevels, isNot(contains('low'))); - expect(composer.thinkingLevels, isNot(contains('medium'))); - - // A model that cannot reason exposes no levels. - channel.emit( - sessionFrame( - thinking: 'high', - thinkingSupported: false, - thinkingLevels: const ['low', 'medium', 'high'], - ), - ); - await _flush(); - composer = controller.state.composer; - expect(composer.thinkingLevels, isEmpty); - - // Submitting the current level sends the canonical raw value. - channel.emit( - sessionFrame( - thinking: 'high', - thinkingSupported: true, - thinkingLevels: const ['low', 'medium', 'high'], - ), - ); - await _flush(); - final setting = controller.setSessionThinking('high'); - await _flush(); - final sent = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'session.thinking.set', - ); - expect(sent['args'], {'level': 'high'}); - channel.emit( - _response( - sent, - command: 'session.thinking.set', - result: {'updated': true}, - ), - ); - await setting; - }, - ); -} - -const String _token = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; -Map _settingsCatalog({ - String hostId = 'host-settings', - String revision = 'catalog-rev-1', -}) => { - 'v': 'omp-app/1', - 'type': 'catalog', - 'hostId': hostId, - 'revision': revision, - 'items': [ - { - 'id': 'setting:appearance.compact', - 'kind': 'setting', - 'name': 'appearance.compact', - 'metadata': { - 'path': 'appearance.compact', - 'label': 'Compact appearance', - 'description': 'Use less space.', - 'controlType': 'boolean', - 'scopes': ['global', 'session'], - 'tab': 'appearance', - }, - }, - { - 'id': 'setting:provider.token', - 'kind': 'setting', - 'name': 'provider.token', - 'metadata': { - 'path': 'provider.token', - 'label': 'Provider token', - 'controlType': 'string', - 'sensitive': true, - 'configured': true, - 'tab': 'providers', - }, - }, - { - 'id': 'setting:future.setting', - 'kind': 'setting', - 'name': 'future.setting', - 'metadata': { - 'path': 'future.setting', - 'label': 'Future setting', - 'controlType': 'boolean', - 'unreviewedKey': true, - }, - }, - ], -}; - -Map _settingsValues({ - String hostId = 'host-settings', - String revision = 'settings-rev-1', -}) => { - 'v': 'omp-app/1', - 'type': 'settings', - 'hostId': hostId, - 'revision': revision, - 'settings': { - 'appearance.compact': { - 'effective': false, - 'effectiveSource': 'global', - 'configured': true, - }, - 'provider.token': { - 'sensitive': true, - 'configured': true, - 'effective': 'must-never-render', - 'effectiveSource': 'global', - }, - }, -}; - -T4ClientController _controller( - HostDirectoryStore directory, - HostCredentialStore credentials, - _FakeConnector connector, { - TranscriptTailStore? transcriptTailStore, -}) => T4ClientController( - hostDirectoryStore: directory, - hostCredentialStore: credentials, - transcriptTailStore: transcriptTailStore, - webSocketConnector: connector.call, -); - -DurableEntry _durableMessage( - String id, { - required String hostId, - required String sessionId, - required String text, -}) { - final raw = { - 'id': id, - 'parentId': null, - 'hostId': hostId, - 'sessionId': sessionId, - 'kind': 'message', - 'timestamp': '2026-07-20T08:00:00.000Z', - 'data': {'role': 'assistant', 'text': text}, - }; - return DurableEntry( - id: id, - parentId: null, - hostId: hostId, - sessionId: sessionId, - kind: 'message', - timestamp: raw['timestamp']! as String, - data: raw['data']! as Map, - raw: raw, - ); -} - -HostProfile _profile(String name) => - HostProfile.parseTailnetAddress('$name.example.ts.net'); - -Map _welcome( - String hostId, { - String authentication = 'paired', - List capabilities = const ['sessions.read'], - List features = const [], -}) => { - 'v': 'omp-app/1', - 'type': 'welcome', - 'selectedProtocol': 'omp-app/1', - 'hostId': hostId, - 'ompVersion': 'test', - 'ompBuild': 'test', - 'appserverVersion': 'test', - 'appserverBuild': 'test', - 'epoch': 'host-epoch', - 'authentication': authentication, - 'grantedCapabilities': capabilities, - 'grantedFeatures': features, - 'negotiatedLimits': {}, - 'resumed': false, -}; - -Map _sessions(String hostId) => { - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': hostId, - ..._sessionListResult(hostId), -}; - -Map _sessionListResult( - String hostId, { - String epoch = 'index', - int seq = 1, -}) => _sessionListResultFor( - hostId, - const ['session-alpha'], - epoch: epoch, - seq: seq, -); - -Map _sessionListResultFor( - String hostId, - List sessionIds, { - String epoch = 'index', - Map? attention, - int seq = 1, -}) => { - 'cursor': {'epoch': epoch, 'seq': seq}, - 'sessions': sessionIds - .map( - (sessionId) => _sessionRef( - hostId, - sessionId, - projectId: 'project-$sessionId', - projectName: sessionId == 'session-alpha' - ? 'Project Alpha' - : 'Project Beta', - revision: 'revision-$sessionId', - title: '$sessionId title', - attention: sessionId == 'session-alpha' ? attention : null, - ), - ) - .toList(growable: false), - 'totalCount': sessionIds.length, - 'truncated': false, -}; - -Map _response( - Map request, { - required String command, - required Object? result, -}) => { - 'v': 'omp-app/1', - 'type': 'response', - 'requestId': request['requestId'], - 'commandId': request['commandId'], - 'hostId': request['hostId'], - if (request['sessionId'] != null) 'sessionId': request['sessionId'], - 'command': command, - 'ok': true, - 'result': result, -}; - -Map _sessionRef( - String hostId, - String sessionId, { - required String projectId, - required String projectName, - required String revision, - required String title, - String status = 'idle', - Map? attention, -}) => { - 'hostId': hostId, - 'sessionId': sessionId, - 'project': {'projectId': projectId, 'name': projectName}, - 'revision': revision, - 'title': title, - 'status': status, - 'updatedAt': '2026-07-19T00:00:00.000Z', - 'attention': ?attention, -}; - -Map _snapshot( - String hostId, - String sessionId, { - required String revision, -}) => { - 'v': 'omp-app/1', - 'type': 'snapshot', - 'hostId': hostId, - 'sessionId': sessionId, - 'cursor': {'epoch': 'transcript', 'seq': 0}, - 'revision': revision, - 'entries': [], -}; - -Map _transcriptEvent( - String hostId, - String sessionId, { - required int seq, - required Map event, -}) => { - 'v': 'omp-app/1', - 'type': 'event', - 'hostId': hostId, - 'sessionId': sessionId, - 'cursor': {'epoch': 'transcript', 'seq': seq}, - 'event': event, -}; - -Map _transcriptMessageEntry( - String hostId, - String sessionId, { - required int seq, - required String entryId, - required String role, - required String text, -}) => { - 'v': 'omp-app/1', - 'type': 'entry', - 'hostId': hostId, - 'sessionId': sessionId, - 'cursor': {'epoch': 'transcript', 'seq': seq}, - 'revision': 'revision-$seq', - 'entry': { - 'id': entryId, - 'parentId': null, - 'hostId': hostId, - 'sessionId': sessionId, - 'kind': 'message', - 'timestamp': '2026-07-20T00:00:00.000Z', - 'data': {'role': role, 'text': text}, - }, -}; - -Map _sessionDelta( - String hostId, - String sessionId, { - required String revision, - required int seq, - Map? upsert, - String? remove, -}) => { - 'v': 'omp-app/1', - 'type': 'session.delta', - 'hostId': hostId, - 'sessionId': sessionId, - 'cursor': {'epoch': 'index', 'seq': seq}, - 'revision': revision, - 'upsert': ?upsert, - 'remove': ?remove, -}; - -Map _confirmation( - Map command, - String confirmationId, -) => { - 'v': 'omp-app/1', - 'type': 'confirmation', - 'confirmationId': confirmationId, - 'commandId': command['commandId'], - 'hostId': command['hostId'], - if (command['sessionId'] != null) 'sessionId': command['sessionId'], - 'commandHash': 'sha256:test', - 'revision': command['expectedRevision'], - 'expiresAt': '2030-01-01T00:00:00.000Z', - 'summary': command['command'], -}; - -Future _flush() async { - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); -} - -Future _until(bool Function() predicate) async { - for (var attempt = 0; attempt < 100; attempt++) { - if (predicate()) return; - await Future.delayed(Duration.zero); - } - fail('condition was not reached'); -} - -final class _MemoryDirectoryStore implements HostDirectoryStore { - _MemoryDirectoryStore({ - this.directory = const HostDirectory.empty(), - List? events, - }) : events = events ?? []; - - HostDirectory directory; - final List events; - final List saved = []; - - @override - Future load() async { - events.add('load'); - return directory; - } - - @override - Future save(HostDirectory directory) async { - events.add('save:${directory.activeEndpointKey}'); - saved.add(directory); - this.directory = directory; - } -} - -final class _MemoryCredentialStore implements HostCredentialStore { - _MemoryCredentialStore({List? events}) - : events = events ?? []; - - final List events; - final Map values = {}; - final Map> delayedReads = - >{}; - final List readProfiles = []; - final List deleted = []; - Object? deleteError; - - @override - Future read(HostProfile profile) async { - events.add('read:${profile.endpointKey}'); - readProfiles.add(profile.endpointKey); - final delayed = delayedReads[profile.endpointKey]; - if (delayed != null) return delayed.future; - return values[profile.endpointKey]; - } - - @override - Future write(HostProfile profile, DeviceCredentials credentials) async { - values[profile.endpointKey] = credentials; - } - - @override - Future delete(HostProfile profile) async { - deleted.add(profile.endpointKey); - events.add('delete:${profile.endpointKey}'); - final error = deleteError; - if (error != null) throw error; - values.remove(profile.endpointKey); - } -} - -final class _FakeConnector { - _FakeConnector({ - List? events, - this.readyGate, - this.hangFirstClose = false, - }) : events = events ?? []; - - final Completer? readyGate; - final bool hangFirstClose; - // Constructor is declared above so tests can delay a probe handshake. - - final List events; - final List uris = []; - final List<_FakeWebSocketChannel> channels = <_FakeWebSocketChannel>[]; - - Future call(Uri uri) async { - events.add('connect:$uri'); - uris.add(uri); - final channel = _FakeWebSocketChannel( - channels.length, - events, - readyGate: readyGate, - hangClose: hangFirstClose && channels.isEmpty, - ); - channels.add(channel); - return channel; - } -} - -final class _FakeWebSocketChannel implements WebSocketChannel { - _FakeWebSocketChannel( - this.index, - this.events, { - this.readyGate, - bool hangClose = false, - }) : sink = _FakeWebSocketSink(index, events, hangClose: hangClose); - - final int index; - final List events; - final Completer? readyGate; - final StreamController _incoming = StreamController(); - @override - final _FakeWebSocketSink sink; - - List get sent => sink.sent.cast(); - List> get sentJson => sent - .map((value) => (jsonDecode(value) as Map)) - .toList(growable: false); - - void emit(Map frame) => _incoming.add(jsonEncode(frame)); - void fail(Object error) => _incoming.addError(error); - - @override - Future get ready => readyGate?.future ?? Future.value(); - @override - Stream get stream => _incoming.stream; - @override - String? get protocol => null; - @override - int? get closeCode => null; - @override - String? get closeReason => null; - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -final class _FakeWebSocketSink implements WebSocketSink { - _FakeWebSocketSink(this.index, this.events, {this.hangClose = false}); - - final int index; - final List events; - final bool hangClose; - final List sent = []; - final Completer _done = Completer(); - - @override - void add(Object? data) => sent.add(data); - @override - void addError(Object error, [StackTrace? stackTrace]) => - _done.completeError(error, stackTrace); - @override - Future addStream(Stream stream) async => - sent.addAll(await stream.toList()); - @override - Future close([int? closeCode, String? closeReason]) async { - events.add('close:$index'); - if (hangClose) return Completer().future; - if (!_done.isCompleted) _done.complete(); - } - - @override - Future get done => _done.future; -} diff --git a/apps/flutter/test/client/transcript_tail_store_test.dart b/apps/flutter/test/client/transcript_tail_store_test.dart deleted file mode 100644 index e35a907c..00000000 --- a/apps/flutter/test/client/transcript_tail_store_test.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/transcript_tail_store.dart'; -import 'package:t4code/src/protocol/protocol.dart'; - -void main() { - test('persistent tail cache keeps only the newest bounded entries', () async { - final storage = _MemoryStorage(); - final store = PersistentTranscriptTailStore(storage: storage); - final entries = [ - for (var index = 0; index < 70; index++) - _entry(index, hostId: 'host-a', sessionId: 'session-a'), - ]; - - await store.save( - hostId: 'host-a', - sessionId: 'session-a', - generation: 'generation-a', - entries: entries, - ); - final loaded = await store.load(hostId: 'host-a', sessionId: 'session-a'); - - expect(loaded?.entries, hasLength(64)); - expect(loaded?.entries.first.id, 'entry-6'); - expect(loaded?.entries.last.id, 'entry-69'); - expect(loaded?.generation, 'generation-a'); - expect(storage.values.values.join(), isNot(contains('nextCursor'))); - }); - - test( - 'persistent tail cache prunes old sessions and ignores damage', - () async { - final storage = _MemoryStorage(); - final store = PersistentTranscriptTailStore(storage: storage); - for (var index = 0; index < 9; index++) { - await Future.delayed(const Duration(milliseconds: 1)); - await store.save( - hostId: 'host-a', - sessionId: 'session-$index', - generation: 'generation-$index', - entries: [ - _entry(index, hostId: 'host-a', sessionId: 'session-$index'), - ], - ); - } - - expect( - await store.load(hostId: 'host-a', sessionId: 'session-0'), - isNull, - ); - expect( - await store.load(hostId: 'host-a', sessionId: 'session-8'), - isNotNull, - ); - - await store.save( - hostId: 'host-a', - sessionId: 'damaged', - generation: 'generation-damaged', - entries: [ - _entry(99, hostId: 'host-a', sessionId: 'damaged'), - ], - ); - final damagedKey = storage.values.entries - .where((entry) => entry.key != transcriptTailStorageKey) - .singleWhere((entry) { - final decoded = jsonDecode(entry.value); - return decoded is Map && - decoded['sessionId'] == 'damaged'; - }) - .key; - storage.values[damagedKey] = jsonEncode({ - 'version': 1, - 'hostId': 'host-a', - 'sessionId': 'damaged', - 'generation': 'generation-damaged', - 'entries': [ - {'id': 'missing-required-fields'}, - ], - }); - expect(await store.load(hostId: 'host-a', sessionId: 'damaged'), isNull); - }, - ); -} - -DurableEntry _entry( - int index, { - required String hostId, - required String sessionId, -}) { - final raw = { - 'id': 'entry-$index', - 'parentId': index == 0 ? null : 'entry-${index - 1}', - 'hostId': hostId, - 'sessionId': sessionId, - 'kind': 'message', - 'timestamp': DateTime.utc( - 2026, - 7, - 20, - 8, - ).add(Duration(minutes: index)).toIso8601String(), - 'data': { - 'role': index.isEven ? 'assistant' : 'user', - 'text': 'message $index', - }, - }; - return DurableEntry( - id: raw['id']! as String, - parentId: raw['parentId'] as String?, - hostId: hostId, - sessionId: sessionId, - kind: 'message', - timestamp: raw['timestamp']! as String, - data: raw['data']! as Map, - raw: raw, - ); -} - -final class _MemoryStorage implements TranscriptTailStorage { - final Map values = {}; - - @override - Future getString(String key) async => values[key]; - - @override - Future setString(String key, String value) async { - values[key] = value; - } - - @override - Future delete(String key) async { - values.remove(key); - } -} diff --git a/apps/flutter/test/demo/demo_app_test.dart b/apps/flutter/test/demo/demo_app_test.dart deleted file mode 100644 index e180b43d..00000000 --- a/apps/flutter/test/demo/demo_app_test.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/demo/demo_app.dart'; - -void main() { - testWidgets('public demo renders the canonical Flutter session workspace', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1280, 800); - addTearDown(tester.view.reset); - - await tester.pumpWidget(const T4DemoApp()); - // The demo seeds a working session whose rail spinner animates forever, - // so settle with bounded pumps instead of pumpAndSettle. - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - await tester.pump(const Duration(milliseconds: 400)); - - expect(find.text('T4 CODE'), findsWidgets); - expect( - find.text('Public preview · sample data · actions disabled'), - findsOneWidget, - ); - expect(find.text('Fix quick-open stale results'), findsWidgets); - expect( - find.textContaining( - 'Quick open now keys its cache', - findRichText: true, - ), - findsOneWidget, - ); - expect(find.text('Connect to T4'), findsNothing); - }); -} diff --git a/apps/flutter/test/host/legacy_credential_migration_test.dart b/apps/flutter/test/host/legacy_credential_migration_test.dart deleted file mode 100644 index 94a51664..00000000 --- a/apps/flutter/test/host/legacy_credential_migration_test.dart +++ /dev/null @@ -1,247 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/host/persistent_host_stores.dart'; - -void main() { - group('legacy Android credential migration', () { - test( - 'migrates a discovered keyed endpoint into endpoint storage', - () async { - final profile = _profile(); - final storage = _MemoryCredentialStorage(); - final legacy = _MemoryLegacySource( - credentials: const LegacyHostCredentials( - deviceId: 'legacy-device', - deviceToken: 'legacy-token', - source: 'opaque-keyed-source', - ), - ); - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - - final credentials = await store.read(profile); - - expect(credentials!.deviceId, 'legacy-device'); - expect(legacy.hostKeyRequests, >[ - [profile.endpointKey, profile.origin], - ]); - expect(legacy.includeUnkeyedRequests, [true]); - expect(legacy.clearedSources, ['opaque-keyed-source']); - expect( - jsonDecode(storage.values[_credentialKey(profile.endpointKey)]!), - { - 'deviceId': 'legacy-device', - 'deviceToken': 'legacy-token', - }, - ); - }, - ); - - test( - 'checks endpoint, origin, then requests the unkeyed fallback', - () async { - final profile = _profile(); - final storage = _MemoryCredentialStorage(); - final legacy = _MemoryLegacySource(); - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - storage.values[_credentialKey(profile.origin)] = jsonEncode( - { - 'deviceId': 'origin-device', - 'deviceToken': 'origin-token', - }, - ); - - expect((await store.read(profile))!.deviceId, 'origin-device'); - expect(storage.readKeys, [ - _credentialKey(profile.endpointKey), - _credentialKey(profile.origin), - ]); - expect(legacy.hostKeyRequests, isEmpty); - - storage.values.clear(); - storage.readKeys.clear(); - expect(await store.read(profile), isNull); - expect(storage.readKeys, [ - _credentialKey(profile.endpointKey), - _credentialKey(profile.origin), - ]); - expect(legacy.hostKeyRequests.single, [ - profile.endpointKey, - profile.origin, - ]); - expect(legacy.includeUnkeyedRequests.single, isTrue); - }, - ); - - test( - 'named profiles never request origin or unkeyed credentials', - () async { - final profile = _profile(profileId: 'work'); - final storage = _MemoryCredentialStorage(); - final legacy = _MemoryLegacySource(); - storage.values[_credentialKey(profile.origin)] = jsonEncode( - { - 'deviceId': 'default-device', - 'deviceToken': 'default-token', - }, - ); - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - - expect(await store.read(profile), isNull); - expect(storage.readKeys, [_credentialKey(profile.endpointKey)]); - expect(legacy.hostKeyRequests.single, [profile.endpointKey]); - expect(legacy.includeUnkeyedRequests.single, isFalse); - expect(storage.values, contains(_credentialKey(profile.origin))); - }, - ); - - test( - 'failed endpoint write preserves the discovered legacy source', - () async { - final profile = _profile(); - final currentKey = _credentialKey(profile.endpointKey); - final storage = _MemoryCredentialStorage() - ..failingWriteKey = currentKey; - final legacy = _MemoryLegacySource( - credentials: const LegacyHostCredentials( - deviceId: 'legacy-device', - deviceToken: 'legacy-token', - source: 'opaque-source', - ), - ); - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - - await expectLater(store.read(profile), throwsStateError); - - expect(storage.values, isNot(contains(currentKey))); - expect(legacy.clearedSources, isEmpty); - expect(legacy.credentials, isNotNull); - }, - ); - - test('failed legacy clear rolls back and never reports success', () async { - final profile = _profile(); - final currentKey = _credentialKey(profile.endpointKey); - final storage = _MemoryCredentialStorage(); - final legacy = _MemoryLegacySource( - credentials: const LegacyHostCredentials( - deviceId: 'legacy-device', - deviceToken: 'legacy-token', - source: 'opaque-source', - ), - )..failClear = true; - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - - await expectLater(store.read(profile), throwsStateError); - - expect(storage.values, isNot(contains(currentKey))); - expect(storage.deletedKeys, contains(currentKey)); - expect(legacy.credentials, isNotNull); - expect(legacy.clearedSources, isEmpty); - await expectLater(store.read(profile), throwsStateError); - }); - - test( - 'validates discovered credentials before writing or clearing', - () async { - final profile = _profile(); - final storage = _MemoryCredentialStorage(); - final legacy = _MemoryLegacySource( - credentials: const LegacyHostCredentials( - deviceId: '', - deviceToken: 'legacy-token', - source: 'opaque-source', - ), - ); - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - - await expectLater(store.read(profile), throwsFormatException); - - expect(storage.values, isEmpty); - expect(legacy.clearedSources, isEmpty); - }, - ); - }); -} - -HostProfile _profile({String profileId = defaultHostProfileId}) => - HostProfile.parseTailnetAddress( - 'https://workstation.example.ts.net', - profileId: profileId, - ); - -String _credentialKey(String hostKey) => - 't4-code:device-credentials:v1:' - '${base64Url.encode(utf8.encode(hostKey)).replaceAll('=', '')}'; - -final class _MemoryCredentialStorage implements HostCredentialStorage { - final Map values = {}; - final List readKeys = []; - final List deletedKeys = []; - String? failingWriteKey; - - @override - Future read(String key) async { - readKeys.add(key); - return values[key]; - } - - @override - Future write(String key, String value) async { - if (key == failingWriteKey) throw StateError('injected write failure'); - values[key] = value; - } - - @override - Future delete(String key) async { - deletedKeys.add(key); - values.remove(key); - } -} - -final class _MemoryLegacySource implements LegacyHostCredentialSource { - _MemoryLegacySource({this.credentials}); - - LegacyHostCredentials? credentials; - bool failClear = false; - final List> hostKeyRequests = >[]; - final List includeUnkeyedRequests = []; - final List clearedSources = []; - - @override - Future discover({ - required List hostKeys, - required bool includeUnkeyed, - }) async { - hostKeyRequests.add(List.of(hostKeys)); - includeUnkeyedRequests.add(includeUnkeyed); - return credentials; - } - - @override - Future clear(String source) async { - if (failClear) throw StateError('injected clear failure'); - if (credentials?.source != source) throw StateError('wrong source'); - clearedSources.add(source); - credentials = null; - } -} diff --git a/apps/flutter/test/host/persistent_host_stores_test.dart b/apps/flutter/test/host/persistent_host_stores_test.dart deleted file mode 100644 index 815ab085..00000000 --- a/apps/flutter/test/host/persistent_host_stores_test.dart +++ /dev/null @@ -1,250 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/host/persistent_host_stores.dart'; - -void main() { - group('PersistentHostDirectoryStore', () { - test('returns an empty directory when storage is absent', () async { - final preferences = _MemoryPreferences(); - final store = PersistentHostDirectoryStore(preferences: preferences); - - final directory = await store.load(); - - expect(directory.profiles, isEmpty); - expect(directory.activeEndpointKey, isNull); - }); - - test('round trips through the canonical preference key', () async { - final preferences = _MemoryPreferences(); - final store = PersistentHostDirectoryStore(preferences: preferences); - final profile = _profile(); - - await store.save(const HostDirectory.empty().upsert(profile)); - final restored = await store.load(); - - expect(preferences.values.keys, [hostDirectoryStorageKey]); - expect(restored.activeEndpointKey, profile.endpointKey); - expect(restored.profiles.single.endpointKey, profile.endpointKey); - }); - - test('deletes storage when the directory becomes empty', () async { - final preferences = _MemoryPreferences(); - final store = PersistentHostDirectoryStore(preferences: preferences); - final populated = const HostDirectory.empty().upsert(_profile()); - await store.save(populated); - - await store.save(populated.remove(populated.activeEndpointKey!)); - - expect(preferences.values, isEmpty); - expect(preferences.removedKeys, [hostDirectoryStorageKey]); - }); - - test('surfaces malformed and structurally damaged data', () async { - final preferences = _MemoryPreferences(); - final store = PersistentHostDirectoryStore(preferences: preferences); - - preferences.values[hostDirectoryStorageKey] = '{'; - await expectLater(store.load(), throwsFormatException); - - preferences.values[hostDirectoryStorageKey] = jsonEncode({ - 'version': hostProfileSchemaVersion, - 'activeEndpointKey': 'missing', - 'backends': [], - }); - await expectLater(store.load(), throwsFormatException); - expect(preferences.values, contains(hostDirectoryStorageKey)); - }); - }); - - group('VolatileHostCredentialStore', () { - test( - 'keeps credentials only in the owning process-local instance', - () async { - final profile = _profile(); - final store = VolatileHostCredentialStore(); - await store.write(profile, _credentials('volatile-device')); - - expect((await store.read(profile))!.deviceId, 'volatile-device'); - expect(await VolatileHostCredentialStore().read(profile), isNull); - - await store.delete(profile); - expect(await store.read(profile), isNull); - }, - ); - }); - - group('SecureHostCredentialStore', () { - test('round trips credentials and isolates profiles by endpoint', () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final defaultProfile = _profile(); - final workProfile = _profile(profileId: 'work'); - final defaultCredentials = _credentials('default-device'); - final workCredentials = _credentials('work-device'); - - await store.write(defaultProfile, defaultCredentials); - await store.write(workProfile, workCredentials); - - expect((await store.read(defaultProfile))!.deviceId, 'default-device'); - expect((await store.read(workProfile))!.deviceId, 'work-device'); - expect( - storage.values.keys, - containsAll([ - _credentialKey(defaultProfile.endpointKey), - _credentialKey(workProfile.endpointKey), - ]), - ); - expect(storage.values, hasLength(2)); - }); - - test('surfaces malformed and non-exact credential records', () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final profile = _profile(profileId: 'work'); - final key = _credentialKey(profile.endpointKey); - - for (final damaged in [ - '{', - jsonEncode({'deviceId': 'device'}), - jsonEncode({ - 'deviceId': 'device', - 'deviceToken': 'token', - 'unexpected': true, - }), - jsonEncode({'deviceId': 7, 'deviceToken': 'token'}), - jsonEncode({'deviceId': '', 'deviceToken': 'token'}), - ]) { - storage.values[key] = damaged; - await expectLater(store.read(profile), throwsFormatException); - } - expect(storage.values, contains(key)); - }); - - test('deletes only the selected endpoint credentials', () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final first = _profile(profileId: 'first'); - final second = _profile(profileId: 'second'); - await store.write(first, _credentials('first-device')); - await store.write(second, _credentials('second-device')); - - await store.delete(first); - - expect(await store.read(first), isNull); - expect((await store.read(second))!.deviceId, 'second-device'); - }); - - test( - 'migrates the default origin key within the same secure-storage backend', - () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final profile = _profile(); - final legacyKey = _credentialKey(profile.origin); - final currentKey = _credentialKey(profile.endpointKey); - storage.values[legacyKey] = jsonEncode({ - 'deviceId': 'legacy-device', - 'deviceToken': 'legacy-token', - }); - - final restored = await store.read(profile); - - expect(restored!.deviceId, 'legacy-device'); - expect(jsonDecode(storage.values[currentKey]!), { - 'deviceId': 'legacy-device', - 'deviceToken': 'legacy-token', - }); - expect(storage.values, isNot(contains(legacyKey))); - expect(storage.deletedKeys, contains(legacyKey)); - }, - ); - - test('keeps the legacy key when migration write fails', () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final profile = _profile(); - final legacyKey = _credentialKey(profile.origin); - final currentKey = _credentialKey(profile.endpointKey); - storage.values[legacyKey] = jsonEncode({ - 'deviceId': 'legacy-device', - 'deviceToken': 'legacy-token', - }); - storage.failingWriteKey = currentKey; - - await expectLater(store.read(profile), throwsStateError); - - expect(storage.values, contains(legacyKey)); - expect(storage.values, isNot(contains(currentKey))); - expect(storage.deletedKeys, isNot(contains(legacyKey))); - }); - - test('does not use an origin fallback for named profiles', () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final profile = _profile(profileId: 'work'); - storage.values[_credentialKey(profile.origin)] = jsonEncode( - { - 'deviceId': 'legacy-device', - 'deviceToken': 'legacy-token', - }, - ); - - expect(await store.read(profile), isNull); - }); - }); -} - -HostProfile _profile({String profileId = defaultHostProfileId}) => - HostProfile.parseTailnetAddress( - 'https://workstation.example.ts.net', - profileId: profileId, - ); - -DeviceCredentials _credentials(String deviceId) => - DeviceCredentials(deviceId: deviceId, deviceToken: 'device-token'); - -String _credentialKey(String hostKey) => - 't4-code:device-credentials:v1:' - '${base64Url.encode(utf8.encode(hostKey)).replaceAll('=', '')}'; - -final class _MemoryPreferences implements HostDirectoryPreferences { - final Map values = {}; - final List removedKeys = []; - - @override - Future getString(String key) async => values[key]; - - @override - Future setString(String key, String value) async { - values[key] = value; - } - - @override - Future remove(String key) async { - removedKeys.add(key); - values.remove(key); - } -} - -final class _MemoryCredentialStorage implements HostCredentialStorage { - final Map values = {}; - final List deletedKeys = []; - String? failingWriteKey; - - @override - Future read(String key) async => values[key]; - - @override - Future write(String key, String value) async { - if (key == failingWriteKey) throw StateError('injected write failure'); - values[key] = value; - } - - @override - Future delete(String key) async { - deletedKeys.add(key); - values.remove(key); - } -} diff --git a/apps/flutter/test/platform/platform_lifecycle_controller_test.dart b/apps/flutter/test/platform/platform_lifecycle_controller_test.dart deleted file mode 100644 index 0f10e463..00000000 --- a/apps/flutter/test/platform/platform_lifecycle_controller_test.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/platform/platform_lifecycle.dart'; -import 'package:t4code/src/platform/platform_lifecycle_controller.dart'; - -void main() { - test('initializes supported runtime and updater state', () async { - final bridge = _FakeBridge(runtimeSupported: true, updatesSupported: true); - final controller = PlatformLifecycleController.withBridge(bridge); - - await controller.initialize(); - - expect(controller.state.initializing, isFalse); - expect(controller.state.runtime.service, RuntimeServicePhase.stopped); - expect(controller.state.update.phase, PlatformUpdatePhase.idle); - expect(bridge.runtimeInspections, 1); - expect(bridge.updateReads, 1); - }); - - test( - 'keeps supported controls recoverable after initialization errors', - () async { - final bridge = _FakeBridge(runtimeSupported: true, updatesSupported: true) - ..runtimeInspectionFailure = StateError('runtime unavailable') - ..updateReadFailure = StateError('update unavailable'); - final controller = PlatformLifecycleController.withBridge(bridge); - - await controller.initialize(); - - expect(controller.state.runtime.supported, isTrue); - expect(controller.state.runtime.service, RuntimeServicePhase.unknown); - expect(controller.state.runtime.issueCode, 'runtime_status_unavailable'); - expect(controller.state.update.supported, isTrue); - expect(controller.state.update.phase, PlatformUpdatePhase.error); - expect(controller.state.update.error, 'update_status_unavailable'); - expect(controller.state.errorMessage, contains('runtime unavailable')); - }, - ); - - test('serializes runtime actions and publishes the returned state', () async { - final bridge = _FakeBridge(runtimeSupported: true); - final controller = PlatformLifecycleController.withBridge(bridge); - await controller.initialize(); - - await controller.startRuntime(); - await controller.restartRuntime(); - - expect(bridge.runtimeStarts, 1); - expect(bridge.runtimeRestarts, 1); - expect(controller.state.runtime.service, RuntimeServicePhase.running); - expect(controller.state.runtimeOperationPending, isFalse); - }); - - test('keeps unsupported operations inert', () async { - final bridge = _FakeBridge(); - final controller = PlatformLifecycleController.withBridge(bridge); - await controller.initialize(); - - await controller.startRuntime(); - await controller.checkForUpdates(); - - expect(bridge.runtimeStarts, 0); - expect(bridge.updateChecks, 0); - expect(controller.state.runtime.supported, isFalse); - expect(controller.state.update.supported, isFalse); - }); - - test( - 'reports bounded platform errors without leaving a pending action', - () async { - final bridge = _FakeBridge(runtimeSupported: true) - ..runtimeFailure = StateError('failed token=super-secret'); - final controller = PlatformLifecycleController.withBridge(bridge); - await controller.initialize(); - - await controller.startRuntime(); - - expect(controller.state.runtimeOperationPending, isFalse); - expect(controller.state.errorMessage, contains('failed')); - expect(controller.state.errorMessage, isNot(contains('super-secret'))); - expect(controller.state.errorMessage, contains('token=[REDACTED]')); - }, - ); -} - -final class _FakeBridge implements PlatformLifecycleBridge { - _FakeBridge({this.runtimeSupported = false, this.updatesSupported = false}); - - final bool runtimeSupported; - final bool updatesSupported; - int runtimeInspections = 0; - int runtimeStarts = 0; - int runtimeRestarts = 0; - int updateReads = 0; - int updateChecks = 0; - Object? runtimeFailure; - Object? runtimeInspectionFailure; - Object? updateReadFailure; - - @override - bool get supportsRuntimeService => runtimeSupported; - - @override - bool get supportsUpdates => updatesSupported; - - RuntimeServiceStatus get _stopped => const RuntimeServiceStatus( - supported: true, - available: true, - definition: RuntimeDefinitionState.current, - service: RuntimeServicePhase.stopped, - diagnostics: 'Stopped.', - executable: '/usr/local/bin/omp', - ); - - RuntimeServiceStatus get _running => const RuntimeServiceStatus( - supported: true, - available: true, - definition: RuntimeDefinitionState.current, - service: RuntimeServicePhase.running, - diagnostics: 'Running.', - executable: '/usr/local/bin/omp', - ); - - @override - Future inspectRuntime() async { - runtimeInspections += 1; - if (runtimeInspectionFailure case final Object error) throw error; - return _stopped; - } - - @override - Future installRuntime() async => _stopped; - - @override - Future startRuntime() async { - runtimeStarts += 1; - if (runtimeFailure case final Object error) throw error; - return _running; - } - - @override - Future stopRuntime() async => _stopped; - - @override - Future restartRuntime() async { - runtimeRestarts += 1; - return _running; - } - - @override - Future uninstallRuntime() async => _stopped; - - PlatformUpdateStatus get _update => const PlatformUpdateStatus( - supported: true, - currentVersion: '0.1.24', - phase: PlatformUpdatePhase.idle, - ); - - @override - Future getUpdateState() async { - updateReads += 1; - if (updateReadFailure case final Object error) throw error; - return _update; - } - - @override - Future checkForUpdates() async { - updateChecks += 1; - return _update; - } - - @override - Future downloadUpdate() async => _update; - - @override - Future installUpdate() async => _update; -} diff --git a/apps/flutter/test/platform/platform_lifecycle_test.dart b/apps/flutter/test/platform/platform_lifecycle_test.dart deleted file mode 100644 index 550bc670..00000000 --- a/apps/flutter/test/platform/platform_lifecycle_test.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/platform/platform_lifecycle.dart'; - -void main() { - test('runtime inspection accepts the bounded native contract', () { - final status = RuntimeServiceStatus.fromMap({ - 'available': true, - 'executable': '/usr/local/bin/omp', - 'definition': 'current', - 'service': 'running', - 'diagnostics': 'OMP appserver is healthy.', - }); - - expect(status.supported, isTrue); - expect(status.available, isTrue); - expect(status.definition, RuntimeDefinitionState.current); - expect(status.service, RuntimeServicePhase.running); - expect(status.executable, '/usr/local/bin/omp'); - }); - - test('runtime inspection rejects unknown native states', () { - expect( - () => RuntimeServiceStatus.fromMap({ - 'available': true, - 'definition': 'installed-ish', - 'service': 'running', - 'diagnostics': 'invalid', - }), - throwsFormatException, - ); - }); - - test('Android update state accepts progress and installer phases', () { - final status = PlatformUpdateStatus.fromMap({ - 'currentVersion': '0.1.24', - 'latestVersion': '0.1.25', - 'phase': 'downloading', - 'checkedAt': 1, - 'revision': 25, - 'progressPercent': 42.5, - 'message': 'Downloading verified package.', - }); - - expect(status.supported, isTrue); - expect(status.phase, PlatformUpdatePhase.downloading); - expect(status.progressPercent, 42.5); - }); - - test('update state rejects unbounded progress and messages', () { - expect( - () => PlatformUpdateStatus.fromMap({ - 'currentVersion': '0.1.24', - 'phase': 'downloading', - 'progressPercent': 101, - }), - throwsFormatException, - ); - expect( - () => PlatformUpdateStatus.fromMap({ - 'currentVersion': '0.1.24', - 'phase': 'error', - 'message': 'x' * 513, - }), - throwsFormatException, - ); - }); -} diff --git a/apps/flutter/test/protocol/wire_conformance_test.dart b/apps/flutter/test/protocol/wire_conformance_test.dart deleted file mode 100644 index b155f326..00000000 --- a/apps/flutter/test/protocol/wire_conformance_test.dart +++ /dev/null @@ -1,1402 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/protocol/protocol.dart'; - -const _corpusPath = - '../../packages/client/test/fixtures/protocol/omp-app-v1-corpus.json'; -const _operationCapabilitiesPath = - '../../packages/host-wire/test/fixtures/operation-capabilities.json'; - -void main() { - final corpus = _loadCorpus(); - - group('inbound wire conformance', () { - final supportedFamilies = { - 'welcome': isA(), - 'successful-response': isA(), - 'authorization-error': isA(), - 'session-list': isA(), - 'transcript-snapshot': isA(), - 'streaming-event': isA(), - 'confirmation-challenge': isA(), - 'terminal-output': isA(), - 'continuity-gap': isA(), - 'heartbeat-pong': isA(), - 'pair-ok': isA(), - }; - - for (final entry in supportedFamilies.entries) { - test('${entry.key} decodes to its sealed frame family', () { - final fixture = _namedCase(corpus, 'inbound', entry.key); - - expect( - WireDecoder.decode(jsonEncode(fixture['wire'])), - entry.value, - reason: entry.key, - ); - }); - } - - test('every canonical invalidInbound case is rejected', () { - final invalidCases = _caseList(corpus, 'invalidInbound'); - - expect(invalidCases, isNotEmpty); - for (final fixture in invalidCases) { - final name = fixture['name']! as String; - expect( - () => WireDecoder.decode(jsonEncode(fixture['wire'])), - throwsA(isA()), - reason: name, - ); - } - }); - - test('every canonical inbound case rejects a missing required field', () { - const requiredFields = { - 'welcome': 'hostId', - 'successful-response': 'requestId', - 'authorization-error': 'code', - 'session-list': 'cursor', - 'transcript-snapshot': 'entries', - 'streaming-event': 'event', - 'confirmation-challenge': 'confirmationId', - 'terminal-output': 'data', - 'continuity-gap': 'from', - 'heartbeat-pong': 'nonce', - 'pair-ok': 'pairingId', - }; - for (final entry in requiredFields.entries) { - final fixture = _namedCase(corpus, 'inbound', entry.key); - final malformed = _cloneMap(fixture['wire'])..remove(entry.value); - expect( - () => WireDecoder.decode(jsonEncode(malformed)), - throwsA(isA()), - reason: '${entry.key} requires ${entry.value}', - ); - } - }); - - test('additive and unknown event data is preserved but immutable', () { - final fixture = _namedCase(corpus, 'inbound', 'streaming-event'); - final wire = _cloneMap(fixture['wire']); - final event = _asMap(wire['event']); - event['type'] = 'future.message.decorated'; - event['futureData'] = { - 'enabled': true, - 'labels': ['preserved'], - }; - wire['futureEnvelopeData'] = {'generation': 2}; - final expectedEvent = _cloneMap(event); - - final frame = WireDecoder.decode(jsonEncode(wire)); - - expect(frame, isA()); - final eventFrame = frame as EventFrame; - expect(eventFrame.event, expectedEvent); - expect(eventFrame.raw['futureEnvelopeData'], { - 'generation': 2, - }); - expect(() => eventFrame.event['added'] = true, throwsUnsupportedError); - final futureData = _asMap(eventFrame.event['futureData']); - expect(() => futureData['enabled'] = false, throwsUnsupportedError); - final labels = futureData['labels']! as List; - expect(() => labels.add('changed'), throwsUnsupportedError); - expect( - () => eventFrame.raw['futureEnvelopeData'] = null, - throwsUnsupportedError, - ); - }); - - test('transcript image metadata survives durable entry decoding', () { - final fixture = _namedCase(corpus, 'inbound', 'transcript-snapshot'); - final wire = _cloneMap(fixture['wire']); - final entries = (wire['entries']! as List) - .map(_cloneMap) - .toList(growable: false); - wire['entries'] = entries; - final data = _asMap(entries.first['data']); - data['images'] = [ - { - 'sha256': ''.padRight(64, 'a'), - 'mimeType': 'image/png', - }, - ]; - entries.first['data'] = data; - - final frame = WireDecoder.decode(jsonEncode(wire)) as SnapshotFrame; - - expect(frame.entries.first.data['images'], [ - { - 'sha256': ''.padRight(64, 'a'), - 'mimeType': 'image/png', - }, - ]); - }); - - test('wrong protocol version fails at the version boundary', () { - final fixture = _namedCase(corpus, 'inbound', 'welcome'); - final wire = _cloneMap(fixture['wire'])..['v'] = 'omp-app/2'; - - expect( - () => WireDecoder.decode(jsonEncode(wire)), - throwsA( - isA() - .having((error) => error.path, 'path', 'v') - .having( - (error) => error.message, - 'message', - 'protocol version must be exactly omp-app/1', - ), - ), - ); - }); - - test('unknown top-level frame family fails at the type boundary', () { - final fixture = _namedCase(corpus, 'inbound', 'heartbeat-pong'); - final wire = _cloneMap(fixture['wire'])..['type'] = 'future.frame'; - - expect( - () => WireDecoder.decode(jsonEncode(wire)), - throwsA( - isA() - .having((error) => error.path, 'path', 'type') - .having( - (error) => error.message, - 'message', - 'unknown top-level frame family', - ), - ), - ); - }); - - test('inbound frames larger than 4 MiB are rejected before parsing', () { - final oversizedFrame = ''.padRight(4 * 1024 * 1024 + 1, 'x'); - - expect( - () => WireDecoder.decode(oversizedFrame), - throwsA( - isA().having( - (error) => error.message, - 'message', - 'inbound frame exceeds the 4 MiB UTF-8 limit', - ), - ), - ); - }); - - test('exact pinned ServerFrame union has typed sealed dispatch', () { - final frames = _authoritativeServerFrames(corpus); - final matchers = _authoritativeServerMatchers(); - - expect(frames.keys, unorderedEquals(matchers.keys)); - for (final entry in frames.entries) { - final decoded = WireDecoder.decode(jsonEncode(entry.value)); - expect(decoded, matchers[entry.key], reason: entry.key); - expect(() => decoded.raw['changed'] = true, throwsUnsupportedError); - } - }); - test('shared operation capability fixture decodes into typed models', () { - final scenario = _asMap( - jsonDecode(File(_operationCapabilitiesPath).readAsStringSync()), - ); - final catalog = - WireDecoder.decode(jsonEncode(scenario['catalog'])) as ResponseFrame; - final result = catalog.catalogResult!; - - expect(result.revision, 'capabilities-v1'); - expect( - result.operations!.map((operation) => operation.operationId), - [ - 'session.prompt', - 'slash.compact', - 'slash.plan', - 'goal.create', - ], - ); - expect( - result.operations!.map((operation) => operation.execution), - [ - OperationExecution.typed, - OperationExecution.headless, - OperationExecution.terminalOnly, - OperationExecution.unavailable, - ], - ); - expect( - result.operations! - .skip(2) - .map((operation) => operation.disabledReason!.code), - ['terminal_only', 'capability_unavailable'], - ); - expect( - () => result.operations!.add(result.operations!.first), - throwsUnsupportedError, - ); - - final rejections = _asList(scenario['rejections']) - .map((wire) => WireDecoder.decode(jsonEncode(wire)) as ResponseFrame) - .toList(growable: false); - expect(rejections.every((frame) => !frame.ok), isTrue); - expect(rejections.map((frame) => frame.error!.code), [ - 'terminal_only', - 'capability_unavailable', - ]); - }); - - test( - 'catalog decoding preserves missing versus authoritative empty operations', - () { - Map catalog([List? operations]) { - final frame = { - 'v': 'omp-app/1', - 'type': 'catalog', - 'hostId': 'host-alpha', - 'revision': operations == null ? 'legacy' : 'authoritative-empty', - 'items': [], - }; - if (operations != null) frame['operations'] = operations; - return frame; - } - - final legacy = - WireDecoder.decode(jsonEncode(catalog())) as CatalogFrame; - final authoritative = - WireDecoder.decode(jsonEncode(catalog([]))) - as CatalogFrame; - expect(legacy.operations, isNull); - expect(authoritative.operations, isEmpty); - }, - ); - - test( - 'every non-corpus ServerFrame branch rejects a missing requirement', - () { - const requiredFields = { - 'entry': 'entry', - 'agent': 'state', - 'terminal': 'stream', - 'files': 'path', - 'review': 'findings', - 'audit': 'actor', - 'pair.error': 'message', - 'bye': 'retryable', - 'host.watch': 'watchId', - 'session.watch': 'watchId', - 'session.state': 'state', - 'session.delta': 'remove', - 'lease': 'kind', - 'prompt.lease': 'kind', - 'agent.state': 'state', - 'agent.lifecycle': 'lifecycle', - 'agent.progress': 'progress', - 'agent.event': 'event', - 'agent.transcript': 'entries', - 'terminal.exit': 'exitCode', - 'files.list': 'entries', - 'files.read': 'content', - 'files.write': 'revision', - 'files.patch': 'patch', - 'files.diff': 'diff', - 'audit.tail': 'events', - 'audit.event': 'event', - 'catalog': 'items', - 'settings': 'settings', - 'preview.launch': 'url', - 'preview.state': 'state', - 'preview.navigation': 'cursor', - 'preview.capture': 'capture', - 'preview.error': 'code', - }; - final frames = _authoritativeServerFrames(corpus); - for (final entry in requiredFields.entries) { - final malformed = _cloneMap(frames[entry.key])..remove(entry.value); - expect( - () => WireDecoder.decode(jsonEncode(malformed)), - throwsA(isA()), - reason: '${entry.key} requires ${entry.value}', - ); - } - }, - ); - - test('usage and broker command results decode to strict typed models', () { - final responseFixture = _namedCase( - corpus, - 'inbound', - 'successful-response', - ); - final usageWire = _cloneMap(responseFixture['wire']); - usageWire['command'] = 'usage.read'; - usageWire['result'] = { - 'generatedAt': 1720000000000, - 'reports': [ - { - 'provider': 'openai', - 'fetchedAt': 1720000000000, - 'limits': [ - { - 'id': 'requests', - 'label': 'Requests', - 'scope': {'provider': 'openai'}, - 'amount': { - 'used': 4, - 'limit': 10, - 'unit': 'requests', - }, - 'status': 'ok', - }, - ], - 'notes': [], - }, - ], - 'accountsWithoutUsage': [], - 'capacity': { - 'openai': [ - { - 'window': 'team', - 'accounts': 2, - 'usedAccounts': 1.5, - 'remainingAccounts': 0.5, - }, - ], - }, - }; - final usage = WireDecoder.decode(jsonEncode(usageWire)) as ResponseFrame; - - expect(usage.usageReadResult?.reports.single.provider, 'openai'); - expect( - usage.usageReadResult?.reports.single.limits.single.amount.limit, - 10, - ); - expect( - usage.usageReadResult?.capacity['openai']?.single.usedAccounts, - 1.5, - ); - - final brokerWire = _cloneMap(responseFixture['wire']); - brokerWire['command'] = 'broker.status'; - brokerWire['result'] = { - 'state': 'connected', - 'endpoint': 'https://broker.example.test', - 'generation': 3, - }; - final broker = - WireDecoder.decode(jsonEncode(brokerWire)) as ResponseFrame; - - expect(broker.brokerStatusResult?.state, BrokerState.connected); - expect(broker.brokerStatusResult?.generation, 3); - }); - - test('usage and broker results reject unsafe additive data', () { - final responseFixture = _namedCase( - corpus, - 'inbound', - 'successful-response', - ); - final malformedUsage = _cloneMap(responseFixture['wire']); - malformedUsage['command'] = 'usage.read'; - malformedUsage['result'] = { - 'generatedAt': 1, - 'reports': [], - 'accountsWithoutUsage': [], - 'capacity': {}, - 'token': 'secret', - }; - expect( - () => WireDecoder.decode(jsonEncode(malformedUsage)), - throwsA(isA()), - ); - - final malformedBroker = _cloneMap(responseFixture['wire']); - malformedBroker['command'] = 'broker.status'; - malformedBroker['result'] = { - 'state': 'connected', - 'endpoint': 'https://user:secret@broker.example.test', - 'generation': 1, - }; - expect( - () => WireDecoder.decode(jsonEncode(malformedBroker)), - throwsA(isA()), - ); - - final malformedLocalBroker = _cloneMap(responseFixture['wire']); - malformedLocalBroker['command'] = 'broker.status'; - malformedLocalBroker['result'] = { - 'state': 'local', - 'endpoint': 'https://broker.example.test', - 'generation': 1, - }; - expect( - () => WireDecoder.decode(jsonEncode(malformedLocalBroker)), - throwsA(isA()), - ); - }); - - test('ping is client-only and rejected by server dispatch', () { - final fixture = _namedCase(corpus, 'outbound', 'heartbeat-ping'); - expect( - () => WireDecoder.decode(jsonEncode(fixture['wire'])), - throwsA(isA()), - ); - }); - }); - - group('outbound wire conformance', () { - test('hello matches the canonical correlated frame without mutation', () { - final fixture = _namedCase( - corpus, - 'outbound', - 'hello-with-resume-and-authentication', - ); - final message = _asMap(fixture['message']); - final client = _asMap(message['client']); - final authentication = _asMap(message['authentication']); - final requestedFeatures = _strings(message['requestedFeatures']); - final capabilities = _strings(message['capabilities']); - final savedCursors = (_asList(message['savedCursors'])).map((value) { - final saved = _asMap(value); - final cursor = _asMap(saved['cursor']); - return SavedCursor( - hostId: saved['hostId']! as String, - sessionId: saved['sessionId']! as String, - cursor: TranscriptCursor( - epoch: cursor['epoch']! as String, - seq: cursor['seq']! as int, - ), - ); - }).toList(); - final requestedFeaturesBefore = List.of(requestedFeatures); - final capabilitiesBefore = List.of(capabilities); - final savedCursorsBefore = List.of(savedCursors); - - final encoded = WireEncoder.hello( - client: ClientIdentity( - name: client['name']! as String, - version: client['version']! as String, - build: client['build']! as String, - platform: client['platform']! as String, - ), - requestedFeatures: requestedFeatures, - savedCursors: savedCursors, - capabilities: capabilities, - authentication: DeviceAuthentication( - deviceId: authentication['deviceId']! as String, - deviceToken: authentication['deviceToken']! as String, - ), - ); - - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(requestedFeatures, requestedFeaturesBefore); - expect(capabilities, capabilitiesBefore); - expect(savedCursors, savedCursorsBefore); - }); - - test('session prompt matches the canonical correlated command', () { - final fixture = _namedCase(corpus, 'outbound', 'session-prompt-command'); - final message = _asMap(fixture['message']); - final args = _asMap(message['args']); - final before = jsonEncode(message); - - final encoded = WireEncoder.sessionPrompt( - requestId: message['requestId']! as String, - commandId: message['commandId']! as String, - hostId: message['hostId']! as String, - sessionId: message['sessionId']! as String, - expectedRevision: message['expectedRevision']! as String, - text: args['text']! as String, - ); - - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('session prompt includes uploaded image references', () { - final encoded = WireEncoder.sessionPrompt( - requestId: 'request-image', - commandId: 'command-image', - hostId: 'host-image', - sessionId: 'session-image', - expectedRevision: 'revision-image', - text: 'Inspect these', - imageIds: const ['image-one', 'image-two'], - ); - - final frame = jsonDecode(encoded) as Map; - expect(frame['args'], { - 'message': 'Inspect these', - 'images': [ - {'imageId': 'image-one'}, - {'imageId': 'image-two'}, - ], - }); - }); - - test( - 'session attach uses canonical command correlation and cursor JSON', - () { - final commandFixture = _namedCase( - corpus, - 'outbound', - 'session-prompt-command', - ); - final snapshotFixture = _namedCase( - corpus, - 'inbound', - 'transcript-snapshot', - ); - final command = _asMap(commandFixture['message']); - final snapshotWire = _asMap(snapshotFixture['wire']); - final cursorJson = _asMap(snapshotWire['cursor']); - final cursor = TranscriptCursor( - epoch: cursorJson['epoch']! as String, - seq: cursorJson['seq']! as int, - ); - final commandBefore = jsonEncode(command); - final cursorBefore = _cloneMap(cursorJson); - final expected = _cloneMap(commandFixture['wire']); - expected - ..remove('expectedRevision') - ..['command'] = 'session.attach' - ..['args'] = {'cursor': _cloneMap(cursorJson)}; - - final encoded = WireEncoder.sessionAttach( - requestId: command['requestId']! as String, - commandId: command['commandId']! as String, - hostId: command['hostId']! as String, - sessionId: command['sessionId']! as String, - cursor: cursor, - ); - - expect(jsonDecode(encoded), expected); - expect(jsonEncode(command), commandBefore); - expect(cursorJson, cursorBefore); - expect( - cursor, - TranscriptCursor( - epoch: cursorBefore['epoch']! as String, - seq: cursorBefore['seq']! as int, - ), - ); - }, - ); - - test('approved-confirmation matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'approved-confirmation'); - final message = _asMap(fixture['message']); - final before = jsonEncode(message); - final encoded = WireEncoder.confirm( - requestId: message['requestId']! as String, - confirmationId: message['confirmationId']! as String, - commandId: message['commandId']! as String, - hostId: message['hostId']! as String, - sessionId: message['sessionId']! as String, - decision: message['decision']! as String, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('pair-start matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'pair-start'); - final message = _asMap(fixture['message']); - final capabilities = _strings(message['requestedCapabilities']); - final before = jsonEncode(message); - final capabilitiesBefore = List.of(capabilities); - final encoded = WireEncoder.pairStart( - requestId: message['requestId']! as String, - code: message['code']! as String, - deviceId: message['deviceId']! as String, - deviceName: message['deviceName']! as String, - platform: message['platform']! as String, - requestedCapabilities: capabilities, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - expect(capabilities, capabilitiesBefore); - }); - - test('terminal-input-base64 matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'terminal-input-base64'); - final message = _asMap(fixture['message']); - final before = jsonEncode(message); - final encoded = WireEncoder.terminalInput( - hostId: message['hostId']! as String, - sessionId: message['sessionId']! as String, - terminalId: message['terminalId']! as String, - data: message['data']! as String, - encoding: message['encoding']! as String, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('terminal-resize matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'terminal-resize'); - final message = _asMap(fixture['message']); - final before = jsonEncode(message); - final encoded = WireEncoder.terminalResize( - hostId: message['hostId']! as String, - sessionId: message['sessionId']! as String, - terminalId: message['terminalId']! as String, - cols: message['cols']! as int, - rows: message['rows']! as int, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('terminal-close matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'terminal-close'); - final message = _asMap(fixture['message']); - final before = jsonEncode(message); - final encoded = WireEncoder.terminalClose( - hostId: message['hostId']! as String, - sessionId: message['sessionId']! as String, - terminalId: message['terminalId']! as String, - reason: message['reason']! as String, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('heartbeat-ping matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'heartbeat-ping'); - final message = _asMap(fixture['message']); - final before = jsonEncode(message); - final encoded = WireEncoder.ping( - nonce: message['nonce']! as String, - timestamp: message['timestamp']! as String, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('bootstrap command encoders keep cursor domains distinct', () { - final promptFixture = _namedCase( - corpus, - 'outbound', - 'session-prompt-command', - ); - final sessionsFixture = _namedCase(corpus, 'inbound', 'session-list'); - final command = _asMap(promptFixture['message']); - final sessionWire = _asMap(sessionsFixture['wire']); - final cursorWire = _asMap(sessionWire['cursor']); - final cursor = SessionIndexCursor( - epoch: cursorWire['epoch']! as String, - seq: cursorWire['seq']! as int, - ); - - expect( - jsonDecode( - WireEncoder.sessionList( - requestId: command['requestId']! as String, - commandId: command['commandId']! as String, - hostId: command['hostId']! as String, - ), - ), - { - 'v': ompAppProtocolVersion, - 'type': 'command', - 'requestId': command['requestId'], - 'commandId': command['commandId'], - 'hostId': command['hostId'], - 'command': 'session.list', - 'args': {}, - }, - ); - expect( - jsonDecode( - WireEncoder.hostWatch( - requestId: command['requestId']! as String, - commandId: command['commandId']! as String, - hostId: command['hostId']! as String, - cursor: cursor, - ), - ), - { - 'v': ompAppProtocolVersion, - 'type': 'command', - 'requestId': command['requestId'], - 'commandId': command['commandId'], - 'hostId': command['hostId'], - 'command': 'host.watch', - 'args': {'cursor': cursorWire}, - }, - ); - }); - - test('session-list result decoder is strict, typed, and immutable', () { - final fixture = _namedCase(corpus, 'inbound', 'session-list'); - final wire = _asMap(fixture['wire']); - final source = { - 'cursor': _cloneMap(wire['cursor']), - 'sessions': jsonDecode(jsonEncode(wire['sessions'])), - }; - final before = jsonEncode(source); - - final decoded = WireDecoder.decodeSessionListResult(source); - - expect(decoded.cursor, isA()); - expect(decoded.sessions, hasLength(1)); - expect(decoded.totalCount, 1); - expect(decoded.truncated, isFalse); - expect(jsonEncode(source), before); - expect(() => decoded.raw['changed'] = true, throwsUnsupportedError); - expect( - () => decoded.sessions.add(decoded.sessions.single), - throwsUnsupportedError, - ); - - final responseFixture = _namedCase( - corpus, - 'inbound', - 'successful-response', - ); - final responseWire = _cloneMap(responseFixture['wire']) - ..['command'] = 'session.list' - ..['result'] = source; - final response = - WireDecoder.decode(jsonEncode(responseWire)) as ResponseFrame; - expect(response.sessionListResult, isA()); - expect(response.sessionListResult?.cursor, decoded.cursor); - - final malformed = _cloneMap(source)..['totalCount'] = 0; - expect( - () => WireDecoder.decodeSessionListResult(malformed), - throwsA(isA()), - ); - }); - - test( - 'transcript search response is decoded into bounded typed results', - () { - final responseWire = - _cloneMap( - _namedCase(corpus, 'inbound', 'successful-response')['wire'], - ) - ..['command'] = 'transcript.search' - ..['result'] = { - 'items': [ - { - 'sessionId': 'session-1', - 'projectId': 'project-1', - 'sessionTitle': 'Search fixture', - 'anchorId': 'entry-1', - 'role': 'assistant', - 'timestamp': '2026-07-19T12:00:00.000Z', - 'snippet': 'fixed the parser', - 'highlights': [ - {'start': 0, 'end': 5}, - ], - }, - ], - 'incomplete': false, - 'index': { - 'state': 'ready', - 'indexedSessions': 4, - 'knownSessions': 4, - 'generation': 'generation-1', - }, - }; - - final response = - WireDecoder.decode(jsonEncode(responseWire)) as ResponseFrame; - final result = response.transcriptSearchResult; - - expect(result, isNotNull); - expect(result?.items.single.sessionTitle, 'Search fixture'); - expect(result?.items.single.role, TranscriptSearchRole.assistant); - expect(result?.items.single.highlights.single.end, 5); - expect(result?.index.state, TranscriptSearchIndexState.ready); - expect( - () => result?.items.add(result.items.single), - throwsUnsupportedError, - ); - - final malformed = _cloneMap(responseWire); - final malformedResult = _cloneMap(malformed['result']); - final malformedItems = _asList(malformedResult['items']); - final malformedItem = _cloneMap(malformedItems.single); - malformedItem['highlights'] = [ - {'start': 0, 'end': 100}, - ]; - malformedResult['items'] = [malformedItem]; - malformed['result'] = malformedResult; - expect( - () => WireDecoder.decode(jsonEncode(malformed)), - throwsA(isA()), - ); - }, - ); - - test('transcript context response validates its anchor row', () { - final responseWire = - _cloneMap( - _namedCase(corpus, 'inbound', 'successful-response')['wire'], - ) - ..['command'] = 'transcript.context' - ..['sessionId'] = 'session-1' - ..['result'] = { - 'anchorId': 'entry-2', - 'rows': [ - { - 'anchorId': 'entry-1', - 'role': 'user', - 'timestamp': '2026-07-19T11:59:00.000Z', - 'text': 'Please fix the parser.', - }, - { - 'anchorId': 'entry-2', - 'role': 'assistant', - 'timestamp': '2026-07-19T12:00:00.000Z', - 'text': 'Fixed the parser.', - }, - ], - 'anchorIndex': 1, - 'hasBefore': true, - 'hasAfter': false, - 'generation': 'generation-1', - }; - - final response = - WireDecoder.decode(jsonEncode(responseWire)) as ResponseFrame; - final context = response.transcriptContextResult; - - expect(context?.rows, hasLength(2)); - expect(context?.rows[context.anchorIndex].anchorId, 'entry-2'); - expect(context?.hasBefore, isTrue); - - final malformed = _cloneMap(responseWire); - final malformedResult = _cloneMap(malformed['result']) - ..['anchorIndex'] = 0; - malformed['result'] = malformedResult; - expect( - () => WireDecoder.decode(jsonEncode(malformed)), - throwsA(isA()), - ); - }); - - test('transcript page response is bounded, typed, and immutable', () { - final entry = { - 'id': 'entry-latest', - 'parentId': null, - 'hostId': 'host-test', - 'sessionId': 'session-1', - 'kind': 'message', - 'timestamp': '2026-07-20T09:00:00.000Z', - 'data': { - 'role': 'assistant', - 'text': 'Latest durable response', - }, - }; - final responseWire = - _cloneMap( - _namedCase(corpus, 'inbound', 'successful-response')['wire'], - ) - ..['command'] = 'transcript.page' - ..['sessionId'] = 'session-1' - ..['result'] = { - 'entries': [entry], - 'nextCursor': 'older-page', - 'hasMore': true, - 'generation': 'generation-1', - }; - - final response = - WireDecoder.decode(jsonEncode(responseWire)) as ResponseFrame; - final page = response.transcriptPageResult; - - expect(page?.entries.single.id, 'entry-latest'); - expect(page?.entries.single.data['text'], 'Latest durable response'); - expect(page?.nextCursor, 'older-page'); - expect(page?.hasMore, isTrue); - expect(page?.generation, 'generation-1'); - expect( - () => page?.entries.add(page.entries.single), - throwsUnsupportedError, - ); - - final malformed = _cloneMap(responseWire); - final malformedResult = _cloneMap(malformed['result']) - ..['entries'] = List.filled(129, entry); - malformed['result'] = malformedResult; - expect( - () => WireDecoder.decode(jsonEncode(malformed)), - throwsA(isA()), - ); - - final inconsistent = _cloneMap(responseWire); - inconsistent['result'] = { - ..._cloneMap(inconsistent['result']), - 'hasMore': false, - }; - expect( - () => WireDecoder.decode(jsonEncode(inconsistent)), - throwsA(isA()), - ); - }); - }); -} - -Map _authoritativeServerMatchers() => { - 'welcome': isA(), - 'sessions': isA(), - 'snapshot': isA(), - 'entry': isA(), - 'event': isA(), - 'agent': isA(), - 'terminal': isA(), - 'files': isA(), - 'review': isA(), - 'audit': isA(), - 'pair.ok': isA(), - 'pair.error': isA(), - 'confirmation': isA(), - 'response': isA(), - 'gap': isA(), - 'error': isA(), - 'pong': isA(), - 'bye': isA(), - 'host.watch': isA(), - 'session.watch': isA(), - 'session.state': isA(), - 'session.delta': isA(), - 'lease': isA(), - 'prompt.lease': isA(), - 'agent.state': isA(), - 'agent.lifecycle': isA(), - 'agent.progress': isA(), - 'agent.event': isA(), - 'agent.transcript': isA(), - 'terminal.output': isA(), - 'terminal.exit': isA(), - 'files.list': isA(), - 'files.read': isA(), - 'files.write': isA(), - 'files.patch': isA(), - 'files.diff': isA(), - 'audit.tail': isA(), - 'audit.event': isA(), - 'catalog': isA(), - 'settings': isA(), - 'preview.launch': isA(), - 'preview.state': isA(), - 'preview.navigation': isA(), - 'preview.capture': isA(), - 'preview.error': isA(), -}; - -Map> _authoritativeServerFrames( - Map corpus, -) { - final frames = >{}; - for (final fixture in _caseList(corpus, 'inbound')) { - final wire = _cloneMap(fixture['wire']); - frames[wire['type']! as String] = wire; - } - const cursor = {'epoch': 'epoch-union', 'seq': 7}; - const hostId = 'host-union'; - const sessionId = 'session-union'; - const revision = 'revision-union'; - const entry = { - 'id': 'entry-union', - 'parentId': null, - 'hostId': hostId, - 'sessionId': sessionId, - 'kind': 'message', - 'timestamp': '2030-01-01T00:00:00.000Z', - 'data': {'role': 'assistant', 'text': 'union'}, - }; - - Map frame( - String type, [ - Map fields = const {}, - ]) => {'v': ompAppProtocolVersion, 'type': type, ...fields}; - - Map owned([Map fields = const {}]) => - {'hostId': hostId, 'sessionId': sessionId, ...fields}; - - frames.addAll(>{ - 'entry': frame( - 'entry', - owned({ - 'cursor': cursor, - 'revision': revision, - 'entry': entry, - }), - ), - 'agent': frame( - 'agent', - owned({ - 'agentId': 'agent-union', - 'state': 'running', - 'progress': 0.5, - 'detail': {'step': 'decode'}, - }), - ), - 'terminal': frame( - 'terminal', - owned({ - 'terminalId': 'terminal-union', - 'stream': 'stdout', - 'data': 'output', - }), - ), - 'files': frame( - 'files', - owned({ - 'path': 'src/main.dart', - 'content': 'void main() {}', - 'truncated': false, - }), - ), - 'review': frame( - 'review', - owned({ - 'reviewId': 'review-union', - 'status': 'complete', - 'findings': [ - {'severity': 'info'}, - ], - }), - ), - 'audit': frame('audit', { - 'hostId': hostId, - 'sessionId': sessionId, - 'action': 'session.read', - 'actor': 'device-union', - 'timestamp': '2030-01-01T00:00:00.000Z', - }), - 'pair.error': frame('pair.error', { - 'code': 'PAIRING_INVALID', - 'message': 'pairing rejected', - 'requestId': 'request-union', - }), - 'bye': frame('bye', { - 'code': 'shutdown', - 'reason': 'maintenance', - 'retryable': true, - }), - 'host.watch': frame('host.watch', { - 'watchId': 'watch-host', - 'hostId': hostId, - 'cursor': cursor, - 'state': 'ready', - 'revision': revision, - 'metadata': {'source': 'union'}, - }), - 'session.watch': frame( - 'session.watch', - owned({ - 'watchId': 'watch-session', - 'cursor': cursor, - 'state': 'started', - 'revision': revision, - }), - ), - 'session.state': frame( - 'session.state', - owned({ - 'cursor': cursor, - 'revision': revision, - 'state': 'working', - }), - ), - 'session.delta': frame( - 'session.delta', - owned({ - 'cursor': cursor, - 'revision': revision, - 'remove': sessionId, - }), - ), - 'lease': frame( - 'lease', - owned({ - 'leaseId': 'lease-controller', - 'cursor': cursor, - 'kind': 'controller', - 'state': 'acquired', - 'owner': 'device-union', - 'expiresAt': '2030-01-01T00:10:00.000Z', - }), - ), - 'prompt.lease': frame( - 'prompt.lease', - owned({ - 'leaseId': 'lease-prompt', - 'cursor': cursor, - 'kind': 'prompt', - 'state': 'renewed', - 'owner': 'device-union', - 'expiresAt': '2030-01-01T00:10:00.000Z', - 'revision': revision, - }), - ), - 'agent.state': frame( - 'agent.state', - owned({ - 'agentId': 'agent-union', - 'cursor': cursor, - 'state': 'running', - 'revision': revision, - }), - ), - 'agent.lifecycle': frame( - 'agent.lifecycle', - owned({ - 'agentId': 'agent-union', - 'cursor': cursor, - 'lifecycle': 'completed', - 'revision': revision, - }), - ), - 'agent.progress': frame( - 'agent.progress', - owned({ - 'agentId': 'agent-union', - 'cursor': cursor, - 'progress': 0.75, - 'revision': revision, - }), - ), - 'agent.event': frame( - 'agent.event', - owned({ - 'agentId': 'agent-union', - 'cursor': cursor, - 'event': 'tool.started', - 'revision': revision, - 'data': {'tool': 'read'}, - }), - ), - 'agent.transcript': frame( - 'agent.transcript', - owned({ - 'agentId': 'agent-union', - 'cursor': cursor, - 'entries': [entry], - 'revision': revision, - }), - ), - 'terminal.exit': frame( - 'terminal.exit', - owned({ - 'terminalId': 'terminal-union', - 'cursor': cursor, - 'exitCode': 0, - 'signal': 'SIGTERM', - }), - ), - 'files.list': frame( - 'files.list', - owned({ - 'path': 'src', - 'entries': [ - { - 'path': 'src/main.dart', - 'kind': 'file', - 'size': 64, - 'revision': revision, - }, - ], - 'cursor': cursor, - 'revision': revision, - }), - ), - 'files.read': frame( - 'files.read', - owned({ - 'path': 'src/main.dart', - 'content': 'void main() {}', - 'revision': revision, - }), - ), - 'files.write': frame( - 'files.write', - owned({ - 'path': 'src/main.dart', - 'content': 'void main() {}', - 'revision': revision, - }), - ), - 'files.patch': frame( - 'files.patch', - owned({ - 'path': 'src/main.dart', - 'patch': '@@ -1 +1 @@', - 'revision': revision, - }), - ), - 'files.diff': frame( - 'files.diff', - owned({ - 'path': 'src/main.dart', - 'diff': '@@ -1 +1 @@', - 'fromRevision': 'revision-before', - 'toRevision': revision, - }), - ), - }); - - final auditEvent = { - 'eventId': 'operation-union', - 'hostId': hostId, - 'sessionId': sessionId, - 'action': 'session.read', - 'actor': 'device-union', - 'timestamp': '2030-01-01T00:00:00.000Z', - }; - frames.addAll(>{ - 'audit.tail': frame('audit.tail', { - 'hostId': hostId, - 'cursor': cursor, - 'events': [auditEvent], - }), - 'audit.event': frame('audit.event', { - 'hostId': hostId, - 'cursor': cursor, - 'event': auditEvent, - }), - 'catalog': frame('catalog', { - 'hostId': hostId, - 'revision': revision, - 'items': [ - { - 'id': 'catalog-union', - 'kind': 'tool', - 'name': 'Read', - 'capabilities': ['files.read'], - 'supported': true, - }, - ], - }), - 'settings': frame('settings', { - 'hostId': hostId, - 'revision': revision, - 'settings': {}, - }), - }); - - final preview = { - 'hostId': hostId, - 'sessionId': sessionId, - 'previewId': 'preview-union', - 'state': 'ready', - 'url': 'https://example.test/', - 'revision': revision, - 'cursor': cursor, - 'title': 'Preview', - 'canGoBack': false, - 'canGoForward': true, - 'viewport': { - 'width': 1280, - 'height': 720, - 'deviceScaleFactor': 2, - }, - 'authority': { - 'id': 'isolated', - 'label': 'Isolated', - 'kind': 'isolated-session', - 'requiresExplicitOptIn': false, - }, - 'availableActions': ['navigate', 'capture'], - }; - final capture = { - 'captureId': 'capture-union', - 'mimeType': 'image/png', - 'size': 1024, - 'width': 1280, - 'height': 720, - 'capturedAt': 1893456000000, - 'sha256': ''.padRight(64, 'a'), - }; - frames.addAll(>{ - 'preview.launch': frame('preview.launch', preview), - 'preview.state': frame('preview.state', { - ...preview, - 'error': 'recoverable', - }), - 'preview.navigation': frame('preview.navigation', preview), - 'preview.capture': frame('preview.capture', { - ...preview, - 'capture': capture, - }), - 'preview.error': frame( - 'preview.error', - owned({ - 'previewId': 'preview-union', - 'cursor': cursor, - 'revision': revision, - 'code': 'capture_failed', - 'message': 'capture failed', - }), - ), - }); - return frames; -} - -Map _loadCorpus() { - final file = File(_corpusPath); - if (!file.existsSync()) { - throw StateError('Canonical protocol corpus not found at $_corpusPath'); - } - return jsonDecode(file.readAsStringSync()) as Map; -} - -List> _caseList( - Map corpus, - String section, -) => _asList(corpus[section]).map(_asMap).toList(growable: false); - -Map _namedCase( - Map corpus, - String section, - String name, -) => _caseList( - corpus, - section, -).singleWhere((fixture) => fixture['name'] == name); - -Map _cloneMap(Object? value) => - jsonDecode(jsonEncode(value)) as Map; - -Map _asMap(Object? value) => value! as Map; - -List _asList(Object? value) => value! as List; - -List _strings(Object? value) => _asList(value).cast().toList(); diff --git a/apps/flutter/test/ui/command_palette_test.dart b/apps/flutter/test/ui/command_palette_test.dart deleted file mode 100644 index 9b040090..00000000 --- a/apps/flutter/test/ui/command_palette_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/ui/t4_app.dart'; - -void main() { - testWidgets('command palette filters, runs on Enter, and closes', ( - tester, - ) async { - final ran = []; - final commands = [ - PaletteCommand( - id: 'open-settings', - title: 'Open Settings', - shortcutLabel: '⌘,', - run: () => ran.add('open-settings'), - ), - PaletteCommand( - id: 'new-session', - title: 'New Session', - shortcutLabel: '⌘N', - run: () => ran.add('new-session'), - ), - PaletteCommand( - id: 'toggle-inbox', - title: 'Toggle Inbox', - run: () => ran.add('toggle-inbox'), - ), - ]; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) => Center( - child: ElevatedButton( - onPressed: () => - showCommandPalette(context, commands: commands), - child: const Text('Palette'), - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('Palette')); - await tester.pumpAndSettle(); - - // All three commands listed initially. - expect(find.byType(TextField), findsOneWidget); - expect(find.textContaining('Session'), findsOneWidget); - - // Filter down to just "New Session" via word-prefix fuzzy match. - await tester.enterText(find.byType(TextField), 'new ses'); - await tester.pump(); - expect( - find.byKey(const ValueKey('palette-command-new-session')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('palette-command-open-settings')), - findsNothing, - ); - - // Enter runs the single remaining command and closes the dialog. - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pumpAndSettle(); - - expect(ran, ['new-session']); - expect(find.byType(TextField), findsNothing); - }); -} diff --git a/apps/flutter/test/ui/host_flow_test.dart b/apps/flutter/test/ui/host_flow_test.dart deleted file mode 100644 index 2128227b..00000000 --- a/apps/flutter/test/ui/host_flow_test.dart +++ /dev/null @@ -1,2075 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/app_state.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/protocol/protocol.dart'; -import 'package:t4code/src/ui/t4_app.dart'; - -void main() { - const compactPhone = Size(390, 844); - const compactDesktop = Size(979, 800); - const wideDesktop = Size(980, 800); - - Future pumpApp( - WidgetTester tester, { - required T4ViewState state, - required _FakeActions actions, - required Size size, - bool credentialsAreVolatile = false, - }) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = size; - addTearDown(tester.view.reset); - await tester.pumpWidget( - T4App( - state: state, - actions: actions, - credentialsAreVolatile: credentialsAreVolatile, - ), - ); - await tester.pump(); - } - - T4ViewState keyboardTranscriptState({ - List? messages, - bool transcriptHistoryHasMore = false, - bool transcriptHistoryLoading = false, - String? transcriptHistoryError, - bool transcriptTailFromCache = false, - }) { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - return T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Keyboard inset test', - revision: 'revision-alpha', - status: 'idle', - ), - ], - transcriptHistoryHasMore: transcriptHistoryHasMore, - transcriptHistoryLoading: transcriptHistoryLoading, - transcriptHistoryError: transcriptHistoryError, - transcriptTailFromCache: transcriptTailFromCache, - messages: - messages ?? - List.generate( - 24, - (index) => TranscriptMessage( - id: 'message-$index', - role: index.isEven ? MessageRole.user : MessageRole.assistant, - text: 'Transcript message ${index + 1}', - ), - ), - ); - } - - Finder transcriptList() => find.byWidgetPredicate( - (widget) => - widget is ListView && - widget.keyboardDismissBehavior == - ScrollViewKeyboardDismissBehavior.onDrag, - ); - - group('shared T4 theme', () { - testWidgets('applies canonical neutral light tokens and typography', ( - tester, - ) async { - await pumpApp( - tester, - state: const T4ViewState( - connectionPhase: ConnectionPhase.disconnected, - themePreference: T4ThemePreference.light, - ), - actions: _FakeActions(), - size: compactPhone, - ); - - final theme = Theme.of(tester.element(find.byType(Scaffold).first)); - final semantic = theme.extension()!; - expect(theme.colorScheme.primary, const Color(0xffb8245b)); - expect(theme.colorScheme.surface, const Color(0xffffffff)); - expect(theme.colorScheme.onSurface, const Color(0xff262626)); - expect(theme.textTheme.bodyMedium?.fontFamily, 'DM Sans'); - expect(semantic.brand, const Color(0xffe83174)); - expect(semantic.statusDone, const Color(0xff009966)); - }); - - testWidgets('applies canonical neutral dark tokens', (tester) async { - await pumpApp( - tester, - state: const T4ViewState( - connectionPhase: ConnectionPhase.disconnected, - themePreference: T4ThemePreference.dark, - ), - actions: _FakeActions(), - size: compactPhone, - ); - - final theme = Theme.of(tester.element(find.byType(Scaffold).first)); - final semantic = theme.extension()!; - expect(theme.colorScheme.primary, const Color(0xfff67399)); - expect(theme.colorScheme.surface, const Color(0xff161616)); - expect(theme.colorScheme.onSurface, const Color(0xfff5f5f5)); - expect(semantic.brand, const Color(0xffe83174)); - expect(semantic.statusDone, const Color(0xff00d492)); - }); - }); - - testWidgets('labels compaction entries as earlier chat summaries', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState( - messages: const [ - TranscriptMessage( - id: 'compaction', - role: MessageRole.system, - kind: TranscriptKind.compaction, - text: 'Recovered compacted history', - ), - ], - ), - actions: _FakeActions(), - size: compactPhone, - ); - - expect(find.text('EARLIER CHAT SUMMARY'), findsOneWidget); - expect(find.text('SYSTEM'), findsNothing); - }); - - group('host onboarding', () { - testWidgets('shows an empty-host onboarding form', (tester) async { - await pumpApp( - tester, - state: const T4ViewState.disconnected(), - actions: _FakeActions(), - size: compactPhone, - ); - - expect(find.text('Connect to T4'), findsOneWidget); - expect(find.text('Tailnet HTTPS address'), findsOneWidget); - expect(find.text('Profile ID (optional)'), findsOneWidget); - expect(find.widgetWithText(FilledButton, 'Add host'), findsOneWidget); - expect(find.byType(SafeArea), findsWidgets); - }); - - testWidgets('surfaces the controller validation error', (tester) async { - final actions = _FakeActions( - addHostError: const FormatException( - 'Use the full Tailscale hostname ending in .ts.net.', - ), - ); - await pumpApp( - tester, - state: const T4ViewState.disconnected(), - actions: actions, - size: compactPhone, - ); - - await tester.enterText( - find.byType(TextField).first, - 'https://not-a-tailnet.example', - ); - await tester.tap(find.widgetWithText(FilledButton, 'Add host')); - await tester.pumpAndSettle(); - - expect( - find.text('Use the full Tailscale hostname ending in .ts.net.'), - findsOneWidget, - ); - expect(actions.addedAddresses, ['https://not-a-tailnet.example']); - }); - - testWidgets('shows progress while adding a host', (tester) async { - final completion = Completer(); - final actions = _FakeActions(addHostCompletion: completion); - await pumpApp( - tester, - state: const T4ViewState.disconnected(), - actions: actions, - size: compactPhone, - ); - - await tester.enterText( - find.byType(TextField).first, - 'https://alpha.tailnet-name.ts.net', - ); - await tester.tap(find.widgetWithText(FilledButton, 'Add host')); - await tester.pump(); - - expect(find.bySemanticsLabel('Adding host'), findsWidgets); - final button = tester.widget(find.byType(FilledButton)); - expect(button.onPressed, isNull); - - completion.complete(); - await tester.pumpAndSettle(); - }); - }); - - testWidgets('host manager switches and removes saved hosts', (tester) async { - final beta = HostProfile.parseTailnetAddress( - 'https://beta.tailnet-name.ts.net', - ); - final alpha = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final directory = HostDirectory.empty().upsert(beta).upsert(alpha); - final actions = _FakeActions(); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: directory, - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - ), - actions: actions, - size: wideDesktop, - ); - - await tester.tap(find.byTooltip('Host menu')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Manage hosts')); - await tester.pumpAndSettle(); - - expect(find.text('Saved hosts'), findsOneWidget); - expect(find.text(alpha.origin), findsOneWidget); - expect(find.text(beta.origin), findsOneWidget); - expect(find.text('Current host · Ready · Paired'), findsOneWidget); - - await tester.tap(find.bySemanticsLabel('Switch to ${beta.label}')); - await tester.pumpAndSettle(); - expect(actions.activatedEndpointKeys, [beta.endpointKey]); - - await tester.ensureVisible(find.widgetWithText(TextButton, 'Remove').last); - await tester.pump(); - expect( - find.byWidgetPredicate( - (widget) => - widget is Semantics && - widget.properties.label == 'Remove ${alpha.label}', - ), - findsOneWidget, - ); - await tester.tap(find.widgetWithText(TextButton, 'Remove').last); - await tester.pumpAndSettle(); - expect(find.text('Remove ${alpha.label}?'), findsOneWidget); - expect( - find.textContaining('pairing credential from this device'), - findsOneWidget, - ); - await tester.tap(find.widgetWithText(FilledButton, 'Remove host')); - await tester.pumpAndSettle(); - expect(actions.removedEndpointKeys, [alpha.endpointKey]); - }); - - testWidgets('pairing-required state submits six digits and clears the form', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.synchronizing, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.pairingRequired, - ), - actions: actions, - size: compactPhone, - ); - - expect(find.text('Pair this device'), findsOneWidget); - expect(find.text(t4PairCommand), findsOneWidget); - final code = List.filled(6, '1').join(); - await tester.enterText(find.byType(TextField), code); - await tester.pump(); - await tester.ensureVisible( - find.widgetWithText(FilledButton, 'Pair device'), - ); - await tester.pump(); - await tester.tap(find.widgetWithText(FilledButton, 'Pair device')); - await tester.pump(); - - expect(actions.pairingCodes, [code]); - final codeField = tester.widget(find.byType(TextField)); - expect(codeField.controller?.text, isEmpty); - expect(find.text(code), findsNothing); - }); - - testWidgets('pairing-required state surfaces the host rejection', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.synchronizing, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.pairingRequired, - errorMessage: - 'Pairing failed (INVALID_CODE). Check the code and try again.', - ), - actions: _FakeActions(), - size: compactPhone, - ); - - expect( - find.textContaining('Pairing failed (INVALID_CODE)'), - findsOneWidget, - ); - }); - - testWidgets('connected host can be deliberately disconnected', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - ), - actions: actions, - size: wideDesktop, - ); - - await tester.tap(find.byTooltip('Host menu')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Disconnect')); - await tester.pumpAndSettle(); - expect(actions.disconnectCalls, 1); - }); - - testWidgets( - 'configured endpoint can reconnect after a deliberate disconnect', - (tester) async { - final actions = _FakeActions(); - await pumpApp( - tester, - state: const T4ViewState( - connectionPhase: ConnectionPhase.disconnected, - targetConfigured: true, - ), - actions: actions, - size: compactPhone, - ); - - expect(find.byTooltip('Connect'), findsOneWidget); - expect(find.text('Connect to T4'), findsNothing); - await tester.tap(find.byTooltip('Connect')); - await tester.pumpAndSettle(); - expect(actions.connectCalls, 1); - }, - ); - - testWidgets('compact layout exposes host manager in the drawer', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.disconnected, - hostDirectory: HostDirectory.empty().upsert(profile), - ), - actions: _FakeActions(), - size: compactPhone, - ); - - expect(find.byTooltip('Open navigation'), findsOneWidget); - await tester.tap(find.byTooltip('Open navigation')); - await tester.pumpAndSettle(); - - expect(find.text('Navigation'), findsOneWidget); - expect(find.byTooltip('Host menu'), findsOneWidget); - expect(find.byType(Drawer), findsOneWidget); - }); - - testWidgets('left-edge swipe opens compact session navigation', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - ), - actions: _FakeActions(), - size: compactPhone, - ); - - await tester.dragFrom(const Offset(36, 420), const Offset(260, 0)); - await tester.pumpAndSettle(); - - expect(find.text('Navigation'), findsOneWidget); - expect(find.byTooltip('Host menu'), findsOneWidget); - }); - - testWidgets('unsigned macOS development mode is visibly identified', ( - tester, - ) async { - await pumpApp( - tester, - state: const T4ViewState.disconnected(), - actions: _FakeActions(), - size: compactPhone, - credentialsAreVolatile: true, - ); - - expect( - find.text('Unsigned development · credentials reset on quit'), - findsOneWidget, - ); - }); - - testWidgets('responsive split changes from drawer to rail at 980 pixels', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final state = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - ); - final actions = _FakeActions(); - - await pumpApp(tester, state: state, actions: actions, size: compactDesktop); - expect(find.byTooltip('Open navigation'), findsOneWidget); - - tester.view.physicalSize = wideDesktop; - await tester.pumpWidget( - T4App(state: state, actions: actions, credentialsAreVolatile: false), - ); - await tester.pumpAndSettle(); - - expect(find.byTooltip('Open navigation'), findsNothing); - expect(find.byTooltip('Host menu'), findsOneWidget); - expect(find.text('T4'), findsOneWidget); - }); - testWidgets( - 'session rail groups projects, searches, creates, archives, and restores', - (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - final state = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'First investigation', - revision: 'revision-alpha', - status: 'idle', - ), - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-beta', - projectId: 'project-beta', - projectName: 'Project Beta', - title: 'Second investigation', - revision: 'revision-beta', - status: 'idle', - ), - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-archived', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Archived investigation', - revision: 'revision-archived', - status: 'closed', - archivedAt: '2026-07-18T00:00:00.000Z', - ), - ], - ); - await pumpApp(tester, state: state, actions: actions, size: wideDesktop); - - expect(find.text('PROJECT ALPHA'), findsOneWidget); - expect(find.text('PROJECT BETA'), findsOneWidget); - expect(find.text('Archived investigation'), findsNothing); - - await tester.enterText( - find.widgetWithText(TextField, 'Search sessions'), - 'second', - ); - await tester.pump(); - expect( - find.ancestor( - of: find.text('First investigation'), - matching: find.byType(ListTile), - ), - findsNothing, - ); - expect( - find.ancestor( - of: find.text('Second investigation'), - matching: find.byType(ListTile), - ), - findsOneWidget, - ); - await tester.tap(find.byTooltip('Clear search')); - await tester.pump(); - - await tester.tap(find.byTooltip('New session')); - await tester.pumpAndSettle(); - expect(find.text('New session'), findsOneWidget); - await tester.enterText(find.byType(TextField).last, 'Fresh session'); - await tester.tap(find.widgetWithText(FilledButton, 'Create')); - await tester.pumpAndSettle(); - expect(actions.createdSessions, <({String projectId, String? title})>[ - (projectId: 'project-alpha', title: 'Fresh session'), - ]); - - await tester.tap(find.byTooltip('Session actions').first); - await tester.pumpAndSettle(); - await tester.tap(find.text('Archive').last); - await tester.pumpAndSettle(); - expect(actions.archivedSessionIds, ['session-alpha']); - - await tester.tap(find.text('Archived')); - await tester.pumpAndSettle(); - expect(find.text('Archived investigation'), findsOneWidget); - await tester.tap(find.byTooltip('Session actions').first); - await tester.pumpAndSettle(); - await tester.tap(find.text('Restore').last); - await tester.pumpAndSettle(); - expect(actions.restoredSessionIds, ['session-archived']); - }, - ); - testWidgets('new-session dialog fits compact phones', (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'First investigation', - revision: 'revision-alpha', - status: 'idle', - ), - ], - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Open navigation')); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('New session')); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - await tester.enterText( - find.byType(TextField).last, - 'T4_IOS_SIM_ACCEPTANCE_20260719', - ); - await tester.pump(); - expect(tester.takeException(), isNull); - await tester.tap(find.widgetWithText(FilledButton, 'Create')); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - }); - testWidgets( - 'composer preserves per-session drafts and exposes turn controls', - (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - T4ViewState stateFor( - String selectedSessionId, { - bool turnActive = false, - }) => T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: selectedSessionId, - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'T4_IOS_SIM_ACCEPTANCE_20260719', - revision: 'revision-alpha', - status: 'idle', - ), - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-beta', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Second investigation', - revision: 'revision-beta', - status: 'idle', - ), - ], - composer: SessionComposerState( - modelLabel: 'openai-codex/gpt-5.6-sol', - modelSelector: 'fixture/model', - modelChoices: const [ - ComposerModelChoice( - label: 'Fixture model', - selector: 'fixture/model', - ), - ], - thinking: 'medium', - thinkingLevels: const ['off', 'medium', 'high'], - fastAvailable: true, - turnActive: turnActive, - queuedFollowUpCount: turnActive ? 2 : 0, - ), - ); - - await pumpApp( - tester, - state: stateFor('session-alpha'), - actions: actions, - size: compactPhone, - ); - await tester.enterText(find.byType(TextField).last, 'Alpha draft'); - - await tester.pumpWidget( - T4App( - state: stateFor('session-beta'), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - expect(find.widgetWithText(TextField, 'Alpha draft'), findsNothing); - await tester.enterText(find.byType(TextField).last, 'Beta draft'); - - await tester.pumpWidget( - T4App( - state: stateFor('session-alpha', turnActive: true), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - - expect(find.widgetWithText(TextField, 'Alpha draft'), findsOneWidget); - expect(find.text('openai-codex/gpt-5.6-sol'), findsOneWidget); - expect(find.text('medium'), findsOneWidget); - expect(find.text('Stop'), findsOneWidget); - expect(find.text('Queue (2)'), findsOneWidget); - - // The circular send button doubles as the steer affordance while a - // turn is active. - await tester.tap(find.byTooltip('Steer')); - await tester.pumpAndSettle(); - expect(actions.submittedPrompts, ['Alpha draft']); - }, - ); - testWidgets( - 'composer stays disabled when prompt permission was not granted', - (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final state = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: const {'sessions.read', 'catalog.read'}, - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Read-only session', - revision: 'revision-alpha', - status: 'idle', - ), - ], - ); - - await pumpApp( - tester, - state: state, - actions: _FakeActions(), - size: compactPhone, - ); - await tester.enterText(find.byType(TextField).last, 'Cannot send'); - await tester.pump(); - final send = tester.widget( - find.ancestor( - of: find.byIcon(Icons.arrow_upward), - matching: find.byType(IconButton), - ), - ); - expect(send.onPressed, isNull); - }, - ); - testWidgets( - 'Fast toggles from the model selector menu and reflects the current state', - (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - T4ViewState stateFor({required bool fastEnabled}) => T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Fast toggle state', - revision: 'revision-alpha', - status: 'idle', - ), - ], - composer: SessionComposerState( - modelLabel: 'Fixture model', - modelSelector: 'fixture/model', - modelChoices: const [ - ComposerModelChoice( - label: 'Fixture model', - selector: 'fixture/model', - ), - ], - thinking: 'medium', - thinkingLevels: const ['off', 'medium', 'high'], - fastAvailable: true, - fastEnabled: fastEnabled, - ), - ); - - // Fast lives inside the model selector menu as a checkable item; it has - // no standalone chip in the composer row. - await pumpApp( - tester, - state: stateFor(fastEnabled: false), - actions: actions, - size: compactPhone, - ); - await tester.pumpAndSettle(); - expect(find.text('Fast'), findsNothing); - - await tester.tap(find.text('Fixture model')); - await tester.pumpAndSettle(); - final offItem = tester.widget( - find.widgetWithText(CheckboxMenuButton, 'Fast'), - ); - expect(offItem.value, isFalse); - - // Activating the unchecked item requests enabling Fast. - await tester.tap(find.text('Fast')); - await tester.pumpAndSettle(); - expect(actions.selectedFastModes, [true]); - - // On: the menu item reads as checked. - await tester.pumpWidget( - T4App( - state: stateFor(fastEnabled: true), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Fixture model')); - await tester.pumpAndSettle(); - final onItem = tester.widget( - find.widgetWithText(CheckboxMenuButton, 'Fast'), - ); - expect(onItem.value, isTrue); - }, - ); - - testWidgets('slash menu finds aliases and explains terminal-only commands', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final state = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Capability-aware slash menu', - revision: 'revision-alpha', - status: 'idle', - ), - ], - composer: const SessionComposerState( - slashCommands: [ - ComposerSlashCommand( - name: '/compact', - aliases: ['/compress'], - description: 'Compact the active conversation', - insert: '/compact ', - ), - ComposerSlashCommand( - name: '/plan', - description: 'Toggle plan mode', - insert: '/plan ', - disabledReason: '/plan requires the OMP terminal interface.', - ), - ], - ), - ); - - await pumpApp( - tester, - state: state, - actions: _FakeActions(), - size: compactPhone, - ); - await tester.enterText(find.byType(TextField).last, '/'); - await tester.pump(); - expect(find.text('/compact'), findsOneWidget); - expect(find.text('/plan'), findsOneWidget); - expect( - find.text('/plan requires the OMP terminal interface.'), - findsOneWidget, - ); - - await tester.enterText(find.byType(TextField).last, '/compress'); - await tester.pump(); - expect(find.text('/compact'), findsOneWidget); - expect(find.text('/plan'), findsNothing); - }); - testWidgets('keeps the latest message visible when the keyboard opens', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState(), - actions: _FakeActions(), - size: compactPhone, - ); - await tester.pumpAndSettle(); - - final list = transcriptList(); - final scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - expect( - scrollable.position.pixels, - greaterThanOrEqualTo(scrollable.position.maxScrollExtent - 0.5), - ); - - tester.view.viewInsets = const FakeViewPadding(bottom: 320); - await tester.pump(); - await tester.pump(); - - final resizedScrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - expect( - resizedScrollable.position.pixels, - greaterThanOrEqualTo(resizedScrollable.position.maxScrollExtent - 0.5), - ); - }); - - testWidgets('a user drag cancels bottom follow before viewport changes', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState(), - actions: _FakeActions(), - size: compactPhone, - ); - await tester.pumpAndSettle(); - - final list = transcriptList(); - final scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - final gesture = await tester.startGesture(tester.getCenter(list)); - await gesture.moveBy(const Offset(0, 48)); - await tester.pump(); - await gesture.moveBy(const Offset(0, 48)); - await tester.pump(); - final positionAfterDrag = scrollable.position.pixels; - expect( - scrollable.position.maxScrollExtent - positionAfterDrag, - greaterThan(0), - ); - - tester.view.viewInsets = const FakeViewPadding(bottom: 80); - await tester.pump(); - await tester.pump(); - - expect(scrollable.position.pixels, closeTo(positionAfterDrag, 0.5)); - await gesture.up(); - }); - - testWidgets('a desktop wheel scroll cancels bottom follow', (tester) async { - await pumpApp( - tester, - state: keyboardTranscriptState(), - actions: _FakeActions(), - size: compactDesktop, - ); - await tester.pumpAndSettle(); - - final list = transcriptList(); - final scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - await tester.sendEventToBinding( - PointerScrollEvent( - position: tester.getCenter(list), - scrollDelta: const Offset(0, -320), - ), - ); - await tester.pump(); - final positionAfterWheel = scrollable.position.pixels; - expect( - scrollable.position.maxScrollExtent - positionAfterWheel, - greaterThan(96), - ); - - tester.view.viewInsets = const FakeViewPadding(bottom: 80); - await tester.pump(); - await tester.pump(); - - expect(scrollable.position.pixels, closeTo(positionAfterWheel, 0.5)); - }); - - testWidgets('preserves transcript history position when the keyboard opens', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState(), - actions: _FakeActions(), - size: compactPhone, - ); - await tester.pumpAndSettle(); - - final list = transcriptList(); - await tester.drag(list, const Offset(0, 320)); - await tester.pumpAndSettle(); - final scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - final positionBeforeKeyboard = scrollable.position.pixels; - expect( - scrollable.position.maxScrollExtent - positionBeforeKeyboard, - greaterThan(96), - ); - - tester.view.viewInsets = const FakeViewPadding(bottom: 320); - await tester.pump(); - await tester.pump(); - - final resizedScrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - expect( - resizedScrollable.position.pixels, - closeTo(positionBeforeKeyboard, 0.5), - ); - }); - - testWidgets('older transcript pages preserve the visible history anchor', ( - tester, - ) async { - final actions = _FakeActions(); - final current = List.generate( - 24, - (index) => TranscriptMessage( - id: 'message-$index', - role: index.isEven ? MessageRole.user : MessageRole.assistant, - text: 'Transcript message ${index + 1}', - ), - ); - await pumpApp( - tester, - state: keyboardTranscriptState( - messages: current, - transcriptHistoryHasMore: true, - ), - actions: actions, - size: compactPhone, - ); - await tester.pumpAndSettle(); - - final list = transcriptList(); - await tester.drag(list, const Offset(0, 320)); - await tester.pumpAndSettle(); - final scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - final distanceFromEnd = - scrollable.position.maxScrollExtent - scrollable.position.pixels; - expect(distanceFromEnd, greaterThan(96)); - - final older = List.generate( - 4, - (index) => TranscriptMessage( - id: 'older-$index', - role: MessageRole.assistant, - text: 'Older transcript message ${index + 1}', - ), - ); - await pumpApp( - tester, - state: keyboardTranscriptState( - messages: [...older, ...current], - transcriptHistoryHasMore: true, - ), - actions: actions, - size: compactPhone, - ); - await tester.pumpAndSettle(); - - expect( - scrollable.position.maxScrollExtent - scrollable.position.pixels, - closeTo(distanceFromEnd, 0.5), - ); - await tester.drag(list, const Offset(0, 2000)); - await tester.pumpAndSettle(); - expect(find.text('Load earlier messages'), findsOneWidget); - await tester.tap(find.text('Load earlier messages')); - await tester.pump(); - expect(actions.loadEarlierCalls, 1); - }); - - testWidgets('labels a cached transcript until the live refresh arrives', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState(transcriptTailFromCache: true), - actions: _FakeActions(), - size: compactPhone, - ); - - expect( - find.text( - 'Showing encrypted saved messages while the live transcript connects.', - ), - findsOneWidget, - ); - }); - - testWidgets('collapses completed tool runs into a work group', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState( - messages: const [ - TranscriptMessage( - id: 'message-user', - role: MessageRole.user, - text: 'Fix the flaky test.', - ), - TranscriptMessage( - id: 'message-write', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: 'files.write', - toolTitle: 'Write lib/main.dart', - toolArguments: '{"path": "lib/main.dart", "content": ""}', - toolSucceeded: true, - ), - TranscriptMessage( - id: 'message-test', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: 'terminal.run', - toolTitle: 'Run flutter test', - toolArguments: '{"command": "flutter test"}', - toolSucceeded: true, - ), - TranscriptMessage( - id: 'message-assistant', - role: MessageRole.assistant, - text: 'Done.', - ), - ], - ), - actions: _FakeActions(), - size: compactPhone, - ); - - expect(find.text('Worked · 2 steps'), findsOneWidget); - expect(find.text('Edited 1 file'), findsOneWidget); - expect(find.text('main.dart'), findsOneWidget); - expect(find.text('Write lib/main.dart'), findsNothing); - expect(find.text('Run flutter test'), findsNothing); - - await tester.tap(find.text('Worked · 2 steps')); - await tester.pumpAndSettle(); - expect(find.text('Write lib/main.dart'), findsOneWidget); - expect(find.text('Run flutter test'), findsOneWidget); - }); - - testWidgets('renders actionable approvals inline below the transcript', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - final approval = AttentionItem( - key: 'session-alpha:approval:approval-inline', - kind: AttentionKind.approval, - sessionId: 'session-alpha', - sessionTitle: 'First investigation', - revision: 'revision-alpha', - title: 'Allow file write?', - summary: 'OMP wants to update lib/main.dart.', - at: DateTime.utc(2026, 7, 21), - requestId: 'approval-inline', - actionable: true, - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'First investigation', - revision: 'revision-alpha', - status: 'active', - ), - ], - messages: const [ - TranscriptMessage( - id: 'message-user', - role: MessageRole.user, - text: 'Please update main.dart.', - ), - TranscriptMessage( - id: 'message-assistant', - role: MessageRole.assistant, - text: 'Requesting write access now.', - ), - ], - attentionItems: [approval], - ), - actions: actions, - size: compactPhone, - ); - - expect(find.text('Allow file write?'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Approve')); - await tester.pump(); - expect(actions.attentionResponses, hasLength(1)); - expect( - actions.attentionResponses.single.response.decision, - AttentionDecision.approve, - ); - }); - - testWidgets('attention inbox exposes decisions and agent updates', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - final approval = AttentionItem( - key: 'session-alpha:approval:approval-1', - kind: AttentionKind.approval, - sessionId: 'session-alpha', - sessionTitle: 'First investigation', - revision: 'revision-alpha', - title: 'Allow file write?', - summary: 'OMP wants to update lib/main.dart.', - at: DateTime.utc(2026, 7, 19), - requestId: 'approval-1', - actionable: true, - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'First investigation', - revision: 'revision-alpha', - status: 'active', - ), - ], - attentionItems: [approval], - agentActivities: [ - AgentActivity( - agentId: 'agent-parent', - sessionId: 'session-alpha', - label: 'Coordinator', - status: 'running', - progress: 0.75, - updatedAt: DateTime.utc(2026, 7, 19), - ), - AgentActivity( - agentId: 'agent-child', - sessionId: 'session-alpha', - label: 'Review child', - status: 'running', - progress: 0.5, - updatedAt: DateTime.utc(2026, 7, 19, 0, 1), - parentAgentId: 'agent-parent', - description: 'Reviewing changes', - model: 'fixture-model', - currentTool: 'read', - ), - ], - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Open inbox')); - await tester.pumpAndSettle(); - expect(find.text('Inbox · 1 waiting'), findsOneWidget); - expect(find.text('Needs you (1)'), findsOneWidget); - expect(find.text('Allow file write?'), findsOneWidget); - - await tester.tap(find.widgetWithText(FilledButton, 'Approve')); - await tester.pumpAndSettle(); - expect(actions.attentionResponses, hasLength(1)); - expect( - actions.attentionResponses.single.response.decision, - AttentionDecision.approve, - ); - - await tester.tap(find.text('Agents (2)')); - await tester.pumpAndSettle(); - expect(find.text('Coordinator'), findsOneWidget); - expect(find.text('Review child'), findsOneWidget); - expect(find.text('Reviewing changes'), findsOneWidget); - expect(find.text('running · fixture-model · read'), findsOneWidget); - - await tester.tap(find.byTooltip('Stop Review child')); - await tester.pumpAndSettle(); - expect(find.text('Stop background agent?'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Stop agent')); - await tester.pumpAndSettle(); - expect(actions.cancelledAgentIds, ['agent-child']); - }); - testWidgets('phone transcript search loads highlighted historical context', ( - tester, - ) async { - const searchResult = TranscriptSearchResult( - items: [ - TranscriptSearchItem( - sessionId: 'session-history', - projectId: 'project-history', - sessionTitle: 'Parser investigation', - anchorId: 'entry-anchor', - role: TranscriptSearchRole.assistant, - timestamp: '2026-07-19T12:00:00.000Z', - snippet: 'Fixed the parser boundary', - highlights: [ - TranscriptSearchHighlight(start: 0, end: 5), - ], - ), - ], - incomplete: false, - index: TranscriptSearchIndexStatus( - state: TranscriptSearchIndexState.ready, - indexedSessions: 3, - knownSessions: 3, - generation: 'generation-1', - ), - ); - const contextResult = TranscriptContextResult( - anchorId: 'entry-anchor', - rows: [ - TranscriptContextRow( - anchorId: 'entry-before', - role: TranscriptSearchRole.user, - timestamp: '2026-07-19T11:59:00.000Z', - text: 'Please inspect the parser boundary.', - ), - TranscriptContextRow( - anchorId: 'entry-anchor', - role: TranscriptSearchRole.assistant, - timestamp: '2026-07-19T12:00:00.000Z', - text: 'The parser boundary is fixed.', - ), - ], - anchorIndex: 1, - hasBefore: false, - hasAfter: false, - generation: 'generation-1', - ); - final actions = _FakeActions( - transcriptSearchResult: searchResult, - transcriptContextResult: contextResult, - ); - await pumpApp( - tester, - state: const T4ViewState( - connectionPhase: ConnectionPhase.ready, - authenticationPhase: AuthenticationPhase.paired, - targetConfigured: true, - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Open navigation')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Search').last); - await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextField, 'Search transcripts'), - 'parser', - ); - await tester.tap(find.byTooltip('Search')); - await tester.pumpAndSettle(); - - expect(actions.transcriptQueries, ['parser']); - expect(find.text('Parser investigation'), findsOneWidget); - expect(find.text('Fixed the parser boundary'), findsOneWidget); - expect(find.text('3 sessions indexed'), findsOneWidget); - - await tester.tap(find.text('Show context')); - await tester.pumpAndSettle(); - expect(actions.contextAnchors, ['entry-anchor']); - expect(find.text('Please inspect the parser boundary.'), findsOneWidget); - expect(find.text('The parser boundary is fixed.'), findsOneWidget); - }); - - testWidgets('phone usage surface shows provider and broker status', ( - tester, - ) async { - const usage = UsageReadResult( - generatedAt: 1720000000000, - reports: [ - UsageReport( - provider: 'OpenAI', - fetchedAt: 1720000000000, - limits: [ - UsageLimit( - id: 'requests', - label: 'Requests', - scope: UsageScope(provider: 'OpenAI'), - amount: UsageAmount(used: 4, limit: 10, unit: UsageUnit.requests), - status: UsageStatus.ok, - notes: [], - ), - ], - notes: [], - metadata: {}, - ), - ], - accountsWithoutUsage: [], - capacity: >{}, - ); - final actions = _FakeActions( - usageReadResult: usage, - brokerStatusResult: const BrokerStatusResult( - state: BrokerState.connected, - generation: 1, - endpoint: 'https://broker.example.test', - ), - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - authenticationPhase: AuthenticationPhase.paired, - targetConfigured: true, - grantedCapabilities: const {'usage.read', 'broker.read'}, - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Open navigation')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Usage').last); - await tester.pumpAndSettle(); - - expect(find.text('Broker connected'), findsOneWidget); - expect(find.text('https://broker.example.test'), findsOneWidget); - expect(find.text('OpenAI'), findsOneWidget); - expect(find.text('Requests'), findsOneWidget); - expect(find.text('4 / 10 requests'), findsOneWidget); - }); - - testWidgets('developer tools expose activity, files, and review on phones', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'First investigation', - revision: 'revision-alpha', - status: 'active', - ), - ], - activities: [ - DeveloperActivity( - id: 'activity-1', - category: 'tool', - title: 'files.read', - detail: 'lib/main.dart', - at: DateTime.utc(2026, 7, 19), - raw: '{"path":"lib/main.dart"}', - ), - ], - fileWorkspace: const FileWorkspaceState( - path: 'lib/main.dart', - entries: [ - DeveloperFileEntry( - path: 'lib/main.dart', - kind: 'file', - size: 42, - revision: 'revision-file', - ), - ], - content: 'void main() {}', - diff: '-void old() {}\n+void main() {}', - revision: 'revision-file', - ), - reviews: const [ - ReviewWorkspaceItem( - reviewId: 'review-1', - sessionId: 'session-alpha', - status: 'pending', - path: 'lib/main.dart', - findings: >[ - {'message': 'Avoid an empty main body.'}, - ], - ), - ], - previews: const [ - PreviewWorkspaceState( - previewId: 'preview-1', - sessionId: 'session-alpha', - state: 'ready', - url: 'https://preview.example.test', - revision: 'revision-preview', - title: 'Fixture preview', - canGoBack: false, - canGoForward: false, - ), - ], - activePreviewId: 'preview-1', - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Open developer tools')); - await tester.pumpAndSettle(); - expect(find.text('Activity'), findsOneWidget); - expect(find.text('files.read'), findsOneWidget); - - await tester.tap(find.text('Files')); - await tester.pumpAndSettle(); - expect(find.text('lib/main.dart'), findsWidgets); - expect(find.text('void main() {}'), findsOneWidget); - await tester.enterText(find.byType(TextField), 'void main() { run(); }'); - await tester.pump(); - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - expect(actions.fileWrites, <({String path, String content})>[ - (path: 'lib/main.dart', content: 'void main() { run(); }'), - ]); - - await tester.tap(find.text('Review')); - await tester.pumpAndSettle(); - expect(find.text('Reload diff'), findsOneWidget); - expect(find.textContaining('+void main() {}'), findsOneWidget); - expect(find.text('Avoid an empty main body.'), findsOneWidget); - await tester.tap(find.byTooltip('Apply review')); - await tester.pumpAndSettle(); - expect(actions.appliedReviewIds, ['review-1']); - - await tester.drag(find.byType(TabBar), const Offset(-300, 0)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Preview')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Interact')); - await tester.pumpAndSettle(); - expect(find.text('Preview interaction'), findsOneWidget); - await tester.tap(find.text('Run click')); - await tester.pumpAndSettle(); - expect(actions.previewInteractions.single.previewId, 'preview-1'); - expect(actions.previewInteractions.single.action, 'click'); - expect(actions.previewInteractions.single.args, { - 'selector': 'button', - }); - }); - - testWidgets('quick open searches the selected project and opens its file', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions( - projectFileSearchResult: const ProjectFileSearchResult( - paths: ['lib/main.dart', 'test/main_test.dart'], - truncated: false, - ), - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - grantedFeatures: const {'files.search'}, - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Quick open fixture', - revision: 'revision-alpha', - status: 'idle', - ), - ], - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Quick open project file')); - await tester.pumpAndSettle(); - expect(find.text('Quick open'), findsOneWidget); - - await tester.enterText( - find.widgetWithText(TextField, 'Find a project file'), - 'main', - ); - await tester.pump(const Duration(milliseconds: 250)); - await tester.pumpAndSettle(); - expect(actions.projectFileQueries, ['main']); - expect(find.text('lib/main.dart'), findsOneWidget); - - await tester.tap(find.text('lib/main.dart')); - await tester.pumpAndSettle(); - expect(actions.readFilePaths, ['lib/main.dart']); - expect(find.text('Developer tools'), findsOneWidget); - expect(find.text('Files'), findsOneWidget); - }); - - testWidgets('composer exposes pause, resume, and manual compaction', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - T4ViewState stateFor({required bool turnActive, required bool isPaused}) => - T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Control fixture', - revision: 'revision-alpha', - status: turnActive ? 'active' : 'idle', - turnActive: turnActive, - isPaused: isPaused, - ), - ], - composer: SessionComposerState( - turnActive: turnActive, - isPaused: isPaused, - ), - ); - - await pumpApp( - tester, - state: stateFor(turnActive: true, isPaused: false), - actions: actions, - size: compactPhone, - ); - expect(find.text('Pause'), findsOneWidget); - expect(find.text('Stop'), findsOneWidget); - await tester.tap(find.text('Pause')); - await tester.pumpAndSettle(); - expect(actions.pauseSessionCalls, 1); - - await tester.pumpWidget( - T4App( - state: stateFor(turnActive: false, isPaused: true), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - expect(find.text('Resume'), findsOneWidget); - expect(find.text('Resume the session to continue'), findsOneWidget); - await tester.tap(find.text('Resume')); - await tester.pumpAndSettle(); - expect(actions.resumeSessionCalls, 1); - - await tester.pumpWidget( - T4App( - state: stateFor(turnActive: false, isPaused: false), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - // Manual compaction moved into the model selector menu. - await tester.tap(find.text('Model')); - await tester.pumpAndSettle(); - expect(find.text('Compact'), findsOneWidget); - await tester.tap(find.text('Compact')); - await tester.pumpAndSettle(); - expect(actions.compactSessionCalls, 1); - }); -} - -final class _FakeActions implements T4Actions { - _FakeActions({ - this.addHostError, - this.addHostCompletion, - this.transcriptSearchResult, - this.transcriptContextResult, - this.usageReadResult, - this.brokerStatusResult, - this.projectFileSearchResult, - }); - - final Object? addHostError; - final Completer? addHostCompletion; - final TranscriptSearchResult? transcriptSearchResult; - final TranscriptContextResult? transcriptContextResult; - final UsageReadResult? usageReadResult; - final BrokerStatusResult? brokerStatusResult; - final ProjectFileSearchResult? projectFileSearchResult; - final List transcriptQueries = []; - final List contextAnchors = []; - final List addedAddresses = []; - final List addedProfileIds = []; - final List activatedEndpointKeys = []; - int connectCalls = 0; - final List removedEndpointKeys = []; - final List pairingCodes = []; - int cancelHostProbeCalls = 0; - int disconnectCalls = 0; - final List<({String projectId, String? title})> createdSessions = - <({String projectId, String? title})>[]; - final List<({String sessionId, String title})> renamedSessions = - <({String sessionId, String title})>[]; - final List terminatedSessionIds = []; - final List archivedSessionIds = []; - final List restoredSessionIds = []; - final List submittedPrompts = []; - final List queuedPrompts = []; - int cancelTurnCalls = 0; - int pauseSessionCalls = 0; - int resumeSessionCalls = 0; - int compactSessionCalls = 0; - int loadEarlierCalls = 0; - final List selectedModels = []; - final List selectedThinkingLevels = []; - final List selectedFastModes = []; - final List deletedSessionIds = []; - final List<({AttentionItem item, AttentionResponse response})> - attentionResponses = <({AttentionItem item, AttentionResponse response})>[]; - final List retriedSessionIds = []; - final List cancelledAgentIds = []; - final List projectFileQueries = []; - final List readFilePaths = []; - final List<({String path, String content})> fileWrites = - <({String path, String content})>[]; - final List refreshedReviewIds = []; - final List appliedReviewIds = []; - final List<({String previewId, String action, Map args})> - previewInteractions = - <({String previewId, String action, Map args})>[]; - - @override - Future setThemePreference(T4ThemePreference preference) async {} - - @override - Future refreshSettings() async {} - - @override - Future writeSetting( - String path, - String scope, { - Object? value, - bool reset = false, - }) async {} - - @override - Future handleLifecyclePhase(T4LifecyclePhase phase) async {} - - @override - Future addHost( - String address, { - String profileId = defaultHostProfileId, - }) async { - addedAddresses.add(address); - addedProfileIds.add(profileId); - final error = addHostError; - if (error != null) throw error; - await addHostCompletion?.future; - } - - @override - void cancelHostProbe() { - cancelHostProbeCalls += 1; - } - - @override - Future activateHost(String endpointKey) async { - activatedEndpointKeys.add(endpointKey); - } - - @override - Future createSession(String projectId, {String? title}) async { - createdSessions.add((projectId: projectId, title: title)); - } - - @override - Future renameSession(String sessionId, String title) async { - renamedSessions.add((sessionId: sessionId, title: title)); - } - - @override - Future terminateSession(String sessionId) async { - terminatedSessionIds.add(sessionId); - } - - @override - Future archiveSession(String sessionId) async { - archivedSessionIds.add(sessionId); - } - - @override - Future restoreSession(String sessionId) async { - restoredSessionIds.add(sessionId); - } - - @override - Future deleteSession(String sessionId) async { - deletedSessionIds.add(sessionId); - } - - @override - Future searchTranscripts({ - required String query, - String? cursor, - String? projectId, - List? roles, - String archived = 'include', - DateTime? from, - DateTime? to, - }) async { - transcriptQueries.add(query); - return transcriptSearchResult ?? - (throw UnsupportedError('transcript search is not configured')); - } - - @override - Future loadTranscriptContext({ - required String sessionId, - required String anchorId, - int before = 8, - int after = 8, - }) async { - contextAnchors.add(anchorId); - return transcriptContextResult ?? - (throw UnsupportedError('transcript context is not configured')); - } - - @override - Future loadEarlierTranscript() async { - loadEarlierCalls += 1; - } - - @override - Future readUsage() async => - usageReadResult ?? (throw UnsupportedError('usage is not configured')); - - @override - Future readBrokerStatus() async => - brokerStatusResult ?? - (throw UnsupportedError('broker status is not configured')); - - @override - Future cancelAgent(String agentId) async { - cancelledAgentIds.add(agentId); - } - - @override - Future connect() async { - connectCalls += 1; - } - - @override - Future disconnect() async { - disconnectCalls += 1; - } - - @override - Future pairHost(String code) async { - pairingCodes.add(code); - } - - @override - Future removeHost(String endpointKey) async { - removedEndpointKeys.add(endpointKey); - } - - @override - Future selectSession(String sessionId) async {} - - @override - Future submitPrompt( - String message, { - List images = const [], - }) async { - submittedPrompts.add(message); - return true; - } - - @override - Future queuePrompt(String message) async { - queuedPrompts.add(message); - return true; - } - - @override - Future cancelTurn() async { - cancelTurnCalls += 1; - } - - @override - Future pauseSession() async { - pauseSessionCalls += 1; - } - - @override - Future resumeSession() async { - resumeSessionCalls += 1; - } - - @override - Future compactSession({String? instructions}) async { - compactSessionCalls += 1; - } - - @override - Future setSessionModel(String selector) async { - selectedModels.add(selector); - } - - @override - Future setSessionThinking(String level) async { - selectedThinkingLevels.add(level); - } - - @override - Future setSessionFast(bool enabled) async { - selectedFastModes.add(enabled); - } - - @override - Future respondToAttention( - AttentionItem item, - AttentionResponse response, - ) async { - attentionResponses.add((item: item, response: response)); - return true; - } - - @override - Future retrySession(String sessionId) async { - retriedSessionIds.add(sessionId); - } - - @override - Future refreshActivity() async {} - - @override - Future openTerminal({String? cwd}) async => 'terminal-test'; - - @override - void sendTerminalInput(String terminalId, String data) {} - - @override - void resizeTerminal(String terminalId, int cols, int rows) {} - - @override - void closeTerminal(String terminalId) {} - - @override - Future listFiles([String path = '']) async {} - - @override - Future searchProjectFiles( - String query, { - int limit = 12, - }) async { - projectFileQueries.add(query); - return projectFileSearchResult ?? - (throw UnsupportedError('project file search is not configured')); - } - - @override - Future readFile(String path) async { - readFilePaths.add(path); - } - - @override - Future loadSessionDiff() async {} - - @override - Future writeFile(String path, String content) async { - fileWrites.add((path: path, content: content)); - } - - @override - Future refreshReview(String reviewId) async { - refreshedReviewIds.add(reviewId); - } - - @override - Future applyReview(String reviewId) async { - appliedReviewIds.add(reviewId); - } - - @override - Future runPreviewInteraction( - String previewId, - String action, - Map args, - ) async { - previewInteractions.add((previewId: previewId, action: action, args: args)); - } - - @override - Future launchPreview(String url) async => 'preview-test'; - - @override - Future selectPreview(String previewId) async {} - - @override - Future navigatePreview(String previewId, String url) async {} - - @override - Future runPreviewAction(String previewId, String action) async {} - - @override - Future capturePreview(String previewId) async {} - - @override - Future readTranscriptImage( - String entryId, - TranscriptImageMetadata image, - ) async => Uint8List(0); -} diff --git a/apps/flutter/test/ui/settings_pane_test.dart b/apps/flutter/test/ui/settings_pane_test.dart deleted file mode 100644 index 601f829b..00000000 --- a/apps/flutter/test/ui/settings_pane_test.dart +++ /dev/null @@ -1,614 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/app_state.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/platform/platform_lifecycle.dart'; -import 'package:t4code/src/platform/platform_lifecycle_controller.dart'; -import 'package:t4code/src/ui/t4_app.dart'; - -void main() { - const phone = Size(390, 844); - const wide = Size(1440, 900); - - Future pumpApp( - WidgetTester tester, { - required T4ViewState state, - required _FakeActions actions, - required Size size, - PlatformLifecycleViewState platformState = - const PlatformLifecycleViewState.initial(), - PlatformLifecycleActions? platformActions, - }) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = size; - addTearDown(tester.view.reset); - await tester.pumpWidget( - T4App( - state: state, - actions: actions, - credentialsAreVolatile: false, - platformState: platformState, - platformActions: platformActions, - ), - ); - await tester.pump(); - } - - Future openSettings(WidgetTester tester, Size size) async { - if (size.width < 980) { - await tester.tap(find.byTooltip('Open navigation')); - await tester.pumpAndSettle(); - } - await tester.tap(find.text('Settings').last); - await tester.pumpAndSettle(); - } - - Future selectSettingsCategory( - WidgetTester tester, - Size size, - String label, - ) async { - if (size.width >= 980) { - await tester.tap(find.text(label).last); - } else { - await tester.tap(find.byKey(const Key('settings-category-picker'))); - await tester.pumpAndSettle(); - await tester.tap(find.text(label).last); - } - await tester.pumpAndSettle(); - } - - testWidgets('phone navigation changes the live theme preference', ( - tester, - ) async { - final actions = _FakeActions(); - final state = _state( - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - ); - await pumpApp(tester, state: state, actions: actions, size: phone); - - await openSettings(tester, phone); - - expect(find.byKey(const Key('settings-category-picker')), findsOneWidget); - expect(find.text('Appearance'), findsWidgets); - expect( - find.text('Choose how T4 follows your device appearance.'), - findsOneWidget, - ); - expect( - find.text( - 'Changes are staged here. Save sends them to the host, where host-scoped changes may require inbox approval.', - ), - findsNothing, - ); - await tester.tap(find.text('Dark')); - await tester.pump(); - - expect(actions.themePreferences, [ - T4ThemePreference.dark, - ]); - - await tester.pumpWidget( - T4App( - state: _state( - themePreference: T4ThemePreference.dark, - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - ), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pump(); - - expect( - tester.widget(find.byType(MaterialApp)).themeMode, - ThemeMode.dark, - ); - }); - - testWidgets( - 'wide settings shows permission, unavailable, secret, and restart states', - (tester) async { - final actions = _FakeActions(); - final state = _state( - capabilities: const {'catalog.read', 'config.read'}, - entries: [ - _entry( - path: 'runtime.enabled', - label: 'Runtime enabled', - control: HostSettingControlKind.boolean, - effectiveValue: true, - ), - _entry( - path: 'runtime.preview', - label: 'Preview runtime', - control: HostSettingControlKind.boolean, - effectiveValue: false, - available: false, - restartRequired: true, - ), - _entry( - path: 'provider.token', - label: 'Provider token', - control: HostSettingControlKind.secret, - configured: true, - sensitive: true, - ), - ], - ); - await pumpApp(tester, state: state, actions: actions, size: wide); - - await openSettings(tester, wide); - await selectSettingsCategory(tester, wide, 'OMP settings'); - - expect(find.byTooltip('Close settings'), findsOneWidget); - expect(find.text('Permission denied'), findsOneWidget); - expect(find.text('Unavailable'), findsOneWidget); - expect(find.text('Restart required'), findsOneWidget); - expect(find.text('Configured'), findsOneWidget); - expect( - find.text('A value is configured. Its contents are hidden.'), - findsOneWidget, - ); - expect( - find.byKey(const Key('setting-control-runtime.enabled')), - findsNothing, - ); - expect(find.byType(Drawer), findsNothing); - }, - ); - - testWidgets('OMP settings renders only the selected setting group', ( - tester, - ) async { - final actions = _FakeActions(); - final state = _state( - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - entries: [ - _entry( - path: 'runtime.enabled', - label: 'Runtime · Enabled', - control: HostSettingControlKind.boolean, - effectiveValue: true, - ), - _entry( - path: 'provider.enabled', - label: 'Provider · Enabled', - control: HostSettingControlKind.boolean, - effectiveValue: false, - ), - ], - ); - await pumpApp(tester, state: state, actions: actions, size: wide); - await openSettings(tester, wide); - await selectSettingsCategory(tester, wide, 'OMP settings'); - - expect(find.byKey(const Key('host-settings-group-picker')), findsOneWidget); - expect(find.text('Runtime · Enabled'), findsOneWidget); - expect(find.text('Provider · Enabled'), findsNothing); - - await tester.tap(find.byKey(const Key('host-settings-group-picker'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Provider (1)').last); - await tester.pumpAndSettle(); - - expect(find.text('Runtime · Enabled'), findsNothing); - expect(find.text('Provider · Enabled'), findsOneWidget); - }); - - testWidgets( - 'phone controls stage changes and require Save for write and reset', - (tester) async { - final actions = _FakeActions(); - final state = _state( - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - entries: [ - _entry( - path: 'runtime.enabled', - label: 'Runtime enabled', - control: HostSettingControlKind.boolean, - effectiveValue: true, - configured: true, - ), - ], - ); - await pumpApp(tester, state: state, actions: actions, size: phone); - await openSettings(tester, phone); - await selectSettingsCategory(tester, phone, 'OMP settings'); - - final control = find.byKey(const Key('setting-control-runtime.enabled')); - await tester.ensureVisible(control); - await tester.tap(control); - await tester.pumpAndSettle(); - - expect(actions.settingWrites, isEmpty); - expect(find.text('1 staged change'), findsOneWidget); - await tester.tap(find.byKey(const Key('save-settings'))); - await tester.pumpAndSettle(); - - expect(actions.settingWrites, <_SettingWrite>[ - const _SettingWrite( - path: 'runtime.enabled', - scope: 'host', - value: false, - reset: false, - ), - ]); - - final reset = find.widgetWithText(TextButton, 'Reset'); - await tester.ensureVisible(reset); - await tester.tap(reset); - await tester.pumpAndSettle(); - expect(find.text('Reset staged'), findsOneWidget); - expect(actions.settingWrites, hasLength(1)); - - await tester.tap(find.byKey(const Key('save-settings'))); - await tester.pumpAndSettle(); - expect( - actions.settingWrites.last, - const _SettingWrite( - path: 'runtime.enabled', - scope: 'host', - value: null, - reset: true, - ), - ); - }, - ); - - testWidgets('host revision changes surface a staged-edit conflict', ( - tester, - ) async { - final actions = _FakeActions(); - final entry = _entry( - path: 'runtime.enabled', - label: 'Runtime enabled', - control: HostSettingControlKind.boolean, - effectiveValue: true, - configured: true, - ); - await pumpApp( - tester, - state: _state( - revision: 'revision-1', - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - entries: [entry], - ), - actions: actions, - size: wide, - ); - await openSettings(tester, wide); - await selectSettingsCategory(tester, wide, 'OMP settings'); - await tester.tap(find.byKey(const Key('setting-control-runtime.enabled'))); - await tester.pump(); - - await tester.pumpWidget( - T4App( - state: _state( - revision: 'revision-2', - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - entries: [entry], - ), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pump(); - - expect(find.text('Settings changed on the host'), findsOneWidget); - expect( - tester - .widget(find.byKey(const Key('save-settings'))) - .onPressed, - isNull, - ); - }); - - testWidgets('native runtime and update controls dispatch explicit actions', ( - tester, - ) async { - final actions = _FakeActions(); - final platformActions = _FakePlatformActions(); - await pumpApp( - tester, - state: _state(), - actions: actions, - size: wide, - platformState: _nativePlatformState(), - platformActions: platformActions, - ); - await openSettings(tester, wide); - await selectSettingsCategory(tester, wide, 'App and runtime'); - - final runtimeRestart = find.byKey(const Key('runtime-restart')); - await tester.ensureVisible(runtimeRestart); - await tester.tap(runtimeRestart); - await tester.pumpAndSettle(); - expect(platformActions.calls, ['runtime.restart']); - - final updateCheck = find.byKey(const Key('update-check')); - await tester.ensureVisible(updateCheck); - await tester.tap(updateCheck); - await tester.pumpAndSettle(); - expect(platformActions.calls, ['runtime.restart', 'update.check']); - expect(find.text('1.2.4'), findsWidgets); - expect(find.text('1.2.5'), findsOneWidget); - }); - - test('diagnostics export includes only allowlisted redacted values', () { - final state = _state( - revision: 'revision-safe', - capabilities: const { - 'catalog.read', - 'config.write', - 'config.read', - }, - entries: [ - _entry( - path: 'ui.scale', - label: 'UI scale', - control: HostSettingControlKind.number, - effectiveValue: 1.25, - ), - _entry( - path: 'provider.token', - label: 'Provider token', - control: HostSettingControlKind.secret, - effectiveValue: 'HOST-CREDENTIAL-DO-NOT-EXPORT', - configured: true, - sensitive: true, - ), - _entry( - path: 'private.note', - label: 'Private note', - control: HostSettingControlKind.text, - effectiveValue: 'DEVICE-TOKEN-DO-NOT-EXPORT', - sensitive: true, - ), - ], - issues: const ['settings frame omitted optional metadata'], - ); - - final encoded = buildT4DiagnosticsJson( - state, - generatedAt: DateTime.utc(2026, 7, 19), - platformState: _nativePlatformState(), - ); - final decoded = jsonDecode(encoded) as Map; - final settings = decoded['settings']! as List; - - expect(encoded, isNot(contains('HOST-CREDENTIAL-DO-NOT-EXPORT'))); - expect(encoded, isNot(contains('DEVICE-TOKEN-DO-NOT-EXPORT'))); - expect(encoded, isNot(contains('rawFrame'))); - expect(encoded, isNot(contains('transcript'))); - expect(encoded, isNot(contains('PRIVATE-HOME'))); - expect(decoded['kind'], 't4-code.flutter-diagnostics'); - expect(decoded['hostLabel'], 'T4 on alpha'); - expect(decoded['capabilityNames'], [ - 'catalog.read', - 'config.read', - 'config.write', - ]); - final platform = decoded['platform']! as Map; - expect( - (platform['runtime']! as Map)['service'], - 'running', - ); - expect((platform['update']! as Map)['phase'], 'available'); - expect(settings.cast>().first['effectiveValue'], 1.25); - expect( - settings.cast>()[1].containsKey('effectiveValue'), - isFalse, - ); - expect( - settings.cast>()[2].containsKey('effectiveValue'), - isFalse, - ); - }); -} - -T4ViewState _state({ - T4ThemePreference themePreference = T4ThemePreference.system, - Set capabilities = const {}, - List entries = const [], - List issues = const [], - String revision = 'revision-1', -}) { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - return T4ViewState( - connectionPhase: ConnectionPhase.ready, - authenticationPhase: AuthenticationPhase.paired, - hostDirectory: HostDirectory.empty().upsert(profile), - targetConfigured: true, - grantedCapabilities: capabilities, - grantedFeatures: const {'catalog.metadata', 'settings.metadata'}, - themePreference: themePreference, - settings: HostSettingsState( - revision: revision, - entries: entries, - issues: issues, - ), - ); -} - -PlatformLifecycleViewState _nativePlatformState() => - const PlatformLifecycleViewState( - runtime: RuntimeServiceStatus( - supported: true, - available: true, - definition: RuntimeDefinitionState.current, - service: RuntimeServicePhase.running, - diagnostics: '', - executable: '/Users/PRIVATE-HOME/bin/omp', - ), - update: PlatformUpdateStatus( - supported: true, - currentVersion: '1.2.4', - phase: PlatformUpdatePhase.available, - latestVersion: '1.2.5', - checkedAt: 1784419200000, - revision: 7, - ), - ); - -HostSettingEntry _entry({ - required String path, - required String label, - required HostSettingControlKind control, - Object? effectiveValue, - bool configured = false, - bool restartRequired = false, - bool available = true, - bool sensitive = false, -}) => HostSettingEntry( - path: path, - section: 'Runtime', - label: label, - help: 'Published by the connected host.', - control: control, - effectiveValue: effectiveValue, - configured: configured, - effectiveSource: 'host', - options: const [], - writableScopes: const ['host'], - restartRequired: restartRequired, - available: available, - sensitive: sensitive, -); - -final class _FakeActions implements T4Actions { - final List themePreferences = []; - final List<_SettingWrite> settingWrites = <_SettingWrite>[]; - int refreshCalls = 0; - - @override - Future setThemePreference(T4ThemePreference preference) async { - themePreferences.add(preference); - } - - @override - Future refreshSettings() async { - refreshCalls += 1; - } - - @override - Future writeSetting( - String path, - String scope, { - Object? value, - bool reset = false, - }) async { - settingWrites.add( - _SettingWrite(path: path, scope: scope, value: value, reset: reset), - ); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -final class _FakePlatformActions implements PlatformLifecycleActions { - final List calls = []; - - @override - Future refreshPlatformState() async { - calls.add('platform.refresh'); - } - - @override - Future installRuntime() async { - calls.add('runtime.install'); - } - - @override - Future startRuntime() async { - calls.add('runtime.start'); - } - - @override - Future stopRuntime() async { - calls.add('runtime.stop'); - } - - @override - Future restartRuntime() async { - calls.add('runtime.restart'); - } - - @override - Future uninstallRuntime() async { - calls.add('runtime.uninstall'); - } - - @override - Future checkForUpdates() async { - calls.add('update.check'); - } - - @override - Future downloadUpdate() async { - calls.add('update.download'); - } - - @override - Future installUpdate() async { - calls.add('update.install'); - } -} - -final class _SettingWrite { - const _SettingWrite({ - required this.path, - required this.scope, - required this.value, - required this.reset, - }); - - final String path; - final String scope; - final Object? value; - final bool reset; - - @override - bool operator ==(Object other) => - other is _SettingWrite && - path == other.path && - scope == other.scope && - value == other.value && - reset == other.reset; - - @override - int get hashCode => Object.hash(path, scope, value, reset); - - @override - String toString() => - '_SettingWrite(path: $path, scope: $scope, value: $value, reset: $reset)'; -} diff --git a/apps/flutter/test/ui/shell_shortcuts_test.dart b/apps/flutter/test/ui/shell_shortcuts_test.dart deleted file mode 100644 index b92f1931..00000000 --- a/apps/flutter/test/ui/shell_shortcuts_test.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/app_state.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/ui/t4_app.dart'; - -void main() { - const wideDesktop = Size(1200, 800); - - T4ViewState shellState() { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - return T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Shortcut test session', - revision: 'revision-alpha', - status: 'idle', - ), - ], - messages: const [ - TranscriptMessage( - id: 'message-0', - role: MessageRole.assistant, - text: 'Hello from the transcript', - ), - ], - ); - } - - Future pressChord( - WidgetTester tester, - List keys, - ) async { - for (final key in keys) { - await tester.sendKeyDownEvent(key); - } - for (final key in keys.reversed) { - await tester.sendKeyUpEvent(key); - } - await tester.pumpAndSettle(); - } - - testWidgets( - 'mod+shift+F toggles transcript search and Escape returns to conversation', - (tester) async { - // Linux → the shell binds control as the primary modifier, which keeps - // the test free of meta-key platform quirks. - debugDefaultTargetPlatformOverride = TargetPlatform.linux; - - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = wideDesktop; - addTearDown(tester.view.reset); - - await tester.pumpWidget( - T4App( - state: shellState(), - actions: _StubActions(), - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Search transcripts'), findsNothing); - expect(find.text('Hello from the transcript'), findsOneWidget); - - // mod+shift+F opens the search surface. - await pressChord(tester, const [ - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.keyF, - ]); - expect(find.text('Search transcripts'), findsWidgets); - - // Same chord toggles it back off. - await pressChord(tester, const [ - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.keyF, - ]); - expect(find.text('Search transcripts'), findsNothing); - expect(find.text('Hello from the transcript'), findsOneWidget); - - // Reopen, then Escape returns to the conversation. - await pressChord(tester, const [ - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.keyF, - ]); - expect(find.text('Search transcripts'), findsWidgets); - - await pressChord(tester, const [LogicalKeyboardKey.escape]); - expect(find.text('Search transcripts'), findsNothing); - expect(find.text('Hello from the transcript'), findsOneWidget); - - // Reset inside the body: the binding asserts foundation vars before - // tearDown callbacks run. - debugDefaultTargetPlatformOverride = null; - }, - ); -} - -/// Minimal stub: the shortcut flows under test never reach the host, so any -/// unexpected action call surfaces loudly as a type error instead of passing -/// silently. -final class _StubActions implements T4Actions { - @override - Object? noSuchMethod(Invocation invocation) => Future.value(); -} diff --git a/apps/flutter/web/favicon.png b/apps/flutter/web/favicon.png deleted file mode 100644 index 9a5cd0fd..00000000 Binary files a/apps/flutter/web/favicon.png and /dev/null differ diff --git a/apps/flutter/web/flutter_bootstrap.js b/apps/flutter/web/flutter_bootstrap.js deleted file mode 100644 index e4384b60..00000000 --- a/apps/flutter/web/flutter_bootstrap.js +++ /dev/null @@ -1,23 +0,0 @@ -// eslint-disable-next-line no-unused-expressions -- replaced by Flutter at build time -{{flutter_js}} -// eslint-disable-next-line no-unused-expressions -- replaced by Flutter at build time -{{flutter_build_config}} - -// The public demo follows current main. Remove service workers left by older -// builds before starting so they cannot keep an obsolete UI in front of the -// freshly invalidated CloudFront files. -const startT4Demo = () => _flutter.loader.load(); -if ('serviceWorker' in navigator) { - navigator.serviceWorker - .getRegistrations() - .then((registrations) => - Promise.all( - registrations - .filter((registration) => new URL(registration.scope).pathname.startsWith('/demo/')) - .map((registration) => registration.unregister()), - ), - ) - .then(startT4Demo, startT4Demo); -} else { - startT4Demo(); -} diff --git a/apps/flutter/web/icons/Icon-192.png b/apps/flutter/web/icons/Icon-192.png deleted file mode 100644 index 7b04e4bf..00000000 Binary files a/apps/flutter/web/icons/Icon-192.png and /dev/null differ diff --git a/apps/flutter/web/icons/Icon-512.png b/apps/flutter/web/icons/Icon-512.png deleted file mode 100644 index 225c077d..00000000 Binary files a/apps/flutter/web/icons/Icon-512.png and /dev/null differ diff --git a/apps/flutter/web/icons/Icon-maskable-192.png b/apps/flutter/web/icons/Icon-maskable-192.png deleted file mode 100644 index 7b04e4bf..00000000 Binary files a/apps/flutter/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/apps/flutter/web/icons/Icon-maskable-512.png b/apps/flutter/web/icons/Icon-maskable-512.png deleted file mode 100644 index 225c077d..00000000 Binary files a/apps/flutter/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/apps/flutter/web/index.html b/apps/flutter/web/index.html deleted file mode 100644 index 1c3f3913..00000000 --- a/apps/flutter/web/index.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - T4 Code - - - - - - - diff --git a/apps/flutter/web/manifest.json b/apps/flutter/web/manifest.json deleted file mode 100644 index 369f1e82..00000000 --- a/apps/flutter/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "T4 Code", - "short_name": "T4", - "start_url": ".", - "display": "standalone", - "background_color": "#FFFFFF", - "theme_color": "#0D0D0D", - "description": "T4 Code Flutter client", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/apps/flutter/windows/.gitignore b/apps/flutter/windows/.gitignore deleted file mode 100644 index d492d0d9..00000000 --- a/apps/flutter/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ephemeral/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/apps/flutter/windows/CMakeLists.txt b/apps/flutter/windows/CMakeLists.txt deleted file mode 100644 index 6252dce1..00000000 --- a/apps/flutter/windows/CMakeLists.txt +++ /dev/null @@ -1,108 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(t4code LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "t4code") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) diff --git a/apps/flutter/windows/flutter/CMakeLists.txt b/apps/flutter/windows/flutter/CMakeLists.txt deleted file mode 100644 index 903f4899..00000000 --- a/apps/flutter/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,109 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# Set fallback configurations for older versions of the flutter tool. -if (NOT DEFINED FLUTTER_TARGET_PLATFORM) - set(FLUTTER_TARGET_PLATFORM "windows-x64") -endif() - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - ${FLUTTER_TARGET_PLATFORM} $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/apps/flutter/windows/flutter/generated_plugin_registrant.cc b/apps/flutter/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index b53f20e2..00000000 --- a/apps/flutter/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,17 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - FileSelectorWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FileSelectorWindows")); - FlutterSecureStorageWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); -} diff --git a/apps/flutter/windows/flutter/generated_plugin_registrant.h b/apps/flutter/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85..00000000 --- a/apps/flutter/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/flutter/windows/flutter/generated_plugins.cmake b/apps/flutter/windows/flutter/generated_plugins.cmake deleted file mode 100644 index 4873bb84..00000000 --- a/apps/flutter/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,26 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - file_selector_windows - flutter_secure_storage_windows -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/apps/flutter/windows/runner/CMakeLists.txt b/apps/flutter/windows/runner/CMakeLists.txt deleted file mode 100644 index 394917c0..00000000 --- a/apps/flutter/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,40 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the build version. -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/apps/flutter/windows/runner/Runner.rc b/apps/flutter/windows/runner/Runner.rc deleted file mode 100644 index 76bfa601..00000000 --- a/apps/flutter/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) -#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD -#else -#define VERSION_AS_NUMBER 1,0,0,0 -#endif - -#if defined(FLUTTER_VERSION) -#define VERSION_AS_STRING FLUTTER_VERSION -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.lycaonsolutions" "\0" - VALUE "FileDescription", "t4code" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "t4code" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 com.lycaonsolutions. All rights reserved." "\0" - VALUE "OriginalFilename", "t4code.exe" "\0" - VALUE "ProductName", "t4code" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/apps/flutter/windows/runner/flutter_window.cpp b/apps/flutter/windows/runner/flutter_window.cpp deleted file mode 100644 index 955ee303..00000000 --- a/apps/flutter/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,71 +0,0 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - - flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); - }); - - // Flutter can complete the first frame before the "show window" callback is - // registered. The following call ensures a frame is pending to ensure the - // window is shown. It is a no-op if the first frame hasn't completed yet. - flutter_controller_->ForceRedraw(); - - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} diff --git a/apps/flutter/windows/runner/flutter_window.h b/apps/flutter/windows/runner/flutter_window.h deleted file mode 100644 index 6da0652f..00000000 --- a/apps/flutter/windows/runner/flutter_window.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/apps/flutter/windows/runner/main.cpp b/apps/flutter/windows/runner/main.cpp deleted file mode 100644 index e24a7da4..00000000 --- a/apps/flutter/windows/runner/main.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.Create(L"t4code", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/apps/flutter/windows/runner/resource.h b/apps/flutter/windows/runner/resource.h deleted file mode 100644 index 66a65d1e..00000000 --- a/apps/flutter/windows/runner/resource.h +++ /dev/null @@ -1,16 +0,0 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif diff --git a/apps/flutter/windows/runner/resources/app_icon.ico b/apps/flutter/windows/runner/resources/app_icon.ico deleted file mode 100644 index cf755780..00000000 Binary files a/apps/flutter/windows/runner/resources/app_icon.ico and /dev/null differ diff --git a/apps/flutter/windows/runner/runner.exe.manifest b/apps/flutter/windows/runner/runner.exe.manifest deleted file mode 100644 index 153653e8..00000000 --- a/apps/flutter/windows/runner/runner.exe.manifest +++ /dev/null @@ -1,14 +0,0 @@ - - - - - PerMonitorV2 - - - - - - - - - diff --git a/apps/flutter/windows/runner/utils.cpp b/apps/flutter/windows/runner/utils.cpp deleted file mode 100644 index 3cb71466..00000000 --- a/apps/flutter/windows/runner/utils.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - // First, find the length of the string with a safe upper bound (CWE-126). - // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. - int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); - // Now use that bounded length to determine the required buffer size. - // When an explicit length is passed, WideCharToMultiByte does not include - // the null terminator in its returned size. - int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, nullptr, 0, nullptr, nullptr); - std::string utf8_string; - if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, utf8_string.data(), target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} diff --git a/apps/flutter/windows/runner/utils.h b/apps/flutter/windows/runner/utils.h deleted file mode 100644 index 3879d547..00000000 --- a/apps/flutter/windows/runner/utils.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ diff --git a/apps/flutter/windows/runner/win32_window.cpp b/apps/flutter/windows/runner/win32_window.cpp deleted file mode 100644 index 60608d0f..00000000 --- a/apps/flutter/windows/runner/win32_window.cpp +++ /dev/null @@ -1,288 +0,0 @@ -#include "win32_window.h" - -#include -#include - -#include "resource.h" - -namespace { - -/// Window attribute that enables dark mode window decorations. -/// -/// Redefined in case the developer's machine has a Windows SDK older than -/// version 10.0.22000.0. -/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute -#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE -#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 -#endif - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -/// Registry key for app theme preference. -/// -/// A value of 0 indicates apps should use dark mode. A non-zero or missing -/// value indicates apps should use light mode. -constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; -constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - } - FreeLibrary(user32_module); -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registrar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - UpdateTheme(window); - - return OnCreate(); -} - -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - - case WM_DWMCOLORIZATIONCOLORCHANGED: - UpdateTheme(hwnd); - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} - -void Win32Window::UpdateTheme(HWND const window) { - DWORD light_mode; - DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); - - if (result == ERROR_SUCCESS) { - BOOL enable_dark_mode = light_mode == 0; - DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, - &enable_dark_mode, sizeof(enable_dark_mode)); - } -} diff --git a/apps/flutter/windows/runner/win32_window.h b/apps/flutter/windows/runner/win32_window.h deleted file mode 100644 index e901dde6..00000000 --- a/apps/flutter/windows/runner/win32_window.h +++ /dev/null @@ -1,102 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates a win32 window with |title| that is positioned and sized using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size this function will scale the inputted width and height as - // as appropriate for the default monitor. The window is invisible until - // |Show| is called. Returns true if the window was created successfully. - bool Create(const std::wstring& title, const Point& origin, const Size& size); - - // Show the current window. Returns true if the window was successfully shown. - bool Show(); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - // Update the window frame's theme to match the system theme. - static void UpdateTheme(HWND const window); - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/apps/mobile/capacitor.config.json b/apps/mobile/capacitor.config.json index 7755eff6..88f0d4ba 100644 --- a/apps/mobile/capacitor.config.json +++ b/apps/mobile/capacitor.config.json @@ -3,7 +3,7 @@ "appName": "T4 Code", "webDir": "dist", "loggingBehavior": "debug", - "appendUserAgent": " T4CodeMobile/0.1.30", + "appendUserAgent": " T4CodeMobile/0.1.31", "android": { "path": "android", "minWebViewVersion": 60, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 50e5b7bd..be106ffa 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/mobile", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "scripts": { diff --git a/apps/site/package.json b/apps/site/package.json index b47f3790..b861c334 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/site", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "scripts": { diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index 898dbba3..b6fdd89d 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -13,8 +13,8 @@ export const OMP_UPSTREAM_TAG = "v17.0.5"; export const OMP_UPSTREAM_COMMIT = "9fd6e97113f5ed3a847e66d346970efdf8afcad9"; export const OMP_UPSTREAM_URL = `${OMP_URL}/tree/${OMP_UPSTREAM_TAG}`; export const APP_WIRE_VERSION = "0.7.0"; -export const RELEASE_TAG = "v0.1.30"; -export const RELEASE_VERSION = "0.1.30"; +export const RELEASE_TAG = "v0.1.31"; +export const RELEASE_VERSION = "0.1.31"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; export const RELEASE_MANIFEST_URL = `${SITE_URL}/releases/latest.json`; @@ -49,11 +49,11 @@ function asset( } export const RELEASE_ASSETS: readonly ReleaseAsset[] = [ - asset("android", "apk", "universal", "T4-Code-0.1.30-android.apk", "Android APK"), - asset("linux", "deb", "x86_64", "T4-Code-0.1.30-linux-amd64.deb", "Linux .deb"), - asset("linux", "appimage", "x86_64", "T4-Code-0.1.30-linux-x86_64.AppImage", "Linux AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-0.1.30-mac-arm64.dmg", "macOS .dmg"), - asset("mac", "zip", "arm64", "T4-Code-0.1.30-mac-arm64.zip", "macOS .zip"), + asset("android", "apk", "universal", "T4-Code-0.1.31-android.apk", "Android APK"), + asset("linux", "deb", "x86_64", "T4-Code-0.1.31-linux-amd64.deb", "Linux .deb"), + asset("linux", "appimage", "x86_64", "T4-Code-0.1.31-linux-x86_64.AppImage", "Linux AppImage"), + asset("mac", "dmg", "arm64", "T4-Code-0.1.31-mac-arm64.dmg", "macOS .dmg"), + asset("mac", "zip", "arm64", "T4-Code-0.1.31-mac-arm64.zip", "macOS .zip"), ]; export function assetsFor(platform: Platform): readonly ReleaseAsset[] { diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index b973b998..6d215991 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -1,4 +1,4 @@ -// Release contract guard: exact v0.1.30 asset names and URLs, and the +// Release contract guard: exact v0.1.31 asset names and URLs, and the // platform-detection rule the hero download button relies on. import { describe, expect, it } from "vite-plus/test"; import { @@ -20,13 +20,13 @@ import { } from "../src/release.ts"; describe("release assets", () => { - it("carries the five contracted v0.1.30 filenames", () => { + it("carries the five contracted v0.1.31 filenames", () => { expect(RELEASE_ASSETS.map((a) => a.filename)).toEqual([ - "T4-Code-0.1.30-android.apk", - "T4-Code-0.1.30-linux-amd64.deb", - "T4-Code-0.1.30-linux-x86_64.AppImage", - "T4-Code-0.1.30-mac-arm64.dmg", - "T4-Code-0.1.30-mac-arm64.zip", + "T4-Code-0.1.31-android.apk", + "T4-Code-0.1.31-linux-amd64.deb", + "T4-Code-0.1.31-linux-x86_64.AppImage", + "T4-Code-0.1.31-mac-arm64.dmg", + "T4-Code-0.1.31-mac-arm64.zip", ]); }); @@ -38,8 +38,8 @@ describe("release assets", () => { it("targets the public LycaonLLC repo", () => { expect(REPO_URL).toBe("https://github.com/LycaonLLC/t4-code"); - expect(RELEASE_TAG).toBe("v0.1.30"); - expect(RELEASE_VERSION).toBe("0.1.30"); + expect(RELEASE_TAG).toBe("v0.1.31"); + expect(RELEASE_VERSION).toBe("0.1.31"); expect(RELEASE_MANIFEST_URL).toBe("https://t4code.net/releases/latest.json"); }); diff --git a/apps/web/package.json b/apps/web/package.json index bdc96cd2..4a35c4af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/web", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/platform/browser-shell-port.ts b/apps/web/src/platform/browser-shell-port.ts index 6ee3c729..7dd36e13 100644 --- a/apps/web/src/platform/browser-shell-port.ts +++ b/apps/web/src/platform/browser-shell-port.ts @@ -339,7 +339,7 @@ export function createBrowserShellPort( }, client: { name: "T4 Code", - version: "0.1.30", + version: "0.1.31", build: mobilePlatform ?? "browser", platform: mobilePlatform ?? (platform === "darwin" ? "darwin" : "linux"), }, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6c71db6c..e05b84d1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -11,7 +11,8 @@ export default defineConfig(({ mode }) => ({ ? [ { name: "t4-demo-document-root", - transformIndexHtml: (html: string) => html.replaceAll('="./', '="/demo/'), + transformIndexHtml: (html: string) => + html.replace("", '\n ').replaceAll('="./', '="/demo/'), }, ] : []), diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index d2b56506..7c5ccf2f 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -256,12 +256,12 @@ }, "t4Host": { "wirePackage": "@t4-code/host-wire", - "wirePackageVersion": "0.1.30", + "wirePackageVersion": "0.1.31", "wireSchemaVersion": "0.7.0", "servicePackage": "@t4-code/host-service", - "servicePackageVersion": "0.1.30", + "servicePackageVersion": "0.1.31", "daemonPackage": "@t4-code/host-daemon", - "daemonPackageVersion": "0.1.30", + "daemonPackageVersion": "0.1.31", "protocol": "omp-app/1", "authorityBridgeProtocol": "t4-omp-authority/1", "sourcePaths": [ @@ -281,6 +281,6 @@ }, "desktop": { "package": "@t4-code/protocol", - "version": "0.1.30" + "version": "0.1.31" } } diff --git a/deploy/charts/t4-cluster/Chart.yaml b/deploy/charts/t4-cluster/Chart.yaml index 550297cc..cc1f12e0 100644 --- a/deploy/charts/t4-cluster/Chart.yaml +++ b/deploy/charts/t4-cluster/Chart.yaml @@ -2,6 +2,6 @@ apiVersion: v2 name: t4-cluster description: Default-off portable T4 Kubernetes operator and stateless cluster gateway version: 0.1.0 -appVersion: "0.1.30" +appVersion: "0.1.31" type: application kubeVersion: ">=1.30.0-0" diff --git a/docs/CURRENT_RELEASE_NOTES.md b/docs/CURRENT_RELEASE_NOTES.md index fd248970..0efe290a 100644 --- a/docs/CURRENT_RELEASE_NOTES.md +++ b/docs/CURRENT_RELEASE_NOTES.md @@ -1,59 +1,18 @@ -## Flutter permanent foundations (development) - -The local migration branch now contains the Flutter/Dart Stage 2 foundations for macOS, iOS, -Android, and Web, with generated Windows and Linux targets. The client strictly decodes and -encodes the pinned `omp-app/1` corpus, correlates commands, restores typed transcript and -session-index cursors, negotiates host watching, and handles reconnect, resume, and continuity -gaps without moving protocol logic into widgets. - -Saved Tailnet hosts, active-host selection, device pairing, host switching, and credential removal -now use shared Dart contracts. Host metadata is stored separately from device credentials; Android -uses encrypted storage and migrates the released app's keyed credentials without exposing them to -Dart, while Apple targets use Keychain-backed storage. Compact and wide layouts share the same -immutable state and command surface, including onboarding, pairing, and host management. - -This is still a development migration, not a release cutover. The deterministic fixture suite, -exact 390x844 and 1440x900 browser checks, iOS and Android target smokes, and an authenticated -disposable T4 host connection backed by OMP's authority bridge pass locally. The existing Electron, -React, browser, and Capacitor clients remain the released implementation until the complete feature -matrix, packaging, update, migration, security, and release gates pass. - -Stage 3 host parity is now complete on the local migration branch. The Flutter client presents -negotiated device permissions, deliberate disconnect/reconnect controls, cancellable pre-save host -checks, an exact least-authority pairing command, pairing failures, and confirmed host removal that -deletes only the device-local address and credential. - -Stage 3 project and session parity is also complete locally. The shared session rail consumes the -canonical session index, groups and searches current or archived sessions, creates and selects -sessions, and exposes rename, runtime termination, archive, restore, and confirmed permanent -deletion through revisioned app-wire commands. Compact drawers and wide rails share the same state -and actions. - -Stage 3 transcript and composer parity is complete locally. Durable and live transcript events now -render Markdown, reasoning, tool progress/results, streaming state, and integrity-checked images. -The composer preserves a separate draft and attachment set per session, uploads bounded images -through the chunked app-wire protocol, exposes catalog-backed slash and model choices, applies -thinking and fast-mode controls, and supports steering, queued follow-ups, and confirmed turn -cancellation. Focused protocol, controller, and compact/wide widget coverage passes alongside the -Web release build and fixture-connected macOS, iOS Simulator, and Android emulator smokes. - -Stage 3 decisions and attention parity is complete locally. The shared inbox groups approvals, -questions, plans, security confirmations, failures, completions, and background-agent progress. -Actions remain bound to the host session revision, acquire a negotiated prompt lease before -dispatch, and reject replaced or expired requests. The full Flutter suite, Web release build, -compact widget coverage, and an interactive compact-browser decision smoke pass. - -Stage 3 developer surfaces are complete locally. A shared developer workspace exposes redacted -activity with filters and pause/copy controls, file browsing and source inspection, selected-file -diff review, and protocol-backed PTY tabs with bounded scrollback, resize/input forwarding, exit -state, and guarded paste. Preview navigation stays host-authoritative: Flutter renders only -integrity-checked capture bytes and never executes page HTML or JavaScript. Focused controller and -compact widget coverage, the full Flutter suite, and fixture-connected Web, iOS Simulator, and -Android emulator smokes exercise the new path. +## Electron and React are the product authority + +T4 Code has standardized on the Electron desktop shell and canonical React renderer. The abandoned +Flutter migration, its duplicate platform targets, and its CI and release plumbing have been +removed. macOS is the primary desktop target, Linux remains supported, and React/Capacitor Android +plus the responsive Tailnet browser/PWA remain compatibility clients for paired hosts. + +The standardization preserves T4's standalone host, typed wire, OMP authority boundary, current +session and transcript behavior, native Browser workspace, terminal, files, review, secure +credentials, and signed update path. The public demo now builds from the same React client shipped +inside Electron, eliminating a second product implementation. ## A session rail built for large libraries -T4 Code v0.1.30 makes a large session library easier to navigate. The rail now supports text search, activity filters, newest/oldest sorting, grouped and flat layouts, collapsible project folders, and saved display preferences. Those controls follow the Codex desktop organization model while keeping OMP as the source of truth. +T4 Code v0.1.31 makes a large session library easier to navigate. The rail now supports text search, activity filters, newest/oldest sorting, grouped and flat layouts, collapsible project folders, and saved display preferences. Those controls follow the Codex desktop organization model while keeping OMP as the source of truth. Project menus can create a session in that folder, reveal the folder in the system file manager, collapse the group, or hide it from the rail. Hidden projects are not deleted and can be restored from the filter menu. The reveal action is deliberately narrow: the host accepts only project paths already present in its session catalog. @@ -89,7 +48,7 @@ Session-linked Host Browser Previews continue to open in their dedicated workspa ## Runtime provenance -T4 Code v0.1.30 vendors app-wire 0.7.0 from integration commit [796bb7dc](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. The frozen package remains compatibility evidence; T4 owns the active `omp-app/1` wire schema. +T4 Code v0.1.31 vendors app-wire 0.7.0 from integration commit [796bb7dc](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. The frozen package remains compatibility evidence; T4 owns the active `omp-app/1` wire schema. The verified OMP 17.0.5 runtime is built from commit [8476f445](https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4) and tagged [t4code-17.0.5-appserver-10](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-10). It provides the bounded authority bridge used by T4's standalone host and no longer exposes the old public appserver launchers. It preserves bounded newest-first transcript paging, stale-owner recovery, privacy-safe local project reveal, lazy session indexing, cross-session attention and transcript search, and the negotiated browser-preview command surface. Unsupported optional capabilities remain hidden when the host does not advertise them. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 88f9e4ba..a3455ffe 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -83,36 +83,6 @@ The browser build uses deterministic sample sessions and labels them **Sample da fastest path for layout, interaction, accessibility, and renderer work. It does not prove that a real OMP host can connect or execute a command. -### Flutter migration client - -Start the deterministic fixture host from the repository root: - -```sh -T4_FIXTURE_SCENARIO=stream-v1 node_modules/.bin/jiti e2e/fixture-process.ts -``` - -Copy the `wsUrl` from `T4_FIXTURE_READY`, then launch a target from another terminal: - -```sh -T4_DEVELOPMENT_ENDPOINT=ws://127.0.0.1:PORT/fixture pnpm dev:flutter -- -d macos -``` - -For the Android emulator, run `adb reverse tcp:PORT tcp:PORT`, then keep -`T4_DEVELOPMENT_ENDPOINT=ws://127.0.0.1:PORT/fixture`. Do not substitute `10.0.2.2`: the -deterministic fixture intentionally binds only to host loopback, so that address leaves the app -retrying with no session selected. Do not expose the fixture through Funnel or a public firewall -rule. - -`T4_DEVELOPMENT_ENDPOINT` is only for deterministic fixture work. Without it, the app starts from -its persisted host directory and accepts an exact Tailnet HTTPS address, then performs normal OMP -device pairing. Unsigned macOS `Debug` builds keep the credential in memory only and identify that -behavior in the window; signed Apple configurations and Android use platform secure storage. - - -Use `pnpm check:flutter` for analysis and `pnpm test:flutter` for the Dart protocol and real-fixture -lifecycle checks. This proof is the migration path under active development; the existing released -clients remain authoritative until the feature and release gates pass. - ### Live desktop work Install the exact verified OMP integration shown by the setup check, then run: @@ -126,10 +96,11 @@ locations. The desktop builds and starts `t4-host`, which launches the compatibl bridge. Do not point development at a personal production profile when testing destructive session lifecycle behavior; use a disposable OMP profile and session root. -### Remote browser or Android work +### Remote browser, iPhone/iPad, or Android work -Start with `docs/TAILNET_REMOTE.md`. Tailscale Serve is the access boundary. Never enable Funnel or -open a public firewall port for development. +Start with `docs/TAILNET_REMOTE.md`. Browser and iPhone/iPad access use the responsive React/PWA +client; Android uses the React/Capacitor wrapper. Tailscale Serve is the access boundary. Never +enable Funnel or open a public firewall port for development. ## 4. Verify a change diff --git a/docs/ELECTRON_STABILIZATION_SPRINT.md b/docs/ELECTRON_STABILIZATION_SPRINT.md new file mode 100644 index 00000000..1cbbc25a --- /dev/null +++ b/docs/ELECTRON_STABILIZATION_SPRINT.md @@ -0,0 +1,378 @@ +# Electron recovery and stabilization sprint + +- Status: active; source standardization, automated proof, and unsigned macOS candidate proof complete +- Baseline: `origin/main` at `49442848e0e07558de8033d894e647eb68691ddb` +- Primary product: Electron shell plus the React renderer +- Primary platform: macOS +- Supported release platform: Linux desktop +- Compatibility surfaces: Tailnet browser/PWA and React/Capacitor Android +- Deferred: native Swift macOS and iOS clients + +## Outcome + +Restore one unambiguous, releasable T4 product around the existing Electron and +React implementation. Preserve the current T4-owned host, OMP authority boundary, +wire protocol, browser workspace, and recent product work. Port only validated +client behavior that Electron missed, prove the installed application against a +real runtime, remove the abandoned Flutter product path, and prepare a new stable +Electron release. + +This is a recovery sprint, not a visual rewrite or a new-client project. Swift +planning resumes only after the Electron release and rollback are stable. + +## Execution evidence + +- Exact baseline `49442848e0e07558de8033d894e647eb68691ddb` completed all required GitHub CI + jobs successfully. +- PRs #130 and #146 were closed as superseded; independent heartbeat PR #145 was rebased, + reverified, and merged as `0eb07497f4345254134965e666d337dff438058a`. +- Node 24.13.1 and pnpm 11.10.0 pass `pnpm check`, the full 16-workspace test and build graph, + 32 Playwright scenarios, 49 packaging contracts, the bounded 10k-history proof, and 20 + consecutive reconnect cycles. +- Electron/React is now the sole active desktop product graph. Flutter source, duplicate CI lanes, + build commands, and demo deployment were removed; the historical migration material is archived. +- The public demo builds from the same React renderer used by Electron and validates its `/demo/` + containment contract. +- Exact-head CI for `59f1926b3e800eaf692ebaefcd790655a814dc04` passed core, Android debug, + tooling, cluster, official OMP proof on Linux x64, Linux arm64, and macOS arm64, legacy bridge + continuity, and the aggregate verifier. +- The unsigned arm64 v0.1.31 candidate produced a 189 MB DMG and 184 MB ZIP, passed ZIP package + inspection with 956 ASAR entries, and bundled executable arm64 `t4-host` and `omp` binaries. + Its exact app bundle reports v0.1.31, launched with native macOS window chrome, connected to + Local OMP, and loaded the live 1,000-session inventory and host controls. +- Remaining evidence is release-bound: protected signed artifacts, a real prompt round trip, Linux + package launch, Android native smoke, and physical Tailnet Safari proof. No release or tag is + authorized by this document. + +## Current state at planning time + +| Evidence | Current state | Sprint consequence | +|---|---|---| +| Public release | v0.1.28 is the latest GitHub release and contains Electron macOS/Linux, React/Capacitor Android, updater metadata, and checksums | Use it as installed-product rollback evidence, not as source to reset onto | +| Current main | Electron, React, Flutter, standalone `t4-host`, and the current OMP wire coexist | Keep current host/protocol work; remove only obsolete client duplication | +| Release workflow | Main still builds Electron macOS/Linux and React/Capacitor Android | Repair and strengthen this existing route rather than adopting PR #146 | +| Draft PR #146 | Deletes Electron and makes Flutter the desktop release | Close as superseded before the sprint PR becomes reviewable | +| PR #130 | Mixes a Flutter macOS launcher with a Flutter endpoint-precedence fix and a small host-contract edit | Do not merge wholesale; salvage only independently valid non-Flutter behavior | +| PR #145 | Focused remote-heartbeat host policy fix | May merge independently; rebase the sprint afterward | +| PR #144 | Site and deployment replacement touching release/site consistency | Coordinate its final site copy and release metadata with this sprint | +| Main CI | `465f0fa` was the last observed complete green main run; `4944284` was still running when this plan was written | Begin implementation only from an exact green main commit | +| Version tags | v0.1.29 and v0.1.30 exist only as local tags in this checkout, not public origin tags/releases | Prefer a fresh unambiguous stabilization version, currently expected to be v0.1.31 | + +The primary checkout contains unrelated host-contract edits and is not a sprint +workspace. Use a clean worktree and preserve those edits. + +## Product authority after the sprint + +```text +macOS / Linux + Electron shell + | + +-- React workspace renderer + +-- native window, browser, terminal, files, updates, secure storage + +-- local Unix WebSocket transport + | + v + t4-host ---> pinned OMP authority + +iPhone / iPad / Android / browser compatibility + React responsive client over a paired Tailnet host +``` + +The client never becomes runtime authority. `t4-host` continues to own the generic +host and wire boundary, while OMP remains authoritative for sessions, locks, +execution, credentials, tools, and durable transcript truth. + +## Sprint guardrails + +- Do not implement Swift during this sprint. +- Do not reset to v0.1.28 or discard host/protocol changes made after it. +- Do not merge PR #146 or delete Electron before installed Electron proof passes. +- Do not continue Flutter feature development except a security fix required for + an already distributed artifact. +- Do not port Flutter code line by line. Recover observable behavior and tests. +- Do not adopt an unmerged Flutter-only feature unless current main's product + contracts or `FEATURE_MATRIX.md` require it. +- Do not weaken local Unix-socket, credential, browser-profile, path-containment, + prompt-lease, or runtime-ownership boundaries to simplify recovery. +- Do not call daemon health a verified user experience without a real session + round trip in an installed app. +- Production release and tag creation remain a separate authorized boundary. + +## Delivery shape + +Use one long-running draft pull request for the coherent Electron recovery. Keep +the branch checkpointed with focused commits, but avoid multiple overlapping +client PRs. Flutter deletion belongs near the end of the same draft only after the +Electron gates pass on that exact branch. + +Focused independent changes such as PR #145 may merge first. Rebase the sprint +after each such merge and require fresh exact-head CI before final review. + +## Work packages + +### E0 - Freeze direction and establish a clean baseline + +1. Wait for exact current-main CI to complete successfully or diagnose it before + branching implementation work. +2. Close PR #146 as superseded by the Electron recovery direction. +3. Split, close, or supersede PR #130. Retain only a host or compatibility fix that + reproduces outside Flutter and passes its own focused test. +4. Record the disposition of PRs #144 and #145 and rebase after any merge. +5. Open the Electron stabilization draft from exact green `origin/main`. +6. Run the unmodified baseline with Node 24.13.1 and pnpm 11.10.0: + +```sh +mise exec node@24.13.1 -- pnpm install --frozen-lockfile +mise exec node@24.13.1 -- pnpm check +mise exec node@24.13.1 -- pnpm test +mise exec node@24.13.1 -- pnpm build:web +mise exec node@24.13.1 -- pnpm build:desktop +mise exec node@24.13.1 -- pnpm test:e2e +mise exec node@24.13.1 -- pnpm test:packaging +``` + +Gate E0: the exact baseline commit, tool versions, raw command results, cache state, +and failures are recorded under `.artifacts/electron-stabilization//`. +Failures become tracker rows; they are not hidden by changing the test first. + +### E1 - Turn the feature map into a parity ledger + +Audit current main, not every historical Flutter branch. For each capability: + +1. Identify the host/wire authority and required capability. +2. Identify current Electron/React implementation and tests. +3. Inspect Flutter only for a user-visible contract or failure case worth keeping. +4. Classify the row as verified, missing, stale claim, intentional compatibility + limitation, or obsolete Flutter-only behavior. +5. Add an exact automated and physical proof requirement. + +Start with `docs/ELECTRON_STABILIZATION_TRACKER.csv`. The highest-risk audit targets +are: + +- host selection, local discovery, pairing, reconnect, and profile precedence; +- tail-first transcript pagination, wheel/scroll anchoring, and encrypted cache; +- capability-derived slash commands, modes, models, reasoning, fast tier, and + prompt/operation gating; +- session create/rename/archive/restore/delete and stale revision handling; +- approvals, user questions, plans, confirmations, attention, and agent control; +- files, review, terminal, browser, working set, settings, usage, broker status, + diagnostics, and update lifecycle; +- wide desktop, narrow touch browser, and Android compatibility behavior. + +Gate E1: every launch-priority `FEATURE_MATRIX.md` row has a stable Electron gap ID, +current source evidence, status, and next proof. A roadmap row alone is not treated +as shipped behavior. + +### E2 - Restore product and documentation authority + +Make the repository describe one active product before deleting code: + +- `PRODUCT_BRIEF.md`, `README.md`, `PRODUCT.md`, `DESIGN.md`, and development docs + identify Electron/React as the primary client. +- Flutter is labeled frozen and pending removal until the final cleanup commit. +- Root `dev`, build, package, performance, and release commands point to Electron. +- The public site and release metadata describe the artifacts that are actually + built from the branch. +- The Android and browser paths are labeled compatibility clients with explicit + reduced/unavailable states rather than desktop parity. +- The Swift proposal is retained outside the active product contract as deferred + follow-up material. + +Gate E2: release-consistency mutation tests fail when any product surface, package +version, download record, command, or client identity drifts back to Flutter. + +### E3 - Stabilize Electron host and connection lifecycle + +Exercise and repair the complete local-first path: + +- discover the bundled `t4-host` and compatible OMP authority; +- install/start/inspect/restart/stop the per-user service safely; +- connect through the local Unix WebSocket without opening a public listener; +- preserve exact profile and root ownership; fail closed on conflicts; +- support paired remote hosts without silently replacing the selected host; +- reconnect after transport loss and host restart without duplicate commands; +- preserve prompt leases, revision checks, command correlation, and outcome-unknown + behavior; +- show actionable redacted diagnostics for missing, incompatible, starting, + unhealthy, and version-skewed runtimes; +- keep the long real-profile cold-start window distinct from a deadlock. + +Gate E3: focused desktop lifecycle tests, host integration tests, the legacy bridge +continuity gate, official OMP packaged proof, and a real local Mac session round +trip all pass from the same commit. + +### E4 - Converge the React workspace on current contracts + +Repair only validated gaps, in dependency order: + +1. Host/profile and project/session inventory. +2. Transcript open, paging, live stream, reconnect, cache, and scroll anchoring. +3. Composer, attachments, command/model/mode controls, dispatch, steering, and + cancellation. +4. Approval/question/plan/confirmation and attention settlement. +5. Agent view, activity, usage, and status truthfulness. +6. Files, turn review, artifacts, transcript search, and export. +7. Universal Working Set and the shared action/Quick Open registry. + +Use existing host-wire fixtures and production commands. Do not reimplement a +Flutter decoder or introduce a second client protocol. + +Gate E4: focused unit/integration tests and Playwright exercise loading, empty, +offline, observer, read-only, stale, streaming, completed, cancelled, failed, and +reconnected states. A 10k-history run remains bounded and does not stall the +composer or session switch. + +### E5 - Prove native desktop surfaces + +Treat the Electron main process and native surfaces as product work, not packaging +plumbing: + +- Browser workspace: trusted profiles, navigation, capture, input, downloads, + uploads, permission dialogs, handoff, crashes, and bounded context capture. +- Terminal: PTY lifecycle, tabs, resize, backpressure, paste guard, reconnect, and + exact session authority. +- Files and review: native pickers, remote-safe paths, save conflicts, diff/comment + actions, confirmations, and artifact reads. +- Application shell: traffic lights, titlebar, menus, shortcuts, multi-window + focus, deep links, notifications, accessibility, and state restoration. +- Security: context isolation, narrow preload/IPC, origin checks, navigation and + popup policy, no wildcard `postMessage`, redaction, and no credential exposure. +- Updates: signed metadata, download/apply/restart, rollback, and failure recovery. + +Gate E5: unit tests plus a real macOS Electron window verify OS-drawn chrome and +native interactions. Browser-only screenshots are insufficient for these checks. + +### E6 - Stabilize compatibility clients + +Keep the shared React client usable without expanding mobile scope: + +- Tailnet-only Safari/PWA on iPhone and iPad; +- React/Capacitor Android APK with secure credentials and signed-update checks; +- paired-host selection takes precedence over unavailable local runtime state; +- touch controls remain reachable at 320, 360, and 390 pixel widths; +- file/image pickers, safe areas, resume/reconnect, model selection, prompt round + trip, and shared transcript convergence work on physical or native clients; +- Funnel remains disabled. + +Gate E6: Android debug CI, signed APK inspection in the protected release lane, +physical/native Android smoke, and Tailnet Safari touch proof pass. Desktop-only +features show an explicit unavailable reason. + +### E7 - Remove Flutter and simplify the repository + +Only after E3 through E6 pass on the branch: + +- remove `apps/flutter` and the Dart/Flutter lockfiles, packages, generated native + runners, tests, coverage tooling, and build scripts; +- remove Flutter CI classifiers and the `flutter`, `flutter-android`, and + `flutter-apple` jobs; +- remove Flutter packaging, demo, site, release, and maintainer paths; remove live + Flutter provenance requirements while preserving historical provenance records; +- retire `docs/FLUTTER_MIGRATION_GOAL.md` and `docs/FLUTTER_STAGE1_PROOF.md` while + preserving useful behavioral contracts in the parity tracker and Git history; +- remove Flutter-specific product copy and compatibility assertions; +- keep the React/Capacitor Android path and public site; +- ensure no active source imports or invokes deleted Flutter artifacts. + +Gate E7: an active-tree scan, release consistency, provenance, CI classification, +documentation commands, and package manager checks contain no live Flutter product +reference. Historical changelogs and provenance may retain clearly historical +references. + +### E8 - Make Electron release proof required in CI + +The final required check set should include: + +- core lint, typecheck, all non-Flutter tests, production builds, Playwright, and + packaging contracts; +- T4/OMP bridge continuity and official packaged OMP gates; +- tooling, provenance, release-consistency, and security mutation tests; +- Android compatibility debug build; +- Linux Electron package build and inspection; +- macOS Electron unsigned package/smoke on pull requests when desktop, packaging, + runtime, or release paths change; +- the existing cluster lanes only where path classification selects them; +- a fail-closed aggregate `verify` check over every selected lane. + +Gate E8: fresh exact-head CI passes after the Flutter deletion commit. No required +check may be green merely because its deleted Flutter job was skipped. + +### E9 - Installed release candidate and stabilization + +Prepare a fresh release version, expected to be v0.1.31 unless the public version +sequence changes before cutover. Do not reuse ambiguous local-only v0.1.29 or +v0.1.30 tags. + +Run the complete release contract: + +1. Build and install the signed/notarized macOS DMG and ZIP. +2. Download the DMG through a browser, copy the app to Applications, and launch it + under Gatekeeper without clearing quarantine. +3. Prove local discovery, session inventory, transcript, prompt, stream, + cancellation, reconnect, restart recovery, browser, terminal, files/review, and + update behavior from the installed app. +4. Prove a large real profile separately from a disposable lifecycle profile. +5. Build and inspect Linux `.deb`, AppImage, and updater metadata. +6. Build and inspect the signed Android compatibility APK. +7. Prove two-client convergence between installed Electron and a Tailnet touch + client. +8. Verify the exact public artifact set, checksums, immutable source commit, + compatibility matrix, site metadata, and download links. +9. Retain the v0.1.28 public artifacts and the immediately previous known-good + runtime pair as rollback. + +Gate E9: Wolfgang explicitly authorizes production release/tag publication after +reviewing the installed-artifact evidence. Merge readiness alone is not release +authorization. + +## Critical path and parallel lanes + +```text +E0 baseline and PR disposition + | + v +E1 parity ledger -----> E2 product authority + | + +-------> E3 host/lifecycle --------+ + +-------> E4 React workspace -------+--> E7 Flutter removal + +-------> E5 native surfaces -------+ | + +-------> E6 compatibility clients -+ v + E8 exact-head CI + | + v + E9 release candidate +``` + +E3 through E6 can proceed independently once E1 fixes their contracts. E7 is the +convergence point; no lane deletes shared Flutter material early. + +## Definition of stable + +The sprint is complete only when all of the following are true: + +- Electron/React is the sole primary product client in source, docs, CI, packages, + release metadata, and the public site. +- Every launch-priority capability is verified or carries an explicit accepted + limitation with a user-visible reason. +- The exact branch passes focused and full tests, builds, packaging checks, OMP + continuity, official packaged-runtime proof, performance/soak, and exact-head CI. +- A signed installed Mac app completes a real user session round trip and native + browser/terminal/file workflows. +- Linux packages and Android compatibility artifacts pass their native inspection + and smoke paths. +- Tailnet-only iPhone/iPad access and multi-client convergence are verified. +- Flutter is absent from the active product tree and required CI. +- No credential, local identity path, private host detail, or secret-like value is + present in diffs or evidence. +- The release has an exact rollback pair and does not depend on local unpushed tags. +- Swift work remains deferred and has not complicated the stabilization branch. + +## Sprint artifacts + +- `docs/ELECTRON_STABILIZATION_TRACKER.csv`: live capability and proof ledger. +- `.artifacts/electron-stabilization//baseline.json`: exact tools and source. +- `.artifacts/electron-stabilization//test-results/`: raw bounded results. +- `.artifacts/electron-stabilization//installed-macos/`: package and UI proof. +- `.artifacts/electron-stabilization//compatibility/`: Tailnet and Android proof. +- Updated PR description: scope, completed gates, exact commands, unresolved risks, + and release boundary. diff --git a/docs/ELECTRON_STABILIZATION_TRACKER.csv b/docs/ELECTRON_STABILIZATION_TRACKER.csv new file mode 100644 index 00000000..f955ca77 --- /dev/null +++ b/docs/ELECTRON_STABILIZATION_TRACKER.csv @@ -0,0 +1,29 @@ +"ID","Area","Acceptance contract","Current evidence","Priority","Status","Next proof" +"EL-AUTH-001","Product authority","Electron and React are the sole primary product clients across source docs CI release metadata and site","Electron React and Capacitor are the only active client graph; release consistency demo contracts and exact-head CI pass","P0","verified","Keep the aggregate CI guard required" +"EL-PR-001","Conflicting work","PR 146 is closed as superseded and cannot delete Electron","PR 146 closed as superseded by the Electron standardization sprint","P0","verified","None" +"EL-PR-002","Mixed Flutter fix","PR 130 is split closed or superseded without losing a proven non-Flutter contract","PR 130 closed after audit found its changes coupled to the abandoned Flutter path","P0","verified","None" +"EL-RUN-001","Local host lifecycle","Installed Electron safely discovers installs starts inspects restarts and stops bundled t4-host","Unsigned v0.1.31 Electron candidate launched with native chrome connected to Local OMP and loaded the live 1000-session inventory and host controls","P0","native-verified","Complete real prompt round trip without mutating an existing user session" +"EL-RUN-002","OMP authority","Electron works with the exact released T4 host and pinned OMP authority without assuming client truth","Sprint exact-head legacy continuity and official packaged OMP gates pass on Linux x64 Linux arm64 and macOS arm64","P0","verified","Keep the authority gates required" +"EL-CONN-001","Host precedence","An explicitly paired host is not replaced by unavailable local runtime state","Current React and Electron behavior requires audit; PR 130 proves only Flutter changes","P0","audit-needed","Add focused React or desktop reproduction" +"EL-CONN-002","Reconnect","Transport loss and host restart resume once without duplicate commands or transcript entries","Twenty-drop soak and reconnect failure matrix pass","P0","automated-verified","Complete installed session restart proof" +"EL-PROT-001","Wire conformance","The production TypeScript client consumes canonical host-wire fixtures and rejects invalid frames","Host-wire protocol client fixture and mutation suites pass on the standardized graph","P0","verified","Require exact-head CI" +"EL-SESS-001","Session inventory","Create attach rename archive restore terminate and delete converge across two clients with stale revision refusal","React runtime and release gate describe these flows","P0","source-evidence","Playwright plus two-client real host proof" +"EL-TRANS-001","Transcript tail","Cold open paints a bounded newest page then prepends history without moving reading anchor or live cursor","React paging tests and the bounded 10k-history E2E proof pass","P0","automated-verified","Confirm wheel anchor in installed Electron window" +"EL-TRANS-002","Transcript cache","Electron uses a bounded encrypted display-only cache and never authorizes writes from cache","Projection cache and Electron secure storage require joint audit","P0","audit-needed","Corruption stale and offline cache tests" +"EL-COMP-001","Composer","Draft attachments models reasoning slash commands modes prompt leases steering follow-ups and cancellation obey negotiated capabilities","React client and host operation suites plus Playwright composer paths pass","P0","automated-verified","Complete real installed prompt round trip" +"EL-ATTN-001","Attention","Approvals questions plans confirmations and outcomes settle against current indexed authority","Attention projection and React actions exist","P0","source-evidence","Stale offline observer and expiry integration proof" +"EL-AGENT-001","Agent operations","Agent hierarchy progress selection cancellation and attention remain bounded and authoritative","Agent view and host projections exist","P1","source-evidence","10k hierarchy and live cancel proof" +"EL-FILE-001","Files","File search preview and writes enforce remote-safe paths revisions confirmations and conflict handling","React file surfaces and host path contracts exist","P0","source-evidence","Traversal conflict and real write proof" +"EL-REVIEW-001","Review","Turn diffs artifacts comments and keep or discard actions are lazy bounded and confirmation-gated","Turn review and artifact code exists","P1","source-evidence","Binary missing huge stale and applied proof" +"EL-TERM-001","Terminal","Native PTY tabs handle input resize paste guard backpressure exit restart and reconnect","Electron and React terminal tests exist","P0","source-evidence","Real installed terminal round trip" +"EL-BROW-001","Browser workspace","Native browser profiles navigation capture input download upload permission crash and context boundaries remain isolated","Large Electron browser implementation and tests exist","P0","source-evidence","Real window profile isolation and security smoke" +"EL-WORK-001","Working set","Files transcript review terminal selection and browser accessibility capture share one bounded reviewed prompt tray","ADR 018 and React actions exist","P1","source-evidence","Cross-source settlement and redaction proof" +"EL-SET-001","Settings and diagnostics","Typed settings usage broker provider and diagnostics surfaces never project secrets or unrecognized metadata","React settings and host contracts exist","P1","audit-needed","Capability schema and redacted export proof" +"EL-UPD-001","Desktop updates","Signed metadata download apply restart failure and rollback work from packaged Electron","Electron updater and release inspection code exist","P0","source-evidence","Installed signed update proof" +"EL-MAC-001","macOS package","DMG and ZIP are Developer ID signed notarized stapled Gatekeeper accepted and bundle exact t4-host","Unsigned v0.1.31 DMG and ZIP built; ZIP inspection found 956 ASAR entries and executable arm64 t4-host plus OMP; exact candidate app launched natively","P0","unsigned-native-verified","Build and inspect signed v0.1.31 artifacts on protected runner" +"EL-LINUX-001","Linux package","Deb AppImage and latest-linux metadata are internally consistent and launch installed Electron","Release workflow and inspectors exist","P1","source-evidence","Native Linux package build inspect and launch" +"EL-MOBILE-001","Android compatibility","React Capacitor APK preserves secure credentials paired host touch reachability and signed update checks","Android debug and release lanes exist","P1","audit-needed","Physical or native smoke and signed artifact inspection" +"EL-MOBILE-002","Tailnet touch client","iPhone and iPad browser or PWA reconnect and converge through Tailscale Serve with Funnel off","Release gate and prior dogfood path exist","P1","external-proof","Physical Safari round trip at narrow widths" +"EL-CI-001","Required checks","Exact-head verify fails closed over every selected Electron host tooling packaging Android and platform lane","Exact sprint head passed core Android tooling cluster three-platform official OMP legacy continuity and aggregate verify jobs","P0","verified","Keep exact-head verification required" +"EL-REMOVE-001","Flutter removal","No live Dart Flutter client build CI docs demo release or provenance path remains","Flutter client and dedicated scripts removed; migration documents archived; source scan and exact-head CI pass","P0","verified","None" +"EL-REL-001","Release identity","A fresh unambiguous version has exact source runtime artifacts checksums site metadata and rollback","Source contracts align to v0.1.31 and the unsigned Mac DMG ZIP and app bundle passed local package and native launch proof; no tag or release exists","P0","candidate-built","Complete exact-head CI and request release authorization" diff --git a/docs/MACOS_SIGNING.md b/docs/MACOS_SIGNING.md index 6188e84d..33e3b48a 100644 --- a/docs/MACOS_SIGNING.md +++ b/docs/MACOS_SIGNING.md @@ -42,29 +42,9 @@ Secrets are available only to the protected release job. Pull requests and ordin ## Local commands -### Flutter migration development +### Electron application -Flutter macOS `Debug` builds deliberately omit Keychain Sharing so contributors can build and run -the app without joining the Apple Developer team: - -```bash -pnpm dev:flutter -- -d macos -``` - -These builds use a process-local credential store and show an **Unsigned development** strip. -Saved host metadata remains available, but paired device credentials are never written to disk and -must be paired again after the app exits. `Profile` and `Release` use separate entitlements that -retain Keychain Sharing and therefore require Apple development signing. Never add a plaintext -fallback, remove the warning, or remove Keychain Sharing from the signed configurations. - -Signed Flutter milestone artifacts must be produced by a protected maintainer workflow or signing -machine. Contributors do not need the certificate, provisioning profile, or Apple team membership -to run those artifacts. - -### Released Electron application - - -Contributors can build the released Electron application without Apple credentials: +Contributors can build the Electron application without Apple credentials: ```bash pnpm package:mac:unsigned diff --git a/docs/OWNERSHIP.md b/docs/OWNERSHIP.md index c15207f5..b5f1dfa7 100644 --- a/docs/OWNERSHIP.md +++ b/docs/OWNERSHIP.md @@ -8,13 +8,13 @@ integration owner or land the smaller shared contract first. | Path | Primary owner | | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | -| `apps/flutter/**` | Flutter client and provider owner | | `packages/host-wire/**`, network-frame changes in `packages/protocol/**` | Protocol owner | | `packages/host-service/**`, `packages/host-daemon/**` | T4 Local systems owner | | `packages/client/**`, `packages/fixture-server/**` | Client data and fixtures owner | | `packages/remote/**`, `packages/service-manager/**` | Pairing, remote connection, and native service-lifecycle owner | -| `apps/web/**`, `packages/ui/**`, visible copy/assets/screenshots | Compatibility client experience owner | -| `apps/desktop/**` | Compatibility desktop systems owner; coordinate visible UI changes with the client owner | +| `apps/web/**`, `packages/ui/**`, visible copy/assets/screenshots | Canonical React client experience owner | +| `apps/desktop/**` | Primary Electron desktop systems owner; coordinate visible UI changes with the client owner | +| `apps/mobile/**` | React/Capacitor Android compatibility owner | | Root manifests, workspace configuration, and `pnpm-lock.yaml` | Integration owner | | `docs/adr/**`, architecture, licenses, notices, and provenance | Architecture/provenance owner | diff --git a/docs/adr/014-transcript-tail-pagination.md b/docs/adr/014-transcript-tail-pagination.md index e94e1233..1cf8fec4 100644 --- a/docs/adr/014-transcript-tail-pagination.md +++ b/docs/adr/014-transcript-tail-pagination.md @@ -55,9 +55,10 @@ continue to append normally. - A stale paging generation is an explicit retryable history error; it does not disturb the live transcript. -## Flutter handoff +## Offline compatibility-client follow-up -Flutter should use the same two-lane contract rather than copy the web implementation: +Any future offline cache should use the same two-lane contract rather than copy the React +implementation: ```text open screen diff --git a/docs/FLUTTER_MIGRATION_GOAL.md b/docs/archive/flutter-migration/FLUTTER_MIGRATION_GOAL.md similarity index 99% rename from docs/FLUTTER_MIGRATION_GOAL.md rename to docs/archive/flutter-migration/FLUTTER_MIGRATION_GOAL.md index a4e56d49..d321aec9 100644 --- a/docs/FLUTTER_MIGRATION_GOAL.md +++ b/docs/archive/flutter-migration/FLUTTER_MIGRATION_GOAL.md @@ -1,4 +1,6 @@ -# T4 Flutter Rewrite Goal Prompt +# T4 Flutter Rewrite Goal Prompt (archived) + +> Historical record only. This migration was abandoned during the Electron standardization sprint. Use this prompt to start or resume the T4 Flutter rewrite in OMP goal mode. diff --git a/docs/FLUTTER_STAGE1_PROOF.md b/docs/archive/flutter-migration/FLUTTER_STAGE1_PROOF.md similarity index 95% rename from docs/FLUTTER_STAGE1_PROOF.md rename to docs/archive/flutter-migration/FLUTTER_STAGE1_PROOF.md index c945c0d3..cc134504 100644 --- a/docs/FLUTTER_STAGE1_PROOF.md +++ b/docs/archive/flutter-migration/FLUTTER_STAGE1_PROOF.md @@ -1,4 +1,6 @@ -# Flutter Stage 1 Proof Contract +# Flutter Stage 1 Proof Contract (archived) + +> Historical record only. This migration was abandoned during the Electron standardization sprint. ## Scope diff --git a/docs/OMP_T4_CAPABILITY_AUDIT.md b/docs/archive/flutter-migration/OMP_T4_CAPABILITY_AUDIT.md similarity index 99% rename from docs/OMP_T4_CAPABILITY_AUDIT.md rename to docs/archive/flutter-migration/OMP_T4_CAPABILITY_AUDIT.md index 7e7b52ae..9d8afdec 100644 --- a/docs/OMP_T4_CAPABILITY_AUDIT.md +++ b/docs/archive/flutter-migration/OMP_T4_CAPABILITY_AUDIT.md @@ -1,4 +1,7 @@ -# OMP to T4 Capability Audit +# OMP to T4 Capability Audit (archived Flutter-era snapshot) + +> Historical record only. Current Electron evidence is tracked in +> `docs/ELECTRON_STABILIZATION_TRACKER.csv`. **Audit date:** 2026-07-20, refreshed 2026-07-21 after T4 PRs #109, #111, #113, and #114 diff --git a/docs/OMP_T4_CAPABILITY_TRACKER.csv b/docs/archive/flutter-migration/OMP_T4_CAPABILITY_TRACKER.csv similarity index 100% rename from docs/OMP_T4_CAPABILITY_TRACKER.csv rename to docs/archive/flutter-migration/OMP_T4_CAPABILITY_TRACKER.csv diff --git a/docs/archive/flutter-migration/README.md b/docs/archive/flutter-migration/README.md new file mode 100644 index 00000000..738d69dd --- /dev/null +++ b/docs/archive/flutter-migration/README.md @@ -0,0 +1,8 @@ +# Archived Flutter migration material + +These files preserve the abandoned 2026 Flutter migration's goals, proof contract, and capability +audit for historical reference. They do not describe the active T4 product or release path. + +T4 is standardized on the Electron desktop shell and canonical React renderer. The responsive +React browser/PWA and React/Capacitor Android builds are compatibility clients. Native Swift work +is deferred until the Electron line is stable. diff --git a/e2e/site-mobile-docs.spec.ts b/e2e/site-mobile-docs.spec.ts index da4544f4..7ad34b78 100644 --- a/e2e/site-mobile-docs.spec.ts +++ b/e2e/site-mobile-docs.spec.ts @@ -130,7 +130,7 @@ test("offers the Android APK without hiding desktop downloads", async ({ page }) await expect(androidDownload).toBeVisible(); await expect(androidDownload).toHaveAttribute( "href", - "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-android.apk", + "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-android.apk", ); await expect(page.getByRole("link", { name: /Linux/u }).first()).toBeVisible(); await expect(page.getByRole("link", { name: /macOS/u }).first()).toBeVisible(); diff --git a/infra/site/cloudformation.yml b/infra/site/cloudformation.yml index 94f6240d..2c9a8ce6 100644 --- a/infra/site/cloudformation.yml +++ b/infra/site/cloudformation.yml @@ -100,7 +100,7 @@ Resources: Properties: ResponseHeadersPolicyConfig: Name: t4-code-demo-security - Comment: Security headers for the Flutter web demo + Comment: Security headers for the read-only React web demo CustomHeadersConfig: Items: - Header: Cross-Origin-Opener-Policy @@ -111,7 +111,7 @@ Resources: Value: camera=(), geolocation=(), microphone=(), payment=(), usb=() SecurityHeadersConfig: ContentSecurityPolicy: - ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; connect-src 'self' https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests" + ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; connect-src 'self' https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests" Override: true ContentTypeOptions: Override: true diff --git a/package.json b/package.json index b3f80664..5c443500 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/root", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "scripts": { @@ -8,17 +8,12 @@ "dev:web": "vp run --filter @t4-code/web dev", "dev:desktop": "vp run --filter @t4-code/desktop dev", "dev:site": "vp run --filter @t4-code/site dev", - "dev:flutter": "node scripts/run-flutter.mjs", "serve:tailnet": "node scripts/tailnet-gateway.mjs", "build": "vp run -r build", "build:web": "pnpm --filter @t4-code/web build", "build:desktop": "pnpm --filter @t4-code/desktop build", "build:host": "pnpm --filter @t4-code/host-daemon build:binary", "build:site": "pnpm --filter @t4-code/site build", - "build:flutter:web": "vp run --filter @t4-code/flutter build:web", - "build:flutter:macos": "pnpm build:host && vp run --filter @t4-code/flutter build:macos", - "build:flutter:android": "vp run --filter @t4-code/flutter build:android", - "build:flutter:ios": "vp run --filter @t4-code/flutter build:ios", "build:demo": "node scripts/build-demo.mjs", "deploy:site": "node scripts/deploy-site.mjs", "deploy:demo": "node scripts/deploy-demo.mjs", @@ -33,16 +28,13 @@ "inspect:package": "node scripts/inspect-package.mjs", "inspect:dmg": "node scripts/inspect-macos-dmg.mjs", "test:packaging": "node --test scripts/packaging.test.mjs scripts/inspect-linux-update.test.mjs scripts/inspect-macos-dmg.test.mjs scripts/inspect-macos-release.test.mjs", - "test:tooling": "node --test scripts/benchmark-omp-codex-transport.test.mjs scripts/check-adr-numbering.test.mjs scripts/check-flutter-coverage.test.mjs scripts/check-host-ownership.test.mjs scripts/check-release-consistency.test.mjs scripts/check-release-publication.test.mjs scripts/check-provenance.test.mjs scripts/deploy-demo.test.mjs scripts/deploy-site.test.mjs scripts/dispatch-site-deployment.test.mjs scripts/generate-release-manifest.test.mjs scripts/perf/perf.test.mjs scripts/reconcile-release-assets.test.mjs scripts/t4-maintainer-contract.test.mjs scripts/t4-maintainer-integration.test.mjs scripts/t4-maintainer-omp-publish.test.mjs scripts/test-temporary-directory.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs scripts/wait-for-exact-ci.test.mjs scripts/wait-for-release-assets.test.mjs", + "test:tooling": "node --test scripts/benchmark-omp-codex-transport.test.mjs scripts/check-adr-numbering.test.mjs scripts/check-host-ownership.test.mjs scripts/check-release-consistency.test.mjs scripts/check-release-publication.test.mjs scripts/check-provenance.test.mjs scripts/deploy-demo.test.mjs scripts/deploy-site.test.mjs scripts/dispatch-site-deployment.test.mjs scripts/generate-release-manifest.test.mjs scripts/perf/perf.test.mjs scripts/reconcile-release-assets.test.mjs scripts/t4-maintainer-contract.test.mjs scripts/t4-maintainer-integration.test.mjs scripts/t4-maintainer-omp-publish.test.mjs scripts/test-temporary-directory.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs scripts/wait-for-exact-ci.test.mjs scripts/wait-for-release-assets.test.mjs", "check:release": "node scripts/check-release-consistency.mjs", "check:provenance": "node scripts/check-provenance.mjs", "lint": "vp lint --deny-warnings", "check": "pnpm check:release && pnpm check:provenance && pnpm lint && pnpm typecheck", "typecheck": "vp run -r typecheck", - "check:flutter": "vp run --filter @t4-code/flutter typecheck", "test": "vp run -r test", - "test:flutter": "vp run --filter @t4-code/flutter test", - "test:flutter:device": "pnpm --filter @t4-code/flutter test:device", "test:e2e": "pnpm build:web && pnpm build:site && playwright test", "test:soak": "pnpm --filter @t4-code/client exec vp test run test/projection.test.ts -t \"retains a 10k snapshot\" && pnpm build:web && playwright test e2e/remote-app.spec.ts --grep \"@soak\"", "test:protocol": "vp run --filter @t4-code/protocol --filter @t4-code/fixture-server test", diff --git a/packages/client/package.json b/packages/client/package.json index c960a2b3..fa95d4f3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/client", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/packages/client/src/omp-client-frames.ts b/packages/client/src/omp-client-frames.ts index f056747a..427f0928 100644 --- a/packages/client/src/omp-client-frames.ts +++ b/packages/client/src/omp-client-frames.ts @@ -84,7 +84,7 @@ export function sendClientHello( } catch { fatal(); return; } const encoded = encodeOutgoingMessage(provider, { kind: "hello", - client: options.client ?? { name: "t4-code", version: "0.1.30", build: "client", platform: "electron" }, + client: options.client ?? { name: "t4-code", version: "0.1.31", build: "client", platform: "electron" }, requestedFeatures: [...(options.requestedFeatures ?? ["resume"])], savedCursors, ...(options.capabilities === undefined ? {} : { capabilities: options.capabilities }), diff --git a/packages/cluster-server/package.json b/packages/cluster-server/package.json index 7b7b3a24..bb7e6951 100644 --- a/packages/cluster-server/package.json +++ b/packages/cluster-server/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/cluster-server", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "description": "Stateless Kubernetes-backed omp-app/1 cluster gateway", diff --git a/packages/cluster-server/src/gateway.ts b/packages/cluster-server/src/gateway.ts index 76b3f5e0..ddc54f52 100644 --- a/packages/cluster-server/src/gateway.ts +++ b/packages/cluster-server/src/gateway.ts @@ -102,7 +102,7 @@ export class ClusterGateway { this.#connector = options.connector; this.#mutations = options.mutations; this.#ci = options.ciProvider; - this.#version = options.appserverVersion ?? "0.1.30"; + this.#version = options.appserverVersion ?? "0.1.31"; this.#build = options.appserverBuild ?? "cluster"; } get connectionCount(): number { return this.#connections.size; } diff --git a/packages/cluster-server/src/main.ts b/packages/cluster-server/src/main.ts index 0ac12d7f..01dee33d 100755 --- a/packages/cluster-server/src/main.ts +++ b/packages/cluster-server/src/main.ts @@ -13,9 +13,9 @@ import { WoodpeckerProvider } from "./woodpecker.ts"; export async function runClusterServer(env: Readonly> = process.env): Promise { const config = clusterServerConfigFromEnv(env); - const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.30", namespace: config.namespace }); + const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.31", namespace: config.namespace }); const health = new ClusterServerHealth(); - const metrics = new ClusterMetrics({ component: "cluster-server", version: "0.1.30", namespace: config.namespace }); + const metrics = new ClusterMetrics({ component: "cluster-server", version: "0.1.31", namespace: config.namespace }); const ca = await loadKubernetesCa(config); const kubernetes = new KubernetesApiClient({ baseUrl: config.kubernetesBaseUrl, @@ -109,7 +109,7 @@ export async function runClusterServer(env: Readonly { try { await runClusterServer(); } catch (error) { - const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.30" }); + const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.31" }); logger.error("cluster server failed", { condition: error instanceof Error ? error.name : "unknown", result: "failure" }); process.exitCode = 1; } diff --git a/packages/cluster-server/src/pod-host-router.ts b/packages/cluster-server/src/pod-host-router.ts index db26affc..b38b43ea 100644 --- a/packages/cluster-server/src/pod-host-router.ts +++ b/packages/cluster-server/src/pod-host-router.ts @@ -61,7 +61,7 @@ export class WebSocketPodHostConnector implements PodHostConnector { socket.send(JSON.stringify({ v: "omp-app/1", type: "hello", protocol: { min: "omp-app/1", max: "omp-app/1" }, - client: { name: "cluster-server", version: "0.1.30", build: "cluster", platform: "linux" }, + client: { name: "cluster-server", version: "0.1.31", build: "cluster", platform: "linux" }, requestedFeatures: PROTOCOL_FEATURES.filter(feature => feature !== "cluster.operator"), savedCursors: [], capabilities: { client: DEVICE_CAPABILITIES.filter(capability => capability !== "ci.trigger") }, diff --git a/packages/cluster-server/src/session-host-main.ts b/packages/cluster-server/src/session-host-main.ts index 72181224..cef901c2 100755 --- a/packages/cluster-server/src/session-host-main.ts +++ b/packages/cluster-server/src/session-host-main.ts @@ -55,7 +55,7 @@ export async function runSessionHost( attentionOutcomePath: join(config.stateRoot, "attention-outcomes.json"), ompVersion: ready.ompVersion, ompBuild: ready.ompBuild, - appserverVersion: "0.1.30", + appserverVersion: "0.1.31", appserverBuild: "cluster-session", sessionAuthority: authorities.sessionAuthority, discovery: authorities.discovery, diff --git a/packages/cluster-server/test/pod-host-router.test.ts b/packages/cluster-server/test/pod-host-router.test.ts index 2c6b05f3..8b620271 100644 --- a/packages/cluster-server/test/pod-host-router.test.ts +++ b/packages/cluster-server/test/pod-host-router.test.ts @@ -11,7 +11,7 @@ const welcome = { hostId: "host-a", ompVersion: "17.0.5", ompBuild: "test", - appserverVersion: "0.1.30", + appserverVersion: "0.1.31", appserverBuild: "cluster-session", epoch: "pod-epoch", grantedCapabilities: [], diff --git a/packages/fixture-server/package.json b/packages/fixture-server/package.json index a410979a..2d157d93 100644 --- a/packages/fixture-server/package.json +++ b/packages/fixture-server/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/fixture-server", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/packages/host-daemon/package.json b/packages/host-daemon/package.json index 7e668438..544ff7d7 100644 --- a/packages/host-daemon/package.json +++ b/packages/host-daemon/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/host-daemon", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "description": "Standalone T4-owned host daemon with a thin OMP authority bridge", diff --git a/packages/host-daemon/src/cli.ts b/packages/host-daemon/src/cli.ts index b6700ebd..37bd6267 100644 --- a/packages/host-daemon/src/cli.ts +++ b/packages/host-daemon/src/cli.ts @@ -19,7 +19,7 @@ import { } from "@t4-code/host-service"; import { COMMAND_DESCRIPTORS, type ProjectId, type SessionId } from "@t4-code/protocol"; -export const T4_HOST_VERSION = "0.1.30"; +export const T4_HOST_VERSION = "0.1.31"; export const OFFICIAL_OMP_VERSION = "17.0.6"; export const OFFICIAL_OMP_BUILD = "89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6"; const PROFILE = /^[a-z0-9][a-z0-9._-]{0,63}$/u; diff --git a/packages/host-service/package.json b/packages/host-service/package.json index f607c163..c609ac8c 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/host-service", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "description": "T4-owned host service with runtime adapters over Unix WebSocket", diff --git a/packages/host-service/test/runtime-workspace-server.test.ts b/packages/host-service/test/runtime-workspace-server.test.ts index bb1f15df..65395086 100644 --- a/packages/host-service/test/runtime-workspace-server.test.ts +++ b/packages/host-service/test/runtime-workspace-server.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, setDefaultTimeout, test } from "bun:test"; import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,6 +10,8 @@ import { RawUdsWebSocket } from "./raw-uds-client.ts"; const host = hostId("runtime-workspace-test-host"); +setDefaultTimeout(30_000); + async function git(cwd: string, ...arguments_: string[]): Promise { const child = Bun.spawn(["git", "-C", cwd, ...arguments_], { stdout: "pipe", stderr: "pipe" }); const [exitCode, stdout, stderr] = await Promise.all([ diff --git a/packages/host-wire/package.json b/packages/host-wire/package.json index 444a62a4..a286026a 100644 --- a/packages/host-wire/package.json +++ b/packages/host-wire/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/host-wire", - "version": "0.1.30", + "version": "0.1.31", "type": "module", "description": "Dependency-free T4 host protocol frames and bounded decoders", "license": "MIT", diff --git a/packages/host-wire/test/app-wire.test.ts b/packages/host-wire/test/app-wire.test.ts index 18354ed1..9eba5c07 100644 --- a/packages/host-wire/test/app-wire.test.ts +++ b/packages/host-wire/test/app-wire.test.ts @@ -723,7 +723,7 @@ describe("app-wire authority", () => { test("exports the bridge schema version independently from the T4 package release", async () => { const metadata = (await Bun.file(new URL("../package.json", import.meta.url)).json()) as { version: string }; expect(APP_WIRE_VERSION).toBe("0.7.0"); - expect(metadata.version).toBe("0.1.30"); + expect(metadata.version).toBe("0.1.31"); }); test("session project wire data is opaque and live state is secret-free", () => { const providerTransport = { diff --git a/packages/host-wire/test/cluster-operator.test.ts b/packages/host-wire/test/cluster-operator.test.ts index 7c8d2c7d..2bb1c878 100644 --- a/packages/host-wire/test/cluster-operator.test.ts +++ b/packages/host-wire/test/cluster-operator.test.ts @@ -50,7 +50,7 @@ describe("cluster operator wire contract", () => { hostId: "cluster-host-uid-1", ompVersion: "17.0.5", ompBuild: "8476f4451ed95c5d5401785d279a93d3c659fac4", - appserverVersion: "0.1.30", + appserverVersion: "0.1.31", appserverBuild: "cluster", epoch: "replica-pod-uid-1", grantedCapabilities: ["sessions.read", "ci.trigger"], diff --git a/packages/model-gateway/package.json b/packages/model-gateway/package.json index 5949e8ab..1500518b 100644 --- a/packages/model-gateway/package.json +++ b/packages/model-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/model-gateway", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "description": "Credential-isolating provider gateway for T4 cluster sessions", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index a2475381..5f8691be 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/protocol", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/packages/protocol/test/distribution.test.ts b/packages/protocol/test/distribution.test.ts index 637f2655..d8e1edfa 100644 --- a/packages/protocol/test/distribution.test.ts +++ b/packages/protocol/test/distribution.test.ts @@ -166,7 +166,7 @@ describe("T4 host-wire distribution", () => { readFileSync(join(installedRoot, "package.json"), "utf8"), ) as Record; expect(installedPackage.name).toBe("@t4-code/host-wire"); - expect(installedPackage.version).toBe("0.1.30"); + expect(installedPackage.version).toBe("0.1.31"); expect(installedPackage.version).not.toBe(manifest.version); expect(installedPackage.dependencies ?? {}).toEqual({}); }); diff --git a/packages/protocol/test/fixtures/platform-boundaries.ts b/packages/protocol/test/fixtures/platform-boundaries.ts index abbe59e0..d2762430 100644 --- a/packages/protocol/test/fixtures/platform-boundaries.ts +++ b/packages/protocol/test/fixtures/platform-boundaries.ts @@ -25,7 +25,7 @@ export const androidUpdateFixtures = Object.freeze({ }, { currentVersion: "0.1.22", - latestVersion: "0.1.30", + latestVersion: "0.1.31", checkedAt: 1_721_234_567_890, phase: "available", revision: 7, @@ -33,7 +33,7 @@ export const androidUpdateFixtures = Object.freeze({ }, { currentVersion: "0.1.22", - latestVersion: "0.1.30", + latestVersion: "0.1.31", phase: "installer", revision: 8, message: "Installer opened.\nReview Android's prompt.", diff --git a/packages/remote/package.json b/packages/remote/package.json index 40d2b642..4628db2e 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/remote", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/packages/service-manager/package.json b/packages/service-manager/package.json index f1102cbe..ad1436ac 100644 --- a/packages/service-manager/package.json +++ b/packages/service-manager/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/service-manager", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/packages/ui/package.json b/packages/ui/package.json index d95d188f..9b4b6e87 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/ui", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a71ecc1d..5c2a961d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,8 +118,6 @@ importers: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) - apps/flutter: {} - apps/mobile: dependencies: '@capacitor/core': diff --git a/scripts/build-demo.mjs b/scripts/build-demo.mjs index 76b188a1..c8833cff 100644 --- a/scripts/build-demo.mjs +++ b/scripts/build-demo.mjs @@ -1,5 +1,4 @@ import { spawnSync } from "node:child_process"; -import { rmSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -20,25 +19,9 @@ export function buildDemo( const output = resolve(repoRoot, "apps/site/dist/demo"); runCommand( "pnpm", - [ - "--filter", - "@t4-code/flutter", - "exec", - "flutter", - "build", - "web", - "--base-href", - DEMO_BASE_HREF, - "--csp", - "--no-web-resources-cdn", - "--dart-define", - "T4_DEMO_MODE=true", - "--output", - output, - ], + ["--filter", "@t4-code/web", "exec", "vp", "build", "--mode", "demo", "--outDir", output], repoRoot, ); - rmSync(resolve(output, "flutter_service_worker.js"), { force: true }); } const isMain = diff --git a/scripts/check-flutter-coverage.mjs b/scripts/check-flutter-coverage.mjs deleted file mode 100644 index 67a7b905..00000000 --- a/scripts/check-flutter-coverage.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const DEFAULT_REPORT = resolve(import.meta.dirname, "../apps/flutter/coverage/lcov.info"); -const DEFAULT_MINIMUM_PERCENT = 65; - -function boundedPercentage(value, label) { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) { - throw new Error(`${label} must be a finite percentage between 0 and 100`); - } - return parsed; -} - -export function flutterLineCoverage(lcov) { - if (typeof lcov !== "string" || lcov.length === 0) throw new Error("LCOV report is empty"); - const files = new Map(); - let source; - for (const rawLine of lcov.split(/\r?\n/u)) { - if (rawLine.startsWith("SF:")) { - source = rawLine.slice(3); - if (source.length === 0) throw new Error("LCOV source path is empty"); - if (!files.has(source)) files.set(source, new Map()); - continue; - } - if (!rawLine.startsWith("DA:")) continue; - if (source === undefined) throw new Error("LCOV line data appeared before a source file"); - const match = /^DA:(\d+),(\d+)(?:,.*)?$/u.exec(rawLine); - if (match === null) throw new Error(`Malformed LCOV line data: ${rawLine}`); - const line = Number(match[1]); - const hits = Number(match[2]); - if (!Number.isSafeInteger(line) || line <= 0 || !Number.isSafeInteger(hits) || hits < 0) { - throw new Error(`Invalid LCOV line data: ${rawLine}`); - } - const lines = files.get(source); - lines.set(line, Math.max(lines.get(line) ?? 0, hits)); - } - let found = 0; - let covered = 0; - for (const lines of files.values()) { - found += lines.size; - for (const hits of lines.values()) if (hits > 0) covered += 1; - } - if (found === 0) throw new Error("LCOV report contains no executable lines"); - return { covered, found, percent: (covered / found) * 100 }; -} - -export function requireFlutterLineCoverage(lcov, minimumPercent = DEFAULT_MINIMUM_PERCENT) { - const minimum = boundedPercentage(minimumPercent, "minimum coverage"); - const result = flutterLineCoverage(lcov); - if (result.percent + Number.EPSILON < minimum) { - throw new Error( - `Flutter line coverage ${result.percent.toFixed(2)}% (${result.covered}/${result.found}) is below ${minimum.toFixed(2)}%`, - ); - } - return result; -} - -if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { - const report = process.argv[2] ?? DEFAULT_REPORT; - const minimum = process.argv[3] ?? DEFAULT_MINIMUM_PERCENT; - try { - const result = requireFlutterLineCoverage(readFileSync(report, "utf8"), minimum); - console.log(`Flutter line coverage ${result.percent.toFixed(2)}% (${result.covered}/${result.found})`); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - } -} diff --git a/scripts/check-flutter-coverage.test.mjs b/scripts/check-flutter-coverage.test.mjs deleted file mode 100644 index 08569f3f..00000000 --- a/scripts/check-flutter-coverage.test.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { flutterLineCoverage, requireFlutterLineCoverage } from "./check-flutter-coverage.mjs"; - -const REPORT = `TN: -SF:lib/a.dart -DA:1,1 -DA:2,0 -end_of_record -SF:lib/b.dart -DA:4,3 -DA:5,0 -end_of_record -`; - -test("computes line coverage across source records", () => { - assert.deepEqual(flutterLineCoverage(REPORT), { covered: 2, found: 4, percent: 50 }); -}); - -test("deduplicates repeated source lines using the highest hit count", () => { - const repeated = `${REPORT}SF:lib/a.dart\nDA:1,0\nDA:2,2\nend_of_record\n`; - assert.deepEqual(flutterLineCoverage(repeated), { covered: 3, found: 4, percent: 75 }); -}); - -test("enforces the configured minimum", () => { - assert.equal(requireFlutterLineCoverage(REPORT, 50).percent, 50); - assert.throws(() => requireFlutterLineCoverage(REPORT, 50.01), /below 50\.01%/u); -}); - -test("rejects empty and malformed reports", () => { - assert.throws(() => flutterLineCoverage(""), /empty/u); - assert.throws(() => flutterLineCoverage("DA:1,1\n"), /before a source/u); - assert.throws(() => flutterLineCoverage("SF:lib/a.dart\nDA:nope\n"), /Malformed/u); - assert.throws(() => flutterLineCoverage("SF:lib/a.dart\n"), /no executable lines/u); -}); diff --git a/scripts/check-host-ownership.test.mjs b/scripts/check-host-ownership.test.mjs index b1d11555..dbc94c77 100644 --- a/scripts/check-host-ownership.test.mjs +++ b/scripts/check-host-ownership.test.mjs @@ -37,7 +37,8 @@ test("product and ownership documents link the canonical architecture", () => { const ownership = readFileSync(join(root, "docs", "OWNERSHIP.md"), "utf8"); const architecture = readFileSync(join(root, "docs", "T4_ARCHITECTURE.html"), "utf8"); - assert.match(brief, /Flutter desktop, mobile, and web workspace/u); + assert.match(brief, /Electron desktop workspace/u); + assert.match(brief, /React compatibility client/u); assert.match(brief, /docs\/T4_ARCHITECTURE\.html/u); assert.doesNotMatch(brief, /packages\/host-service/u); assert.match(ownership, /packages\/host-wire/u); @@ -76,7 +77,7 @@ test("compatibility metadata records the artifact-backed OMP bridge", () => { assert.equal(matrix.t4Host.deploymentState, "standalone-t4-host-thin-omp-bridge"); assert.equal(matrix.t4Host.wireSchemaVersion, "0.7.0"); assert.equal(matrix.t4Host.daemonPackage, "@t4-code/host-daemon"); - assert.equal(matrix.t4Host.daemonPackageVersion, "0.1.30"); + assert.equal(matrix.t4Host.daemonPackageVersion, "0.1.31"); assert.equal(matrix.t4Host.authorityBridgeProtocol, "t4-omp-authority/1"); assert.equal(matrix.verifiedRuntime.artifacts["darwin-arm64"].releaseCodeSignature, "adhoc"); assert.equal( diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index 7694fc50..22f456e2 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -46,15 +46,6 @@ const SHA_PATTERN = /^[0-9a-f]{40}$/u; const SHA256_PATTERN = /^[0-9a-f]{64}$/u; const PATCH_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -function compareStableVersions(left, right) { - const leftParts = left.split(".").map(Number); - const rightParts = right.split(".").map(Number); - for (let index = 0; index < 3; index += 1) { - if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index]; - } - return 0; -} - export function expectedReleaseAssetNames(version) { return [ `T4-Code-${version}-android.apk`, @@ -823,15 +814,9 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { } const securityPolicy = files.get("SECURITY.md") ?? ""; - const firstSignedVersion = String(macosIdentity?.firstSignedReleaseTag ?? "").replace(/^v/u, ""); - const signedRelease = VERSION_PATTERN.test(firstSignedVersion) - ? compareStableVersions(version, firstSignedVersion) >= 0 - : false; requireText( securityPolicy, - signedRelease - ? `The macOS ${expectedTag} build is signed with Apple Developer ID and notarized by Apple` - : `The macOS ${expectedTag} build is unsigned and unnotarized`, + "Published macOS builds are signed with Apple Developer ID and notarized by Apple", "SECURITY.md", errors, ); @@ -957,18 +942,9 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { "run: go test ./...", "run: helm lint deploy/charts/t4-cluster", "android-debug:", - "flutter:", - "flutter-android:", - "flutter-apple:", - "Run Flutter iOS launch smoke test", - 'xcrun simctl install "$DEVICE_ID" build/ios/iphonesimulator/Runner.app', - 'kill -0 "$app_pid"', - "Build standalone T4 host for Flutter macOS", - "Verify bundled Flutter macOS host", - "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host", "name: verify", "if: ${{ always() }}", - "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug]", 'test "$CHANGES_RESULT" = success', 'test "$CORE_RESULT" = success', "for result in \\", diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index b049aba9..bd6e848f 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -120,7 +120,7 @@ test("pins official OMP artifacts and the Gate 0 proof contract", () => { test("rejects a tag that differs from the package version", () => { assert.ok( collectReleaseConsistencyErrors(files, "v9.9.9").some((error) => - error.includes("release tag v9.9.9 does not match v0.1.30"), + error.includes("release tag v9.9.9 does not match v0.1.31"), ), ); }); @@ -149,7 +149,7 @@ test("tagged releases reject published provenance drift", () => { for (const [field, mutate] of appWireCases) { const drifted = changedRuntime("publishedAppWire", mutate); assert.ok( - collectReleaseConsistencyErrors(drifted, "v0.1.30").some((error) => + collectReleaseConsistencyErrors(drifted, "v0.1.31").some((error) => error.includes( `published app-wire ${field} must match current app-wire for tagged releases`, ), @@ -192,7 +192,7 @@ test("tagged releases reject published provenance drift", () => { for (const [field, mutate] of runtimeCases) { const drifted = changedRuntime("publishedRuntime", mutate); assert.ok( - collectReleaseConsistencyErrors(drifted, "v0.1.30").some((error) => + collectReleaseConsistencyErrors(drifted, "v0.1.31").some((error) => error.includes( `published runtime ${field} must match current verified runtime for tagged releases`, ), @@ -204,7 +204,7 @@ test("tagged releases reject published provenance drift", () => { runtime.artifactSha256 = "0".repeat(64); }); assert.ok( - collectReleaseConsistencyErrors(extended, "v0.1.30").some((error) => + collectReleaseConsistencyErrors(extended, "v0.1.31").some((error) => error.includes( "published runtime must exactly match current verified runtime for tagged releases", ), @@ -214,15 +214,15 @@ test("tagged releases reject published provenance drift", () => { test("rejects workspace, site, README, and runtime version drift", () => { const cases = [ - ["apps/web/package.json", (text) => text.replace('"version": "0.1.30"', '"version": "0.1.3"')], + ["apps/web/package.json", (text) => text.replace('"version": "0.1.31"', '"version": "0.1.3"')], [ "apps/site/src/release.ts", - (text) => text.replace('RELEASE_TAG = "v0.1.30"', 'RELEASE_TAG = "v0.1.3"'), + (text) => text.replace('RELEASE_TAG = "v0.1.31"', 'RELEASE_TAG = "v0.1.3"'), ], - ["README.md", (text) => text.replace("Download v0.1.30", "Download v0.1.3")], + ["README.md", (text) => text.replace("Download v0.1.31", "Download v0.1.3")], [ "apps/desktop/src/target-manager.ts", - (text) => text.replace('version: "0.1.30"', 'version: "0.1.3"'), + (text) => text.replace('version: "0.1.31"', 'version: "0.1.3"'), ], [ "apps/site/src/docs/content.ts", @@ -275,7 +275,7 @@ test("rejects updater channel, stable manifest, and publication-contract drift", ".github/workflows/ci.yml", (text) => text.replace( - "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug]", "needs: [changes, core, tooling, android-debug]", ), ], @@ -519,7 +519,7 @@ test("rejects stale README release URLs while allowing historical prose", () => const staleLink = changed("README.md", (text) => `${text}\n[Old release](${oldReleaseUrl})\n`); assert.ok( collectReleaseConsistencyErrors(staleLink).some((error) => - error.includes("release URL for v0.1.3; expected v0.1.30"), + error.includes("release URL for v0.1.3; expected v0.1.31"), ), ); assert.deepEqual(collectReleaseConsistencyErrors(files), []); @@ -559,28 +559,11 @@ test("deploys release site source only after artifact publication", () => { assert.ok(ciWorkflow.includes("run: pnpm test:cluster:ci")); assert.ok(ciWorkflow.includes("run: go test ./...")); assert.ok(ciWorkflow.includes("run: helm lint deploy/charts/t4-cluster")); - assert.ok(ciWorkflow.includes("flutter:")); - assert.ok(ciWorkflow.includes("flutter-android:")); - assert.ok(ciWorkflow.includes("flutter-apple:")); - assert.ok(ciWorkflow.includes("Run Flutter iOS launch smoke test")); - assert.ok( - ciWorkflow.includes( - 'xcrun simctl install "$DEVICE_ID" build/ios/iphonesimulator/Runner.app', - ), - ); - assert.ok(ciWorkflow.includes('kill -0 "$app_pid"')); - assert.ok(ciWorkflow.includes("Build standalone T4 host for Flutter macOS")); - assert.ok(ciWorkflow.includes("Verify bundled Flutter macOS host")); - assert.ok( - ciWorkflow.includes( - "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host", - ), - ); assert.ok(ciWorkflow.includes("name: verify")); assert.ok(ciWorkflow.includes("if: ${{ always() }}")); assert.ok( ciWorkflow.includes( - "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug]", ), ); assert.ok(ciWorkflow.includes('test "$CHANGES_RESULT" = success')); diff --git a/scripts/ci-paths.mjs b/scripts/ci-paths.mjs index 5f6daa67..1009a12d 100755 --- a/scripts/ci-paths.mjs +++ b/scripts/ci-paths.mjs @@ -31,7 +31,7 @@ const GROUP_PATTERNS = Object.freeze({ official_omp_gate0: [ /^\.github\/workflows\/ci\.yml$/u, /^compat\/(?:official-omp-gate0|omp-app-matrix)\.json$/u, - /^docs\/(?:OMP_T4_CAPABILITY_AUDIT\.md|OMP_T4_CAPABILITY_TRACKER\.csv|T4_ARCHITECTURE\.html)$/u, + /^docs\/(?:archive\/flutter-migration\/(?:OMP_T4_CAPABILITY_AUDIT\.md|OMP_T4_CAPABILITY_TRACKER\.csv)|T4_ARCHITECTURE\.html)$/u, /^packages\/host-service\/(?:bin\/official-omp-gate0\.ts|package\.json)$/u, /^packages\/host-service\/src\/(?:official-omp-profile-authority|rpc-child|server|types)\.ts$/u, /^packages\/host-daemon\/(?:bin\/official-omp-packaged-proof\.ts|package\.json|src\/cli\.ts)$/u, @@ -50,14 +50,6 @@ const GROUP_PATTERNS = Object.freeze({ /^packages\/(?:client|ui)\//u, /^packages\/host-wire\//u, ], - flutter: [/^apps\/flutter\//u, /^packages\/host-wire\//u], - flutter_android: [/^apps\/flutter\//u, /^packages\/host-wire\//u], - flutter_apple: [ - /^apps\/flutter\//u, - /^packages\/host-(?:daemon|wire)\//u, - /^apps\/desktop\/build\/entitlements\.omp-runtime\.plist$/u, - /^scripts\/(?:package-mac|stage-omp-runtime)/u, - ], }); function normalizePath(path) { diff --git a/scripts/ci-paths.test.mjs b/scripts/ci-paths.test.mjs index 302558ae..ffda26c4 100644 --- a/scripts/ci-paths.test.mjs +++ b/scripts/ci-paths.test.mjs @@ -8,9 +8,6 @@ const none = { official_omp_gate0: false, tooling: false, android_debug: false, - flutter: false, - flutter_android: false, - flutter_apple: false, }; test("host runtime source runs host gates without unrelated platform builds", () => { @@ -32,7 +29,7 @@ test("official lifecycle inputs run their native proof and tooling", () => { ]), { ...none, official_omp_gate0: true, tooling: true }, ); - assert.deepEqual(classifyCiPaths(["docs/OMP_T4_CAPABILITY_TRACKER.csv"]), { + assert.deepEqual(classifyCiPaths(["docs/archive/flutter-migration/OMP_T4_CAPABILITY_TRACKER.csv"]), { ...none, official_omp_gate0: true, tooling: true, @@ -46,15 +43,6 @@ test("cluster implementation changes run the cluster gate", () => { }); }); -test("Flutter changes run all Flutter legs", () => { - assert.deepEqual(classifyCiPaths(["apps/flutter/lib/src/client/t4_client_controller.dart"]), { - ...none, - flutter: true, - flutter_android: true, - flutter_apple: true, - }); -}); - test("host wire changes run every dependent client and continuity gate", () => { assert.deepEqual(classifyCiPaths(["packages/host-wire/src/command.ts"]), { continuity: true, @@ -62,23 +50,18 @@ test("host wire changes run every dependent client and continuity gate", () => { official_omp_gate0: false, tooling: true, android_debug: true, - flutter: true, - flutter_android: true, - flutter_apple: true, }); }); -test("host daemon changes run the Apple packaging leg", () => { +test("host daemon changes run its host gates", () => { assert.deepEqual(classifyCiPaths(["packages/host-daemon/src/main.ts"]), { ...none, tooling: true, - flutter_apple: true, }); assert.deepEqual(classifyCiPaths(["packages/host-daemon/src/cli.ts"]), { ...none, official_omp_gate0: true, tooling: true, - flutter_apple: true, }); }); @@ -97,9 +80,6 @@ test("dependency graph changes conservatively run every leg", () => { official_omp_gate0: true, tooling: true, android_debug: true, - flutter: true, - flutter_android: true, - flutter_apple: true, }); } }); @@ -114,9 +94,9 @@ test("workflow changes run tooling on the PR and the full matrix after merge", ( }); test("paths are normalized and GitHub outputs are stable", () => { - const result = classifyCiPaths(["./apps\\flutter\\pubspec.yaml", "./apps/flutter/pubspec.yaml"]); + const result = classifyCiPaths(["./apps\\web\\package.json", "./apps/web/package.json"]); assert.equal( formatGitHubOutputs(result), - "continuity=false\ncluster=false\nofficial_omp_gate0=false\ntooling=false\nandroid_debug=false\nflutter=true\nflutter_android=true\nflutter_apple=true\n", + "continuity=false\ncluster=false\nofficial_omp_gate0=false\ntooling=false\nandroid_debug=true\n", ); }); diff --git a/scripts/cluster-ci/cluster-ci-contract.test.mjs b/scripts/cluster-ci/cluster-ci-contract.test.mjs index 8e43d77b..60d71e52 100644 --- a/scripts/cluster-ci/cluster-ci-contract.test.mjs +++ b/scripts/cluster-ci/cluster-ci-contract.test.mjs @@ -253,21 +253,13 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication assert.deepEqual(coreCommands, [ 'export PATH="$PWD/.ci:$PATH"', "corepack enable", - "pnpm check:release && pnpm check:provenance && pnpm lint && pnpm --filter '!@t4-code/flutter' -r typecheck", - "VP_RUN_CONCURRENCY_LIMIT=1 pnpm --filter '!@t4-code/flutter' -r test", - "pnpm --filter '!@t4-code/flutter' -r build", + "pnpm check", + "VP_RUN_CONCURRENCY_LIMIT=1 pnpm test", + "pnpm build", "pnpm exec playwright install --with-deps chromium", "pnpm test:e2e", "pnpm test:packaging", ]); - const unfilteredSdkCommand = /(?:^|\s)pnpm(?:\s+-r)?\s+(?:check|typecheck|test|build)(?:\s|$)/u; - for (const command of coreCommands) { - assert.doesNotMatch( - command, - unfilteredSdkCommand, - `pipeline 38:64 reproduced unfiltered core workspace traversal as "Failed to find executable flutter": ${command}`, - ); - } assert.deepEqual(steps["legacy-authority-build"].depends_on, [ "legacy-authority-source", "bun-runtime", diff --git a/scripts/deploy-demo.mjs b/scripts/deploy-demo.mjs index 3f9c844b..7bd0e3a6 100644 --- a/scripts/deploy-demo.mjs +++ b/scripts/deploy-demo.mjs @@ -30,8 +30,8 @@ export function assertDemoDocumentPaths(document) { !url.startsWith("https:") && !url.startsWith("#"), ); - if (!localUrls.includes("flutter_bootstrap.js")) { - throw new Error("demo index is not a Flutter web build"); + if (!localUrls.some((url) => url?.startsWith("/demo/assets/"))) { + throw new Error("demo index is not a React production build"); } const escaped = localUrls.find((url) => { const resolved = new URL(url, "https://t4code.net/demo/"); @@ -43,18 +43,8 @@ export function assertDemoDocumentPaths(document) { export function validateDemoBuild(repoRoot) { const document = readFileSync(resolve(repoRoot, "apps/site/dist/demo/index.html"), "utf8"); assertDemoDocumentPaths(document); - const bootstrap = readFileSync( - resolve(repoRoot, "apps/site/dist/demo/flutter_bootstrap.js"), - "utf8", - ); - if (!bootstrap.includes("registration.unregister()")) { - throw new Error("demo bootstrap must retire stale service workers"); - } - if (!bootstrap.includes('"useLocalCanvasKit":true')) { - throw new Error("demo bootstrap must use local Flutter renderer assets"); - } - if (existsSync(resolve(repoRoot, "apps/site/dist/demo/flutter_service_worker.js"))) { - throw new Error("demo build must not publish a Flutter service worker"); + if (existsSync(resolve(repoRoot, "apps/site/dist/demo/service-worker.js"))) { + throw new Error("demo build must not publish a service worker"); } } diff --git a/scripts/deploy-demo.test.mjs b/scripts/deploy-demo.test.mjs index 93ffc737..4c9ecff4 100644 --- a/scripts/deploy-demo.test.mjs +++ b/scripts/deploy-demo.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; import { buildDemo } from "./build-demo.mjs"; import { assertDemoDocumentPaths, deployDemo } from "./deploy-demo.mjs"; -test("demo build compiles the Flutter client for the /demo/ path", () => { +test("demo build compiles the React client for the /demo/ path", () => { const calls = []; buildDemo("/repo", (command, args, cwd) => calls.push({ command, args, cwd })); @@ -13,18 +13,13 @@ test("demo build compiles the Flutter client for the /demo/ path", () => { command: "pnpm", args: [ "--filter", - "@t4-code/flutter", + "@t4-code/web", "exec", - "flutter", + "vp", "build", - "web", - "--base-href", - "/demo/", - "--csp", - "--no-web-resources-cdn", - "--dart-define", - "T4_DEMO_MODE=true", - "--output", + "--mode", + "demo", + "--outDir", "/repo/apps/site/dist/demo", ], cwd: "/repo", @@ -32,23 +27,18 @@ test("demo build compiles the Flutter client for the /demo/ path", () => { ]); }); -test("site workflow deploys the Flutter demo independently from release publication", async () => { +test("site workflow deploys the React demo independently from release publication", async () => { const { readFile } = await import("node:fs/promises"); const workflow = await readFile(".github/workflows/deploy-site.yml", "utf8"); const infrastructure = await readFile("infra/site/cloudformation.yml", "utf8"); - assert.match(workflow, /- "apps\/flutter\/\*\*"/u); - assert.doesNotMatch(workflow, /- "apps\/web\/\*\*"/u); + assert.match(workflow, /- "apps\/web\/\*\*"/u); assert.match(workflow, /demo:\n if: \$\{\{ github\.event_name == 'push' \}\}/u); - assert.match(workflow, /id: demo_csp/u); - assert.match(workflow, /grep -Fq "'wasm-unsafe-eval'"/u); - assert.match(workflow, /if: \$\{\{ steps\.demo_csp\.outputs\.ready == 'true' \}\}/u); - assert.match(workflow, /Defer Flutter demo until its response policy is active/u); + assert.doesNotMatch(workflow, /flutter-action/u); assert.match(workflow, /run: pnpm deploy:demo/u); assert.match(workflow, /run: pnpm deploy:site/u); assert.doesNotMatch(workflow, /deploy:site-bundle/u); assert.match(infrastructure, /PathPattern: demo\*/u); - assert.match(infrastructure, /script-src 'self' 'wasm-unsafe-eval'/u); assert.match(infrastructure, /connect-src 'self' https:\/\/fonts\.gstatic\.com/u); }); @@ -79,29 +69,29 @@ test("demo deploy replaces only the demo prefix after immutable assets", () => { test("demo build keeps every local document URL under /demo", () => { assert.doesNotThrow(() => assertDemoDocumentPaths( - '', + '', ), ); assert.throws( () => assertDemoDocumentPaths( - '', + '', ), /demo asset escapes/u, ); assert.throws( () => assertDemoDocumentPaths( - '', + '', ), /demo asset escapes/u, ); assert.throws( - () => assertDemoDocumentPaths(''), + () => assertDemoDocumentPaths(''), /base href/u, ); assert.throws( () => assertDemoDocumentPaths(''), - /not a Flutter web build/u, + /not a React production build/u, ); }); diff --git a/scripts/run-flutter.mjs b/scripts/run-flutter.mjs deleted file mode 100644 index 4d1ecf80..00000000 --- a/scripts/run-flutter.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const args = process.argv.slice(2); -if (args[0] === "--") args.shift(); - -const developmentEndpoint = process.env.T4_DEVELOPMENT_ENDPOINT?.trim(); -if ( - developmentEndpoint && - !args.some((argument) => - argument.startsWith("--dart-define=T4_DEVELOPMENT_ENDPOINT="), - ) -) { - args.push(`--dart-define=T4_DEVELOPMENT_ENDPOINT=${developmentEndpoint}`); -} - -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const hostBuild = spawnSync("pnpm", ["build:host"], { - cwd: repositoryRoot, - env: process.env, - stdio: "inherit", -}); -if (hostBuild.status !== 0) { - process.exitCode = hostBuild.status ?? 1; - process.exit(); -} -const hostExecutable = resolve(repositoryRoot, "packages/host-daemon/dist/t4-host"); -const child = spawn("flutter", ["run", ...args], { - cwd: resolve(repositoryRoot, "apps/flutter"), - env: { - ...process.env, - T4_HOST_EXECUTABLE: process.env.T4_HOST_EXECUTABLE ?? hostExecutable, - }, - stdio: "inherit", -}); - -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => child.kill(signal)); -} - -child.once("error", (error) => { - console.error(`Unable to launch Flutter: ${error.message}`); - process.exitCode = 1; -}); - -child.once("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exitCode = code ?? 1; -});