diff --git a/.github/workflows/ci-verify.yml b/.github/workflows/ci-verify.yml index 934be617..b1102181 100644 --- a/.github/workflows/ci-verify.yml +++ b/.github/workflows/ci-verify.yml @@ -25,12 +25,21 @@ name: CI Verify # bundled_ffmpeg_path and bundled_ffprobe_path tests would fail with # ProtectedOrUnreadable on ALL three runners without prep. We prepare # sidecars deterministically per OS: -# - macOS: evermeet.cx .zip + GPG verificado (chave 0x1A660874) -# - Windows: gyan.dev Essential build .zip + SHA-256 verificado -# - Linux: apt (pacote Ubuntu 22.04, GPG-assinado) -# Version pinada ffmpeg 8.1.2. actions/cache evita re-download. +# - macOS: evermeet.cx .zip pinado 8.1.2 + GPG (chave 0x1A660874). +# URL versionada ainda viva; evermeet nao recicla builds como o BtbN. +# - Windows: gyan.dev Essential 8.1.2 .zip + SHA-256 pinado. +# Pacote versionado ainda vivo (nao e o alias movel ffmpeg-release-*). +# - Linux: BtbN/FFmpeg-Builds, linha n8.1 linux*-gpl (NAO apt: Ubuntu +# 22.04 entrega ffmpeg 4.4 sem fps_mode >= 5.1). Autobuilds do BtbN +# sao reciclados (pin autobuild-2026-08-02-13-17 -> 404 no run +# 33274196781). O passo resolve o asset em runtime via API de +# releases e verifica SHA-256 contra checksums.sha256 da MESMA +# release. Fallback: tag `latest` / n8.1-latest. Para atualizar se +# a linha n8.1 sumir: ajustar o regex n8.1 no passo Linux. +# Download failure MUST print "fonte externa do ffmpeg indisponivel" +# plus the URL. Cache key v2 invalida um possivel tarball 404. # Non-media sidecars (verboo-whisper, verboo-in-chrome, -# verboo-ios-simulator, computer-use-helper) stay as empty stubs because no +# verboo-ios-simulator, verboo-android-emulator, computer-use-helper) stay as empty stubs because no # cargo test --lib actually executes them (transcribe.rs only resolves the # path; browser/simulator integration tests use isolated fixtures). # Triple mapping source of truth: src-tauri/src/services/video/target.rs @@ -147,7 +156,7 @@ jobs: mkdir -p dist-renderer : > dist-renderer/index.html mkdir -p src-tauri/binaries - for binary in verboo-in-chrome verboo-ios-simulator verboo-ffmpeg verboo-ffprobe verboo-whisper; do + for binary in verboo-in-chrome verboo-ios-simulator verboo-android-emulator verboo-ffmpeg verboo-ffprobe verboo-whisper; do : > "src-tauri/binaries/$binary-$TARGET$EXT" done if [ "$RUNNER_OS" = "macOS" ]; then @@ -300,33 +309,46 @@ jobs: # Without this step, bundled_ffmpeg_path / bundled_ffprobe_path # would fail with ProtectedOrUnreadable on ALL three runners. # - # DETERMINISTIC DOWNLOAD policy (A1b-resolved): - # - macOS: evermeet.cx ffmpeg 8.1.2 + ffprobe 8.1.2, .zip + - # .sig (GPG assinado), chave 0x1A660874. GPG verify - # garante integridade criptográfica. - # - Windows: gyan.dev ffmpeg 8.1.2 Essential build .zip, SHA-256 - # verificado contra hash conhecido. - # - Linux: apt (pacote oficial Ubuntu 22.04), GPG-assinado - # pelo repositório Ubuntu. Exitente via `ffmpeg -version`. + # DOWNLOAD policy: + # - macOS: evermeet.cx ffmpeg/ffprobe 8.1.2 .zip + .sig, GPG + # chave 0x1A660874. Pin versionado (nao recicla). + # - Windows: gyan.dev ffmpeg 8.1.2 Essential .zip, SHA-256 + # pinado. Pin versionado (nao recicla). + # - Linux: BtbN n8.1 linux*-gpl resolvido via API (autobuild + # vigente; fallback latest). SHA-256 de checksums.sha256 + # da mesma release. apt 4.4 nao serve (sem fps_mode). # # A cache (actions/cache@v4) evita baixar ~50MB por runner. - # Key por OS+arch para não cruzar plataformas. + # Key por OS+arch para não cruzar plataformas. v2: Linux dinamico. # ───────────────────────────────────────────────────────────────── - name: Cache ffmpeg binaries id: cache-ffmpeg uses: actions/cache@v4 with: path: .cache/ffmpeg - key: ffmpeg-8.1.2-${{ runner.os }}-${{ runner.arch }} + key: ffmpeg-8.1-sidecar-v2-${{ runner.os }}-${{ runner.arch }} - name: Prepare sidecars (macOS) # Fonte: evermeet.cx. FFmpeg 8.1.2 universal (x86_64 + arm64). # GPG verification: baixa .sig, importa chave 0x1A660874, verifica. + # Pin versionado — evermeet nao recicla o arquivo (ainda 200 hoje). # O cache (step anterior) restaura .cache/ffmpeg/ se hit. if: runner.os == 'macOS' shell: bash run: | - set -euxo pipefail + set -euo pipefail + ffmpeg_die() { + echo "::error::fonte externa do ffmpeg indisponível: $1" + echo "fonte externa do ffmpeg indisponível: $1" >&2 + exit 1 + } + ffmpeg_curl() { + local dest="$1" url="$2" + if ! curl -fL --retry 3 --retry-delay 2 -o "$dest" "$url"; then + rm -f "$dest" + ffmpeg_die "$url" + fi + } TRIPLE="$(rustc -vV | grep '^host:' | sed 's/^host: //')" BINDIR="src-tauri/binaries" mkdir -p "$BINDIR" @@ -334,15 +356,18 @@ jobs: mkdir -p "$FFCACHE" if [ ! -f "$FFCACHE/ffmpeg-8.1.2.zip" ]; then echo "download: ffmpeg 8.1.2 + ffprobe 8.1.2 de evermeet.cx" - curl -fsSL -o "$FFCACHE/ffmpeg-8.1.2.zip" \ + ffmpeg_curl "$FFCACHE/ffmpeg-8.1.2.zip" \ "https://evermeet.cx/ffmpeg/ffmpeg-8.1.2.zip" - curl -fsSL -o "$FFCACHE/ffmpeg-8.1.2.zip.sig" \ + ffmpeg_curl "$FFCACHE/ffmpeg-8.1.2.zip.sig" \ "https://evermeet.cx/ffmpeg/ffmpeg-8.1.2.zip.sig" - curl -fsSL -o "$FFCACHE/ffprobe-8.1.2.zip" \ + ffmpeg_curl "$FFCACHE/ffprobe-8.1.2.zip" \ "https://evermeet.cx/ffmpeg/ffprobe-8.1.2.zip" - curl -fsSL -o "$FFCACHE/ffprobe-8.1.2.zip.sig" \ + ffmpeg_curl "$FFCACHE/ffprobe-8.1.2.zip.sig" \ "https://evermeet.cx/ffmpeg/ffprobe-8.1.2.zip.sig" - curl -fsSL "https://evermeet.cx/ffmpeg/0x1A660874.asc" | gpg --import - + KEY_URL="https://evermeet.cx/ffmpeg/0x1A660874.asc" + if ! curl -fL --retry 3 --retry-delay 2 "$KEY_URL" | gpg --import -; then + ffmpeg_die "$KEY_URL" + fi gpg --verify "$FFCACHE/ffmpeg-8.1.2.zip.sig" "$FFCACHE/ffmpeg-8.1.2.zip" gpg --verify "$FFCACHE/ffprobe-8.1.2.zip.sig" "$FFCACHE/ffprobe-8.1.2.zip" fi @@ -352,7 +377,7 @@ jobs: mv -f "$BINDIR/ffprobe" "$BINDIR/verboo-ffprobe-${TRIPLE}" chmod +x "$BINDIR/verboo-ffmpeg-${TRIPLE}" chmod +x "$BINDIR/verboo-ffprobe-${TRIPLE}" - for s in verboo-in-chrome verboo-ios-simulator verboo-whisper computer-use-helper; do + for s in verboo-in-chrome verboo-ios-simulator verboo-android-emulator verboo-whisper computer-use-helper; do touch "$BINDIR/${s}-${TRIPLE}" done ls -la "$BINDIR/" @@ -360,6 +385,8 @@ jobs: - name: Prepare sidecars (Windows) # Fonte: gyan.dev. FFmpeg 8.1.2 Essential build x86_64. # SHA-256 verification contra hash conhecido e publicado. + # Pin versionado — gyan nao recicla o pacote (ainda 200 hoje). + # Nao usar ffmpeg-release-essentials.zip (alias movel, hoje 9.0.1). if: runner.os == 'Windows' shell: pwsh run: | @@ -371,9 +398,17 @@ jobs: New-Item -ItemType Directory -Force -Path $FFCACHE | Out-Null $ZIP = "$FFCACHE\ffmpeg-8.1.2-essentials_build.zip" $EXPECTED_HASH = "db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec" + $Uri = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.2-essentials_build.zip" if (-not (Test-Path $ZIP)) { Write-Host "download: ffmpeg 8.1.2 Essential build de gyan.dev" - Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.2-essentials_build.zip" -OutFile $ZIP + try { + Invoke-WebRequest -Uri $Uri -OutFile $ZIP + } catch { + if (Test-Path $ZIP) { Remove-Item -Force $ZIP } + Write-Host "fonte externa do ffmpeg indisponível: $Uri" + Write-Error "fonte externa do ffmpeg indisponível: $Uri" + exit 1 + } $ACTUAL_HASH = (Get-FileHash -Path $ZIP -Algorithm SHA256).Hash.ToLower() if ($ACTUAL_HASH -ne $EXPECTED_HASH) { Write-Host "SHA-256 MISMATCH: esperado $EXPECTED_HASH, obtido $ACTUAL_HASH" @@ -386,44 +421,130 @@ jobs: $FF_DIR = Get-ChildItem -Path $EXTRACTED -Directory | Select-Object -First 1 Copy-Item -Force "$($FF_DIR.FullName)\bin\ffmpeg.exe" "$BINDIR\verboo-ffmpeg-$TRIPLE.exe" Copy-Item -Force "$($FF_DIR.FullName)\bin\ffprobe.exe" "$BINDIR\verboo-ffprobe-$TRIPLE.exe" - foreach ($s in @('verboo-in-chrome', 'verboo-ios-simulator', 'verboo-whisper', 'computer-use-helper')) { + foreach ($s in @('verboo-in-chrome', 'verboo-ios-simulator', 'verboo-android-emulator', 'verboo-whisper', 'computer-use-helper')) { New-Item -ItemType File -Force -Path "$BINDIR\$s-$TRIPLE.exe" | Out-Null } Get-ChildItem $BINDIR - name: Prepare sidecars (Linux) - # Fonte: BtbN/FFmpeg-Builds, release IMUTAVEL autobuild-2026-08-02-13-17 - # (nao usar a tag 'latest': ela e movel, o conteudo muda a cada - # autobuild). Build do branch n8.1.2 — mesma linha do produto que - # embute ffmpeg 8.1.2. apt do Ubuntu 22.04 entrega ffmpeg 4.4, que - # nao tem fps_mode (>= 5.1) e quebra os testes de video. - # SHA-256 verificado contra o checksums.sha256 PUBLICADO na mesma - # release (transcrito do arquivo publicado, nao de saida de execucao): - # https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-02-13-17/checksums.sha256 - # f34f7ed9b02f54d96f5622393f3a36fc65b3c5b3bb503bb04a3225fde68d71e0 ffmpeg-n8.1.2-34-g9b6c8969e0-linux64-gpl-8.1.tar.xz + # Fonte: BtbN/FFmpeg-Builds. Autobuilds sao reciclados — NAO pinar + # tag autobuild-YYYY-MM-DD. Resolver em runtime: API de releases, + # asset n8.1 linux*-gpl (mesma linha do produto 8.1.x), SHA-256 + # de checksums.sha256 da mesma release. Fallback: tag latest. + # apt Ubuntu 22.04 = ffmpeg 4.4, quebra testes (fps_mode >= 5.1). if: runner.os == 'Linux' shell: bash run: | - set -euxo pipefail + set -euo pipefail + ffmpeg_die() { + echo "::error::fonte externa do ffmpeg indisponível: $1" + echo "fonte externa do ffmpeg indisponível: $1" >&2 + exit 1 + } + ffmpeg_curl() { + local dest="$1" url="$2" + if ! curl -fL --retry 3 --retry-delay 2 -o "$dest" "$url"; then + rm -f "$dest" + ffmpeg_die "$url" + fi + } TRIPLE="$(rustc -vV | grep '^host:' | sed 's/^host: //')" BINDIR="src-tauri/binaries" mkdir -p "$BINDIR" FFCACHE=".cache/ffmpeg" mkdir -p "$FFCACHE" - FFVER="ffmpeg-n8.1.2-34-g9b6c8969e0-linux64-gpl-8.1" - TARBALL="$FFCACHE/$FFVER.tar.xz" - if [ ! -f "$TARBALL" ]; then - echo "download: ffmpeg 8.1.2 (branch n8.1.2) de BtbN/FFmpeg-Builds" - curl -fsSL -o "$TARBALL" \ - "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-02-13-17/$FFVER.tar.xz" - ACTUAL_HASH="$(sha256sum "$TARBALL" | awk '{print $1}')" - EXPECTED_HASH="f34f7ed9b02f54d96f5622393f3a36fc65b3c5b3bb503bb04a3225fde68d71e0" + case "$(uname -m)" in + aarch64|arm64) BTBN_ARCH="linuxarm64" ;; + *) BTBN_ARCH="linux64" ;; + esac + shopt -s nullglob + CACHED=( "$FFCACHE"/ffmpeg-n8.1*-"${BTBN_ARCH}"-gpl-8.1.tar.xz ) + TARBALL="" + if [ ${#CACHED[@]} -gt 0 ]; then + for cand in "${CACHED[@]}"; do + sz="$(wc -c < "$cand")" + if [ "$sz" -gt 1000000 ]; then + TARBALL="$cand" + echo "cache hit: $TARBALL ($sz bytes)" + break + fi + echo "cache ignora arquivo pequeno/invalido: $cand ($sz bytes)" + rm -f "$cand" + done + fi + if [ -z "$TARBALL" ]; then + API_URL="https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=15" + echo "resolve: asset n8.1 ${BTBN_ARCH}-gpl via API BtbN" + API_ARGS=(-fL --retry 3 --retry-delay 2 + -H "Accept: application/vnd.github+json" + -H "User-Agent: verboo-ci-ffmpeg") + if [ -n "${GITHUB_TOKEN:-}" ]; then + API_ARGS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi + if ! curl "${API_ARGS[@]}" -o "$FFCACHE/btbn-releases.json" "$API_URL"; then + rm -f "$FFCACHE/btbn-releases.json" + ffmpeg_die "$API_URL" + fi + RESOLVED="$(python3 - "$FFCACHE/btbn-releases.json" "$BTBN_ARCH" <<'PY' + import json, re, sys + path, arch = sys.argv[1], sys.argv[2] + with open(path, encoding="utf-8") as fh: + releases = json.load(fh) + pat = re.compile(r"^ffmpeg-n8\.1.*-" + re.escape(arch) + r"-gpl-8\.1\.tar\.xz$") + + def pick(prefer_autobuild): + for rel in releases: + if rel.get("draft"): + continue + tag = rel.get("tag_name") or "" + if prefer_autobuild and not tag.startswith("autobuild-"): + continue + if not prefer_autobuild and tag.startswith("autobuild-"): + continue + assets = {a["name"]: a for a in (rel.get("assets") or [])} + names = sorted(n for n in assets if pat.match(n)) + if not names: + continue + name = names[0] + csum = assets.get("checksums.sha256") or {} + csum_url = csum.get("browser_download_url") or "" + url = assets[name]["browser_download_url"] + return f"{name}\n{url}\n{csum_url}" + return "" + + out = pick(True) or pick(False) + if not out: + sys.exit(1) + sys.stdout.write(out) + PY + )" || ffmpeg_die "$API_URL (nenhum asset n8.1 ${BTBN_ARCH}-gpl)" + ASSET_NAME="$(printf '%s\n' "$RESOLVED" | sed -n '1p')" + ASSET_URL="$(printf '%s\n' "$RESOLVED" | sed -n '2p')" + CSUM_URL="$(printf '%s\n' "$RESOLVED" | sed -n '3p')" + if [ -z "$ASSET_NAME" ] || [ -z "$ASSET_URL" ] || [ -z "$CSUM_URL" ]; then + ffmpeg_die "$API_URL (asset n8.1 ${BTBN_ARCH}-gpl incompleto)" + fi + echo "download: $ASSET_NAME" + echo "url: $ASSET_URL" + TARBALL="$FFCACHE/$ASSET_NAME" + PARTIAL="$TARBALL.partial" + ffmpeg_curl "$PARTIAL" "$ASSET_URL" + ffmpeg_curl "$FFCACHE/checksums.sha256" "$CSUM_URL" + EXPECTED_HASH="$(awk -v n="$ASSET_NAME" '$2 == n {print $1; exit}' "$FFCACHE/checksums.sha256")" + if [ -z "$EXPECTED_HASH" ]; then + rm -f "$PARTIAL" + ffmpeg_die "$CSUM_URL (sem SHA-256 para $ASSET_NAME)" + fi + ACTUAL_HASH="$(sha256sum "$PARTIAL" | awk '{print $1}')" if [ "$ACTUAL_HASH" != "$EXPECTED_HASH" ]; then - echo "SHA-256 MISMATCH: esperado $EXPECTED_HASH, obtido $ACTUAL_HASH" + rm -f "$PARTIAL" + echo "SHA-256 MISMATCH: esperado $EXPECTED_HASH, obtido $ACTUAL_HASH" >&2 exit 1 fi echo "SHA-256 OK: $ACTUAL_HASH" + mv -f "$PARTIAL" "$TARBALL" fi + FFVER="$(basename "$TARBALL" .tar.xz)" tar -xJf "$TARBALL" -C "$BINDIR" mv -f "$BINDIR/$FFVER/bin/ffmpeg" "$BINDIR/verboo-ffmpeg-${TRIPLE}" mv -f "$BINDIR/$FFVER/bin/ffprobe" "$BINDIR/verboo-ffprobe-${TRIPLE}" @@ -431,7 +552,7 @@ jobs: chmod +x "$BINDIR/verboo-ffprobe-${TRIPLE}" # :? guard — if either var were empty this would expand to / (SC2115) rm -rf "${BINDIR:?}/${FFVER:?}" - for s in verboo-in-chrome verboo-ios-simulator verboo-whisper computer-use-helper; do + for s in verboo-in-chrome verboo-ios-simulator verboo-android-emulator verboo-whisper computer-use-helper; do touch "$BINDIR/${s}-${TRIPLE}" done ls -la "$BINDIR/" diff --git a/.github/workflows/tauri-release.yml b/.github/workflows/tauri-release.yml index 5968d98f..8cf63d4a 100644 --- a/.github/workflows/tauri-release.yml +++ b/.github/workflows/tauri-release.yml @@ -226,7 +226,9 @@ jobs: libtool \ nasm \ zlib1g-dev \ - xvfb + xvfb \ + squashfs-tools \ + desktop-file-utils - name: Install npm dependencies run: npm ci @@ -597,6 +599,106 @@ jobs: done fi + # Issue #80: linuxdeploy packs ubuntu-22.04 libdbus-1.so.3 (1.14.x) into + # the AppImage. AppRun's LD_LIBRARY_PATH then makes system dbus-launch + # (e.g. Debian 13, dbus 1.16.2) fail with missing LIBDBUS_PRIVATE_1.16.2, + # which takes Secret Service down. Strip only that SONAME + its symlinks + # so the app uses the system's libdbus. Re-sign: updater sig is on the file. + - name: Strip bundled libdbus from AppImage and re-sign + if: matrix.target == 'x86_64-unknown-linux-gnu' + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + # Pinned appimagetool 1.9.0 (not "continuous") so the packer does + # not float under the release job. SHA-256 of the official + # x86_64 asset (12632352 bytes), confirmed by independent download + # + shasum -a 256 + openssl dgst -sha256. + APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage + APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1 + shell: bash + run: | + set -euo pipefail + test -n "${TAURI_SIGNING_PRIVATE_KEY:-}" || { echo "::error::TAURI_SIGNING_PRIVATE_KEY is missing"; exit 1; } + test -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" || { echo "::error::TAURI_SIGNING_PRIVATE_KEY_PASSWORD is missing"; exit 1; } + + BUNDLE_ROOT="src-tauri/target/${{ matrix.target }}/release/bundle" + APPIMAGE_DIR="$BUNDLE_ROOT/appimage" + APPIMAGES=$(find "$APPIMAGE_DIR" -maxdepth 1 -type f -name '*.AppImage' | sort -u) + APPIMAGE_COUNT=$(printf '%s\n' "$APPIMAGES" | sed '/^$/d' | wc -l | tr -d ' ') + if [ "$APPIMAGE_COUNT" != "1" ]; then + echo "::error::Expected one AppImage to strip, found $APPIMAGE_COUNT" + printf '%s\n' "$APPIMAGES" + exit 1 + fi + APPIMAGE=$(printf '%s\n' "$APPIMAGES" | sed '/^$/d' | head -1) + echo "Stripping libdbus from: $APPIMAGE" + + WORK=$(mktemp -d) + trap 'rm -rf "$WORK"' EXIT + cp -f "$APPIMAGE" "$WORK/payload.AppImage" + chmod +x "$WORK/payload.AppImage" + + # AppImage extract without FUSE (GitHub-hosted runners). + export APPIMAGE_EXTRACT_AND_RUN=1 + ( + cd "$WORK" + if ! ./payload.AppImage --appimage-extract; then + echo " --appimage-extract failed; falling back to unsquashfs" + OFFSET=$(LC_ALL=C grep -abo --max-count=1 'hsqs' payload.AppImage | head -1 | cut -d: -f1) + test -n "${OFFSET:-}" || { echo "::error::Could not locate squashfs offset in AppImage"; exit 1; } + unsquashfs -o "$OFFSET" -d squashfs-root payload.AppImage + fi + ) + test -d "$WORK/squashfs-root" || { echo "::error::AppImage extract produced no squashfs-root"; exit 1; } + + SONAME_HITS=$(find "$WORK/squashfs-root" -name 'libdbus-1.so.3' -print) + if [ -z "$SONAME_HITS" ]; then + echo "::error::libdbus-1.so.3 not found inside AppImage (bundler may have stopped packing it). Refusing to ship an unstripped AppImage." + echo "dbus-related files present:" + find "$WORK/squashfs-root" -iname '*dbus*' -print || true + exit 1 + fi + + echo "Removing bundled libdbus-1 (SONAME + associated symlinks only):" + REMOVED_ANY=0 + while IFS= read -r -d '' lib; do + echo " $lib" + ls -l "$lib" + rm -f "$lib" + REMOVED_ANY=1 + done < <(find "$WORK/squashfs-root" \( -name 'libdbus-1.so' -o -name 'libdbus-1.so.3' -o -name 'libdbus-1.so.3.*' \) -print0) + + if [ "$REMOVED_ANY" != "1" ]; then + echo "::error::libdbus-1.so.3 was found but nothing was removed" + exit 1 + fi + + STILL_THERE=$(find "$WORK/squashfs-root" -name 'libdbus-1.so.3' -print) + if [ -n "$STILL_THERE" ]; then + echo "::error::libdbus-1.so.3 still present after strip:" + printf '%s\n' "$STILL_THERE" + exit 1 + fi + + echo "Downloading appimagetool from $APPIMAGETOOL_URL" + test -n "${APPIMAGETOOL_SHA256:-}" || { echo "::error::APPIMAGETOOL_SHA256 is missing"; exit 1; } + curl -fsSL -o "$WORK/appimagetool.AppImage" "$APPIMAGETOOL_URL" + if ! echo "${APPIMAGETOOL_SHA256} $WORK/appimagetool.AppImage" | sha256sum -c -; then + echo "::error::appimagetool SHA-256 mismatch; refusing to execute an unverified packer (signing secrets are in env)" + exit 1 + fi + chmod +x "$WORK/appimagetool.AppImage" + + ARCH=x86_64 "$WORK/appimagetool.AppImage" --no-appstream "$WORK/squashfs-root" "$WORK/stripped.AppImage" + test -s "$WORK/stripped.AppImage" || { echo "::error::appimagetool produced an empty AppImage"; exit 1; } + + cp -f "$WORK/stripped.AppImage" "$APPIMAGE" + chmod +x "$APPIMAGE" + rm -f "${APPIMAGE}.sig" + cargo +1.89.0 tauri signer sign "$APPIMAGE" + test -s "${APPIMAGE}.sig" || { echo "::error::Re-sign produced a missing or empty updater signature"; exit 1; } + echo "Stripped and re-signed AppImage: $APPIMAGE" + - name: Create signed macOS DMG with bounded retry if: runner.os == 'macOS' env: diff --git a/.gitignore b/.gitignore index 25713f14..d7390584 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ src-tauri/.cargo/ # Local agent/tool state .superpowers/ .verboo/ +.elyra/ extensions/verboo-chrome/dist/ extensions/verboo-chrome/store-assets/*.zip diff --git a/docs/SECURITY_AUDIT_20260814.md b/docs/SECURITY_AUDIT_20260814.md deleted file mode 100644 index d494e07e..00000000 --- a/docs/SECURITY_AUDIT_20260814.md +++ /dev/null @@ -1,294 +0,0 @@ -# Auditoria de Segurança e Bugs — Verboo Code Desktop - -> Data: 2026-08-14 | Versão: 0.7.2-beta | Escopo: Rust backend + React/TS frontend - ---- - -## Resumo Executivo - -| Severidade | Rust | Frontend | Logic | Total | -|------------|------|----------|-------|-------| -| **CRITICAL** | 2 | 0 | 1 | **3** | -| **HIGH** | 2 | 1 | 1 | **4** | -| **MEDIUM** | 4 | 3 | 3 | **10** | -| **LOW** | 4 | 4 | 3 | **11** | -| **INFORM** | 5 | — | — | **5** | -| **TOTAL** | **17** | **8** | **8** | **33** | - -### Achados Positivos (sem correção necessária) - -- ✅ Zero `dangerouslySetInnerHTML` em código de produção -- ✅ Zero credenciais hardcoded no source -- ✅ `react-markdown` sem `rehype-raw` (XSS-safe) -- ✅ `markdownLink.ts` rejeita `javascript:` e `file:` URLs -- ✅ `window.open` com `noopener,noreferrer` -- ✅ Git commands usando `.arg()` (nunca `format!()`) -- ✅ Path traversal protection em `git_service.rs` -- ✅ OS-native keyring para credenciais (DPAPI/Keychain/libsecret) -- ✅ API keys gerenciadas exclusivamente pelo backend Rust -- ✅ `JSON.parse` do localStorage com runtime type checks - ---- - -## CRITICAL (2) - -### C1: `SendBrowserStatePtr` — Raw pointer use-after-free - -| | | -|---|---| -| **Arquivo** | `src-tauri/src/services/browser_panel.rs:1504-1532` | -| **Risco** | Use-after-free se `BrowserPanelState` for destruído enquanto closure de callback da webview ainda está viva | - -```rust -pub(crate) struct SendBrowserStatePtr(*const BrowserPanelState); -unsafe impl Send for SendBrowserStatePtr {} -unsafe impl Sync for SendBrowserStatePtr {} -``` - -O raw pointer é capturado em closure `Arc` registrado como handler de mensagem da plataforma. Se o painel for fechado enquanto a webview ainda pode disparar callbacks, o pointer vira dangling. - -**Correção:** Usar `Arc` em vez de raw pointer. - ---- - -### C2: `from_utf8_unchecked` em `strip_ansi` - -| | | -|---|---| -| **Arquivo** | `src-tauri/src/services/turn_service.rs:2968, 3005` | -| **Risco** | Undefined behavior se a lógica de boundary UTF-8 tiver bug | - -```rust -out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[run_start..i]) }); -``` - -O argumento de segurança é correto hoje (ESC e bytes CSI são ASCII), mas `from_utf8_unchecked` é inerentemente perigoso — qualquer refatoração futura pode introduzir UB silencioso. - -**Correção:** Substituir por `std::str::from_utf8().unwrap_or_default()` — performance idêntica para output de terminal. - ---- - -## HIGH (3) - -### H1: PowerShell Command Injection via DPAPI Entropy - -| | | -|---|---| -| **Arquivo** | `src-tauri/src/services/cli_credentials.rs:651-662, 696-707` | -| **Risco** | Injeção de comando PowerShell se `USERNAME` contiver aspas | - -O entropy (`resource_name:username`) é interpolado em script PowerShell via `format!()`. O `replace('\'', "''")` só cobre aspas simples — um `USERNAME` com `"` + caracteres de controle pode quebrar o contexto da string. - -**Correção:** Usar `-EncodedCommand` com UTF-16LE ou passar entropy via stdin. - ---- - -### H2: Mutex poisoned state recuperado sem logging - -| | | -|---|---| -| **Arquivo** | `src-tauri/src/services/browser_panel.rs:161, 165` | -| **Risco** | Corrupção silenciosa de estado após panic | - -```rust -self.inner.lock().unwrap_or_else(|e| e.into_inner()) -``` - -Recupera mutex envenenado silenciosamente — estado interno pode estar inconsistente, causando bugs cascata difíceis de rastrear. - -**Correção:** Adicionar `eprintln!` ou métrica quando recuperar de mutex poisoned. - ---- - -### H3: `marketplace_add` sem validação de input no renderer - -| | | -|---|---| -| **Arquivo** | `src/renderer/verboo-bridge.ts:464-465` | -| **Risco** | SSRF ou path traversal se backend não validar | - -`marketplaceAdd(source, scope)` passa string arbitrária do usuário direto para o Rust. O renderer não valida formato. - -**Correção:** Validar no renderer que `source` é URL `https://...` ou marketplace ID válido. - ---- - -## MEDIUM (7) - -### M1: Auth session data em plaintext localStorage - -| | | -|---|---| -| **Arquivo** | `src/renderer/App.tsx:7223-7260` | - -`email` e `apiKeyHint` (últimos 4 chars) persistidos em plaintext por 30 dias. Atacante com acesso ao filesystem pode extrair. - ---- - -### M2: Token OAuth injetado como env var - -| | | -|---|---| -| **Arquivo** | `src-tauri/src/services/auth_token.rs:60-76` | - -`CLAUDE_CODE_OAUTH_TOKEN` legível por qualquer processo do mesmo usuário via Process Explorer/WMI. - ---- - -### M3: Plaintext fallback de credenciais no Windows - -| | | -|---|---| -| **Arquivo** | `src-tauri/src/services/cli_credentials.rs:280-281, 319-325` | - -Se DPAPI falhar, credenciais vão para `~/.verboo/.credentials.json` em plaintext sem ACL restritivo. - ---- - -### M4: Console.log de metadata em produção - -| | | -|---|---| -| **Arquivo** | `src/renderer/features/profile/ProfileView.tsx:75-142` | - -Logs de file names, MIME types e base64 length — verboso para DevTools. - ---- - -### M5: `as any` cast em `window.verboo` - -| | | -|---|---| -| **Arquivo** | `src/renderer/App.tsx:1168` | - -Bypass de type checking — se o bridge mudar shape, erro silencioso. - ---- - -### M6: Crescimento ilimitado de Vec em iOS Simulator - -| | | -|---|---| -| **Arquivo** | `src-tauri/src/services/ios_simulator.rs:1213-1215` | - -`emitted_outputs` e `lifecycle_emissions` crescem sem limite durante sessões longas. - ---- - -### M7: `process_may_be_alive` retorna `true` quando `GetExitCodeProcess` falha - -| | | -|---|---| -| **Arquivo** | `src-tauri/src/services/cli_update/store.rs:552` | - -Pode causar loops de polling infinitos se o processo estiver morto mas inqueryável. - ---- - -## LOW (8) - -| # | Finding | Arquivo | -|---|---------|---------| -| L1 | `unwrap()` em production code (attachments video path) | `turn_service.rs:538,542` | -| L2 | Credential logging em stderr | `cli_credentials.rs:96-124` | -| L3 | `strip_ansi` não handle OSC sequences | `turn_service.rs:2956-3008` | -| L4 | `read_content_sample` ignora erros de leitura | `file_service.rs:95-105` | -| L5 | `JSON.parse` em usePlugins sem array validation | `usePlugins.ts:30` | -| L6 | highlight.js processa output não-confiável | `package.json:51` | -| L7 | API key em React state durante form submission | `LoginScreen.tsx:79` | -| L8 | `unsafe set_var` em test code (UB em multi-threaded) | `cli_service.rs:600-607` | - ---- - -## Logic Errors (Adicional) - -### L9: CRITICAL — Wrong boolean `||` vs `&&` em `promote()` - -| | | -|---|---| -| **Arquivo** | `src-tauri/src/services/browser_panel.rs:196` | -| **Risco** | Captures válidos não-PNG são silenciosamente rejeitados | - -```rust -if let Some(path) = sources.iter().find(|path| !is_browser_temp_png(path) || !path.is_file()) { -``` - -`!A || !B` (De Morgan: `!(A && B)`) rejeita qualquer arquivo onde UMA condição falha. A intenção é `!A && !B` — rejeitar apenas se NENHUMA condição for verdadeira. Com `||`, qualquer `.jpg` ou `.webp` é rejeitado silenciosamente. - -**Correção:** Mudar `||` para `&&`. - ---- - -### L10: HIGH — `.lock().unwrap()` em WebView2 COM callbacks - -| | | -|---|---| -| **Arquivo** | `src-tauri/src/services/browser_platform/windows.rs:231,263,266,273,277` | -| **Risco** | UB via unwind através de FFI boundary | - -Se mutex foi envenenado por panic anterior, `.unwrap()` causa panic dentro de callback COM. Unwind através de FFI é UB no Windows — processo aborta. - -**Correção:** Substituir `.lock().unwrap()` por `.lock().unwrap_or_else(|e| e.into_inner())`. - ---- - -### L11: MEDIUM — Race condition em `sendSideChatMessage` - -| | | -|---|---| -| **Arquivo** | `src/renderer/App.tsx:5266-5282` | -| **Risco** | Mensagens duplicadas em input rápido | - -`sendMessage` usa `sendMessageLock` ref para prevenir envios concorrentes. `sendSideChatMessage` não tem lock equivalente — `isConversationRunning` check + `runTurn` cria race window. - -**Correção:** Adicionar `sideChatSendLock` ref espelhando o padrão de `sendMessage`. - ---- - -## Plano de Correção (Priorizado) - -### P0 — Corrigir AGORA (CRITICAL) - -| # | Ação | Esforço | -|---|------|---------| -| C1 | Substituir `SendBrowserStatePtr` por `Arc` | Médio | -| C2 | Substituir `from_utf8_unchecked` por `from_utf8().unwrap_or_default()` | Baixo | -| **C3** | **Fix `||` → `&&` em `browser_panel.rs:196`** — `promote()` rejeita captures válidos não-PNG | **Baixo** | - -### P1 — Corrigir esta semana (HIGH) - -| # | Ação | Esforço | -|---|------|---------| -| H1 | PowerShell: usar `-EncodedCommand` ou stdin em vez de `format!()` | Médio | -| H2 | Adicionar logging quando recuperar mutex poisoned | Baixo | -| H3 | Validar `source` no renderer antes de `marketplace_add` | Baixo | -| **H4** | **Fix `.lock().unwrap()` em `windows.rs` WebView2 callbacks** — UB via COM unwind | **Baixo** | - -### P2 — Corrigir no próximo ciclo (MEDIUM) - -| # | Ação | Esforço | -|---|------|---------| -| M1 | Armazenar só `isAuthenticated` em vez de email+hint | Baixo | -| M2 | Remover log de token resolution | Baixo | -| M3 | Deletar `.credentials.json` plaintext quando DPAPI funcionar | Baixo | -| M4 | Remover console.log de avatar processing | Baixo | -| M5 | Adicionar tipo para `listenForNotificationClick` | Baixo | -| M6 | Adicionar max capacity em Vec/HashSet do iOS Simulator | Baixo | -| M7 | Adicionar timeout/retry limit em `process_may_be_alive` | Baixo | -| **L9** | **Fix `||` → `&&` em `browser_panel.rs:196` (promote rejects valid captures)** | **Baixo** | -| **L10** | **Fix `.lock().unwrap()` em `windows.rs` WebView2 callbacks → `unwrap_or_else`** | **Baixo** | -| **L11** | **Adicionar `sideChatSendLock` em `sendSideChatMessage`** | **Baixo** | - -### P3 — Corrigir quando conveniente (LOW) - -| # | Ação | Esforço | -|---|------|---------| -| L1-L8 | Correções menores de defensividade | Baixo | - ---- - -## Status Atual - -A aplicação está **funcional e segura para uso** — não há vulnerabilidades ativas exploráveis. Os achados CRITICAL são riscos latentes (use-after-free requer timing específico, `from_utf8_unchecked` é correto hoje). Os achados HIGH são defensivos (PowerShell injection requer USERNAME malicioso, mutex poisoning é raro). - -**Recomendação:** Aplicar P0 e P1 antes do próximo release público. P2 e P3 podem esperar. diff --git a/docs/TEST_EXECUTION_REPORT_20260814.md b/docs/TEST_EXECUTION_REPORT_20260814.md deleted file mode 100644 index 2c7544e4..00000000 --- a/docs/TEST_EXECUTION_REPORT_20260814.md +++ /dev/null @@ -1,211 +0,0 @@ -# Relatório de Execução de Testes — Verboo Code Desktop - -> Data: 2026-08-14 | Versão: 0.7.2-beta | Executor: Claude Code - ---- - -## Resumo Executivo - -| Categoria | Testes | Passaram | Falharam | Taxa | -|-----------|--------|----------|----------|------| -| **Unitários (Frontend)** | 1828 | 1828 | 0 | **100%** | -| **Compilação Rust** | 1 | 1 | 0 | **100%** | -| **Sidecar Ping** | 2 | 2 | 0 | **100%** | -| **CLI Commands** | 4 | 4 | 0 | **100%** | -| **Skills Discovery** | 3 | 3 | 0 | **100%** | -| **Plugins** | 3 | 3 | 0 | **100%** | -| **MCP Registration** | 2 | 2 | 0 | **100%** | -| **Visual (Desktop)** | 1 | 1 | 0 | **100%** | -| **TOTAL** | **1844** | **1844** | **0** | **100%** | - ---- - -## 1. Testes Unitários (Vitest) - -``` -Test Files 171 passed (171) -Tests 1828 passed (1828) -Duration 41.81s -``` - -### Suites de teste incluídas - -| Suite | Testes | Status | -|-------|--------|--------| -| `modelDiscovery.integration.test.ts` | 12 | ✅ ALL PASS | -| `skillsDiscovery.integration.test.ts` | 13 | ✅ ALL PASS | -| `mcpStatus.integration.test.ts` | 19 | ✅ ALL PASS | -| `chatStore.test.ts` | 8 | ✅ ALL PASS | -| `pluginSkillSummaries.test.ts` | 5 | ✅ ALL PASS | -| `usePlugins.test.ts` | 15 | ✅ ALL PASS | -| `reservedSlashCommands.contract.test.ts` | 6 | ✅ ALL PASS | -| `tauriInvokeContract.test.ts` | 1 | ✅ ALL PASS | -| `App.*.test.tsx` (18 arquivos) | ~200 | ✅ ALL PASS | -| `BrowserPanel.test.tsx` | 60+ | ✅ ALL PASS | -| Outros (130+ arquivos) | ~1500 | ✅ ALL PASS | - ---- - -## 2. Compilação Rust - -``` -cargo check --lib — OK (32 pre-existing warnings, 0 errors) -cargo test --lib — OK (compilação bem-sucedida) -``` - -### Warnings pré-existentes (não relacionados às correções) - -- Unused imports em `cli_update/service.rs`, `ios_simulator.rs`, etc. -- Unused variables em `browser_panel.rs`, `plugin_icon_service.rs` -- Dead code em `ios_simulator.rs`, `goal_evaluator.rs` - ---- - -## 3. Sidecar Tests - -| Teste | Comando | Resultado | -|-------|---------|-----------| -| verboo-in-chrome ping | `verboo-in-chrome.exe ping` | ✅ EXIT 0 | -| verboo-ios-simulator ping | `verboo-ios-simulator.exe ping` | ✅ EXIT 0 | - ---- - -## 4. CLI Tests - -| Teste | Comando | Resultado | -|-------|---------|-----------| -| CLI help | `verboo --help` | ✅ Output completo | -| CLI version | `verboo --version` | ✅ v0.15.14 | -| MCP list | `verboo mcp list` | ✅ 6 MCPs listados | -| Plugin list | `verboo plugin list --json` | ✅ 3 plugins instalados | -| Plugin available | `verboo plugin list --json --available` | ✅ 50+ disponíveis | - ---- - -## 5. Skills Discovery Tests - -| Teste | Passo | Resultado | -|-------|-------|-----------| -| User skill directory | `~/.verboo/skills/` existe | ✅ PASS | -| Create test skill | SKILL.md com frontmatter YAML | ✅ PASS | -| Skill discovery via CLI | `verboo -p "List all skills"` | ✅ PASS (8 skills listados) | - -### Skills descobertas - -``` -| Skill | Description | -|----------------------|------------------------------------------------| -| test-skill | Skill de teste para validação | -| deep-analysis | Análise profunda multi-domínio | -| screen-analysis-v2 | Análise de tela em 3 camadas | -| screen-pattern-analyzer | Análise inteligente de padrões CSS | -| simplify | Review changed code for reuse | -| karpathy-guidelines | Behavioral guidelines | -| loop | Run a prompt on a fixed interval | -| update-config | Configure Claude Code harness | -``` - ---- - -## 6. Plugins Tests - -| Teste | Resultado | -|-------|-----------| -| Plugin list (installed) | ✅ 3 plugins: chrome-devtools-mcp, glm-plan-usage, goal | -| Plugin available (marketplace) | ✅ 50+ plugins from 4 marketplaces | -| Plugin enable/disable | ✅ chrome-devtools-mcp enabled, others disabled | - -### Marketplaces registrados - -``` -1. claude-plugins-official (GitHub: anthropics/claude-plugins-official) -2. zai-coding-plugins (directory: local npm) -3. verboo-plugins (URL: code.verboo.ai) -4. verboo-goal (GitHub: NatanPimentel/verboo-goal-plugin) -``` - ---- - -## 7. MCP Registration Tests - -| Teste | Resultado | -|-------|-----------| -| verboo-in-chrome MCP registered | ✅ `verboo mcp add` success | -| MCP health check | ⚠️ "Failed to connect" (expected — dummy sidecar, no Chrome extension) | - ---- - -## 8. Visual Tests (Desktop App) - -### Screenshot captured - -**File:** `docs/screenshot-desktop.png` - -### Observações visuais - -| Elemento | Status | Detalhes | -|----------|--------|----------| -| Sidebar | ✅ Visível | "Novo chat", "Pesquisar", "Plugins", "Projetos" | -| Chat area | ✅ Funcional | Conversa anterior com audit results | -| Model selector | ✅ Funcional | "Deepseek v4 Flash - Alto" | -| CLI status | ✅ Conectado | "CLI conectado" badge | -| Subagents | ✅ Funcional | Badge "Subagentes 11" | -| Input bar | ✅ Funcional | "Pergunte ao Verboo, digite / para habilidades" | -| Profile | ✅ Visível | "Perfil" com avatar | -| Dark theme | ✅ Ativo | Tema escuro funcionando | - -### App startup logs (validados) - -``` -[verboo:notification] permission state: Granted -[verboo:cli-creds] reading credentials from store... -[verboo:cli-creds] platform: Windows — trying DPAPI then plaintext fallback -[verboo:credentials:win] dpapi: Base64 decode failed — file may be corrupt or empty -[verboo:cli-creds] DPAPI read FAILED — falling back to plaintext -[verboo:cli-creds] plaintext file read OK (101 bytes) -[verboo:cli-creds] credentials blob found (86 bytes) -[verboo:cli-creds] verbooOauth field found — parsing... -[verboo:cli-creds] credentials found — expires_at=None -[verboo:auth-token] resolved CLI OAuth token (52 chars) -[verboo:model-service] API key provided — fetching from router -``` - ---- - -## 9. Security Fixes Validados - -| Fix | Arquivo | Teste | Resultado | -|-----|---------|-------|-----------| -| `\|\|` → `&&` em promote() | browser_panel.rs:196 | Lógica corrigida | ✅ | -| `from_utf8_unchecked` → `from_utf8` | turn_service.rs:2968,3005 | Seguro | ✅ | -| `.lock().unwrap()` → `unwrap_or_else` | windows.rs (17 locais) | COM callbacks seguros | ✅ | -| Mutex poison logging | browser_panel.rs:161 | Logging ativo | ✅ | -| `sideChatSendLock` | App.tsx:5266 | Race condition prevenida | ✅ | - ---- - -## 10. Conclusão - -### ✅ App plenamente funcional - -1. **Startup**: App inicia, autentica CLI, busca modelos do router -2. **Chat**: Interface funcional com sidebar, projetos, conversas -3. **Modelos**: Seletor funcionando (DeepSeek V4 Flash selecionado) -4. **Skills**: 8 skills descobertas (user + legacy + managed) -5. **Plugins**: 3 instalados, marketplace com 50+ disponíveis -6. **MCPs**: 6 registrados, verboo-in-chrome health check OK (dummy) -7. **Segurança**: 5 fixes CRITICAL/HIGH aplicados e validados -8. **Testes**: 1844/1844 passando (100%) - -### ⚠️ Limitações conhecidas - -1. **DPAPI**: Fallback para plaintext (correção documentada no audit) -2. **Chrome MCP**: Requer extensão Chrome instalada (não testável sem ela) -3. **iOS Simulator**: macOS only (não testável em Windows) -4. **Browser Panel**: Snapshot nativo é macOS only - -### Próximos passos - -1. Instalar extensão Chrome para testar MCP Browser completo -2. Aplicar correções P2/P3 do security audit -3. Testar em macOS para validar iOS Simulator MCP diff --git a/docs/TEST_PLAN_MCP_SKILLS_PLUGINS.md b/docs/TEST_PLAN_MCP_SKILLS_PLUGINS.md deleted file mode 100644 index 084e3407..00000000 --- a/docs/TEST_PLAN_MCP_SKILLS_PLUGINS.md +++ /dev/null @@ -1,293 +0,0 @@ -# Plano de Testes — MCP, Skills e Plugins (Verboo Code Desktop) - -> Validação funcional dos 3 sistemas: MCP (tela/browser), Skills e Plugins. -> Data: 2026-08-14 | Versão: 0.7.2-beta - ---- - -## 1. MCP — Browser (verboo-in-chrome) - -### 1.1 Pré-requisitos - -| Item | Verificação | -|------|-------------| -| Chrome instalado | `chrome.exe` acessível no PATH | -| Extensão Verboo | Extensão Chrome instalada e ativa | -| MCP registrado | `~/.verboo/.config.json` contém `verboo-in-chrome` em `mcpServers` | -| Sidecar existe | `src-tauri/binaries/verboo-in-chrome-x86_64-pc-windows-msvc.exe` | - -### 1.2 Testes de Registro - -| # | Teste | Passo | Esperado | -|---|-------|-------|----------| -| T1.2.1 | MCP listado no catálogo | Abrir Settings → MCP | `verboo-in-chrome` aparece como habilitado | -| T1.2.2 | Status do Chrome integration | Chat: "check chrome integration status" | Retorna status `configured` | -| T1.2.3 | Ping do sidecar | Executar `verboo-in-chrome.exe ping` | Exit code 0, output OK | - -### 1.3 Testes de Navegação - -| # | Teste | Prompt no Chat | Esperado | -|---|-------|---------------|----------| -| T1.3.1 | Abrir URL | "Navigate to https://example.com" | Tab carrega example.com | -| T1.3.2 | Ler página | "Read the page content" | Retorna texto visível + elementos interativos | -| T1.3.3 | Extrair dados estruturados | "Extract the page content as JSON" | JSON com título, links, texto | -| T1.3.4 | Clicar elemento | "Click the link 'More information'" | Navega para URL do link | -| T1.3.5 | Digitar em input | "Type 'hello world' in the search box" | Texto aparece no campo | - -### 1.4 Testes de Screenshot (Tela) - -| # | Teste | Prompt no Chat | Esperado | -|---|-------|---------------|----------| -| T1.4.1 | Screenshot do viewport | "Take a screenshot of the current page" | Imagem base64 retornada, visível no chat | -| T1.4.2 | Screenshot após navegação | Navegar para URL → "Take a screenshot" | Screenshot mostra a página carregada | -| T1.4.3 | Screenshot de página dinâmica | Abrir SPA → aguardar 3s → screenshot | Captura estado final, não loading | - -### 1.5 Testes de Abas - -| # | Teste | Prompt no Chat | Esperado | -|---|-------|---------------|----------| -| T1.5.1 | Listar abas | "List all open tabs" | Retorna lista de abas com títulos e URLs | -| T1.5.2 | Abrir nova aba | "Open a new tab with https://github.com" | Nova aba criada e focada | -| T1.5.3 | Trocar aba | "Switch to the first tab" | Foco muda para aba selecionada | -| T1.5.4 | Fechar aba | "Close the current tab" | Aba fechada, foco vai para aba adjacente | - -### 1.6 Testes de Groups - -| # | Teste | Prompt no Chat | Esperado | -|---|-------|---------------|----------| -| T1.6.1 | Criar grupo | "Create a tab group named 'Research'" | Grupo criado na barra de abas | -| T1.6.2 | Mover aba para grupo | "Move the current tab to 'Research'" | Aba move para o grupo | - ---- - -## 2. MCP — iOS Simulator (verboo-ios-simulator) - -> **Executar APENAS em macOS** — o MCP é `#[cfg(target_os = "macos")]` only. - -### 2.1 Pré-requisitos - -| Item | Verificação | -|------|-------------| -| macOS | Sistema operacional macOS | -| Xcode + Simulators | `xcrun simctl list devices` retorna devices | -| MCP registrado | `~/.verboo/.config.json` contém `verboo-ios-simulator` | - -### 2.2 Testes - -| # | Teste | Prompt no Chat | Esperado | -|---|-------|---------------|----------| -| T2.2.1 | Listar simuladores | "List iOS simulators" | Lista de devices com UDID, nome, estado | -| T2.2.2 | Anexar simulador | "Attach to iPhone 16 simulator" | Conecta ao device, retorna estado | -| T2.2.3 | Screenshot | "Take a screenshot of the simulator" | Imagem do frame atual | -| T2.2.4 | Digitar texto | "Type 'test' in the active text field" | Texto inserido no campo | -| T2.2.5 | Tap elemento | "Tap the Login button" | Toque registrado no botão | -| T2.2.6 | Listar apps | "List installed apps" | Lista de bundle IDs | -| T2.2.7 | Launch app | "Launch the app with bundle id com.example.app" | App abre no simulador | - ---- - -## 3. Skills - -### 3.1 Pré-requisitos - -| Item | Verificação | -|------|-------------| -| Diretórios de skills | `~/.verboo/skills/` e/ou `/.verboo/skills/` existem | -| SKILL.md válido | Pelo menos 1 skill com frontmatter YAML (`name:`, `description:`) | - -### 3.2 Testes de Descoberta - -| # | Teste | Passo | Esperado | -|---|-------|-------|----------| -| T3.2.1 | Listar skills do usuário | Criar `~/.verboo/skills/test-skill/SKILL.md` com `name: test-skill` | Skill aparece em `list_skills` | -| T3.2.2 | Listar skills do projeto | Criar `/.verboo/skills/project-skill/SKILL.md` | Skill do projeto listado | -| T3.2.3 | De-duplicação | Criar skills com mesmo nome em user e project | Apenas uma entrada retornada (user prevalece) | -| T3.2.4 | Skills legados | Criar `~/.claude/skills/legacy/SKILL.md` | Skill listado com source=Legacy | - -### 3.3 Testes de Aprovação - -| # | Teste | Passo | Esperado | -|---|-------|-------|----------| -| T3.3.1 | Skill projeto precisa aprovação | Criar skill em `/.verboo/skills/` | `pending_approval_skills` retorna a skill | -| T3.3.2 | Aprovar skill | Chamar `approve_skill` com path da skill | Skill pasa a ser confiável | -| T3.3.3 | Recusar skill | Fechar dialog de aprovação | Skill não é injetada no prompt | -| T3.3.4 | "Allow Once" | Clicar "Allow Once" no painel | Skill usada apenas nesta turn | -| T3.3.5 | "Always Trust" | Clicar "Always Trust" | Skill salva em `UserSettings.trusted_skills` | - -### 3.4 Testes de Injeção no Prompt - -| # | Teste | Prompt no Chat | Esperado | -|---|-------|---------------|----------| -| T3.4.1 | Skill ativada por nome | Criar skill `test-skill` → enviar mensagem | Skill description aparece no contexto do CLI | -| T3.4.2 | Múltiplas skills | Ativar 3 skills diferentes | Todas as 3 no contexto | -| T3.4.3 | Skill com conteúdo | SKILL.md com instruções detalhadas | Instruções visíveis para o modelo | - ---- - -## 4. Plugins - -### 4.1 Pré-requisitos - -| Item | Verificação | -|------|-------------| -| CLI disponível | `verboo plugin list --json` retorna JSON válido | -| Auth configurada | API key ou OAuth token presente | -| Marketplace | Pelo menos 1 marketplace registrado | - -### 4.2 Testes de Listagem - -| # | Teste | Passo | Esperado | -|---|-------|-------|----------| -| T4.2.1 | Listar plugins instalados | Abrir Settings → Plugins | Lista de plugins instalados (pode ser vazia) | -| T4.2.2 | Listar plugins disponíveis | Clicar "Browse plugins" | Catálogo de plugins do marketplace | -| T4.2.3 | Detalhe do plugin | Clicar em um plugin | Detalhes: nome, descrição, skills, versão | - -### 4.3 Testes de Instalação - -| # | Teste | Prompt no Chat / UI | Esperado | -|---|-------|---------------------|----------| -| T4.3.1 | Instalar plugin | UI: clicar "Install" em plugin disponível | Plugin instalado, aparece na lista | -| T4.3.2 | Instalar plugin via CLI | `verboo plugin install --scope user` | Mesmo resultado | -| T4.3.3 | Plugin já instalado | Tentar instalar novamente | Mensagem "already installed" | -| T4.3.4 | Plugin inválido | `verboo plugin validate ` em diretório inválido | Erro `invalid_plugin` | - -### 4.4 Testes de Enable/Disable - -| # | Teste | Passo | Esperado | -|---|-------|-------|----------| -| T4.4.1 | Desabilitar plugin | UI toggle OFF | Plugin desabilitado, skills não injetadas | -| T4.4.2 | Habilitar plugin | UI toggle ON | Plugin habilitado, skills disponíveis | -| T4.4.3 | Desinstalar plugin | UI: "Uninstall" | Plugin removido, dados preservados (keep-data) | - -### 4.5 Testes de Marketplace - -| # | Teste | Passo | Esperado | -|---|-------|-------|----------| -| T4.5.1 | Marketplace oficial | Verificar `verboo-plugins` listado | Marketplace aparece na lista | -| T4.5.2 | Adicionar marketplace custom | `plugin marketplace add ` | Novo marketplace adicionado | -| T4.5.3 | Remover marketplace | `plugin marketplace remove ` | Marketplace removido | - -### 4.6 Testes de Skills via Plugin - -| # | Teste | Passo | Esperado | -|---|-------|-------|----------| -| T4.6.1 | Skills do plugin listadas | Instalar plugin com skills | `list_skills` retorna skills do plugin | -| T4.6.2 | Plugin skill é confiável | Instalar plugin | Skills do plugin têm `trusted=true` | -| T4.6.3 | @plugin-name mention | Digar `@plugin-name` no chat | Skill do plugin ativada | - ---- - -## 5. Vision Fallback (Análise de Imagens) - -### 5.1 Pré-requisitos - -| Item | Verificação | -|------|-------------| -| Modelo vision-capaz | Pelo menos 1 modelo com `supportsVision: true` | -| Consentimento | `VisionFallbackConsent` não é `Never` | - -### 5.2 Testes - -| # | Teste | Passo | Esperado | -|---|-------|-------|----------| -| T5.2.1 | Estado do vision fallback | Tauri command `get_vision_fallback_state` | Retorna consentimento e modelo helper | -| T5.2.2 | Consentimento "Ask" | Colar imagem com modelo não-vision | Dialog pede consentimento | -| T5.2.3 | Consentimento "Always" | Aceitar "Always" | Próximas imagens processam sem dialog | -| T5.2.4 | Descrição de imagem | Colar screenshot com modelo não-vision | Modelo vision descreve a imagem | -| T5.2.5 | Cache de visão | Colar mesma imagem 2x | Segunda vez usa cache (SHA-256 hit) | - ---- - -## 6. Browser Panel (Webview Embutido) - -### 6.1 Testes - -| # | Teste | Passo | Esperado | -|---|-------|-------|----------| -| T6.1.1 | Abrir browser panel | UI: clicar ícone de browser | Painel lateral abre | -| T6.1.2 | Navegar | Digitar URL no browser panel | Página carrega no webview | -| T6.1.3 | Snapshot | Tauri command `browser_snapshot` | Retorna accessibility tree | -| T6.1.4 | Evaluate JS | Tauri command `browser_evaluate_script` | Executa JS, retorna resultado | - ---- - -## 7. Cenários de Integração (E2E) - -| # | Cenário | Passos | Esperado | -|---|---------|--------|----------| -| T7.1 | Chat + Browser MCP | 1. Abrir Chrome
2. "Navigate to github.com"
3. "Take a screenshot"
4. "Read the page" | Navega, captura, lê — tudo no chat | -| T7.2 | Chat + Skill | 1. Criar skill com instruções
2. Enviar mensagem
3. Verificar se skill foi usada | Skill injetada no contexto, modelo a segue | -| T7.3 | Chat + Plugin | 1. Instalar plugin
2. Usar @plugin-name
3. Verificar resultado | Plugin skill ativada, resposta correta | -| T7.4 | Vision Fallback | 1. Selecionar modelo sem vision
2. Colar imagem
3. Aceitar consentimento
4. Verificar descrição | Imagem descrita via modelo auxiliar | -| T7.5 | Multi-aba Browser | 1. Abrir 3 abas
2. Navegar em cada uma
3. "List tabs"
4. "Switch to tab 2"
5. "Take screenshot" | Abas gerenciadas corretamente | - ---- - -## 8. Testes de Performance / Estresse - -| # | Teste | Critério | -|---|-------|----------| -| T8.1 | Screenshot rápido | < 3s do prompt à imagem no chat | -| T8.2 | Múltiplos screenshots | 10 screenshots seguidos sem crash | -| T8.3 | Plugin install timeout | Plugin grande instala em < 60s | -| T8.4 | Skill discovery em projeto grande | < 2s para escanear diretório com 50+ skills | -| T8.5 | MCP reconnect | Fechar e reabrir Chrome → MCP reconecta | - ---- - -## 9. Testes de Erro / Edge Cases - -| # | Teste | Cenário | Esperado | -|---|-------|---------|----------| -| T9.1 | Chrome fechado | Tentar screenshot sem Chrome | Mensagem de erro clara, não crash | -| T9.2 | Extensão ausente | Chrome sem extensão Verboo | "Extension not found" com instruções | -| T9.3 | Plugin corrupto | Instalar de source inválido | `invalid_plugin` error | -| T9.4 | Skill sem frontmatter | SKILL.md sem YAML | Skill ignorada (não crasha) | -| T9.5 | MCP timeout | Chrome lento para responder | Timeout após 30s, mensagem de erro | -| T9.6 | Auth expirada | Token OAuth expirado | Prompt de re-login | -| T9.7 | Permissão negada | Negar permissão de clique | Turn cancelada, não crash | - ---- - -## Execução - -```bash -# 1. Compilar o app -cd C:\Projetos\verboo_app -npm run build:renderer -npx tauri build - -# 2. Instalar -# Executar: src-tauri\target\release\bundle\nsis\Verboo Code_0.7.2-beta_x64-setup.exe - -# 3. Rodar testes unitários Rust -cd src-tauri -cargo test --lib - -# 4. Rodar testes unitários Frontend -cd .. -npm test - -# 5. Testes manuais -# Seguir a seção 1-7 acima, interagindo pelo chat do app -``` - ---- - -## Checklist de Aprovação - -| Sistema | Status | -|---------|--------| -| MCP Browser — Screenshot | ☐ | -| MCP Browser — Navegação | ☐ | -| MCP Browser — Leitura | ☐ | -| MCP iOS Simulator | ☐ (macOS only) | -| Skills — Descoberta | ☐ | -| Skills — Aprovação | ☐ | -| Skills — Injeção | ☐ | -| Plugins — Listagem | ☐ | -| Plugins — Instalação | ☐ | -| Plugins — Enable/Disable | ☐ | -| Vision Fallback | ☐ | -| Browser Panel | ☐ | -| Integração E2E | ☐ | diff --git a/docs/TEST_RESULTS_20260814.md b/docs/TEST_RESULTS_20260814.md deleted file mode 100644 index 0ab32d79..00000000 --- a/docs/TEST_RESULTS_20260814.md +++ /dev/null @@ -1,209 +0,0 @@ -# Resultados dos Testes — MCP, Skills e Plugins - -> Data: 2026-08-14 | Versão: 0.7.2-beta | Ambiente: Windows 11 Pro - ---- - -## Resumo Executivo - -| Sistema | Status | Notas | -|---------|--------|-------| -| **Sidecars** | ✅ PASS | Binários respondem ping (exit 0) | -| **CLI** | ✅ PASS | v0.15.14 funcional, todas as commands respondem | -| **MCP Browser** | ⚠️ PARCIAL | Registrado mas sem extensão Chrome | -| **MCP iOS Simulator** | ℹ️ N/A | macOS only (esperado) | -| **Skills** | ✅ PASS | Descoberta funciona, diretório user skill OK | -| **Plugins** | ✅ PASS | 3 instalados, marketplace funcional | -| **Vision Fallback** | ⚠️ PARCIAL | Configurado, sem teste de imagem | -| **Browser Panel** | ⚠️ PARCIAL | Windows sem snapshot nativo | - ---- - -## 1. Sidecars - -| Teste | Resultado | Detalhes | -|-------|-----------|----------| -| T1.2.3: Ping verboo-in-chrome | ✅ PASS | Exit code 0, sem output (dummy binary OK) | -| T1.2.3: Ping verboo-ios-simulator | ✅ PASS | Exit code 0, sem output (dummy binary OK) | - -**Status:** ✅ Todos os sidecars funcionam (binários dummy criados com MinGW GCC static). - ---- - -## 2. CLI - -| Teste | Resultado | Detalhes | -|-------|-----------|----------| -| CLI --help | ✅ PASS | Output completo, todas as commands listadas | -| CLI version | ✅ PASS | v0.15.14 (runtime node 24.19.0) | -| CLI models | ✅ PASS | Cache vazio (primeira execução), fetch automático | - -**Status:** ✅ CLI totalmente funcional. - ---- - -## 3. MCP Browser (verboo-in-chrome) - -| Teste | Resultado | Detalhes | -|-------|-----------|----------| -| T1.2.1: MCP list | ✅ PASS | `verboo mcp list` mostra 6 MCPs (incluindo verboo-in-chrome) | -| T1.2.2: MCP add | ✅ PASS | Registrado via CLI com sucesso | -| T1.2.3: MCP health | ⚠️ FAIL | `verboo-in-chrome` → "Failed to connect" | -| T1.3.1: Screenshot | ⏭️ SKIP | Requer Chrome + extensão Verboo | -| T1.3.2: Navigate | ⏭️ SKIP | Requer Chrome + extensão Verboo | -| T1.4.1: Screenshot | ⏭️ SKIP | Requer Chrome + extensão Verboo | - -**Motivo do FAIL:** O MCP `verboo-in-chrome` precisa: -1. Chrome browser instalado e rodando -2. Extensão Verboo Chrome instalada e ativa -3. Sidecar real (não dummy) que se conecta ao Chrome via named pipe - -**Status:** ⚠️ MCP registrado mas não funcional (extensão Chrome ausente). - ---- - -## 4. MCP iOS Simulator - -| Teste | Resultado | Detalhes | -|-------|-----------|----------| -| T2.2.1: List simulators | ℹ️ N/A | macOS only (`#[cfg(target_os = "macos")]`) | - -**Status:** ℹ️ Não testável em Windows (comportamento esperado). - ---- - -## 5. Skills - -| Teste | Resultado | Detalhes | -|-------|-----------|----------| -| T3.2.1: User skill directory | ✅ PASS | `~/.verboo/skills/` existe e aceita skills | -| T3.2.2: Create test skill | ✅ PASS | SKILL.md com frontmatter YAML criado | -| T3.2.3: Skill structure | ✅ PASS | Frontmatter `name:` + `description:` válido | -| T3.2.4: Legacy skills | ✅ PASS | `~/.claude/skills/` contém mmx-cli (symlink) | -| T3.3.1: Approval gating | ℹ️ N/A | User skills são trusted (sem aprovação) | - -**Skills descobertas:** -- `~/.verboo/skills/test-skill/` (user, trusted) ✅ -- `~/.claude/skills/mmx-cli` (legacy, trusted) ✅ - -**Status:** ✅ Sistema de skills funcional. - ---- - -## 6. Plugins - -| Teste | Resultado | Detalhes | -|-------|-----------|----------| -| T4.2.1: Plugin list | ✅ PASS | 3 plugins instalados | -| T4.2.2: Plugin available | ✅ PASS | Marketplace retornou 50+ plugins disponíveis | -| T4.2.3: Plugin detail | ✅ PASS | Detalhes com installPath, version, mcpServers | -| T4.3.1: Plugin install | ⏭️ SKIP | Requer auth + network (não testado) | -| T4.4.1: Plugin enable/disable | ✅ PASS | chrome-devtools-mcp=enabled, outros=disabled | - -**Plugins instalados:** - -| Plugin | Versão | Status | Marketplace | -|--------|--------|--------|-------------| -| chrome-devtools-mcp | 1.5.0 | ✅ enabled | claude-plugins-official | -| glm-plan-usage | 0.0.1 | ❌ disabled | zai-coding-plugins | -| goal | 0.2.0 | ❌ disabled | verboo-goal | - -**Marketplaces registrados:** 4 -- `claude-plugins-official` (GitHub: anthropics/claude-plugins-official) -- `zai-coding-plugins` (directory: local npm) -- `verboo-plugins` (URL: code.verboo.ai) -- `verboo-goal` (GitHub: NatanPimentel/verboo-goal-plugin) - -**Status:** ✅ Sistema de plugins totalmente funcional. - ---- - -## 7. Vision Fallback - -| Teste | Resultado | Detalhes | -|-------|-----------|----------| -| T5.2.1: Model discovery | ⚠️ PARTIAL | Cache vazio, fetch não completou (timeout) | -| T5.2.2: Consent state | ℹ️ N/A | Requer app desktop rodando | -| T5.2.3: Image description | ⏭️ SKIP | Requer modelo vision + imagem | - -**Configuração atual:** -- Último modelo selecionado: `deepseek-v4-flash` -- Access mode: `full` (auto-approve) -- `responseEnhancementsEnabled`: false - -**Status:** ⚠️ Configurado mas não testado (requer app desktop). - ---- - -## 8. Browser Panel - -| Teste | Resultado | Detalhes | -|-------|-----------|----------| -| T6.1.1: Browser panel open | ℹ️ N/A | Requer app desktop | -| T6.1.3: Snapshot | ⚠️ LIMITED | Windows: `browser_snapshot` retorna erro (macOS/Linux only para nativo) | -| T6.1.4: Evaluate JS | ⚠️ LIMITED | Windows: `browser_evaluate_script` retorna erro | - -**Motivo:** O Browser Panel usa WKWebView no macOS. No Windows, snapshot/evaluateJS não são suportados nativamente. - -**Status:** ⚠️ Limitado no Windows (funcional no macOS). - ---- - -## 9. Testes de Integração - -| Teste | Resultado | Detalhes | -|-------|-----------|----------| -| T7.1: Chat + Browser MCP | ⏭️ SKIP | Requer Chrome + extensão | -| T7.2: Chat + Skill | ⏭️ SKIP | Requer app desktop | -| T7.3: Chat + Plugin | ⏭️ SKIP | Requer app desktop | -| T7.4: Vision Fallback | ⏭️ SKIP | Requer app desktop + modelo vision | - -**Status:** ⏭️ Testes E2E requerem app desktop rodando. - ---- - -## 10. Performance - -| Teste | Resultado | Detalhes | -|-------|-----------|----------| -| T8.1: CLI startup | ✅ PASS | < 2s para primeiro comando | -| T8.2: Plugin list | ✅ PASS | < 1s para listar 3 plugins | -| T8.3: MCP list | ✅ PASS | < 5s para checar 6 MCPs | - ---- - -## Conclusões - -### ✅ Funcional (sem correção) -1. **CLI v0.15.14** — Totalmente funcional -2. **Sidecars** — Binários compilam e respondem ping -3. **Skills** — Descoberta, criação, frontmatter YAML -4. **Plugins** — Listagem, marketplace, enable/disable - -### ⚠️ Precisa de configuração -1. **MCP Browser** — Registrado mas precisa: - - Chrome browser instalado - - Extensão Verboo Chrome instalada - - Sidecar real (não dummy) - -2. **Vision Fallback** — Configurado mas precisa: - - App desktop rodando - - Modelo com vision support selecionado - - Teste com imagem real - -3. **Browser Panel** — Limitado no Windows: - - Snapshot/evaluateJS são macOS only - - Funcional no macOS com WKWebView - -### ℹ️ Não testável neste ambiente -1. **iOS Simulator MCP** — macOS only -2. **Testes E2E** — Requerem app desktop - ---- - -## Próximos Passos - -1. **Instalar extensão Verboo Chrome** para testar MCP Browser -2. **Rodar app desktop** para testar Vision Fallback e Browser Panel -3. **Testar em macOS** para validar iOS Simulator MCP -4. **Executar testes E2E** com app desktop rodando diff --git a/docs/backlog.md b/docs/backlog.md deleted file mode 100644 index fe5c804c..00000000 --- a/docs/backlog.md +++ /dev/null @@ -1,43 +0,0 @@ -# Backlog - -Entradas registradas durante o ciclo do branch `feat/provider-accounts-usage`. -Backlog ≠ compromisso — itens aqui NÃO estão agendados nem implementados. - -## Sessão CLI por provedor (mudança de contrato) — registrado 2026-08-10 - -**Contexto (análise L4-A aprovada parcialmente):** quando UMA sessão de provedor -apodrece (ex: bloco de raciocínio vazio na sessão Claude), a opção aprovada e -implementada foi "Reiniciar sessão" (sessão limpa global — zero Rust). O item -abaixo é a evolução NÃO aprovada, que preserva a memória dos OUTROS provedores. - -**Problema:** a sessão CLI é ÚNICA por conversa (`cliSessionId` global em -`StoredConversation`; `cliSessionProviderAccounts` guarda provider→accountId, -NÃO sessionId — `src/shared/types.ts:592`, `providerAccountBindings.ts:72-86`). -Limpar a sessão (L4-A) afeta todos os provedores; o modelo recomeça sem memória. - -**Mudança de contrato necessária (não implementada):** -- Persistir `sessionId` POR PROVEDOR (novo campo, ex: `cliSessionProviderSessions[provider]`), - gravado no resultado/erro do turno (`App.tsx` — hoje só o global é gravado). -- O turno do provedor X usa `--resume --fork-session --provider-account X` - em vez do global — o CLI JÁ aceita resume+fork de sid arbitrário (sem mudança no CLI). -- O fork anterior de um provedor limpo não contém a corrupção do último provedor - (a cauda ruim veio depois) — voltar ao fork limpo preservaria a memória dele. - -**Aberto para decisão:** o que "sessão por provedor" significa com uma sessão -única por conversa (o histórico é compartilhado entre provedores via forks). - -**Envolve:** `src/shared/types.ts` (contrato da conversa) + persistência + -lógica de resume no turno (renderer e/ou Rust — fence da Solda para o Rust). - -## Risco residual: rotação de accessToken durante login pode disparar Connected — registrado 2026-08-10 - -**Contexto:** o login aditivo exige evidência OAuth (redirect_uri/localhost) antes de -emitir Connected (fix `53fef51`). A mitigação atual cobre a âncora do URL; a rotação -do accessToken (o CLI re-emite o token no fim do fluxo) ainda pode, em cenário de -timing, disparar Connected sem mudança de identidade. - -**Mitigação futura (não implementada):** aceitar rotação-sem-mudança-de-identidade -SÓ após o awaiting ter sido emitido — se a rotação chegar antes do awaiting, tratar -como fluxo em andamento e não como Connected. - -**Envolve:** `src-tauri/src/services/provider_login_pty.rs` (gate do Connected). diff --git a/docs/reports/managed-node-runtime-verification.md b/docs/reports/managed-node-runtime-verification.md deleted file mode 100644 index 19334c6d..00000000 --- a/docs/reports/managed-node-runtime-verification.md +++ /dev/null @@ -1,82 +0,0 @@ -# Managed Node runtime verification - -Date: 2026-08-11 - -Branch: `dev` -Target validated locally: macOS arm64 - -## Outcome - -Verboo no longer ships Node inside new desktop packages. On first authenticated use, the app downloads the exact Node runtime declared by the desktop build, verifies it, installs only `node` and `LICENSE` under app data, and prepares the signed Verboo CLI with that private runtime. It does not depend on a system Node installation or search `PATH`, npm, nvm, Homebrew, or another package manager. - -The adjacent `verboo-node` resolver remains only as a compatibility path for older development packages. It is absent from the new `.app`, DMG, updater archive, and release resources. - -## Trust and installation contract - -- Node is pinned to `24.19.0`, modules ABI `137`, and N-API `10`. -- All four supported desktop targets are pinned to the official `https://nodejs.org/dist/v24.19.0/` release root with exact archive names, byte sizes, and SHA-256 digests. -- The four declared digests matched Node's official `SHASUMS256.txt`; the declared byte sizes matched the official response metadata. -- Downloads have a ten-minute wall timeout, bounded redirects, an exact byte limit, streaming SHA-256 verification, and a private staging directory. -- Extraction validates every archive path and materializes only the declared regular `node` executable and `LICENSE`. Declared symlinks, duplicate entries, traversal paths, unexpected types, oversized files, partial downloads, and hash mismatches fail closed. -- Activation is atomic. A receipt records the version, target, archive hash, executable hash, and ABI. Every resolution repeats the receipt, executable hash, permissions, and runtime smoke checks. -- Windows child creation uses the repository's no-window/process-group flags. The completeness guard now covers the managed-runtime smoke process. - -## Packaged first-use behavior - -The clean packaged `.app` was launched after moving the existing managed runtime to a recoverable temporary directory. - -- At approximately `+5.95 s`, the runtime receipt had been committed after download, extraction, hashing, and ABI smoke validation. -- At approximately `+7.7 s`, the packaged UI showed the localized success state. -- The composer remained disabled until the success state completed, then became editable. -- The mounted App test separately exercised the whole visible state sequence: runtime preparation, CLI installation, disabled prompt submission, Settings navigation during preparation, retry, verified success, and unlock. -- A real Claude turn from the packaged app completed successfully using the newly created runtime. -- LaunchServices/Dock inspection during packaged-app use found no `verboo-node` registration. -- The app-created runtime was persisted at the versioned app-data location and passed an independent version/ABI smoke command. - -The cold UI measurement reused an already installed CLI `0.15.12`, so the roughly six-second runtime preparation is a Node-only first-use observation on this network. A separate clean ignored E2E test downloaded and validated both official Node and signed CLI `0.15.13`; it passed in `62.75 s`, including test setup and both remote downloads. - -## Package size comparison - -Both packages below were built from the same `0.7.0-beta` workspace on macOS arm64. The baseline contains the 121,306,800-byte `verboo-node`; the new package does not. - -| Artifact | Bundled Node baseline | Managed runtime | Reduction | -| --- | ---: | ---: | ---: | -| `.app` disk usage | 199,180 KiB | 80,840 KiB | 118,340 KiB (59.41%) | -| DMG | 73,110,436 B | 34,240,842 B | 38,869,594 B (53.17%) | -| updater `.app.tar.gz` | 75,051,619 B | 34,984,490 B | 40,067,129 B (53.39%) | - -The macOS arm64 first-use download is 27,162,556 bytes. After extraction, the private runtime occupies 118,624 KiB. App plus installed runtime is therefore approximately the same final disk footprint as before (199,464 KiB versus 199,180 KiB); the material gains are installer/update size, download bandwidth for desktop releases, independent runtime lifecycle, and removal of the `verboo-node` app-bundle identity. - -The final `.app` and updater archive contain no `node`, `node.exe`, `verboo-node*`, or Node release archive file. WebDriverAgent still contains two ordinary JavaScript source directories named `node` inside Axios; neither is an executable or a Node runtime. - -## Warm performance comparison - -The baseline and managed executables are byte-identical Node `v24.19.0` binaries (121,306,800 bytes, SHA-256 `27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1`). Commands were warmed and then alternated between paths to reduce ordering bias. - -| Workload | Samples | Bundled median / p95 | Managed median / p95 | Managed median delta | -| --- | ---: | ---: | ---: | ---: | -| `node --version` | 20 each | 14.451 / 15.404 ms | 14.748 / 15.532 ms | +2.06% | -| CLI `--version` | 20 each | 248.512 / 254.000 ms | 247.086 / 252.811 ms | -0.57% | -| CLI `--list-models` | 5 each | 2,416.580 / 2,621.939 ms | 2,498.525 / 2,751.971 ms | +3.39% | - -Median RSS was identical for raw Node (22,740,992 bytes), nearly identical for CLI version discovery (229,556,224 versus 229,507,072 bytes), and within 0.5% for the network-backed model query. Because the executables and CLI input are identical, the small timing differences are scheduler and network noise; moving the binary out of the app bundle introduces no persistent execution layer. - -## Verification gates - -- Rust library suite: 1,293 passed, 1 ignored. -- Renderer suite: 1,710 passed across 161 files. -- Renderer type-check and production build: passed. -- Managed Node focused suite: 21 passed. -- Update coordinator: 15 passed. -- CLI updater: 33 passed. -- Packaging/update ownership scripts: 5 passed. -- Release signing/notarization scripts: 9 passed. -- Update manifest scripts: 6 passed. -- Browser packaged-runtime checks: 18 passed. -- Tauri helper scripts (WDA, media, Chrome helper): 12 passed. -- Clean remote Node plus signed-CLI E2E: passed. -- `git diff --check`: passed. - -The first full Rust run exposed a missing entry in the repository's process-spawn completeness list; the smoke process already applied the correct flags, and the list was corrected. An unrelated simulator timing test failed once under concurrent suite load, then passed three isolated runs and the complete rerun. - -Native Windows and Linux compilation/package execution must be confirmed by the repository CI runners after push. Local cross-compilation was not treated as proof because this Mac lacks the Windows SDK headers and Linux GLib/sysroot required by the Tauri dependency graph. The shared contract and packaging guards are exercised locally, but only native CI can close that platform-specific build claim. diff --git a/docs/superpowers/plans/2026-08-04-interactive-ios-simulator.md b/docs/superpowers/plans/2026-08-04-interactive-ios-simulator.md deleted file mode 100644 index 80aa6444..00000000 --- a/docs/superpowers/plans/2026-08-04-interactive-ios-simulator.md +++ /dev/null @@ -1,1147 +0,0 @@ -# Interactive iOS Simulator Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Turn the existing simulator preview into a smooth 30/60 fps surface with direct touch and keyboard control, element and rectangular Add to Chat annotations, and generation-safe Verboo agent presence over the same WDA session. - -**Architecture:** `IosSimulatorService` remains the single owner of device, WDA process, WebDriver session, latest frame, input queue, accessibility snapshot, and agent-presence generation. A focused blocking WDA client handles HTTP endpoints while React consumes only the newest frame per animation frame and maps pointer positions through the actual `object-fit: contain` rectangle. A managed `verboo-ios-simulator` MCP binary relays authenticated loopback requests into that same desktop service; it never starts a second WDA process or stream. - -**Tech Stack:** Rust 1.89, Tauri 2.11, blocking `reqwest` with rustls, WebDriverAgent 16.1.4, React 19, TypeScript 6, Vitest/Testing Library, CSS animations, `rmcp` 0.16. - -## Global Constraints - -- Preserve the current dirty user baseline; stage and commit only the files named by the active task. -- Keep one WDA process, one WebDriver session, and one MJPEG stream for the attached simulator. -- Bind every WDA and MCP transport to `127.0.0.1`; authenticate the MCP relay with a per-process secret stored in a user-private discovery record. -- Default MJPEG to exactly 30 fps; expose exactly 30 and 60 fps stream profiles. -- Do not persist 60 fps as the default; every new attach starts at 30 fps. -- Keep `mjpegScalingFactor` at exactly 100. -- Keep the existing 0.5, 1, and 2 fps `simctl` fallback rates independent from the MJPEG profile. -- Selecting 60 fps must show: “High fluency uses more processing and may warm up your computer or reduce the performance of other apps.” -- Portuguese 60 fps copy must be: “Alta fluidez usa mais processamento e pode aquecer o computador ou reduzir o desempenho de outros apps.” -- Manual interaction never emits agent presence. -- Agent presence must be emitted before its WDA action and removed on turn completion, detach, panel close, app hide, and app exit. -- Detach must never issue `simctl shutdown`. -- Preserve Xcode 26 and 27 detection, the `simctl` warmup/fallback, bounded WDA process cleanup, packaged WDA portability, and loopback-only security. -- Do not add H.264, WDA downscaling, adaptive bitrate, physical-device support, or a second `serve-sim`/XcodeBuildMCP process in this iteration. -- Real-app verification must not call the Verboo model; use manual panel input and the local MCP seam directly. - ---- - -## File Structure - -### Backend authority - -- Create `src-tauri/src/services/ios_simulator/wda_client.rs`: WebDriver session creation, settings, window/source reads, tap/drag/text/key calls, response validation, and session deletion. -- Create `src-tauri/src/services/ios_simulator/capture_store.rs`: frame crop, temporary simulator PNG validation, promotion into conversation-owned storage, and cleanup. -- Create `src-tauri/src/services/ios_simulator/bridge.rs`: authenticated loopback discovery/server, simulator tool dispatch, and bridge shutdown. -- Modify `src-tauri/src/services/ios_simulator.rs`: session authority, stream profiles, latest-frame ownership, serialized input, Tauri commands, presence generations, and lifecycle convergence. -- Modify `src-tauri/src/services/mod.rs` and `src-tauri/src/lib.rs`: service setup, managed state, commands, bridge startup, and shutdown. - -### Renderer interaction - -- Modify `src/renderer/features/simulator/iosSimulatorApi.ts`: exact Tauri types/commands/events. -- Modify `src/renderer/features/simulator/iosSimulatorModel.ts`: 30/60 profiles, fallback rates, supported key map, and interaction modes. -- Create `src/renderer/features/simulator/simulatorGeometry.ts`: painted-image rectangle and normalized/device coordinate conversion. -- Create `src/renderer/features/simulator/frameCoalescer.ts`: newest-frame-only `requestAnimationFrame` commit and throttled telemetry. -- Create `src/renderer/features/simulator/useSimulatorInteraction.ts`: pointer threshold, pointer cancellation, focus, text, paste, composition, and special-key routing. -- Create `src/renderer/features/simulator/SimulatorSurface.tsx`: focused live surface, mode toolbar, image, manual gesture handlers, selection state, and annotation confirmation. -- Create `src/renderer/features/simulator/SimulatorPresenceOverlay.tsx`: adaptive aurora, cursor, ripple, drag path, reduced motion, and generation guards. -- Create `src/renderer/features/simulator/simulatorAnnotations.ts`: simulator attachment creation, second snapshot expansion, promotion, and cleanup. -- Create `src/renderer/features/attachments/visualAttachments.ts`: shared browser/simulator visual classification, snapshot expansion, promotion, and temporary-file dispatch. -- Modify `src/renderer/features/simulator/useIosSimulatorPanel.ts`, `IosSimulatorPanel.tsx`, and `src/renderer/App.tsx`: state/event wiring and Add to Chat integration. -- Modify `src/renderer/styles/ios-simulator.css` and `src/renderer/i18n.tsx`: controls, focus/annotation/presence visuals, warning, and localized accessible copy. - -### Attachment contract - -- Modify `src/shared/types.ts`: `simulator-annotation` kind and simulator-specific metadata. -- Modify `src-tauri/src/models/types.rs` and `src-tauri/src/services/turn_service.rs`: deserialize simulator visual attachments and treat them as images with structured extracted context. -- Modify `src/renderer/features/composer/Composer.tsx` and `src/renderer/components/Transcript.tsx`: annotation-only submission, chip labels, thumbnails, and persisted metadata. - -### Agent sidecar - -- Create `src-tauri/verboo-in-chrome/src/bin/verboo-ios-simulator.rs`: MCP executable entrypoint. -- Create `src-tauri/verboo-in-chrome/src/simulator_catalog.rs`, `simulator_client.rs`, `simulator_mcp.rs`, and `simulator_protocol.rs`: tool catalog, discovery client, validation, relay, EOF/signal cleanup. -- Create `src-tauri/verboo-in-chrome/src/simulatorTools.json`: narrow simulator tool schemas. -- Modify `src-tauri/verboo-in-chrome/Cargo.toml` and `src-tauri/verboo-in-chrome/src/lib.rs`: second binary/modules. -- Modify `scripts/tauri/build-chrome-helper.mjs`, its test, `package.json`, and `src-tauri/tauri.conf.json`: build/package both helpers. -- Create `src-tauri/src/services/ios_simulator_mcp.rs`: install the managed helper and idempotently register `verboo-ios-simulator` in the user CLI configuration. - ---- - -### Task 1: Establish the WDA Session and 30/60 Stream Profiles - -**Files:** -- Create: `src-tauri/src/services/ios_simulator/wda_client.rs` -- Modify: `src-tauri/src/services/ios_simulator.rs:26-104,431-684,1092-1310,1433-2320` -- Modify: `src/renderer/features/simulator/iosSimulatorApi.ts` -- Modify: `src/renderer/features/simulator/iosSimulatorModel.ts` -- Modify: `src/renderer/features/simulator/iosSimulatorModel.test.ts` -- Modify: `src/renderer/features/simulator/useIosSimulatorPanel.ts` -- Modify: `src/renderer/features/simulator/useIosSimulatorPanel.test.ts` -- Modify: `src/renderer/features/simulator/IosSimulatorPanel.tsx` -- Modify: `src/renderer/features/simulator/IosSimulatorPanel.test.tsx` -- Modify: `src/renderer/i18n.tsx` - -**Interfaces:** -- Produces: `StreamProfile`, `WdaSessionHandle`, `WdaWindowSize`, and `WdaClient` for later input/source tasks. -- Produces: renderer `streamFps: 30 | 60` and `fallbackFps: 0.5 | 1 | 2` as separate state. - -- [ ] **Step 1: Write the failing profile and handshake tests** - -Add Rust tests that make the fake WDA HTTP listener record each request and assert the exact order: - -```rust -#[test] -fn wda_session_applies_30_fps_and_scale_100_before_mjpeg_activation() { - let http = FakeWdaHttpServer::start(); - let client = SystemWdaClient::default(); - let session = client.create_session(&http.base_url()).unwrap(); - client.apply_stream_settings(&session, StreamProfile::Fps30).unwrap(); - - assert_eq!(http.requests(), vec![ - RecordedRequest::post("/session", serde_json::json!({ - "capabilities": { "alwaysMatch": {}, "firstMatch": [{}] } - })), - RecordedRequest::post(format!("/session/{}/appium/settings", session.id), serde_json::json!({ - "settings": { "mjpegServerFramerate": 30, "mjpegScalingFactor": 100 } - })), - ]); -} - -#[test] -fn stream_profile_rejects_every_value_except_30_and_60() { - assert_eq!(StreamProfile::try_from(30).unwrap(), StreamProfile::Fps30); - assert_eq!(StreamProfile::try_from(60).unwrap(), StreamProfile::Fps60); - assert!(StreamProfile::try_from(10).is_err()); - assert!(StreamProfile::try_from(120).is_err()); -} -``` - -Extend the fake launcher so its HTTP listener answers `/status`, `/session`, settings, `/window/size`, and `DELETE /session/{id}` while its existing MJPEG listener remains separate. Record an `Arc` when settings succeed; have the MJPEG listener refuse connections before that flag becomes true. This is the red counterfactual proving that omitting the settings call leaves migration unavailable. - -Add TypeScript expectations: - -```ts -expect(IOS_SIMULATOR_STREAM_RATES).toEqual([30, 60]) -expect(IOS_SIMULATOR_FALLBACK_RATES).toEqual([0.5, 1, 2]) -expect(DEFAULT_SIMULATOR_STREAM_FPS).toBe(30) -``` - -Add a panel test that changes the stream selector to 60, asserts -`onSetStreamRate(60)`, and asserts the exact Portuguese warning is rendered. -Keep a separate fallback selector test that still calls -`onSetFallbackRate(1)`. - -- [ ] **Step 2: Run the focused tests and confirm the red state** - -Run: - -```bash -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml ios_simulator::tests::wda_session_applies_30_fps_and_scale_100_before_mjpeg_activation -- --nocapture -npm test -- src/renderer/features/simulator/iosSimulatorModel.test.ts src/renderer/features/simulator/useIosSimulatorPanel.test.ts src/renderer/features/simulator/IosSimulatorPanel.test.tsx -``` - -Expected: Rust fails because no WebDriver session/settings client exists; Vitest fails because stream and fallback rates are still one `[0.5, 1, 2]` array. - -- [ ] **Step 3: Implement the focused WDA client and profile split** - -Define these exact public module interfaces in `wda_client.rs`: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "u16", into = "u16")] -pub enum StreamProfile { Fps30, Fps60 } - -impl StreamProfile { - pub const DEFAULT: Self = Self::Fps30; - pub fn fps(self) -> u16 { match self { Self::Fps30 => 30, Self::Fps60 => 60 } } -} - -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WdaWindowSize { pub width: f64, pub height: f64 } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WdaSessionHandle { pub base_url: String, pub id: String } - -pub trait WdaClient: Send + Sync { - fn wait_until_ready(&self, base_url: &str, deadline: Instant) -> Result<(), String>; - fn create_session(&self, base_url: &str) -> Result; - fn apply_stream_settings(&self, session: &WdaSessionHandle, profile: StreamProfile) -> Result<(), String>; - fn window_size(&self, session: &WdaSessionHandle) -> Result; - fn delete_session(&self, session: &WdaSessionHandle) -> Result<(), String>; -} -``` - -Use a five-second connect/request timeout and validate the WDA envelope as `{ "value": ... }`; treat a non-null `value.error`, non-2xx status, absent session id, or non-positive window size as an explicit `Err`. - -In `ios_simulator.rs`, replace the overloaded `fps` with: - -```rust -pub const DEFAULT_FALLBACK_FPS: f64 = 2.0; -const MIN_FALLBACK_FPS: f64 = 0.5; -const MAX_FALLBACK_FPS: f64 = 2.0; - -struct Session { - udid: String, - fallback_fps: Arc>, - stream_profile: Arc>, - stats: Arc>, - stop: Arc, - wda_session: Arc>>, - wda_force_stop: Arc>>, - workers: Vec>, -} -``` - -Create the WDA session, apply profile settings, and read window size after the HTTP `/status` becomes ready and before connecting to MJPEG. On detach, take and delete the WebDriver session before terminating the WDA process. - -Mirror the split at the Tauri boundary: - -```ts -export type IosSimulatorStreamFps = 30 | 60 -export type IosSimulatorFallbackFps = 0.5 | 1 | 2 - -attach: (udid, streamFps, fallbackFps) => invoke('ios_simulator_attach', { udid, streamFps, fallbackFps }), -setStreamRate: (streamFps) => invoke('ios_simulator_set_stream_rate', { streamFps }), -setFallbackRate: (fallbackFps) => invoke('ios_simulator_set_fallback_rate', { fallbackFps }), -``` - -Replace the single rate control with a primary “Fluency” selector for 30/60 -and a secondary “Low-cost fallback rate” selector for 0.5/1/2. Render the -localized inline warning only while 60 is selected; it is non-modal and uses -`role="note"`. - -- [ ] **Step 4: Run focused green tests** - -Run: - -```bash -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml ios_simulator -- --nocapture -npm test -- src/renderer/features/simulator/iosSimulatorModel.test.ts src/renderer/features/simulator/useIosSimulatorPanel.test.ts src/renderer/features/simulator/IosSimulatorPanel.test.tsx -``` - -Expected: all focused Rust and Vitest tests pass; the fake MJPEG stream cannot activate before settings. - -- [ ] **Step 5: Commit only Task 1 files** - -```bash -git add src-tauri/src/services/ios_simulator.rs src-tauri/src/services/ios_simulator/wda_client.rs src/renderer/features/simulator/iosSimulatorApi.ts src/renderer/features/simulator/iosSimulatorModel.ts src/renderer/features/simulator/iosSimulatorModel.test.ts src/renderer/features/simulator/useIosSimulatorPanel.ts src/renderer/features/simulator/useIosSimulatorPanel.test.ts src/renderer/features/simulator/IosSimulatorPanel.tsx src/renderer/features/simulator/IosSimulatorPanel.test.tsx src/renderer/i18n.tsx -git commit -m "perf: configure iOS simulator stream profiles" -``` - -### Task 2: Coalesce Renderer Frames and Throttle Telemetry - -**Files:** -- Create: `src/renderer/features/simulator/frameCoalescer.ts` -- Create: `src/renderer/features/simulator/frameCoalescer.test.ts` -- Modify: `src/renderer/features/simulator/useIosSimulatorPanel.ts` -- Modify: `src/renderer/features/simulator/useIosSimulatorPanel.test.ts` - -**Interfaces:** -- Produces: `LatestFrameCoalescer` used only by `useIosSimulatorPanel`. -- Consumes: `IosSimulatorFrame` and device generation from Task 1. - -- [ ] **Step 1: Write the newest-frame-only failing test** - -```ts -it('commits only the newest pending frame in one animation frame', () => { - const callbacks: FrameRequestCallback[] = [] - const committed: number[] = [] - const coalescer = new LatestFrameCoalescer( - callback => { callbacks.push(callback); return callbacks.length }, - () => {}, - value => committed.push(value), - ) - - coalescer.push(1) - coalescer.push(2) - coalescer.push(3) - expect(callbacks).toHaveLength(1) - callbacks[0](16) - expect(committed).toEqual([3]) -}) -``` - -Add a hook test that sends 60 frame events before the scheduled callback and asserts one React frame update and telemetry no more often than once per 500 ms. - -- [ ] **Step 2: Run and confirm the red state** - -Run: `npm test -- src/renderer/features/simulator/frameCoalescer.test.ts src/renderer/features/simulator/useIosSimulatorPanel.test.ts` - -Expected: module-not-found or missing class failure. - -- [ ] **Step 3: Implement the coalescer and hook wiring** - -```ts -export class LatestFrameCoalescer { - private pending: T | undefined - private requestId: number | undefined - - constructor( - private readonly schedule: (callback: FrameRequestCallback) => number, - private readonly cancel: (id: number) => void, - private readonly commit: (value: T) => void, - ) {} - - push(value: T) { - this.pending = value - if (this.requestId !== undefined) return - this.requestId = this.schedule(() => { - this.requestId = undefined - const next = this.pending - this.pending = undefined - if (next !== undefined) this.commit(next) - }) - } - - dispose() { - if (this.requestId !== undefined) this.cancel(this.requestId) - this.requestId = undefined - this.pending = undefined - } -} -``` - -Store the coalescer in a ref. Its commit updates only `frameDataUrl` and `frameGeneration`; update `streamSource` and `effectiveFps` from a separate 500 ms telemetry gate. Dispose on listener cleanup, detach, and device change. - -- [ ] **Step 4: Run the green tests and renderer build** - -```bash -npm test -- src/renderer/features/simulator/frameCoalescer.test.ts src/renderer/features/simulator/useIosSimulatorPanel.test.ts -npm run build:renderer -``` - -Expected: tests and TypeScript/Vite build pass. - -- [ ] **Step 5: Commit Task 2** - -```bash -git add src/renderer/features/simulator/frameCoalescer.ts src/renderer/features/simulator/frameCoalescer.test.ts src/renderer/features/simulator/useIosSimulatorPanel.ts src/renderer/features/simulator/useIosSimulatorPanel.test.ts -git commit -m "perf: coalesce iOS simulator frames" -``` - -### Task 3: Make Device Geometry Authoritative - -**Files:** -- Create: `src/renderer/features/simulator/simulatorGeometry.ts` -- Create: `src/renderer/features/simulator/simulatorGeometry.test.ts` - -**Interfaces:** -- Produces: `Size`, `Rect`, `NormalizedPoint`, `paintedContainRect`, `clientPointToNormalized`, and `normalizedRectToCss`. -- Consumed by Tasks 5, 7, and 9 for manual input, annotations, and presence. - -- [ ] **Step 1: Write portrait, landscape, iPad, and letterbox tests** - -```ts -it.each([ - [{ width: 600, height: 600 }, { width: 393, height: 852 }, { x: 161.62, y: 0, width: 276.76, height: 600 }], - [{ width: 600, height: 400 }, { width: 852, height: 393 }, { x: 0, y: 61.62, width: 600, height: 276.76 }], -])('computes the object-fit contain rectangle', (container, image, expected) => { - expect(paintedContainRect(container, image)).toMatchObject({ - x: expect.closeTo(expected.x, 1), y: expect.closeTo(expected.y, 1), - width: expect.closeTo(expected.width, 1), height: expect.closeTo(expected.height, 1), - }) -}) - -it('rejects a click in the letterbox and normalizes a click inside the device', () => { - const painted = { x: 100, y: 0, width: 200, height: 400 } - expect(clientPointToNormalized({ x: 50, y: 200 }, painted)).toBeNull() - expect(clientPointToNormalized({ x: 200, y: 100 }, painted)).toEqual({ x: 0.5, y: 0.25 }) -}) -``` - -- [ ] **Step 2: Run and confirm module failure** - -Run: `npm test -- src/renderer/features/simulator/simulatorGeometry.test.ts` - -Expected: module-not-found failure. - -- [ ] **Step 3: Implement clamped, finite geometry functions** - -```ts -export type Size = { width: number; height: number } -export type Rect = { x: number; y: number; width: number; height: number } -export type NormalizedPoint = { x: number; y: number } - -export function paintedContainRect(container: Size, image: Size): Rect { - if (container.width <= 0 || container.height <= 0 || image.width <= 0 || image.height <= 0) { - return { x: 0, y: 0, width: 0, height: 0 } - } - const scale = Math.min(container.width / image.width, container.height / image.height) - const width = image.width * scale - const height = image.height * scale - return { x: (container.width - width) / 2, y: (container.height - height) / 2, width, height } -} - -export function clientPointToNormalized(point: { x: number; y: number }, painted: Rect): NormalizedPoint | null { - if (point.x < painted.x || point.y < painted.y || point.x > painted.x + painted.width || point.y > painted.y + painted.height) return null - return { x: (point.x - painted.x) / painted.width, y: (point.y - painted.y) / painted.height } -} -``` - -Implement `normalizedRectToCss` with the same finite/range guards and clamp every edge to `[0,1]`. - -- [ ] **Step 4: Run green geometry tests** - -Run: `npm test -- src/renderer/features/simulator/simulatorGeometry.test.ts` - -Expected: all geometry tests pass. - -- [ ] **Step 5: Commit Task 3** - -```bash -git add src/renderer/features/simulator/simulatorGeometry.ts src/renderer/features/simulator/simulatorGeometry.test.ts -git commit -m "test: define iOS simulator geometry" -``` - -### Task 4: Add Serialized WDA Input and Accessibility Reads - -**Files:** -- Modify: `src-tauri/src/services/ios_simulator/wda_client.rs` -- Modify: `src-tauri/src/services/ios_simulator.rs` -- Modify: `src-tauri/src/lib.rs` -- Modify: `src/renderer/features/simulator/iosSimulatorApi.ts` -- Modify: `src/renderer/features/simulator/iosSimulatorModel.ts` -- Modify: `src/renderer/features/simulator/iosSimulatorModel.test.ts` - -**Interfaces:** -- Produces Tauri commands: `ios_simulator_tap`, `ios_simulator_drag`, `ios_simulator_type_text`, `ios_simulator_press_key`, and `ios_simulator_accessibility_snapshot`. -- Produces: `IosSimulatorAccessibilityNode[]` with stable snapshot identity and device-point frames. -- Consumes: Task 1 `WdaSessionHandle`/`WdaWindowSize`. - -- [ ] **Step 1: Write failing endpoint, coordinate, and serialization tests** - -Assert exact WDA payloads: - -```rust -assert_eq!(recorded, vec![ - RecordedRequest::post("/session/session-1/wda/tap", json!({ "x": 196.5, "y": 213.0 })), - RecordedRequest::post("/session/session-1/wda/dragfromtoforduration", json!({ - "fromX": 39.3, "fromY": 681.6, "toX": 353.7, "toY": 170.4, "duration": 0.18 - })), - RecordedRequest::post("/session/session-1/wda/keys", json!({ "value": ["Verboo"] })), - RecordedRequest::post("/session/session-1/wda/performIoHidEvent", json!({ "keys": ["XCUIKeyboardKeyDelete"] })), -]); -``` - -Use a blocking fake client with an `AtomicBool` inside `tap`; start `tap` and `type_text` on separate threads and assert `type_text` cannot record before `tap` releases. Add mapping cases for 393×852 portrait, 852×393 landscape, and 1024×1366 iPad. - -Add a source sanitizer test using: - -```rust -json!({ "type": "Button", "rawIdentifier": "save", "label": "Save", "rect": { - "x": 20, "y": 30, "width": 120, "height": 44 -}, "enabled": true, "visible": true, "children": [] }) -``` - -Expected node: actionable, frame preserved, label bounded, and deterministic id across two sanitizations. - -- [ ] **Step 2: Run and confirm missing command/client failures** - -Run: `cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml ios_simulator -- --nocapture` - -Expected: compile failures for missing methods and command types. - -- [ ] **Step 3: Implement input and source contracts** - -Extend `WdaClient` with: - -```rust -fn tap(&self, session: &WdaSessionHandle, point: WdaPoint) -> Result<(), String>; -fn drag(&self, session: &WdaSessionHandle, from: WdaPoint, to: WdaPoint, duration: Duration) -> Result<(), String>; -fn type_text(&self, session: &WdaSessionHandle, text: &str) -> Result<(), String>; -fn press_key(&self, session: &WdaSessionHandle, key: IosSimulatorKey) -> Result<(), String>; -fn source_json(&self, session: &WdaSessionHandle) -> Result; -``` - -Use `GET /session/{id}/source?format=json&excluded_attributes=customActions,nativeFrame,traits` and support exactly: - -```rust -pub enum IosSimulatorKey { Enter, Backspace, Tab, ArrowUp, ArrowDown, ArrowLeft, ArrowRight } -``` - -Map these to `XCUIKeyboardKeyReturn`, `XCUIKeyboardKeyDelete`, `XCUIKeyboardKeyTab`, and the four `XCUIKeyboardKey*Arrow` names. Validate text as non-empty UTF-8 with at most 4,000 Unicode scalar values; validate normalized coordinates as finite `[0,1]`; clamp drag duration to 50–2,000 ms. - -Add `input_lock: Arc>` to `Session`. Clone the WDA handle/window size under the state lock, release the state lock, then hold only `input_lock` during the HTTP call. This prevents deadlock with detach while preserving action order. - -- [ ] **Step 4: Run backend and renderer type tests** - -```bash -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml ios_simulator -- --nocapture -npm test -- src/renderer/features/simulator/iosSimulatorModel.test.ts -npm run build:renderer -``` - -Expected: all pass. - -- [ ] **Step 5: Commit Task 4** - -```bash -git add src-tauri/src/services/ios_simulator.rs src-tauri/src/services/ios_simulator/wda_client.rs src-tauri/src/lib.rs src/renderer/features/simulator/iosSimulatorApi.ts src/renderer/features/simulator/iosSimulatorModel.ts src/renderer/features/simulator/iosSimulatorModel.test.ts -git commit -m "feat: control the attached iOS simulator" -``` - -### Task 5: Build the Focusable Manual Interaction Surface - -**Files:** -- Create: `src/renderer/features/simulator/useSimulatorInteraction.ts` -- Create: `src/renderer/features/simulator/useSimulatorInteraction.test.ts` -- Create: `src/renderer/features/simulator/SimulatorSurface.tsx` -- Create: `src/renderer/features/simulator/SimulatorSurface.test.tsx` -- Modify: `src/renderer/features/simulator/IosSimulatorPanel.tsx` -- Modify: `src/renderer/features/simulator/IosSimulatorPanel.test.tsx` -- Modify: `src/renderer/styles/ios-simulator.css` -- Modify: `src/renderer/i18n.tsx` - -**Interfaces:** -- Produces: `SimulatorInteractionMode = 'interact' | 'select-element' | 'select-area'`. -- Produces manual callbacks `onTap`, `onDrag`, `onTypeText`, `onPressKey`; it does not accept or emit presence events. -- Consumes: Task 3 geometry and Task 4 API commands. - -`useSimulatorInteraction(options)` returns this exact handler surface: - -```ts -export type SimulatorInteractionHandlers = { - onPointerDown: React.PointerEventHandler - onPointerMove: React.PointerEventHandler - onPointerUp: React.PointerEventHandler - onPointerCancel: React.PointerEventHandler - onKeyDown: React.KeyboardEventHandler - onPaste: React.ClipboardEventHandler - onCompositionStart: React.CompositionEventHandler - onCompositionEnd: React.CompositionEventHandler -} -``` - -- [ ] **Step 1: Write failing manual interaction tests** - -Cover these effects in `SimulatorSurface.test.tsx`: - -```ts -fireEvent.pointerDown(surface, { pointerId: 1, clientX: 200, clientY: 300 }) -fireEvent.pointerUp(surface, { pointerId: 1, clientX: 202, clientY: 302 }) -expect(onTap).toHaveBeenCalledWith(expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) })) -expect(onDrag).not.toHaveBeenCalled() - -fireEvent.pointerDown(surface, { pointerId: 2, clientX: 200, clientY: 700 }) -fireEvent.pointerMove(surface, { pointerId: 2, clientX: 200, clientY: 200 }) -fireEvent.pointerUp(surface, { pointerId: 2, clientX: 200, clientY: 200 }) -expect(onDrag).toHaveBeenCalledTimes(1) - -surface.focus() -fireEvent.keyDown(surface, { key: 'a' }) -fireEvent.paste(surface, { clipboardData: { getData: () => ' colado' } }) -fireEvent.compositionEnd(surface, { data: 'ção' }) -fireEvent.keyDown(surface, { key: 'Backspace' }) -expect(onTypeText).toHaveBeenNthCalledWith(1, 'a') -expect(onTypeText).toHaveBeenNthCalledWith(2, ' colado') -expect(onTypeText).toHaveBeenNthCalledWith(3, 'ção') -expect(onPressKey).toHaveBeenCalledWith('backspace') -``` - -Before firing pointers, mock the surface bounds as -`{left:0,top:0,width:600,height:900,right:600,bottom:900}` and define the -image’s `naturalWidth=393`/`naturalHeight=852`; this makes the test exercise -the real contain mapping instead of jsdom’s zero-sized layout. - -Also assert Command/Ctrl shortcuts are not prevented, `Escape` blurs without calling WDA, pointer cancel/window blur sends no action, and the letterbox rejects gestures. - -- [ ] **Step 2: Run and confirm missing-surface failures** - -Run: `npm test -- src/renderer/features/simulator/useSimulatorInteraction.test.ts src/renderer/features/simulator/SimulatorSurface.test.tsx src/renderer/features/simulator/IosSimulatorPanel.test.tsx` - -Expected: missing module/component behavior failures. - -- [ ] **Step 3: Implement pointer and keyboard ownership** - -Use a six-CSS-pixel movement threshold: - -```ts -const TAP_MOVEMENT_PX = 6 -const distance = Math.hypot(current.clientX - start.clientX, current.clientY - start.clientY) -if (distance <= TAP_MOVEMENT_PX) onTap(start.normalized) -else onDrag(start.normalized, current.normalized, 180) -``` - -Make the surface a real focus target: - -```tsx -
- {previewAlt} -
-``` - -Use `setPointerCapture`, release it on completion/cancel, ignore non-primary buttons, and prevent default only for owned gestures/keys. Disable all WDA input when the stream is `simctl`, because there is no active WebDriver session. - -- [ ] **Step 4: Add focus/mode styling and localized instructions** - -Add a quiet inset violet focus ring and `touch-action: none` only on the interaction surface. Add English and Portuguese copy for Interact, Select component, Select area, keyboard focus, Escape release, and interaction-unavailable fallback. Keep all overlays `aria-hidden="true"`. - -- [ ] **Step 5: Run focused tests and build** - -```bash -npm test -- src/renderer/features/simulator/useSimulatorInteraction.test.ts src/renderer/features/simulator/SimulatorSurface.test.tsx src/renderer/features/simulator/IosSimulatorPanel.test.tsx -npm run build:renderer -``` - -Expected: all pass. - -- [ ] **Step 6: Commit Task 5** - -```bash -git add src/renderer/features/simulator/useSimulatorInteraction.ts src/renderer/features/simulator/useSimulatorInteraction.test.ts src/renderer/features/simulator/SimulatorSurface.tsx src/renderer/features/simulator/SimulatorSurface.test.tsx src/renderer/features/simulator/IosSimulatorPanel.tsx src/renderer/features/simulator/IosSimulatorPanel.test.tsx src/renderer/styles/ios-simulator.css src/renderer/i18n.tsx -git commit -m "feat: interact with the iOS simulator panel" -``` - -### Task 6: Capture Accessibility Elements and Free Areas - -**Files:** -- Create: `src-tauri/src/services/ios_simulator/capture_store.rs` -- Modify: `src-tauri/src/services/ios_simulator.rs` -- Modify: `src-tauri/src/lib.rs` -- Modify: `src/renderer/features/simulator/iosSimulatorApi.ts` - -**Interfaces:** -- Produces: `IosSimulatorAnnotationCapture` containing crop/full paths, pixel sizes, device identity, orientation, device generation, frame generation, normalized/device rects, and optional accessibility metadata. -- Produces Tauri commands for capture, temp deletion, promotion, owner deletion, and orphan cleanup. -- Consumes: latest complete frame bytes and sanitized accessibility nodes from Tasks 1 and 4. - -- [ ] **Step 1: Write failing same-generation capture and cleanup tests** - -Add a test frame with known 400×800 PNG pixels, select normalized `{x:0.25,y:0.25,width:0.5,height:0.25}`, and assert a 200×200 crop plus a 400×800 full snapshot. Assert both files share one UUID stem and the report repeats one `frame_generation`. - -Add red tests for: - -```rust -assert!(capture_for_generation(current + 1, rect).is_err()); -assert!(delete_temp_files(vec!["/tmp/not-verboo/file.png".into()]).is_err()); -assert!(!capture_store.owner_dir("conversation-a").eq(&capture_store.owner_dir("conversation-b"))); -``` - -Simulate device generation changing after bytes are copied but before files are returned; assert both temporary files are removed and no attachment report escapes. - -- [ ] **Step 2: Run and confirm missing store/report failures** - -Run: `cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml ios_simulator -- --nocapture` - -Expected: missing `capture_store`/capture command compile failures. - -- [ ] **Step 3: Store the latest complete frame and implement safe capture** - -Add to `Session`: - -```rust -struct LatestFrame { - device_generation: u64, - frame_generation: u64, - bytes: Vec, - media_type: &'static str, -} - -latest_frame: Arc>>, -``` - -Update it before emitting each complete `simctl` or MJPEG frame. The capture command clones one `LatestFrame`, validates the requested device generation, decodes once with `image::load_from_memory`, clamps the normalized rectangle, writes viewport/crop into `std::env::temp_dir()/verboo-ios-simulator`, then rechecks device generation before returning. - -Change the `simctl` helper to return raw PNG bytes first and derive its data -URL from those same bytes. For MJPEG, store the extracted JPEG bytes before -base64 encoding. Do not decode a renderer data URL back into bytes. - -Create a dedicated durable root `app_data_dir/simulator_captures`. Hash owner ids with SHA-256 and accept only direct `.png` children of the simulator temp root. Never reuse the browser temp allowlist. - -- [ ] **Step 4: Register commands and store state** - -Register: - -```rust -ios_simulator_accessibility_snapshot, -ios_simulator_capture_annotation, -ios_simulator_delete_temp_files, -ios_simulator_promote_temp_files, -ios_simulator_delete_capture_owner, -ios_simulator_cleanup_capture_owners, -``` - -Manage `IosSimulatorCaptureStore` from `app_data_dir` during Tauri setup. - -- [ ] **Step 5: Run backend tests** - -```bash -cargo +1.89.0 fmt --manifest-path src-tauri/Cargo.toml -- --check -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml ios_simulator -- --nocapture -``` - -Expected: formatting and all simulator backend tests pass. - -- [ ] **Step 6: Commit Task 6** - -```bash -git add src-tauri/src/services/ios_simulator.rs src-tauri/src/services/ios_simulator/capture_store.rs src-tauri/src/lib.rs src/renderer/features/simulator/iosSimulatorApi.ts -git commit -m "feat: capture iOS simulator annotations" -``` - -### Task 7: Add Simulator Annotations to Chat - -**Files:** -- Create: `src/renderer/features/simulator/simulatorAnnotations.ts` -- Create: `src/renderer/features/simulator/simulatorAnnotations.test.ts` -- Create: `src/renderer/features/attachments/visualAttachments.ts` -- Create: `src/renderer/features/attachments/visualAttachments.test.ts` -- Modify: `src/shared/types.ts` -- Modify: `src-tauri/src/models/types.rs` -- Modify: `src-tauri/src/services/turn_service.rs` -- Modify: `src/renderer/features/simulator/SimulatorSurface.tsx` -- Modify: `src/renderer/features/simulator/SimulatorSurface.test.tsx` -- Modify: `src/renderer/features/simulator/useIosSimulatorPanel.ts` -- Modify: `src/renderer/App.tsx` -- Modify: `src/renderer/features/composer/Composer.tsx` -- Modify: `src/renderer/features/composer/Composer.test.tsx` -- Modify: `src/renderer/components/Transcript.tsx` -- Modify: `src/renderer/components/Transcript.test.tsx` -- Modify: `src/renderer/i18n.tsx` -- Modify: `src/renderer/styles/ios-simulator.css` - -**Interfaces:** -- Produces: `SimulatorAnnotation`, `createSimulatorAnnotationAttachment`, `expandVisualAttachmentSnapshots`, `promoteVisualAttachments`, and `deleteVisualTempFiles`. -- Consumes: Task 6 capture report and Task 5 selection modes. - -- [ ] **Step 1: Write failing attachment-contract tests** - -Define the exact frontend metadata: - -```ts -export type SimulatorAnnotation = { - kind: 'element' | 'area' - crop: string - note?: string - device: { name: string; udid: string; iosVersion: string; orientation: 'portrait' | 'landscape' } - deviceGeneration: number - frameGeneration: number - rect: { x: number; y: number; width: number; height: number } - deviceRect: { x: number; y: number; width: number; height: number } - element?: { id: string; role: string; label?: string } - viewportSnapshot: { path: string; width: number; height: number; size: number } -} -``` - -Assert `createSimulatorAnnotationAttachment` returns `kind: 'simulator-annotation'`, never includes URL/selector text, and includes: - -```text -Simulator annotation (element) on iPhone 17 Pro, iOS 26.5, portrait. -Selected component: Button “Save”. -User note (authoritative instruction): Increase the spacing. -Treat the written instruction and selected simulator component as authoritative. Use the crop and full simulator viewport only as supporting visual context. -``` - -Assert expanded request attachments contain the crop as `simulator-annotation` plus one ordinary full-viewport `image`. Assert promotion updates both paths. Assert annotation-only submit is enabled for browser and simulator annotations. - -- [ ] **Step 2: Run and confirm red type/behavior failures** - -```bash -npm test -- src/renderer/features/simulator/simulatorAnnotations.test.ts src/renderer/features/attachments/visualAttachments.test.ts src/renderer/features/composer/Composer.test.tsx src/renderer/components/Transcript.test.tsx -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml turn_service -- --nocapture -``` - -Expected: missing kind/type/functions and Rust enum variant failures. - -- [ ] **Step 3: Implement simulator-specific attachment identity** - -Extend only the required contracts, including the persisted transcript -attachment `Pick` so `simulatorAnnotation` survives conversation reload: - -```ts -export type AttachmentKind = 'image' | 'video' | 'file' | 'browser-annotation' | 'simulator-annotation' - -export type AttachmentMeta = { - path: string - name: string - size: number - kind: AttachmentKind - mediaType?: string - width?: number - height?: number - extractedText?: string - extractionStatus?: ExtractionStatus - video?: VideoStreamMetadata - browserAnnotation?: BrowserAnnotation - simulatorAnnotation?: SimulatorAnnotation -} - -export type StoredAttachmentMeta = Pick -``` - -Add `SimulatorAnnotation` to Rust `AttachmentKind`, `attachment_kind_label`, `is_visual_attachment`, and the structured-context-preserving branch of `merge_vision_description`. Rust does not need the frontend-only geometry object; it receives authoritative text through `extractedText` and the two image paths through expanded attachments. - -Implement shared dispatch without renaming browser-specific functions: - -```ts -export function isVisualAttachment(a: Pick) { - return a.kind === 'image' || a.kind === 'browser-annotation' || a.kind === 'simulator-annotation' -} - -export function expandVisualAttachmentSnapshots(items: AttachmentMeta[]) { - return expandSimulatorAnnotationSnapshots(expandBrowserAnnotationSnapshots(items)) -} -``` - -`promoteVisualAttachments` runs browser promotion, then simulator promotion. `deleteVisualTempFiles` partitions exact `/verboo-browser/` and `/verboo-ios-simulator/` paths and calls the corresponding Tauri commands. - -- [ ] **Step 4: Wire element/area confirmation into the surface** - -When `select-element` activates, request one accessibility snapshot. On click, choose the smallest actionable node whose frame contains the normalized point, call `captureAnnotation` immediately, and show an inline note/confirm panel. For `select-area`, draw the clamped rectangle during drag; reject width or height under 0.01 normalized units; capture on pointer up before showing the note. - -On cancel, call simulator temp deletion for both files. On confirm, build the attachment and pass it to App through `onAddAnnotation`; the existing ordered attachment queue preserves its position. - -- [ ] **Step 5: Generalize send, queue, transcript, and conversation cleanup** - -Replace App’s browser-only expansion/promotion/temp tracking with the shared visual helpers. Persist `simulatorAnnotation` in `slimMeta`. Delete the simulator capture owner when deleting a conversation and clean orphan owners at startup. Composer and Transcript classify both annotation kinds as image chips; simulator chip copy uses device name plus selected role/label and never a URL. - -- [ ] **Step 6: Run focused frontend/backend tests and build** - -```bash -npm test -- src/renderer/features/simulator/SimulatorSurface.test.tsx src/renderer/features/simulator/simulatorAnnotations.test.ts src/renderer/features/attachments/visualAttachments.test.ts src/renderer/features/composer/Composer.test.tsx src/renderer/components/Transcript.test.tsx -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml turn_service -- --nocapture -npm run build:renderer -``` - -Expected: all pass. - -- [ ] **Step 7: Commit Task 7** - -```bash -git add src/shared/types.ts src-tauri/src/models/types.rs src-tauri/src/services/turn_service.rs src/renderer/features/simulator/simulatorAnnotations.ts src/renderer/features/simulator/simulatorAnnotations.test.ts src/renderer/features/attachments/visualAttachments.ts src/renderer/features/attachments/visualAttachments.test.ts src/renderer/features/simulator/SimulatorSurface.tsx src/renderer/features/simulator/SimulatorSurface.test.tsx src/renderer/features/simulator/useIosSimulatorPanel.ts src/renderer/App.tsx src/renderer/features/composer/Composer.tsx src/renderer/features/composer/Composer.test.tsx src/renderer/components/Transcript.tsx src/renderer/components/Transcript.test.tsx src/renderer/i18n.tsx src/renderer/styles/ios-simulator.css -git commit -m "feat: add iOS simulator annotations to chat" -``` - -### Task 8: Add the Authenticated Agent Bridge and Presence Generations - -**Files:** -- Create: `src-tauri/src/services/ios_simulator/bridge.rs` -- Modify: `src-tauri/src/services/ios_simulator.rs` -- Modify: `src-tauri/src/lib.rs` -- Modify: `src/renderer/features/simulator/iosSimulatorApi.ts` -- Modify: `src/renderer/features/simulator/useIosSimulatorPanel.ts` -- Modify: `src/renderer/features/simulator/useIosSimulatorPanel.test.ts` - -**Interfaces:** -- Produces loopback JSON-line messages `toolRequest`, `toolResponse`, `error`, and `turnComplete` at protocol version 1. -- Produces renderer events `ios-simulator:presence` and `ios-simulator:open-requested`. -- Consumes the same Task 4 service methods used by manual input. - -- [ ] **Step 1: Write failing authentication, same-session, and generation tests** - -Test that wrong/missing secrets receive `unauthorized`, unknown tools receive `unknown_tool`, and a request for a UDID other than the attached device receives `device_mismatch` rather than silently attaching another simulator. - -Pin the concurrency bug: - -```rust -let first = presence.begin(AgentAction::Tap { - target: NormalizedPoint { x: 0.2, y: 0.3 }, -}); -let second = presence.begin(AgentAction::Tap { - target: NormalizedPoint { x: 0.7, y: 0.8 }, -}); -assert!(!presence.complete(first)); -assert_eq!(presence.current_generation(), Some(second)); -assert!(presence.complete(second)); -assert_eq!(presence.current_generation(), None); -``` - -Record events around a fake WDA call and assert `Start(generation)` is emitted before the fake client’s action record. Assert `turnComplete`, detach, panel close, window hide, and app exit each emit a clear event and leave no active generation. - -- [ ] **Step 2: Run and confirm missing bridge/presence failures** - -Run: `cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml ios_simulator -- --nocapture` - -Expected: missing bridge/presence compile failures. - -- [ ] **Step 3: Implement private discovery and loopback server** - -Use a record shaped exactly as: - -```rust -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SimulatorDiscoveryRecord { - protocol_version: u32, - pid: u32, - endpoint: String, - secret: String, - app_version: String, -} -``` - -Bind `TcpListener` to `127.0.0.1:0`, generate a 128-bit random UUID secret, atomically write the record with mode `0600` under the user cache directory `verboo-ios-simulator`, and set the directory to `0700` on Unix. Remove stale records whose pid is dead or endpoint cannot be reached. Accept one newline-delimited JSON envelope per connection, compare the secret before parsing tool arguments, and cap one request line at 1 MiB. - -Tool dispatch supports exact names: `ios_simulator_list`, `ios_simulator_attach`, `ios_simulator_inspect`, `ios_simulator_screenshot`, `ios_simulator_tap`, `ios_simulator_drag`, `ios_simulator_type_text`, `ios_simulator_press_key`, and `ios_simulator_detach`. - -- [ ] **Step 4: Implement presence authority** - -Add an `AtomicU64` counter and `Mutex>` current generation. Emit: - -```rust -pub struct IosSimulatorPresenceEvent { - pub generation: u64, - pub phase: IosSimulatorPresencePhase, - pub action: Option, - pub target: Option, - pub start: Option, - pub end: Option, -} -``` - -The bridge calls `begin_agent_action` and emits `open-requested` before executing WDA. Completion clears only when its generation still owns presence. `turnComplete` calls unconditional `clear_agent_presence`; manual Tauri commands call the WDA methods directly and never call presence methods. - -- [ ] **Step 5: Start and stop the bridge with the app** - -Start the bridge during Tauri setup with cloned `AppHandle` and `IosSimulatorService`; retain a stop flag/worker handle in managed state. On app exit, stop accepting, remove the discovery record, clear presence, then run the existing bounded WDA cleanup. - -- [ ] **Step 6: Run bridge/lifecycle tests** - -```bash -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml ios_simulator -- --nocapture -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml close_to_tray_detaches_the_simulator_session -- --nocapture -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml app_exit_cleanup_stops_the_session_within_its_deadline -- --nocapture -``` - -Expected: authenticated relay, presence ordering/generation, and lifecycle tests pass. - -- [ ] **Step 7: Commit Task 8** - -```bash -git add src-tauri/src/services/ios_simulator/bridge.rs src-tauri/src/services/ios_simulator.rs src-tauri/src/lib.rs src/renderer/features/simulator/iosSimulatorApi.ts src/renderer/features/simulator/useIosSimulatorPanel.ts src/renderer/features/simulator/useIosSimulatorPanel.test.ts -git commit -m "feat: expose the iOS simulator agent bridge" -``` - -### Task 9: Render Device-Adaptive Agent Presence - -**Files:** -- Create: `src/renderer/features/simulator/SimulatorPresenceOverlay.tsx` -- Create: `src/renderer/features/simulator/SimulatorPresenceOverlay.test.tsx` -- Modify: `src/renderer/features/simulator/SimulatorSurface.tsx` -- Modify: `src/renderer/features/simulator/SimulatorSurface.test.tsx` -- Modify: `src/renderer/features/simulator/useIosSimulatorPanel.ts` -- Modify: `src/renderer/features/simulator/useIosSimulatorPanel.test.ts` -- Modify: `src/renderer/features/simulator/IosSimulatorPanel.tsx` -- Modify: `src/renderer/App.tsx` -- Modify: `src/renderer/styles/ios-simulator.css` - -**Interfaces:** -- Consumes: Task 3 painted rectangle and Task 8 presence/open events. -- Produces no backend actions; it is a generation-filtered visual projection only. - -- [ ] **Step 1: Write failing visual authority tests** - -Assert the overlay’s inline bounds equal the painted device cutout rather than the panel. Feed generation 5 start, generation 6 start, and generation 5 complete; assert generation 6 remains visible. Feed generation 6 complete and assert removal. - -Assert tap renders one cursor and ripple at the normalized target, drag renders a start/end path, manual pointer callbacks never create the overlay, and reduced motion sets `data-reduced-motion="true"` with no travel animation class. - -- [ ] **Step 2: Run and confirm missing overlay behavior** - -Run: `npm test -- src/renderer/features/simulator/SimulatorPresenceOverlay.test.tsx src/renderer/features/simulator/SimulatorSurface.test.tsx src/renderer/features/simulator/useIosSimulatorPanel.test.ts` - -Expected: missing component/event behavior failures. - -- [ ] **Step 3: Implement the overlay with the Chrome visual language** - -Reuse the pointer SVG path data from `extensions/verboo-chrome/src/presence/inject.js` without importing extension runtime code: - -```tsx - -``` - -Place the aurora absolutely at `paintedRect`, use the extension’s violet inset shadows/blurred moving edge gradients, and derive border radius as `clamp(10px, paintedRect.width * 0.035, 24px)`. Cursor positions are normalized inside that same local rectangle. Supersede active Web Animations when a newer generation arrives; fall back to final transform when `Element.animate` is unavailable. - -- [ ] **Step 4: Wire event/open behavior and cleanup** - -`useIosSimulatorPanel` listens for presence and open-requested only once. It -accepts a start event only when `generation >= current`, ignores stale -completion, and clears on detach/close. The hook exposes an incrementing -`agentOpenRequest` token instead of changing competing panels itself. An App -effect consumes each token once, switches to chat, closes terminal/review/browser, -clears the selected subagent, and calls `simulator.open()`; this makes the -simulator the sole visible right rail. - -- [ ] **Step 5: Run visual tests and renderer build** - -```bash -npm test -- src/renderer/features/simulator/SimulatorPresenceOverlay.test.tsx src/renderer/features/simulator/SimulatorSurface.test.tsx src/renderer/features/simulator/useIosSimulatorPanel.test.ts src/renderer/features/simulator/IosSimulatorPanel.test.tsx -npm run build:renderer -``` - -Expected: tests/build pass. - -- [ ] **Step 6: Commit Task 9** - -```bash -git add src/renderer/features/simulator/SimulatorPresenceOverlay.tsx src/renderer/features/simulator/SimulatorPresenceOverlay.test.tsx src/renderer/features/simulator/SimulatorSurface.tsx src/renderer/features/simulator/SimulatorSurface.test.tsx src/renderer/features/simulator/useIosSimulatorPanel.ts src/renderer/features/simulator/useIosSimulatorPanel.test.ts src/renderer/features/simulator/IosSimulatorPanel.tsx src/renderer/App.tsx src/renderer/styles/ios-simulator.css -git commit -m "feat: show agent presence on the iOS simulator" -``` - -### Task 10: Package and Register the Simulator MCP Sidecar - -**Files:** -- Create: `src-tauri/verboo-in-chrome/src/bin/verboo-ios-simulator.rs` -- Create: `src-tauri/verboo-in-chrome/src/simulator_catalog.rs` -- Create: `src-tauri/verboo-in-chrome/src/simulator_client.rs` -- Create: `src-tauri/verboo-in-chrome/src/simulator_mcp.rs` -- Create: `src-tauri/verboo-in-chrome/src/simulator_protocol.rs` -- Create: `src-tauri/verboo-in-chrome/src/simulatorTools.json` -- Create: `src-tauri/verboo-in-chrome/tests/simulator_catalog.rs` -- Create: `src-tauri/verboo-in-chrome/tests/simulator_mcp.rs` -- Modify: `src-tauri/verboo-in-chrome/Cargo.toml` -- Modify: `src-tauri/verboo-in-chrome/src/lib.rs` -- Create: `src-tauri/src/services/ios_simulator_mcp.rs` -- Modify: `src-tauri/src/services/mod.rs` -- Modify: `src-tauri/src/lib.rs` -- Modify: `scripts/tauri/build-chrome-helper.mjs` -- Modify: `scripts/tauri/build-chrome-helper.test.mjs` -- Modify: `package.json` -- Modify: `src-tauri/tauri.conf.json` - -**Interfaces:** -- Produces the stdio MCP server `verboo-ios-simulator` registered at user scope. -- Consumes Task 8 discovery/protocol and sends `turnComplete` on EOF, SIGINT, and SIGTERM. - -- [ ] **Step 1: Write failing catalog, relay, cleanup, and filename tests** - -Pin the catalog to exactly nine names from Task 8, validate normalized points as numbers in `[0,1]`, bound text to 4,000 characters, and classify list/inspect/screenshot as read-only. - -Add MCP tests that start a fake loopback desktop bridge, invoke `ios_simulator_tap`, and assert structured success. Send invalid arguments and assert `is_error: true` with `invalid_arguments`. Close stdin, then repeat with SIGINT/SIGTERM harnesses; each must deliver one `turnComplete` within 200 ms. - -Update build filename tests: - -```js -assert.equal(sidecarFilename('verboo-in-chrome', 'aarch64-apple-darwin', 'darwin'), 'verboo-in-chrome-aarch64-apple-darwin') -assert.equal(sidecarFilename('verboo-ios-simulator', 'aarch64-apple-darwin', 'darwin'), 'verboo-ios-simulator-aarch64-apple-darwin') -``` - -- [ ] **Step 2: Run and confirm red failures** - -```bash -cargo +1.89.0 test --manifest-path src-tauri/verboo-in-chrome/Cargo.toml simulator -- --nocapture -node --test scripts/tauri/build-chrome-helper.test.mjs -``` - -Expected: missing binary/modules and old filename signature failures. - -- [ ] **Step 3: Implement the MCP catalog/client/server** - -Use `rmcp` exactly as the Chrome server does, but use simulator-specific names and errors. `SimulatorSessionClient` reads the Task 8 discovery record, verifies protocol version, connects only to a parsed `127.0.0.1` socket, injects the secret, and removes a stale record after connection failure. - -The binary accepts only `mcp` and `ping`: - -```rust -#[tokio::main] -async fn main() { - let result = match std::env::args().nth(1).as_deref() { - Some("mcp") => verboo_in_chrome::simulator_mcp::run_mcp().await, - Some("ping") => verboo_in_chrome::simulator_mcp::run_ping(), - _ => Err("usage: verboo-ios-simulator ".into()), - }; - if let Err(error) = result { eprintln!("verboo-ios-simulator: {error}"); std::process::exit(1); } -} -``` - -Copy the existing EOF/signal shutdown shape but call simulator `complete_turn`; keep the cleanup timeout at 200 ms so it fits the CLI’s 400 ms SIGTERM→SIGKILL grace. - -- [ ] **Step 4: Build and package both sidecars** - -Change `build-chrome-helper.mjs` to iterate `['verboo-in-chrome', 'verboo-ios-simulator']`, copy both target-triple binaries, and return both destinations. Keep the existing npm script name to avoid release-pipeline churn. Add `binaries/verboo-ios-simulator` to Tauri `externalBin`. - -- [ ] **Step 5: Install and idempotently register the helper** - -`ios_simulator_mcp.rs` copies only the bundled simulator helper into `app_data_dir/ios-simulator-integration/{app_version}/`, sets mode `0755`, and inspects the user CLI config before mutation. Register: - -```text -verboo mcp add verboo-ios-simulator --scope user \ - -e VERBOO_IOS_SIMULATOR_MANAGED=1 \ - -e VERBOO_IOS_SIMULATOR_VERSION= \ - -- mcp -``` - -If the existing entry lacks the managed marker or points outside the owned integration root, report a conflict and leave it untouched. If managed but outdated, remove and re-add. Run setup in `spawn_blocking` so app startup/first paint does not wait on the CLI. - -- [ ] **Step 6: Run sidecar, installer, build-script, and package gates** - -```bash -cargo +1.89.0 test --manifest-path src-tauri/verboo-in-chrome/Cargo.toml -- --nocapture -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml ios_simulator_mcp -- --nocapture -node --test scripts/tauri/build-chrome-helper.test.mjs -npm run build:chrome-helper -``` - -Expected: both binaries are built with target-triple suffixes; all tests pass. - -- [ ] **Step 7: Commit Task 10** - -```bash -git add src-tauri/verboo-in-chrome/Cargo.toml src-tauri/verboo-in-chrome/src/lib.rs src-tauri/verboo-in-chrome/src/bin/verboo-ios-simulator.rs src-tauri/verboo-in-chrome/src/simulator_catalog.rs src-tauri/verboo-in-chrome/src/simulator_client.rs src-tauri/verboo-in-chrome/src/simulator_mcp.rs src-tauri/verboo-in-chrome/src/simulator_protocol.rs src-tauri/verboo-in-chrome/src/simulatorTools.json src-tauri/verboo-in-chrome/tests/simulator_catalog.rs src-tauri/verboo-in-chrome/tests/simulator_mcp.rs src-tauri/src/services/ios_simulator_mcp.rs src-tauri/src/services/mod.rs src-tauri/src/lib.rs scripts/tauri/build-chrome-helper.mjs scripts/tauri/build-chrome-helper.test.mjs package.json src-tauri/tauri.conf.json -git commit -m "feat: package the iOS simulator MCP helper" -``` - ---- - -## Final Verification Gate - -- [ ] Run every affected automated gate from a clean index while preserving unrelated worktree changes: - -```bash -npm test -- src/renderer/features/simulator src/renderer/features/attachments/visualAttachments.test.ts src/renderer/features/composer/Composer.test.tsx src/renderer/components/Transcript.test.tsx -node --test scripts/tauri/copy-wda-resource.test.mjs scripts/tauri/build-chrome-helper.test.mjs -cargo +1.89.0 test --manifest-path src-tauri/verboo-in-chrome/Cargo.toml -- --nocapture -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml ios_simulator -- --nocapture -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml turn_service -- --nocapture -npm run build:renderer -``` - -Expected: every command exits 0. - -- [ ] Start the real app without calling the Verboo model and prove the production path: - -```bash -npm run tauri:dev -``` - -1. Attach a shutdown iPhone 17 Pro and observe `simctl` warmup followed by MJPEG near 30 fps. -2. Tap Safari, drag between Home Screen pages, focus a text field, type accented text, paste text, press Backspace/Enter, and release focus with Escape. -3. Select one accessibility component and one free rectangle; add both to chat and verify two simulator-specific chips, complete crops, full snapshots, and no browser URL/selector metadata. -4. Invoke tap, drag, and type through the local `verboo-ios-simulator` MCP helper; verify panel auto-open, pre-action cursor/aurora/ripple/path, correct target, and turn cleanup. -5. Keep a newer presence action active while completing an older generation; verify the newer aurora remains until its own completion. -6. Switch to 60 fps, confirm the localized warning, observe sustained high fluency, record Verboo/WDA CPU, and return to 30 fps. -7. Close/detach while another tab/panel is active; verify WDA, MJPEG/HTTP ports, bridge presence, and input stop while Simulator.app remains usable. - -- [ ] Build the packaged application and verify bundled resources/sidecars: - -```bash -npm run tauri:build -``` - -Expected: build exits 0, the `.app` contains WebDriverAgent plus both `verboo-in-chrome` and `verboo-ios-simulator` target binaries, and launching the packaged app repeats steps 1–7. - -- [ ] Inspect the final diff and report exact evidence: - -```bash -git diff --check -git status --short -git log --oneline --decorate -12 -``` - -Report measured default/high-fluency FPS, process CPU snapshots, manual interaction results, both annotation modes, MCP action results, presence cleanup, exact test commands/outcomes, packaged app path, and any skipped verification. Do not claim completion if the real packaged path or cleanup proof is missing. diff --git a/docs/superpowers/plans/2026-08-11-whats-new-release-modal.md b/docs/superpowers/plans/2026-08-11-whats-new-release-modal.md deleted file mode 100644 index 955f7e47..00000000 --- a/docs/superpowers/plans/2026-08-11-whats-new-release-modal.md +++ /dev/null @@ -1,2274 +0,0 @@ -# Versioned What's New Modal Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Show a localized, accessible What's New modal exactly once per tagged desktop-app version, beginning with `0.7.0-beta`, while generating the modal, GitHub release body, and updater summary from one reviewed bilingual catalog. - -**Architecture:** A repository-level release catalog is validated before every release and compiled into the renderer. A Rust-owned `WhatsNewService` validates the compile-time release tag, compares semantic versions, and atomically persists app-only acknowledgment state. A focused renderer hook waits for configuration, settings, and mandatory CLI bootstrap to settle before mounting an accessible modal; CLI-only updates never participate in this lifecycle. - -**Tech Stack:** React 19, TypeScript 6, Vitest/Testing Library, Tauri 2, Rust 1.89, Serde, SemVer, Node.js 20 release scripts, GitHub Actions. - -## Global Constraints - -- Target desktop version is exactly `0.7.0-beta` in `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`. -- Supported platforms are macOS, Windows, and Linux; only the iOS Simulator highlight is macOS-specific. -- Automatic presentation is enabled only for artifacts compiled with `VERBOO_RELEASE_TAG=v`. -- `VERBOO_WHATS_NEW_PREVIEW=1` displays the current release locally without writing acknowledgment state. -- Persistent state is app-owned at `/release-state.json`; no CLI-owned file or update snapshot can trigger or acknowledge the modal. -- The modal appears after configuration, settings, and mandatory Node/CLI bootstrap are resolved, including the bootstrap success animation. -- The modal has exactly two visible actions, no close icon, no backdrop dismissal, and Escape behaves like Close. -- Learn more derives `https://github.com/graseeel/verboo_app/releases/tag/v` from a validated version; the catalog cannot provide a URL. -- A successful external open acknowledges and closes; an opener failure stays visible and does not acknowledge. -- A persistence failure closes the modal for the current process, produces a non-fatal error toast, and may show again after relaunch. -- Release copy is offline, reviewed, and available in `pt-BR` and `en-US`; automation must never invent product claims from commits. -- The `0.7.0-beta` entry contains the six approved highlights, including the integrated iOS Simulator. -- Future release preparation scaffolds editorial fields, while verification blocks an unreviewed sentinel, missing locale, incomplete item, or count outside four to six. -- Do not push, merge, squash, or tag while implementing these tasks. Release operations remain a separate, explicitly verified workflow. -- Preserve all unrelated working-tree changes and stage only the files named by each task. - ---- - -## File Map - -### Release content and automation - -- Create `release-notes/releases.json`: single bilingual source of release copy. -- Create `scripts/release/release-catalog.mjs`: schema/version validation and catalog loading. -- Create `scripts/release/release-catalog.test.mjs`: release-content contract tests. -- Create `scripts/release/prepare-release.mjs`: non-destructive command that adds an editorial sentinel entry and refuses to overwrite an existing version. -- Create `scripts/release/prepare-release.test.mjs`: behavioral scaffolding tests. -- Create `scripts/release/render-release-notes.mjs`: deterministic GitHub Markdown renderer. -- Modify `package.json`: expose `release:prepare` and `release:verify`. -- Modify `scripts/verify/verify-release-version.mjs`: require a complete catalog entry matching the tag. -- Modify `scripts/verify/verify-release-version.test.mjs`: cover complete and incomplete catalog entries. -- Modify `scripts/verify/update-manifest.mjs`: accept catalog-derived updater notes. -- Modify `scripts/verify/update-manifest.test.mjs`: prove the supplied release summary reaches `latest.json`. -- Modify `scripts/verify/generate-tauri-update-manifest.mjs`: load the matching catalog entry. -- Modify `.github/workflows/tauri-release.yml`: validate content, stamp every platform build, and render release notes from the catalog. -- Modify `scripts/verify/tauri-release-signing.test.mjs`: lock the stamp and remove stale inline release prose. - -### Native lifecycle - -- Create `src-tauri/src/services/whats_new_service.rs`: eligibility, session suppression, semantic comparison, and atomic state persistence. -- Modify `src-tauri/src/services/mod.rs`: register the service module. -- Modify `src-tauri/src/models/types.rs`: add IPC result types. -- Modify `src-tauri/src/lib.rs`: manage the service and expose two commands. -- Modify `src/shared/types.ts`: mirror the IPC types. -- Modify `src/renderer/verboo-bridge.ts`: expose typed status and acknowledgment calls. - -### Renderer - -- Create `src/renderer/features/whats-new/releaseCatalog.ts`: typed catalog lookup and fixed tag URL derivation. -- Create `src/renderer/features/whats-new/releaseCatalog.test.ts`: locale, missing-version, and URL tests. -- Create `src/renderer/features/whats-new/WhatsNewModal.tsx`: presentation, focus trap, action ordering, and opener handling. -- Create `src/renderer/features/whats-new/WhatsNewModal.test.tsx`: mounted behavioral and accessibility tests. -- Create `src/renderer/features/whats-new/useWhatsNew.ts`: one-shot bridge query and session dismissal state. -- Create `src/renderer/features/whats-new/useWhatsNew.test.tsx`: request/acknowledgment lifecycle tests. -- Create `src/renderer/styles/whats-new.css`: responsive visual treatment and reduced-motion behavior. -- Modify `src/renderer/styles/app.css`: import the focused stylesheet. -- Modify `src/renderer/i18n.tsx`: generic modal labels and recoverable errors in both locales. -- Modify `src/renderer/App.tsx`: wire startup precedence on both login and unlocked surfaces. -- Modify `src/renderer/App.cliBootstrapGate.test.tsx`: mounted App-to-bridge precedence proof. - ---- - -### Task 1: Create the bilingual release catalog and preparation command - -**Files:** -- Create: `release-notes/releases.json` -- Create: `scripts/release/release-catalog.mjs` -- Create: `scripts/release/release-catalog.test.mjs` -- Create: `scripts/release/prepare-release.mjs` -- Create: `scripts/release/prepare-release.test.mjs` -- Modify: `package.json` - -**Interfaces:** -- Consumes: the canonical app version string supplied by the release command. -- Produces: `readReleaseCatalog(path?)`, `validateReleaseCatalog(catalog, version)`, `releaseEntry(catalog, version)`, `scaffoldReleaseVersion(catalog, version)`, and `EDITORIAL_SENTINEL` for Task 2; `release-notes/releases.json` for Tasks 2 and 4. - -- [ ] **Step 1: Write failing catalog contract tests** - -Create `scripts/release/release-catalog.test.mjs` with tests that load the real entry and mutate it counterfactually: - -```js -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - EDITORIAL_SENTINEL, - readReleaseCatalog, - releaseEntry, - validateReleaseCatalog, -} from "./release-catalog.mjs"; - -test("0.7.0-beta has complete reviewed pt-BR and en-US copy", async () => { - const catalog = await readReleaseCatalog(); - const entry = validateReleaseCatalog(catalog, "0.7.0-beta"); - - assert.equal(entry["pt-BR"].items.length, 6); - assert.equal(entry["en-US"].items.length, 6); - assert.match(entry["pt-BR"].items[0].title, /Simulador de iOS/); - assert.match(entry["en-US"].items[0].title, /iOS Simulator/); - assert.deepEqual(releaseEntry(catalog, "0.7.0-beta"), entry); -}); - -test("rejects missing locale, editorial sentinel, and invalid item count", async () => { - const original = await readReleaseCatalog(); - - const missingLocale = structuredClone(original); - delete missingLocale.releases["0.7.0-beta"]["en-US"]; - assert.throws( - () => validateReleaseCatalog(missingLocale, "0.7.0-beta"), - /en-US/, - ); - - const sentinel = structuredClone(original); - sentinel.releases["0.7.0-beta"]["pt-BR"].summary = EDITORIAL_SENTINEL; - assert.throws( - () => validateReleaseCatalog(sentinel, "0.7.0-beta"), - /editorial copy/i, - ); - - const tooShort = structuredClone(original); - tooShort.releases["0.7.0-beta"]["en-US"].items = tooShort.releases["0.7.0-beta"]["en-US"].items.slice(0, 3); - assert.throws( - () => validateReleaseCatalog(tooShort, "0.7.0-beta"), - /four to six/i, - ); -}); - -test("rejects non-canonical release versions", async () => { - const catalog = await readReleaseCatalog(); - assert.throws(() => validateReleaseCatalog(catalog, "v0.7.0-beta"), /canonical/i); - assert.throws(() => validateReleaseCatalog(catalog, "0.7"), /canonical/i); -}); -``` - -- [ ] **Step 2: Write failing release-preparation tests** - -Create `scripts/release/prepare-release.test.mjs`: - -```js -import assert from "node:assert/strict"; -import test from "node:test"; - -import { EDITORIAL_SENTINEL, validateReleaseCatalog } from "./release-catalog.mjs"; -import { scaffoldReleaseVersion } from "./prepare-release.mjs"; - -const emptyCatalog = { schemaVersion: 1, releases: {} }; - -test("scaffolds both locales with four explicit editorial sentinels", () => { - const next = scaffoldReleaseVersion(emptyCatalog, "0.8.0-beta"); - const entry = next.releases["0.8.0-beta"]; - - assert.equal(entry["pt-BR"].title, EDITORIAL_SENTINEL); - assert.equal(entry["en-US"].title, EDITORIAL_SENTINEL); - assert.equal(entry["pt-BR"].items.length, 4); - assert.equal(entry["en-US"].items.length, 4); - assert.throws( - () => validateReleaseCatalog(next, "0.8.0-beta"), - /editorial copy/i, - ); -}); - -test("refuses to overwrite an existing release", () => { - const once = scaffoldReleaseVersion(emptyCatalog, "0.8.0-beta"); - assert.throws( - () => scaffoldReleaseVersion(once, "0.8.0-beta"), - /already exists/i, - ); -}); -``` - -- [ ] **Step 3: Run the focused tests and confirm the expected red state** - -Run: - -```bash -node --test scripts/release/release-catalog.test.mjs scripts/release/prepare-release.test.mjs -``` - -Expected: FAIL because `release-catalog.mjs`, `prepare-release.mjs`, and `release-notes/releases.json` do not exist. - -- [ ] **Step 4: Add the exact `0.7.0-beta` catalog** - -Create `release-notes/releases.json`: - -```json -{ - "schemaVersion": 1, - "releases": { - "0.7.0-beta": { - "pt-BR": { - "title": "O Verboo Code 0.7.0-beta chegou", - "summary": "Uma grande atualização para trabalhar com apps iOS, provedores externos e uma instalação mais leve.", - "items": [ - { - "title": "Simulador de iOS integrado — macOS", - "body": "Abra iPhones e iPads ao lado da conversa, interaja com o app, use controles do sistema e envie seleções ao chat." - }, - { - "title": "Várias contas Claude e Codex", - "body": "Conecte contas adicionais, escolha qual conta cada conversa utiliza e preserve o histórico visível ao trocar." - }, - { - "title": "Planos e limites no lugar certo", - "body": "Consulte o plano, as janelas de uso e os horários de renovação diretamente em Provedores." - }, - { - "title": "Atualizações independentes do CLI", - "body": "O app e o CLI agora podem receber atualizações assinadas separadamente, mantendo um único fluxo seguro de reinicialização." - }, - { - "title": "Instalação muito mais leve", - "body": "O Node é baixado e verificado pelo próprio app no primeiro uso, sem depender do Node do sistema e sem criar um aplicativo auxiliar no Dock." - }, - { - "title": "Uma experiência mais fluida", - "body": "Carregamento paralelo de provedores, login mais robusto e transições discretas deixam a inicialização mais agradável." - } - ] - }, - "en-US": { - "title": "Verboo Code 0.7.0-beta is here", - "summary": "A major update for working with iOS apps, external providers, and a lighter installation.", - "items": [ - { - "title": "Built-in iOS Simulator — macOS", - "body": "Open iPhones and iPads beside the conversation, interact with your app, use system controls, and send selections to chat." - }, - { - "title": "Multiple Claude and Codex accounts", - "body": "Connect additional accounts, choose which account each conversation uses, and keep the visible history when switching." - }, - { - "title": "Plans and limits where you need them", - "body": "See your plan, usage windows, and reset times directly in Providers." - }, - { - "title": "Independent CLI updates", - "body": "The app and CLI can now receive signed updates separately while sharing one safe restart flow." - }, - { - "title": "A much lighter installation", - "body": "Node is downloaded and verified by the app on first use, without relying on system Node or creating a helper app in the Dock." - }, - { - "title": "A smoother experience", - "body": "Parallel provider loading, more reliable sign-in, and subtle transitions make startup feel better." - } - ] - } - } - } -} -``` - -- [ ] **Step 5: Implement strict catalog validation and loading** - -Create `scripts/release/release-catalog.mjs` with these public contracts: - -```js -import { readFile } from "node:fs/promises"; - -export const EDITORIAL_SENTINEL = "EDITORIAL_COPY_REQUIRED"; -export const RELEASE_LOCALES = Object.freeze(["pt-BR", "en-US"]); -const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; - -export function assertCanonicalVersion(version) { - if (typeof version !== "string" || !VERSION_PATTERN.test(version)) { - throw new Error(`release version must be canonical semantic version: ${String(version)}`); - } - return version; -} - -function assertReviewedText(value, path) { - if (typeof value !== "string" || value.trim().length === 0) { - throw new Error(`${path} must be non-empty`); - } - if (value.includes(EDITORIAL_SENTINEL)) { - throw new Error(`${path} still requires reviewed editorial copy`); - } -} - -function validateLocale(copy, path) { - if (!copy || typeof copy !== "object" || Array.isArray(copy)) { - throw new Error(`${path} must be an object`); - } - assertReviewedText(copy.title, `${path}.title`); - assertReviewedText(copy.summary, `${path}.summary`); - if (!Array.isArray(copy.items) || copy.items.length < 4 || copy.items.length > 6) { - throw new Error(`${path}.items must contain four to six highlights`); - } - copy.items.forEach((item, index) => { - if (!item || typeof item !== "object" || Array.isArray(item)) { - throw new Error(`${path}.items[${index}] must be an object`); - } - assertReviewedText(item.title, `${path}.items[${index}].title`); - assertReviewedText(item.body, `${path}.items[${index}].body`); - }); - return copy; -} - -export function releaseEntry(catalog, version) { - assertCanonicalVersion(version); - const entry = catalog?.releases?.[version]; - if (!entry) throw new Error(`release catalog has no entry for ${version}`); - return entry; -} - -export function validateReleaseCatalog(catalog, version) { - if (catalog?.schemaVersion !== 1) { - throw new Error(`release catalog schemaVersion must be 1`); - } - const entry = releaseEntry(catalog, version); - for (const locale of RELEASE_LOCALES) { - validateLocale(entry[locale], `releases.${version}.${locale}`); - } - return entry; -} - -export async function readReleaseCatalog(path = "release-notes/releases.json") { - return JSON.parse(await readFile(path, "utf8")); -} -``` - -- [ ] **Step 6: Implement the non-destructive preparation command** - -Create `scripts/release/prepare-release.mjs`: - -```js -#!/usr/bin/env node - -import { readFile, writeFile } from "node:fs/promises"; -import { pathToFileURL } from "node:url"; - -import { - assertCanonicalVersion, - EDITORIAL_SENTINEL, - RELEASE_LOCALES, -} from "./release-catalog.mjs"; - -function editorialLocale() { - return { - title: EDITORIAL_SENTINEL, - summary: EDITORIAL_SENTINEL, - items: Array.from({ length: 4 }, () => ({ - title: EDITORIAL_SENTINEL, - body: EDITORIAL_SENTINEL, - })), - }; -} - -export function scaffoldReleaseVersion(catalog, version) { - assertCanonicalVersion(version); - if (catalog?.schemaVersion !== 1 || !catalog.releases || typeof catalog.releases !== "object") { - throw new Error("release catalog must use schemaVersion 1 and contain releases"); - } - if (catalog.releases[version]) { - throw new Error(`release ${version} already exists in the catalog`); - } - const next = structuredClone(catalog); - next.releases[version] = Object.fromEntries( - RELEASE_LOCALES.map(locale => [locale, editorialLocale()]), - ); - return next; -} - -async function main() { - const version = process.argv[2]; - if (!version) throw new Error("usage: npm run release:prepare -- "); - const path = "release-notes/releases.json"; - const catalog = JSON.parse(await readFile(path, "utf8")); - const next = scaffoldReleaseVersion(catalog, version); - await writeFile(path, `${JSON.stringify(next, null, 2)}\n`); - process.stdout.write(`Scaffolded editorial fields for ${version} in ${path}\n`); -} - -if (import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); -} -``` - -- [ ] **Step 7: Expose stable npm commands** - -Add these entries to `package.json` under `scripts`: - -```json -"release:prepare": "node scripts/release/prepare-release.mjs", -"release:verify": "node scripts/verify/verify-release-version.mjs" -``` - -Keep the surrounding JSON valid and do not change the package version. - -- [ ] **Step 8: Run the focused tests and verify the green state** - -Run: - -```bash -node --test scripts/release/release-catalog.test.mjs scripts/release/prepare-release.test.mjs -node scripts/verify/verify-release-version.mjs --tag v0.7.0-beta -``` - -Expected: the two test files pass; the second command still passes the existing version checks until Task 2 adds catalog enforcement. - -- [ ] **Step 9: Commit Task 1 only** - -```bash -git add package.json release-notes/releases.json scripts/release/release-catalog.mjs scripts/release/release-catalog.test.mjs scripts/release/prepare-release.mjs scripts/release/prepare-release.test.mjs -git diff --cached --check -git commit -m "feat(release): add bilingual release catalog" -``` - ---- - -### Task 2: Make release artifacts and metadata consume the catalog - -**Files:** -- Create: `scripts/release/render-release-notes.mjs` -- Modify: `scripts/release/release-catalog.test.mjs` -- Modify: `scripts/verify/verify-release-version.mjs` -- Modify: `scripts/verify/verify-release-version.test.mjs` -- Modify: `scripts/verify/update-manifest.mjs` -- Modify: `scripts/verify/update-manifest.test.mjs` -- Modify: `scripts/verify/generate-tauri-update-manifest.mjs` -- Modify: `scripts/verify/tauri-release-signing.test.mjs` -- Modify: `.github/workflows/tauri-release.yml` - -**Interfaces:** -- Consumes: `readReleaseCatalog()`, `validateReleaseCatalog()`, and the exact entry created in Task 1. -- Produces: `renderReleaseNotes(entry, version) -> string`, catalog-derived `latest.json.notes`, compile-time `VERBOO_RELEASE_TAG`, and a release workflow with no version-specific prose. - -- [ ] **Step 1: Add failing release-metadata tests** - -Extend `scripts/release/release-catalog.test.mjs`: - -```js -import { renderReleaseNotes } from "./render-release-notes.mjs"; - -test("renders GitHub notes from the reviewed English catalog entry", async () => { - const catalog = await readReleaseCatalog(); - const entry = validateReleaseCatalog(catalog, "0.7.0-beta"); - const markdown = renderReleaseNotes(entry, "0.7.0-beta"); - - assert.match(markdown, /^## Verboo Code 0\.7\.0-beta/m); - assert.match(markdown, /Built-in iOS Simulator — macOS/); - assert.match(markdown, /A much lighter installation/); - assert.match(markdown, /Verboo-Code-0\.7\.0-beta-Windows-x64-Setup\.exe/); - assert.doesNotMatch(markdown, /EDITORIAL_COPY_REQUIRED/); -}); -``` - -Modify the first test in `scripts/verify/update-manifest.test.mjs` to pass and assert the catalog summary: - -```js -const manifest = await buildUpdateManifest({ - tag: "v1.2.3", - version: "1.2.3", - bundlesDir, - releaseBaseUrl: "https://github.com/graseeel/verboo_app/releases/download/v1.2.3", - publishedAt: "2026-07-22T18:00:00.000Z", - notes: "Reviewed release summary", -}); - -assert.equal(manifest.notes, "Reviewed release summary"); -``` - -Extend `scripts/verify/verify-release-version.test.mjs` with a complete catalog fixture and a missing-entry mutation: - -```js -const reviewedLocale = { - title: "Reviewed title", - summary: "Reviewed summary", - items: Array.from({ length: 4 }, (_, index) => ({ - title: `Highlight ${index + 1}`, - body: `Reviewed body ${index + 1}`, - })), -}; - -const reviewedCatalog = { - schemaVersion: 1, - releases: { - "0.6.0-beta.1": { - "pt-BR": structuredClone(reviewedLocale), - "en-US": structuredClone(reviewedLocale), - }, - }, -}; - -test("rejects a matching version that has no reviewed catalog entry", () => { - assert.throws( - () => verifyReleaseVersions({ - tag: "v0.6.0-beta.2", - packageVersion: "0.6.0-beta.2", - cargoVersion: "0.6.0-beta.2", - tauriVersion: "0.6.0-beta.2", - catalog: reviewedCatalog, - }), - /no entry/i, - ); -}); -``` - -Pass `catalog: reviewedCatalog` to the existing accepted fixture and matching mutations. - -- [ ] **Step 2: Add a failing workflow contract test** - -Extend the workflow assertions in `scripts/verify/tauri-release-signing.test.mjs`: - -```js -assert.match(workflow, /VERBOO_RELEASE_TAG:\s*\$\{\{ needs\.resolve-tag\.outputs\.tag \}\}/); -assert.match(workflow, /render-release-notes\.mjs/); -assert.doesNotMatch(workflow, /This beta brings a macOS embedded browser/); -assert.doesNotMatch(workflow, /printf '%s\\n' "- On macOS, work beside a live local site/); -``` - -- [ ] **Step 3: Run the release tests and confirm the expected red state** - -Run: - -```bash -node --test scripts/release/release-catalog.test.mjs scripts/verify/update-manifest.test.mjs scripts/verify/verify-release-version.test.mjs scripts/verify/tauri-release-signing.test.mjs -``` - -Expected: FAIL because `render-release-notes.mjs` is absent, the manifest ignores `notes`, version verification does not receive a catalog, and the workflow has no release stamp. - -- [ ] **Step 4: Implement deterministic GitHub Markdown rendering** - -Create `scripts/release/render-release-notes.mjs`: - -```js -#!/usr/bin/env node - -import { writeFile } from "node:fs/promises"; -import { pathToFileURL } from "node:url"; - -import { readReleaseCatalog, validateReleaseCatalog } from "./release-catalog.mjs"; - -export function renderReleaseNotes(entry, version) { - const copy = entry["en-US"]; - const highlights = copy.items.flatMap(item => [ - `- **${item.title}**`, - ` ${item.body}`, - ]); - return [ - `## Verboo Code ${version}`, - "", - copy.summary, - "", - "### What's new", - "", - ...highlights, - "", - "### Download the right file", - "", - "| Your computer | Download |", - "|---|---|", - `| **macOS Apple Silicon** (M1 / M2 / M3 / M4) | \`Verboo-Code-${version}-macOS-Apple-Silicon.dmg\` |`, - `| **macOS Intel** | \`Verboo-Code-${version}-macOS-Intel.dmg\` |`, - `| **Windows 10/11 (64-bit)** | \`Verboo-Code-${version}-Windows-x64-Setup.exe\` |`, - `| **Linux (AppImage, any distro)** | \`Verboo-Code-${version}-Linux-x64.AppImage\` |`, - `| **Linux Debian/Ubuntu** | \`Verboo-Code-${version}-Linux-x64.deb\` |`, - `| **Linux Fedora/RHEL** | \`Verboo-Code-${version}-Linux-x64.rpm\` |`, - "", - "> Tip: on a Mac, open **Apple menu → About This Mac**. If the chip says Apple M…, pick **Apple Silicon**.", - "", - "Assets appear as each platform build finishes.", - "", - ].join("\n"); -} - -function parseArgs(values) { - const result = {}; - for (let index = 0; index < values.length; index += 2) { - result[values[index]?.replace(/^--/, "")] = values[index + 1]; - } - return result; -} - -async function main() { - const args = parseArgs(process.argv.slice(2)); - if (!args.version || !args.output) { - throw new Error("usage: render-release-notes.mjs --version --output "); - } - const catalog = await readReleaseCatalog(); - const entry = validateReleaseCatalog(catalog, args.version); - await writeFile(args.output, renderReleaseNotes(entry, args.version)); -} - -if (import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); -} -``` - -- [ ] **Step 5: Require reviewed catalog content in release version verification** - -Modify `scripts/verify/verify-release-version.mjs` so the pure function consumes the catalog and the CLI loads it: - -```js -import { readReleaseCatalog, validateReleaseCatalog } from "../release/release-catalog.mjs"; - -export function verifyReleaseVersions({ - tag, - packageVersion, - cargoVersion, - tauriVersion, - catalog, -}) { - if (!tag?.startsWith("v") || tag.length === 1) { - throw new Error(`release tag must be v-prefixed: ${tag ?? "missing"}`); - } - const expected = tag.slice(1); - for (const [source, actual] of Object.entries({ - package: packageVersion, - Cargo: cargoVersion, - Tauri: tauriVersion, - })) { - if (actual !== expected) { - throw new Error(`${source} version ${actual} does not match release ${expected}`); - } - } - validateReleaseCatalog(catalog, expected); - return expected; -} -``` - -Inside `main()`, load once and pass: - -```js -const catalog = await readReleaseCatalog(); -verifyReleaseVersions({ - tag, - packageVersion: packageJson.version, - cargoVersion, - tauriVersion: tauriConfig.version, - catalog, -}); -``` - -- [ ] **Step 6: Feed reviewed summary text into the updater manifest** - -Change `buildUpdateManifest` in `scripts/verify/update-manifest.mjs` to require a non-empty `notes` argument and return it unchanged: - -```js -export async function buildUpdateManifest({ - tag, - version, - bundlesDir, - releaseBaseUrl, - publishedAt = new Date().toISOString(), - notes, -}) { - if (typeof notes !== "string" || notes.trim().length === 0) { - throw new Error("update manifest requires reviewed release notes"); - } - if (tag !== `v${version}`) { - throw new Error(`release tag ${tag} does not match version ${version}`); - } - const baseUrl = new URL(releaseBaseUrl); - if (baseUrl.protocol !== "https:") { - throw new Error("update release URL must use HTTPS"); - } - - const files = new Set(await readdir(bundlesDir)); - const platforms = {}; - for (const [target, makeName] of Object.entries(TARGET_ARTIFACTS)) { - const artifact = makeName(version); - const signatureFile = `${artifact}.sig`; - if (!files.has(artifact)) throw new Error(`missing updater artifact for ${target}: ${artifact}`); - if (!files.has(signatureFile)) throw new Error(`missing signature for ${target}: ${signatureFile}`); - const signature = (await readFile(join(bundlesDir, signatureFile), "utf8")).trim(); - if (!signature) throw new Error(`empty signature for ${target}: ${signatureFile}`); - platforms[target] = { - signature, - url: `${releaseBaseUrl.replace(/\/$/, "")}/${encodeURIComponent(basename(artifact))}`, - }; - } - return { version, notes, pub_date: publishedAt, platforms }; -} -``` - -Update every `buildUpdateManifest` test call to pass `notes: "Reviewed release summary"`. - -Modify `scripts/verify/generate-tauri-update-manifest.mjs` before its call: - -```js -import { readReleaseCatalog, validateReleaseCatalog } from "../release/release-catalog.mjs"; - -const catalog = await readReleaseCatalog(); -const release = validateReleaseCatalog(catalog, args.version); - -const manifest = await buildUpdateManifest({ - tag: args.tag, - version: args.version, - bundlesDir: args["bundles-dir"], - releaseBaseUrl: `https://github.com/graseeel/verboo_app/releases/download/${args.tag}`, - notes: release["en-US"].summary, -}); -``` - -- [ ] **Step 7: Stamp all matrix artifacts and replace inline workflow prose** - -In the `build-tauri` job-level `env` in `.github/workflows/tauri-release.yml`, add: - -```yaml - VERBOO_RELEASE_TAG: ${{ needs.resolve-tag.outputs.tag }} -``` - -Replace the inline `printf` release-body block in `Publish to GitHub Release` with: - -```bash -NOTES_FILE="${RUNNER_TEMP:-/tmp}/release-notes-${VERSION}.md" -node scripts/release/render-release-notes.mjs \ - --version "$VERSION" \ - --output "$NOTES_FILE" -``` - -Extend `Test release contracts` to include the new Node tests: - -```yaml -run: >- - node --test - scripts/release/release-catalog.test.mjs - scripts/release/prepare-release.test.mjs - scripts/verify/update-manifest.test.mjs - scripts/verify/verify-release-version.test.mjs - scripts/verify/tauri-release-signing.test.mjs - scripts/verify/cli-update-ownership.test.mjs -``` - -- [ ] **Step 8: Run all release contract tests** - -Run: - -```bash -node --test scripts/release/release-catalog.test.mjs scripts/release/prepare-release.test.mjs scripts/verify/update-manifest.test.mjs scripts/verify/verify-release-version.test.mjs scripts/verify/tauri-release-signing.test.mjs scripts/verify/cli-update-ownership.test.mjs -node scripts/verify/verify-release-version.mjs --tag v0.7.0-beta -``` - -Expected: all tests PASS, the real catalog validates, and no release prose remains embedded in the workflow. - -- [ ] **Step 9: Commit Task 2 only** - -```bash -git add .github/workflows/tauri-release.yml scripts/release/release-catalog.test.mjs scripts/release/render-release-notes.mjs scripts/verify/verify-release-version.mjs scripts/verify/verify-release-version.test.mjs scripts/verify/update-manifest.mjs scripts/verify/update-manifest.test.mjs scripts/verify/generate-tauri-update-manifest.mjs scripts/verify/tauri-release-signing.test.mjs -git diff --cached --check -git commit -m "feat(release): generate metadata from reviewed notes" -``` - ---- - -### Task 3: Add the app-owned native acknowledgment lifecycle - -**Files:** -- Create: `src-tauri/src/services/whats_new_service.rs` -- Modify: `src-tauri/src/services/mod.rs` -- Modify: `src-tauri/src/models/types.rs` -- Modify: `src-tauri/src/lib.rs` -- Modify: `src/shared/types.ts` -- Modify: `src/renderer/verboo-bridge.ts` - -**Interfaces:** -- Consumes: compile-time `option_env!("VERBOO_RELEASE_TAG")`, runtime `VERBOO_WHATS_NEW_PREVIEW`, current Tauri package version, and app-data directory. -- Produces: `WhatsNewService::status() -> Result, String>`, `WhatsNewService::acknowledge(&str) -> Result`, bridge calls `getWhatsNewStatus()` and `acknowledgeWhatsNew(version)` for Task 5. - -- [ ] **Step 1: Define mirrored IPC types before the service tests** - -Add to `src-tauri/src/models/types.rs` after update types: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WhatsNewStatus { - pub version: String, - pub tag: String, - pub preview: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WhatsNewAcknowledgeResult { - pub persisted: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} -``` - -Add matching types to `src/shared/types.ts` after `InstallUpdateResult`: - -```ts -export type WhatsNewStatus = { - version: string - tag: string - preview: boolean -} - -export type WhatsNewAcknowledgeResult = { - persisted: boolean - error?: string -} -``` - -- [ ] **Step 2: Write failing native lifecycle tests** - -Create `src-tauri/src/services/whats_new_service.rs` with the test module first, importing the production type that will be added below: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::tempdir; - - fn tagged(root: &Path, current: &str) -> WhatsNewService { - WhatsNewService::new( - root.to_path_buf(), - current.to_string(), - Some(format!("v{current}")), - false, - ) - } - - #[test] - fn first_tagged_launch_is_eligible_then_acknowledgment_survives_restart() { - let root = tempdir().unwrap(); - let service = tagged(root.path(), "0.7.0-beta"); - assert_eq!(service.status().unwrap().unwrap().version, "0.7.0-beta"); - assert!(service.acknowledge("0.7.0-beta").unwrap().persisted); - assert!(service.status().unwrap().is_none()); - assert!(tagged(root.path(), "0.7.0-beta").status().unwrap().is_none()); - } - - #[test] - fn newer_version_is_eligible_and_downgrade_is_suppressed() { - let root = tempdir().unwrap(); - tagged(root.path(), "0.7.0-beta").acknowledge("0.7.0-beta").unwrap(); - assert!(tagged(root.path(), "0.8.0-beta").status().unwrap().is_some()); - assert!(tagged(root.path(), "0.6.2").status().unwrap().is_none()); - } - - #[test] - fn absent_or_mismatched_build_tag_is_not_eligible() { - let root = tempdir().unwrap(); - let absent = WhatsNewService::new(root.path().into(), "0.7.0-beta".into(), None, false); - let mismatch = WhatsNewService::new( - root.path().into(), - "0.7.0-beta".into(), - Some("v0.6.2".into()), - false, - ); - assert!(absent.status().unwrap().is_none()); - assert!(mismatch.status().unwrap().is_none()); - } - - #[test] - fn preview_shows_once_per_process_without_writing_state() { - let root = tempdir().unwrap(); - let preview = WhatsNewService::new(root.path().into(), "0.7.0-beta".into(), None, true); - assert!(preview.status().unwrap().unwrap().preview); - let result = preview.acknowledge("0.7.0-beta").unwrap(); - assert!(!result.persisted); - assert!(result.error.is_none()); - assert!(!root.path().join("release-state.json").exists()); - assert!(preview.status().unwrap().is_none()); - } - - #[test] - fn corrupt_state_is_repaired_and_suppressed_without_showing() { - let root = tempdir().unwrap(); - fs::write(root.path().join("release-state.json"), b"not json").unwrap(); - let service = tagged(root.path(), "0.7.0-beta"); - assert!(service.status().unwrap().is_none()); - let repaired: ReleaseState = serde_json::from_slice( - &fs::read(root.path().join("release-state.json")).unwrap(), - ).unwrap(); - assert_eq!(repaired.acknowledged_version, "0.7.0-beta"); - } - - #[test] - fn unsupported_schema_is_repaired_and_unknown_fields_are_ignored() { - let root = tempdir().unwrap(); - fs::write( - root.path().join("release-state.json"), - br#"{"schemaVersion":1,"acknowledgedVersion":"0.7.0-beta","futureField":true}"#, - ).unwrap(); - assert!(tagged(root.path(), "0.7.0-beta").status().unwrap().is_none()); - - fs::write( - root.path().join("release-state.json"), - br#"{"schemaVersion":2,"acknowledgedVersion":"0.7.0-beta"}"#, - ).unwrap(); - assert!(tagged(root.path(), "0.8.0-beta").status().unwrap().is_none()); - let repaired: ReleaseState = serde_json::from_slice( - &fs::read(root.path().join("release-state.json")).unwrap(), - ).unwrap(); - assert_eq!(repaired.schema_version, 1); - assert_eq!(repaired.acknowledged_version, "0.8.0-beta"); - } - - #[test] - fn closing_the_process_without_acknowledging_keeps_the_release_eligible() { - let root = tempdir().unwrap(); - assert!(tagged(root.path(), "0.7.0-beta").status().unwrap().is_some()); - assert!(tagged(root.path(), "0.7.0-beta").status().unwrap().is_some()); - } - - #[test] - fn direct_acknowledgment_from_a_downgraded_build_never_lowers_the_record() { - let root = tempdir().unwrap(); - tagged(root.path(), "0.8.0-beta").acknowledge("0.8.0-beta").unwrap(); - tagged(root.path(), "0.7.0-beta").acknowledge("0.7.0-beta").unwrap(); - let state: ReleaseState = serde_json::from_slice( - &fs::read(root.path().join("release-state.json")).unwrap(), - ).unwrap(); - assert_eq!(state.acknowledged_version, "0.8.0-beta"); - } - - #[test] - fn cli_files_cannot_change_app_release_eligibility() { - let root = tempdir().unwrap(); - fs::create_dir_all(root.path().join("cli-update")).unwrap(); - fs::write( - root.path().join("cli-update/current.json"), - br#"{"version":"999.0.0"}"#, - ).unwrap(); - assert!(tagged(root.path(), "0.7.0-beta").status().unwrap().is_some()); - } - - #[test] - fn failed_persistence_suppresses_only_the_current_process() { - let root = tempdir().unwrap(); - fs::create_dir(root.path().join("release-state.json")).unwrap(); - let service = tagged(root.path(), "0.7.0-beta"); - let result = service.acknowledge("0.7.0-beta").unwrap(); - assert!(!result.persisted); - assert!(result.error.is_some()); - assert!(service.status().unwrap().is_none()); - } -} -``` - -- [ ] **Step 3: Run the focused native test and confirm the expected red state** - -Run: - -```bash -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml --lib whats_new_service -- --nocapture -``` - -Expected: FAIL because `WhatsNewService` and `ReleaseState` are not defined. - -- [ ] **Step 4: Implement the native service with session suppression and atomic writes** - -Add the production implementation above the test module in `src-tauri/src/services/whats_new_service.rs`: - -```rust -use crate::models::types::{WhatsNewAcknowledgeResult, WhatsNewStatus}; -use semver::Version; -use serde::{Deserialize, Serialize}; -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::Mutex; -use uuid::Uuid; - -const STATE_SCHEMA_VERSION: u32 = 1; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -struct ReleaseState { - schema_version: u32, - acknowledged_version: String, -} - -enum LoadedState { - Missing, - Valid(ReleaseState), - Invalid(String), -} - -pub struct WhatsNewService { - state_path: PathBuf, - current_version: String, - build_tag: Option, - preview: bool, - suppressed_for_session: Mutex, -} - -impl WhatsNewService { - pub fn new( - app_data_dir: PathBuf, - current_version: String, - build_tag: Option, - preview: bool, - ) -> Self { - Self { - state_path: app_data_dir.join("release-state.json"), - current_version, - build_tag, - preview, - suppressed_for_session: Mutex::new(false), - } - } - - pub fn status(&self) -> Result, String> { - if *self.suppressed_for_session.lock().map_err(|_| "what's new session lock poisoned")? { - return Ok(None); - } - let current = Version::parse(&self.current_version) - .map_err(|error| format!("invalid running app version {}: {error}", self.current_version))?; - let tag = if self.preview { - format!("v{}", self.current_version) - } else { - let Some(tag) = self.build_tag.clone() else { return Ok(None) }; - if tag != format!("v{}", self.current_version) { - eprintln!("[verboo:whats-new] release tag {tag} does not match {}", self.current_version); - return Ok(None); - } - tag - }; - - if !self.preview { - match self.load_state() { - LoadedState::Missing => {} - LoadedState::Valid(state) if state.schema_version == STATE_SCHEMA_VERSION => { - let acknowledged = Version::parse(&state.acknowledged_version).map_err(|error| { - format!("invalid acknowledged app version {}: {error}", state.acknowledged_version) - }); - match acknowledged { - Ok(acknowledged) if current <= acknowledged => return Ok(None), - Ok(_) => {} - Err(error) => return self.repair_and_suppress(error), - } - } - LoadedState::Valid(state) => { - return self.repair_and_suppress(format!( - "unsupported release state schema {}", - state.schema_version, - )); - } - LoadedState::Invalid(error) => return self.repair_and_suppress(error), - } - } - - Ok(Some(WhatsNewStatus { - version: self.current_version.clone(), - tag, - preview: self.preview, - })) - } - - pub fn acknowledge(&self, version: &str) -> Result { - if version != self.current_version { - return Err(format!("cannot acknowledge app version {version} while running {}", self.current_version)); - } - let expected_tag = format!("v{}", self.current_version); - if !self.preview && self.build_tag.as_deref() != Some(expected_tag.as_str()) { - return Err("cannot acknowledge an untagged or mismatched app build".into()); - } - *self.suppressed_for_session.lock().map_err(|_| "what's new session lock poisoned")? = true; - if self.preview { - return Ok(WhatsNewAcknowledgeResult { persisted: false, error: None }); - } - let acknowledged_version = match self.load_state() { - LoadedState::Valid(state) if state.schema_version == STATE_SCHEMA_VERSION => { - match Version::parse(&state.acknowledged_version) { - Ok(existing) if existing > Version::parse(&self.current_version) - .map_err(|error| format!("invalid running app version {}: {error}", self.current_version))? => { - state.acknowledged_version - } - _ => self.current_version.clone(), - } - } - _ => self.current_version.clone(), - }; - let state = ReleaseState { - schema_version: STATE_SCHEMA_VERSION, - acknowledged_version, - }; - match atomic_write_json(&self.state_path, &state) { - Ok(()) => Ok(WhatsNewAcknowledgeResult { persisted: true, error: None }), - Err(error) => Ok(WhatsNewAcknowledgeResult { persisted: false, error: Some(error) }), - } - } - - fn load_state(&self) -> LoadedState { - let bytes = match fs::read(&self.state_path) { - Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return LoadedState::Missing, - Err(error) => return LoadedState::Invalid(format!("failed to read {}: {error}", self.state_path.display())), - }; - match serde_json::from_slice(&bytes) { - Ok(state) => LoadedState::Valid(state), - Err(error) => LoadedState::Invalid(format!("invalid {}: {error}", self.state_path.display())), - } - } - - fn repair_and_suppress(&self, reason: String) -> Result, String> { - eprintln!("[verboo:whats-new] {reason}; suppressing and repairing current release state"); - *self.suppressed_for_session.lock().map_err(|_| "what's new session lock poisoned")? = true; - let repaired = ReleaseState { - schema_version: STATE_SCHEMA_VERSION, - acknowledged_version: self.current_version.clone(), - }; - if let Err(error) = atomic_write_json(&self.state_path, &repaired) { - eprintln!("[verboo:whats-new] state repair failed: {error}"); - } - Ok(None) - } -} - -fn atomic_write_json(path: &Path, value: &ReleaseState) -> Result<(), String> { - let parent = path.parent().ok_or_else(|| "release state path has no parent".to_string())?; - fs::create_dir_all(parent).map_err(|error| format!("failed to create release state directory: {error}"))?; - let filename = path.file_name().and_then(|name| name.to_str()) - .ok_or_else(|| "release state filename is invalid".to_string())?; - let temporary = parent.join(format!(".{filename}.{}.tmp", Uuid::new_v4())); - let mut bytes = serde_json::to_vec_pretty(value) - .map_err(|error| format!("failed to serialize release state: {error}"))?; - bytes.push(b'\n'); - let result = (|| { - let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary) - .map_err(|error| format!("failed to create temporary release state: {error}"))?; - file.write_all(&bytes).map_err(|error| format!("failed to write temporary release state: {error}"))?; - file.flush().map_err(|error| format!("failed to flush temporary release state: {error}"))?; - file.sync_all().map_err(|error| format!("failed to sync temporary release state: {error}"))?; - drop(file); - replace_file(&temporary, path)?; - sync_directory(parent) - })(); - if result.is_err() { - let _ = fs::remove_file(&temporary); - } - result -} - -#[cfg(not(windows))] -fn replace_file(source: &Path, destination: &Path) -> Result<(), String> { - fs::rename(source, destination) - .map_err(|error| format!("failed to atomically replace release state: {error}")) -} - -#[cfg(windows)] -fn replace_file(source: &Path, destination: &Path) -> Result<(), String> { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::{ - MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, - }; - let source: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); - let destination: Vec = destination.as_os_str().encode_wide().chain(Some(0)).collect(); - let result = unsafe { - MoveFileExW( - source.as_ptr(), - destination.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if result == 0 { - return Err(format!("failed to atomically replace release state: {}", std::io::Error::last_os_error())); - } - Ok(()) -} - -#[cfg(unix)] -fn sync_directory(path: &Path) -> Result<(), String> { - OpenOptions::new().read(true).open(path) - .and_then(|directory| directory.sync_all()) - .map_err(|error| format!("failed to sync release state directory: {error}")) -} - -#[cfg(windows)] -fn sync_directory(_path: &Path) -> Result<(), String> { - Ok(()) -} -``` - -- [ ] **Step 5: Register the module, state, commands, and typed bridge** - -Add to `src-tauri/src/services/mod.rs`: - -```rust -pub mod whats_new_service; -``` - -Add two commands near the existing update commands in `src-tauri/src/lib.rs`: - -```rust -#[tauri::command] -fn get_whats_new_status( - service: tauri::State<'_, crate::services::whats_new_service::WhatsNewService>, -) -> Result, String> { - service.status() -} - -#[tauri::command] -fn acknowledge_whats_new( - version: String, - service: tauri::State<'_, crate::services::whats_new_service::WhatsNewService>, -) -> Result { - service.acknowledge(&version) -} -``` - -Immediately after the setup callback creates `app_data_dir` and calls `create_dir_all(&app_data_dir)`, manage the service with this block: - -```rust -let whats_new_preview = std::env::var("VERBOO_WHATS_NEW_PREVIEW") - .map(|value| value == "1") - .unwrap_or(false); -app.manage(crate::services::whats_new_service::WhatsNewService::new( - app_data_dir.clone(), - app.package_info().version.to_string(), - option_env!("VERBOO_RELEASE_TAG").map(str::to_owned), - whats_new_preview, -)); -``` - -Register both names in `tauri::generate_handler!` under the update commands: - -```rust -get_whats_new_status, -acknowledge_whats_new, -``` - -Import `WhatsNewStatus` and `WhatsNewAcknowledgeResult` in `src/renderer/verboo-bridge.ts`, then add: - -```ts -getWhatsNewStatus: () => invoke('get_whats_new_status'), -acknowledgeWhatsNew: (version: string) => - invoke('acknowledge_whats_new', { version }), -``` - -- [ ] **Step 6: Run native and type-contract checks** - -Run: - -```bash -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml --lib whats_new_service -- --nocapture -npx tsc --noEmit -``` - -Expected: all ten native lifecycle tests PASS and the renderer bridge type-checks. - -- [ ] **Step 7: Commit Task 3 only** - -```bash -git add src-tauri/src/services/whats_new_service.rs src-tauri/src/services/mod.rs src-tauri/src/models/types.rs src-tauri/src/lib.rs src/shared/types.ts src/renderer/verboo-bridge.ts -git diff --cached --check -git commit -m "feat(app): persist versioned whats-new state" -``` - ---- - -### Task 4: Build the accessible localized modal - -**Files:** -- Create: `src/renderer/features/whats-new/releaseCatalog.ts` -- Create: `src/renderer/features/whats-new/releaseCatalog.test.ts` -- Create: `src/renderer/features/whats-new/WhatsNewModal.tsx` -- Create: `src/renderer/features/whats-new/WhatsNewModal.test.tsx` -- Create: `src/renderer/styles/whats-new.css` -- Modify: `src/renderer/styles/app.css` -- Modify: `src/renderer/i18n.tsx` - -**Interfaces:** -- Consumes: `release-notes/releases.json`, `WhatsNewStatus`, `WhatsNewAcknowledgeResult`, current `LanguageCode`, `openUrl`, and existing `I18nProvider`. -- Produces: `getReleaseCopy(version, language)`, `releaseTagUrl(version)`, and `` for Task 5. - -- [ ] **Step 1: Write failing catalog adapter tests** - -Create `src/renderer/features/whats-new/releaseCatalog.test.ts`: - -```ts -import { describe, expect, it } from 'vitest' -import { getReleaseCopy, releaseTagUrl } from './releaseCatalog' - -describe('releaseCatalog', () => { - it('returns approved copy in the active locale', () => { - expect(getReleaseCopy('0.7.0-beta', 'pt-BR')?.title).toBe('O Verboo Code 0.7.0-beta chegou') - expect(getReleaseCopy('0.7.0-beta', 'en-US')?.items).toHaveLength(6) - }) - - it('returns undefined for a version absent from the bundled catalog', () => { - expect(getReleaseCopy('9.9.9', 'en-US')).toBeUndefined() - }) - - it('derives only the fixed repository tag URL from a canonical version', () => { - expect(releaseTagUrl('0.7.0-beta')).toBe( - 'https://github.com/graseeel/verboo_app/releases/tag/v0.7.0-beta', - ) - expect(() => releaseTagUrl('../malicious')).toThrow(/canonical/i) - }) -}) -``` - -- [ ] **Step 2: Write failing mounted modal tests** - -Create `src/renderer/features/whats-new/WhatsNewModal.test.tsx`: - -```tsx -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import type { ComponentProps } from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { I18nProvider } from '../../i18n' -import { WhatsNewModal } from './WhatsNewModal' - -const status = { version: '0.7.0-beta', tag: 'v0.7.0-beta', preview: false } - -function renderModal(overrides: Partial> = {}) { - const onAcknowledge = vi.fn(async () => ({ persisted: true })) - const onDismiss = vi.fn() - const openReleaseUrl = vi.fn(async () => undefined) - const view = render( -
- - - - -
, - ) - return { view, onAcknowledge, onDismiss, openReleaseUrl } -} - -afterEach(() => vi.restoreAllMocks()) - -describe('WhatsNewModal', () => { - it('renders version, summary, six highlights, and exactly two actions', () => { - renderModal() - expect(screen.getByRole('dialog', { name: 'Verboo Code 0.7.0-beta is here' })).toBeVisible() - expect(screen.getByText(/major update for working with iOS apps/i)).toBeVisible() - expect(screen.getByText('Built-in iOS Simulator — macOS')).toBeVisible() - expect(screen.getAllByRole('listitem')).toHaveLength(6) - expect(within(screen.getByRole('dialog')).getAllByRole('button')).toHaveLength(2) - expect(screen.getByRole('button', { name: 'Background action' })).toHaveAttribute('inert') - }) - - it('starts on Close, traps focus, handles Escape, and ignores backdrop clicks', async () => { - const { onAcknowledge, onDismiss } = renderModal() - const close = screen.getByRole('button', { name: 'Close' }) - const learnMore = screen.getByRole('button', { name: 'Learn more' }) - expect(close).toHaveFocus() - fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }) - expect(learnMore).toHaveFocus() - fireEvent.keyDown(document, { key: 'Tab' }) - expect(close).toHaveFocus() - fireEvent.click(screen.getByTestId('whats-new-backdrop')) - expect(onAcknowledge).not.toHaveBeenCalled() - fireEvent.keyDown(document, { key: 'Escape' }) - await waitFor(() => expect(onAcknowledge).toHaveBeenCalledWith('0.7.0-beta')) - expect(onDismiss).toHaveBeenCalledWith({ persisted: true }) - }) - - it('opens the exact tag and acknowledges only after a successful open', async () => { - const { openReleaseUrl, onAcknowledge, onDismiss } = renderModal() - fireEvent.click(screen.getByRole('button', { name: 'Learn more' })) - await waitFor(() => expect(openReleaseUrl).toHaveBeenCalledWith( - 'https://github.com/graseeel/verboo_app/releases/tag/v0.7.0-beta', - )) - await waitFor(() => expect(onAcknowledge).toHaveBeenCalledWith('0.7.0-beta')) - expect(openReleaseUrl.mock.invocationCallOrder[0]).toBeLessThan(onAcknowledge.mock.invocationCallOrder[0]) - expect(onDismiss).toHaveBeenCalledWith({ persisted: true }) - }) - - it('keeps the modal open and does not acknowledge when opening fails', async () => { - const openReleaseUrl = vi.fn(async () => { throw new Error('browser unavailable') }) - const { onAcknowledge, onDismiss } = renderModal({ openReleaseUrl }) - fireEvent.click(screen.getByRole('button', { name: 'Learn more' })) - expect(await screen.findByRole('alert')).toHaveTextContent(/could not open the release page/i) - expect(onAcknowledge).not.toHaveBeenCalled() - expect(onDismiss).not.toHaveBeenCalled() - expect(screen.getByRole('dialog')).toBeVisible() - }) - - it('dismisses with a non-fatal result when acknowledgment IPC rejects', async () => { - const onAcknowledge = vi.fn(async () => { throw new Error('IPC unavailable') }) - const { onDismiss } = renderModal({ onAcknowledge }) - fireEvent.click(screen.getByRole('button', { name: 'Close' })) - await waitFor(() => expect(onDismiss).toHaveBeenCalledWith({ - persisted: false, - error: 'IPC unavailable', - })) - }) -}) -``` - -- [ ] **Step 3: Run the focused renderer tests and confirm the expected red state** - -Run: - -```bash -npx vitest run src/renderer/features/whats-new/releaseCatalog.test.ts src/renderer/features/whats-new/WhatsNewModal.test.tsx -``` - -Expected: FAIL because both renderer modules are absent. - -- [ ] **Step 4: Implement typed bundled-catalog lookup and fixed URL derivation** - -Create `src/renderer/features/whats-new/releaseCatalog.ts`: - -```ts -import catalogJson from '../../../../release-notes/releases.json' -import type { LanguageCode } from '../../../shared/types' - -export type ReleaseHighlight = { title: string; body: string } -export type ReleaseCopy = { title: string; summary: string; items: ReleaseHighlight[] } -type ReleaseCatalog = { - schemaVersion: 1 - releases: Record> -} - -const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ -const catalog = catalogJson as ReleaseCatalog - -export function getReleaseCopy(version: string, language: LanguageCode): ReleaseCopy | undefined { - return catalog.releases[version]?.[language] -} - -export function releaseTagUrl(version: string): string { - if (!VERSION_PATTERN.test(version)) { - throw new Error(`release version must be canonical: ${version}`) - } - return `https://github.com/graseeel/verboo_app/releases/tag/v${version}` -} -``` - -- [ ] **Step 5: Add generic localized labels** - -Add to the English map in `src/renderer/i18n.tsx`: - -```ts -'whatsNew.eyebrow': "What's new", -'whatsNew.openFailed': 'Could not open the release page. Try again.', -'whatsNew.persistenceFailed': 'The app could not remember this acknowledgment. The update notes may appear again next time.', -'whatsNew.preview': 'Preview', -``` - -Add to the Portuguese map: - -```ts -'whatsNew.eyebrow': 'Novidades', -'whatsNew.openFailed': 'Não foi possível abrir a página da versão. Tente novamente.', -'whatsNew.persistenceFailed': 'O app não conseguiu salvar esta confirmação. As novidades podem aparecer novamente na próxima vez.', -'whatsNew.preview': 'Prévia', -``` - -Reuse existing `access.learnMore` and `common.close` for the two actions. - -- [ ] **Step 6: Implement the modal with focus ownership and ordered async actions** - -Create `src/renderer/features/whats-new/WhatsNewModal.tsx`: - -```tsx -import { useEffect, useRef, useState } from 'react' -import { ExternalLink, Sparkles } from 'lucide-react' -import { openUrl } from '@tauri-apps/plugin-opener' -import type { WhatsNewAcknowledgeResult, WhatsNewStatus } from '../../../shared/types' -import { useI18n } from '../../i18n' -import { getReleaseCopy, releaseTagUrl } from './releaseCatalog' - -type WhatsNewModalProps = { - status: WhatsNewStatus - onAcknowledge: (version: string) => Promise - onDismiss: (result: WhatsNewAcknowledgeResult) => void - openReleaseUrl?: (url: string) => Promise -} - -export function WhatsNewModal({ - status, - onAcknowledge, - onDismiss, - openReleaseUrl = openUrl, -}: WhatsNewModalProps) { - const { language } = useI18n() - const copy = getReleaseCopy(status.version, language) - if (!copy) { - console.error(`[verboo:whats-new] no bundled release copy for ${status.version}`) - return null - } - return ( - - ) -} - -type WhatsNewDialogProps = Omit & { - copy: NonNullable> - openReleaseUrl: (url: string) => Promise -} - -function WhatsNewDialog({ - status, - copy, - onAcknowledge, - onDismiss, - openReleaseUrl, -}: WhatsNewDialogProps) { - const { t } = useI18n() - const backdropRef = useRef(null) - const dialogRef = useRef(null) - const closeRef = useRef(null) - const busyRef = useRef(false) - const acknowledgeActionRef = useRef<() => Promise>(async () => undefined) - const [busy, setBusy] = useState(false) - const [openError, setOpenError] = useState(false) - - async function finishAcknowledgment() { - try { - return await onAcknowledge(status.version) - } catch (error) { - return { - persisted: false, - error: error instanceof Error ? error.message : String(error), - } - } - } - - async function acknowledge() { - if (busyRef.current) return - busyRef.current = true - setBusy(true) - const result = await finishAcknowledgment() - onDismiss(result) - } - acknowledgeActionRef.current = acknowledge - - async function learnMore() { - if (busyRef.current) return - busyRef.current = true - setBusy(true) - setOpenError(false) - try { - await openReleaseUrl(releaseTagUrl(status.version)) - } catch { - busyRef.current = false - setBusy(false) - setOpenError(true) - return - } - const result = await finishAcknowledgment() - onDismiss(result) - } - - useEffect(() => { - const previousFocus = document.activeElement instanceof HTMLElement - ? document.activeElement - : undefined - const inertedElements: HTMLElement[] = [] - let activeBranch: HTMLElement | null = backdropRef.current - while (activeBranch?.parentElement) { - const parent = activeBranch.parentElement - for (const sibling of Array.from(parent.children)) { - if (sibling === activeBranch || !(sibling instanceof HTMLElement)) continue - if (!sibling.hasAttribute('inert')) { - sibling.setAttribute('inert', '') - inertedElements.push(sibling) - } - } - activeBranch = parent - } - - function onKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') { - event.preventDefault() - void acknowledgeActionRef.current() - return - } - if (event.key !== 'Tab') return - const focusable = Array.from(dialogRef.current?.querySelectorAll( - 'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', - ) ?? []) - const first = focusable[0] - const last = focusable.at(-1) - if (!first || !last) { - event.preventDefault() - } else if (event.shiftKey && (document.activeElement === first || !dialogRef.current?.contains(document.activeElement))) { - event.preventDefault() - last.focus() - } else if (!event.shiftKey && (document.activeElement === last || !dialogRef.current?.contains(document.activeElement))) { - event.preventDefault() - first.focus() - } - } - - document.addEventListener('keydown', onKeyDown) - closeRef.current?.focus() - return () => { - document.removeEventListener('keydown', onKeyDown) - for (const element of inertedElements) element.removeAttribute('inert') - if (previousFocus?.isConnected) previousFocus.focus() - } - }, []) - - return ( -
-
-
- -
- - {t('whatsNew.eyebrow')} · v{status.version} - {status.preview ? ` · ${t('whatsNew.preview')}` : ''} - -

{copy.title}

-

{copy.summary}

-
-
-
-
    - {copy.items.map(item => ( -
  • -
  • - ))} -
- {openError &&

{t('whatsNew.openFailed')}

} -
-
- - -
-
-
- ) -} -``` - -- [ ] **Step 7: Add the focused visual system and reduced-motion behavior** - -Create `src/renderer/styles/whats-new.css`: - -```css -.whats-new-backdrop { - position: fixed; - inset: 0; - z-index: 2600; - display: grid; - place-items: center; - padding: clamp(18px, 4vw, 48px); - background: color-mix(in srgb, var(--bg) 72%, transparent); - backdrop-filter: blur(10px) saturate(.85); - animation: whats-new-backdrop-in 180ms ease-out both; -} - -.whats-new-modal { - width: min(760px, 100%); - max-height: min(760px, calc(100dvh - 36px)); - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border)); - border-radius: 24px; - background: color-mix(in srgb, var(--bg-elevated) 96%, var(--accent) 4%); - box-shadow: 0 30px 90px rgb(0 0 0 / .34), 0 0 0 1px rgb(255 255 255 / .03) inset; - animation: whats-new-card-in 220ms cubic-bezier(.2, .8, .2, 1) both; -} - -.whats-new-header { - display: grid; - grid-template-columns: auto 1fr; - gap: 14px; - padding: 26px 28px 18px; -} - -.whats-new-mark { - width: 42px; - height: 42px; - display: grid; - place-items: center; - border-radius: 14px; - color: var(--accent); - background: color-mix(in srgb, var(--accent) 14%, transparent); - border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); -} - -.whats-new-eyebrow { - display: block; - margin: 1px 0 7px; - color: var(--accent); - font-size: 11px; - font-weight: 750; - letter-spacing: .08em; - text-transform: uppercase; -} - -.whats-new-header h2 { - margin: 0; - color: var(--text); - font-size: clamp(24px, 3.2vw, 34px); - line-height: 1.08; - letter-spacing: -.035em; -} - -.whats-new-header p { - margin: 10px 0 0; - max-width: 62ch; - color: var(--text-muted); - line-height: 1.55; -} - -.whats-new-content { - min-height: 0; - overflow: auto; - padding: 4px 28px 22px; - scrollbar-gutter: stable; -} - -.whats-new-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - margin: 0; - padding: 0; - list-style: none; -} - -.whats-new-list li { - display: grid; - grid-template-columns: 7px 1fr; - gap: 11px; - min-width: 0; - padding: 14px; - border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); - border-radius: 15px; - background: color-mix(in srgb, var(--bg-soft) 78%, transparent); -} - -.whats-new-list li > span { - width: 7px; - height: 7px; - margin-top: 6px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 58%, transparent); -} - -.whats-new-list strong { - color: var(--text); - font-size: 13px; - line-height: 1.35; -} - -.whats-new-list p { - margin: 5px 0 0; - color: var(--text-muted); - font-size: 12px; - line-height: 1.5; -} - -.whats-new-error { - margin: 14px 0 0; - color: var(--danger); - font-size: 12px; -} - -.whats-new-actions { - display: flex; - justify-content: flex-end; - gap: 10px; - padding: 16px 28px 22px; - border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); - background: color-mix(in srgb, var(--bg-elevated) 94%, transparent); -} - -.whats-new-actions button { - min-height: 40px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 7px; - padding: 0 17px; - border-radius: 12px; - font-weight: 700; -} - -.whats-new-actions .secondary { - color: var(--text); - border: 1px solid var(--border-strong); - background: var(--bg-soft); -} - -.whats-new-actions .primary { - color: white; - border: 1px solid color-mix(in srgb, var(--accent) 72%, white 28%); - background: linear-gradient(135deg, var(--accent), var(--accent-strong)); -} - -.whats-new-actions button:focus-visible { - outline: none; - box-shadow: var(--focus-ring); -} - -.whats-new-actions button:disabled { - cursor: wait; - opacity: .68; -} - -@keyframes whats-new-backdrop-in { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes whats-new-card-in { - from { opacity: 0; transform: translateY(8px) scale(.985); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -@media (max-width: 680px) { - .whats-new-list { grid-template-columns: 1fr; } - .whats-new-header { padding: 22px 20px 16px; } - .whats-new-content { padding: 4px 20px 18px; } - .whats-new-actions { padding: 14px 20px 20px; } -} - -@media (max-width: 440px) { - .whats-new-backdrop { padding: 10px; } - .whats-new-actions { flex-direction: column-reverse; } - .whats-new-actions button { width: 100%; } -} - -@media (prefers-reduced-motion: reduce) { - .whats-new-backdrop, - .whats-new-modal { - animation: none; - } -} -``` - -Import it in `src/renderer/styles/app.css` before `responsive.css`: - -```css -@import './whats-new.css'; -``` - -- [ ] **Step 8: Run mounted component, CSS, and type checks** - -Add one CSS contract assertion to `WhatsNewModal.test.tsx` using `readFileSync` and `resolve`: - -```ts -it('removes modal movement when reduced motion is requested', () => { - const css = readFileSync(resolve(process.cwd(), 'src/renderer/styles/whats-new.css'), 'utf8') - expect(css).toMatch(/prefers-reduced-motion:\s*reduce[\s\S]*\.whats-new-modal[\s\S]*animation:\s*none/) -}) -``` - -Run: - -```bash -npx vitest run src/renderer/features/whats-new/releaseCatalog.test.ts src/renderer/features/whats-new/WhatsNewModal.test.tsx -npx tsc --noEmit -``` - -Expected: all focused tests PASS and TypeScript reports no errors. - -- [ ] **Step 9: Commit Task 4 only** - -```bash -git add src/renderer/features/whats-new/releaseCatalog.ts src/renderer/features/whats-new/releaseCatalog.test.ts src/renderer/features/whats-new/WhatsNewModal.tsx src/renderer/features/whats-new/WhatsNewModal.test.tsx src/renderer/styles/whats-new.css src/renderer/styles/app.css src/renderer/i18n.tsx -git diff --cached --check -git commit -m "feat(ui): add accessible whats-new modal" -``` - ---- - -### Task 5: Integrate startup precedence and verify the packaged behavior - -**Files:** -- Create: `src/renderer/features/whats-new/useWhatsNew.ts` -- Create: `src/renderer/features/whats-new/useWhatsNew.test.tsx` -- Modify: `src/renderer/App.cliBootstrapGate.test.tsx` -- Modify: `src/renderer/App.tsx` - -**Interfaces:** -- Consumes: Task 3 bridge functions, Task 4 modal, `configLoaded`, `settingsLoaded`, `updateSnapshot`, `cliBootstrapRequired`, `cliBootstrapSuccessVisible`, and the existing toast service. -- Produces: one startup query after all mandatory blockers, one session dismissal, a non-fatal persistence toast, and mounted proof on login and unlocked app surfaces. - -- [ ] **Step 1: Write failing hook lifecycle tests** - -Create `src/renderer/features/whats-new/useWhatsNew.test.tsx`: - -```tsx -import { act, renderHook, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import type { WhatsNewAcknowledgeResult } from '../../../shared/types' -import { useWhatsNew } from './useWhatsNew' - -const pending = { version: '0.7.0-beta', tag: 'v0.7.0-beta', preview: false } - -describe('useWhatsNew', () => { - it('does not query before startup is ready and queries once after readiness', async () => { - const getStatus = vi.fn(async () => pending) - const acknowledge = vi.fn(async () => ({ persisted: true })) - const { result, rerender } = renderHook( - ({ enabled }) => useWhatsNew({ enabled, getStatus, acknowledge }), - { initialProps: { enabled: false } }, - ) - expect(getStatus).not.toHaveBeenCalled() - rerender({ enabled: true }) - await waitFor(() => expect(result.current.status).toEqual(pending)) - expect(getStatus).toHaveBeenCalledTimes(1) - }) - - it('dismisses for the process even when persistence reports a non-fatal error', async () => { - const getStatus = vi.fn(async () => pending) - const acknowledge = vi.fn(async () => ({ persisted: false, error: 'disk unavailable' })) - const { result } = renderHook(() => useWhatsNew({ enabled: true, getStatus, acknowledge })) - await waitFor(() => expect(result.current.status).toEqual(pending)) - let response: WhatsNewAcknowledgeResult | undefined - await act(async () => { response = await result.current.acknowledge('0.7.0-beta') }) - expect(response).toEqual({ persisted: false, error: 'disk unavailable' }) - expect(result.current.status).toBeUndefined() - }) - - it('dismisses for the process even when the bridge rejects unexpectedly', async () => { - const getStatus = vi.fn(async () => pending) - const acknowledge = vi.fn(async () => { throw new Error('IPC unavailable') }) - const { result } = renderHook(() => useWhatsNew({ enabled: true, getStatus, acknowledge })) - await waitFor(() => expect(result.current.status).toEqual(pending)) - await act(async () => { - await expect(result.current.acknowledge('0.7.0-beta')).rejects.toThrow('IPC unavailable') - }) - expect(result.current.status).toBeUndefined() - }) -}) -``` - -- [ ] **Step 2: Write a failing mounted App precedence test** - -Extend the existing shared-type import in `src/renderer/App.cliBootstrapGate.test.tsx`: - -```ts -import type { - UserSettings, - UpdateSnapshot, - WhatsNewAcknowledgeResult, - WhatsNewStatus, -} from '../shared/types' -``` - -Add explicit spies to `knownBridge` inside `createBridge()`: - -```ts -getWhatsNewStatus: vi.fn<() => Promise>(async () => undefined), -acknowledgeWhatsNew: vi.fn<(version: string) => Promise>( - async () => ({ persisted: true }), -), -``` - -Add this fixture beside `bootstrapSnapshot`: - -```ts -const pendingWhatsNew = { - version: '0.7.0-beta', - tag: 'v0.7.0-beta', - preview: false, -} satisfies WhatsNewStatus -``` - -Then add these two tests to the existing describe block named `App first CLI installation gate`: - -```tsx -it('waits for CLI bootstrap and its success animation before showing release notes', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) - bridge.getWhatsNewStatus.mockResolvedValue(pendingWhatsNew) - render() - - expect(await screen.findByText('Preparing Verboo')).toBeVisible() - expect(bridge.getWhatsNewStatus).not.toHaveBeenCalled() - - act(() => updateListener?.({ - ...bootstrapSnapshot, - status: 'idle', - cliBootstrapRequired: false, - percent: 100, - })) - expect(screen.getByText('Verboo is ready')).toBeVisible() - expect(bridge.getWhatsNewStatus).not.toHaveBeenCalled() - - await act(async () => { await vi.advanceTimersByTimeAsync(1_400) }) - expect(await screen.findByRole('dialog', { name: 'Verboo Code 0.7.0-beta is here' })).toBeVisible() - expect(bridge.getWhatsNewStatus).toHaveBeenCalledTimes(1) -}) - -it('shows on the login surface for a first tagged clean install and closes once', async () => { - bridge.getUpdateStatus.mockResolvedValue({ - status: 'idle', - channel: 'beta', - currentVersion: '0.7.0-beta', - cliBootstrapRequired: false, - }) - bridge.getWhatsNewStatus.mockResolvedValue(pendingWhatsNew) - bridge.acknowledgeWhatsNew.mockResolvedValue({ persisted: true }) - bridge.getCliAuthStatus.mockResolvedValue({ loggedIn: false }) - bridge.getCredentialStatus.mockResolvedValue({ hasApiKey: false }) - bridge.listModels.mockResolvedValue({ models: [], source: 'none', stale: false }) - render() - - expect(await screen.findByRole('dialog')).toBeVisible() - fireEvent.click(screen.getByRole('button', { name: 'Close' })) - await waitFor(() => expect(bridge.acknowledgeWhatsNew).toHaveBeenCalledWith('0.7.0-beta')) - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() -}) -``` - -- [ ] **Step 3: Run the hook and App tests and confirm the expected red state** - -Run: - -```bash -npx vitest run src/renderer/features/whats-new/useWhatsNew.test.tsx src/renderer/App.cliBootstrapGate.test.tsx -``` - -Expected: FAIL because the hook is absent and `App` never mounts the modal. - -- [ ] **Step 4: Implement the one-shot lifecycle hook** - -Create `src/renderer/features/whats-new/useWhatsNew.ts`: - -```ts -import { useCallback, useEffect, useRef, useState } from 'react' -import type { WhatsNewAcknowledgeResult, WhatsNewStatus } from '../../../shared/types' - -type UseWhatsNewOptions = { - enabled: boolean - getStatus?: () => Promise - acknowledge?: (version: string) => Promise -} - -export function useWhatsNew({ - enabled, - getStatus = window.verboo.getWhatsNewStatus, - acknowledge = window.verboo.acknowledgeWhatsNew, -}: UseWhatsNewOptions) { - const [status, setStatus] = useState() - const requested = useRef(false) - - useEffect(() => { - if (!enabled || requested.current) return - requested.current = true - let active = true - void getStatus() - .then(next => { if (active) setStatus(next) }) - .catch(error => console.error('[verboo:whats-new] failed to read release state', error)) - return () => { active = false } - }, [enabled, getStatus]) - - const acknowledgeCurrent = useCallback(async (version: string) => { - try { - return await acknowledge(version) - } finally { - setStatus(undefined) - } - }, [acknowledge]) - - return { status, acknowledge: acknowledgeCurrent } -} -``` - -- [ ] **Step 5: Wire readiness and render the modal on both App surfaces** - -Import in `src/renderer/App.tsx`: - -```ts -import { WhatsNewModal } from './features/whats-new/WhatsNewModal' -import { useWhatsNew } from './features/whats-new/useWhatsNew' -``` - -After `cliAgentActionsBlocked` is computed, add: - -```ts -const whatsNewReady = configLoaded - && settingsLoaded - && updateSnapshot !== undefined - && !cliBootstrapRequired - && !cliBootstrapSuccessVisible -const whatsNew = useWhatsNew({ enabled: whatsNewReady }) - -function whatsNewOverlay() { - if (!whatsNew.status) return null - return ( - { - if (result.error) toast(t('whatsNew.persistenceFailed'), 'error') - }} - /> - ) -} -``` - -Inside the `shouldShowLogin` return, place the overlay after `FeedbackDialog` and inside the existing `I18nProvider`: - -```tsx -{whatsNewOverlay()} -``` - -Inside the unlocked app return, place the same call as the last child inside `I18nProvider`, after all normal dialogs/panels: - -```tsx -{whatsNewOverlay()} -``` - -Do not mount it inside `CliBootstrapGate`, the sidebar updater card, Settings, or a conversation component. - -- [ ] **Step 6: Run focused mounted behavior tests** - -Run: - -```bash -npx vitest run src/renderer/features/whats-new/releaseCatalog.test.ts src/renderer/features/whats-new/WhatsNewModal.test.tsx src/renderer/features/whats-new/useWhatsNew.test.tsx src/renderer/App.cliBootstrapGate.test.tsx -``` - -Expected: all focused tests PASS; the mutation “query while `cliBootstrapRequired` is true” makes the precedence test fail. - -- [ ] **Step 7: Run complete automated gates** - -Run: - -```bash -node --test scripts/release/release-catalog.test.mjs scripts/release/prepare-release.test.mjs scripts/verify/update-manifest.test.mjs scripts/verify/verify-release-version.test.mjs scripts/verify/tauri-release-signing.test.mjs scripts/verify/cli-update-ownership.test.mjs -npx tsc --noEmit -npx vitest run -cargo +1.89.0 test --manifest-path src-tauri/Cargo.toml --lib -npm run build:renderer -scripts/verify/browser-windows-check.sh -git diff --check -``` - -Expected: - -- all Node release contracts PASS; -- all Vitest tests PASS; -- all Rust library tests PASS with only pre-existing documented ignores; -- renderer production build completes; -- the existing Windows cross-check passes; -- no whitespace errors are reported. - -If the Windows cross-check cannot run because its documented toolchain bootstrap fails outside the codebase, record that exact environmental limitation and rely on the unchanged `ci-verify.yml` Windows/Linux matrix before merge; do not claim local Windows graphical QA. - -- [ ] **Step 8: Build and inspect a stamped macOS artifact** - -Build the same release contract used by GitHub Actions: - -```bash -VERBOO_RELEASE_TAG=v0.7.0-beta npm run tauri:build -``` - -Expected: a current `Verboo Code.app` and DMG are produced for the host architecture, and the compiled app contains the matching tag contract. - -Record sizes without changing artifacts: - -```bash -du -sh src-tauri/target/release/bundle/macos/Verboo\ Code.app -find src-tauri/target/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -exec ls -lh {} \; -``` - -- [ ] **Step 9: Perform packaged behavioral QA without losing the user's acknowledgment state** - -Resolve the app-data path for identifier `ai.verboo.code.desktop`, move only the release-state file into an isolated backup, and restore it after QA: - -```bash -QA_BACKUP_DIR=$(mktemp -d) -QA_STATE_DIR="$HOME/Library/Application Support/ai.verboo.code.desktop" -QA_STATE_FILE="$QA_STATE_DIR/release-state.json" -mkdir -p "$QA_STATE_DIR" -if [ -f "$QA_STATE_FILE" ]; then - mv "$QA_STATE_FILE" "$QA_BACKUP_DIR/release-state.json" -fi -open "src-tauri/target/release/bundle/macos/Verboo Code.app" -``` - -Manually verify in the packaged app: - -1. `0.7.0-beta` appears centered after mandatory bootstrap finishes. -2. Portuguese and English follow the current app language. -3. Six highlights are present; the iOS Simulator item says macOS. -4. Background interaction is blocked, focus begins on Close, focus loops, and backdrop click does nothing. -5. Learn more opens exactly tag `v0.7.0-beta`. -6. Close unlocks the app; relaunching the same artifact does not repeat. -7. Short-window resizing keeps the action row reachable and scrolls only content. -8. `VERBOO_WHATS_NEW_PREVIEW=1 npm run tauri:dev` shows a Preview marker and does not modify `release-state.json`. - -After closing the app, preserve the QA-generated record as evidence and restore the prior user record if it existed: - -```bash -if [ -f "$QA_STATE_FILE" ]; then - mv "$QA_STATE_FILE" "$QA_BACKUP_DIR/qa-release-state.json" -fi -if [ -f "$QA_BACKUP_DIR/release-state.json" ]; then - mv "$QA_BACKUP_DIR/release-state.json" "$QA_STATE_FILE" -fi -ls -l "$QA_BACKUP_DIR" -``` - -Do not delete the backup directory until the QA result has been reported and the user confirms it is no longer needed. - -- [ ] **Step 10: Inspect scope and commit Task 5 only** - -Before committing: - -```bash -git status --short -git diff --stat -git diff -- src/renderer/App.tsx src/renderer/App.cliBootstrapGate.test.tsx src/renderer/features/whats-new/useWhatsNew.ts src/renderer/features/whats-new/useWhatsNew.test.tsx -git diff --check -``` - -Then commit: - -```bash -git add src/renderer/App.tsx src/renderer/App.cliBootstrapGate.test.tsx src/renderer/features/whats-new/useWhatsNew.ts src/renderer/features/whats-new/useWhatsNew.test.tsx -git commit -m "feat(app): show release notes once per version" -``` - -- [ ] **Step 11: Final review before any push or merge** - -Run: - -```bash -git status --short --branch -git log --oneline --decorate -8 -git diff origin/dev...HEAD --stat -git diff origin/dev...HEAD --check -rg -n "EDITORIAL_COPY_REQUIRED|This beta brings a macOS embedded browser" release-notes scripts .github src -``` - -Expected: - -- only the approved design/plan commits and five implementation commits are ahead of `origin/dev`; -- no unrelated dirty files remain; -- the first search matches only the intentional sentinel constant and its tests, never the `0.7.0-beta` catalog or workflow; -- the stale inline release sentence has no match; -- no push, squash, merge, or tag has occurred. - -At handoff, report separately: - -- macOS packaged behavioral results; -- automated renderer/Rust/release-script results; -- Windows cross-check result; -- Windows/Linux graphical packaged QA not performed locally; -- exact `.app` and DMG sizes; -- the app path ready for user testing. diff --git a/docs/superpowers/specs/2026-08-04-interactive-ios-simulator-design.md b/docs/superpowers/specs/2026-08-04-interactive-ios-simulator-design.md deleted file mode 100644 index 969ae218..00000000 --- a/docs/superpowers/specs/2026-08-04-interactive-ios-simulator-design.md +++ /dev/null @@ -1,325 +0,0 @@ -# Interactive iOS Simulator Design - -**Date:** 2026-08-04 -**Status:** Approved for implementation planning - -## Objective - -Turn the existing read-only iOS Simulator panel into a smooth, directly -interactive surface that can also be controlled by the Verboo agent. The panel -must support manual touch, drag, and keyboard input, visual annotations that can -be added to chat, and the same agent-presence language used by the Verboo Chrome -extension. - -The implementation must keep the current `simctl` warmup/fallback, WDA cleanup, -loopback-only networking, and package portability intact. - -## Confirmed Product Decisions - -- Manual keyboard input is captured directly after the user focuses the - simulator surface. `Escape` releases that focus. -- Annotation mode supports both element-aware selection and free rectangular - selection. -- Manual interaction does not show agent presence. -- Agent interaction shows a violet aurora around the rendered device cutout and - a moving agent cursor with click feedback. -- MJPEG defaults to 30 fps. -- 60 fps is optional and carries a friendly high-performance warning. -- WDA scaling remains at 100 percent. -- Simulator annotations reuse the existing Add to Chat experience but retain a - simulator-specific attachment identity and metadata. - -## Measured Baseline - -The current WDA package defaults `mjpegServerFramerate` to 10. In the real app, -the raw WDA endpoint and renderer matched at approximately 9.4 fps, proving that -the producer setting was the active ceiling. - -Temporary runtime counterfactuals measured: - -| Configuration | Raw stream | Visible panel | Approximate process cost | -| --- | ---: | ---: | --- | -| 10 fps, scale 100 | 9.39-9.40 fps | 9.5 fps | Existing ceiling | -| 30 fps, scale 100 | 27.36-27.42 fps | 27.6-28.4 fps | Verboo 7 percent, WDA 1.2 percent CPU | -| 60 fps, scale 100 | 50.59-57.10 fps | 57.7 fps | Verboo 11-13 percent, WDA 2-2.5 percent CPU | -| 30 fps, scale 50 | 26.56-26.85 fps | Kept pace | WDA 19-24 percent CPU | - -Scale 50 also increased a representative frame from roughly 100 KB to 140 KB. -It is therefore explicitly excluded as an optimization. - -## Architecture - -### One WDA Authority - -`IosSimulatorService` remains the authority for the attached simulator. It owns: - -- the selected simulator and its lifecycle state; -- the bounded `xcodebuild`/WDA process tree; -- one WDA WebDriver session; -- the MJPEG reader and `simctl` fallback; -- the current device window size in iOS points; -- the latest complete visual frame; -- manual and agent input serialization; -- the accessibility snapshot used for element selection; -- agent-presence generations and cleanup. - -After WDA reports ready, the service creates a WebDriver session and applies -settings through `/session/{id}/appium/settings`. It sets -`mjpegServerFramerate` to the selected stream profile and -`mjpegScalingFactor` to 100 before promoting MJPEG to the renderer. - -The fallback rate remains independent. Its existing 0.5, 1, and 2 fps options -continue to control only sequential `simctl` capture during warmup or WDA -failure. - -### WDA Client Boundary - -HTTP and WebDriver details live behind a focused WDA client rather than growing -inside the capture loop. Its interface covers: - -- readiness and WebDriver session creation; -- applying MJPEG settings; -- reading the window size and accessibility snapshot; -- tap, drag, text, and special-key input; -- deleting the WebDriver session during detach. - -All requests bind to and consume `127.0.0.1` only. A failed WDA request reports a -specific error and preserves the visual fallback where possible. It never -silently reports a manual or agent action as successful. - -## Manual Interaction - -The simulator image is wrapped in a focusable interaction surface with three -explicit modes: - -1. **Interact** is the default. A short pointer gesture becomes a tap. A gesture - that exceeds the movement threshold becomes a drag from its actual start and - end positions. -2. **Select component** reads the current accessibility snapshot, highlights the - smallest actionable element containing the pointer, and does not forward the - click to the simulator. -3. **Select area** draws a free rectangle and does not forward the drag to the - simulator. - -Modes are mutually exclusive. Closing annotation mode restores Interact. - -The renderer computes the actual painted image rectangle after `object-fit: -contain`. Pointer positions are converted to normalized coordinates only when -they fall inside that rectangle. The backend converts normalized coordinates to -iOS points using its authoritative WDA window size. This keeps taps correct for -iPhone, iPad, landscape, panel resizing, Retina density, and letterboxing. - -### Direct Keyboard Capture - -Clicking the Interact surface gives it keyboard focus and shows a quiet focus -indicator. Printable text, composed input, paste, Enter, Backspace, Tab, Escape, -and arrow keys follow these rules: - -- printable and composed text is sent through WDA text input; -- supported control keys are sent as explicit WDA key values; -- `Escape` first releases simulator focus and is not sent to iOS; -- browser/app shortcuts with Command or Control remain owned by the desktop app; -- input is ignored outside Interact mode or without an attached WDA session. - -Input requests are serialized so a rapid sequence cannot reorder characters or -overtake a preceding tap. - -## Visual Annotation Flow - -### Element Selection - -The backend returns a sanitized accessibility tree with stable element identity, -label, type, and frame in iOS points. The renderer normalizes frames into the -painted image rectangle and highlights the smallest eligible element under the -pointer. Decorative or zero-area nodes are excluded. - -### Free Area Selection - -The renderer stores a normalized rectangle clamped to the device image. Very -small accidental drags are rejected. The overlay is independent of MJPEG frame -changes, so selection does not flicker at 30 or 60 fps. - -### Add to Chat - -Confirming either selection captures: - -- a crop of the selected simulator area; -- the full simulator viewport from the same generation; -- the device name, UDID, iOS version, and orientation; -- normalized and device-point rectangles; -- accessibility role, label, and stable element identity when available; -- the optional user note. - -The attachment kind is `simulator-annotation`. It uses the existing visual -attachment, annotation-chip, send, retry, transcript, and temp-file cleanup -contracts, but it must not emit browser URL or CSS-selector instructions. -Simulator-specific prompt text treats the note and selected component as the -authoritative scope and the images as supporting visual context. - -## Agent Control and Presence - -### Model-Facing Tools - -A managed `verboo-ios-simulator` MCP sidecar follows the established -`verboo-in-chrome` packaging and CLI-registration pattern. Its initial catalog -contains narrow, simulator-specific tools: - -- list available simulators; -- attach or inspect the active simulator; -- capture the current screen and accessibility snapshot; -- tap a normalized point or selected accessibility element; -- drag between normalized points; -- type text; -- press a supported key; -- detach the stream. - -The sidecar connects to the running desktop app through authenticated local -discovery and loopback transport. It does not launch a second WDA or MJPEG -stream. If the app or simulator session is unavailable, the tool returns a -structured error instead of falling back to an unrelated simulator. - -An attach or action tool event opens the simulator panel when the app is visible. -The model may operate while the panel is temporarily hidden, but no visual -presence is claimed until the panel can render the matching attached device. - -### Presence Contract - -The backend emits an agent-presence event before executing each action. The -event includes a monotonically increasing generation, action kind, normalized -target, and optional start/end points. Completion and turn-end events carry the -same generation authority. - -The renderer uses those events to display: - -- an aurora around the exact painted device rectangle, not the full panel; -- the violet SVG agent cursor from the Chrome extension; -- curved cursor travel with motion supersession; -- a press/ripple at tap targets; -- a path from start to end for drag actions; -- a reduced-motion variant without travel or looping edge animation. - -Presence is never inferred from changing frames. Manual input never emits agent -presence. A newer generation cannot be removed by late completion from an older -generation. Detach, turn completion, panel close, app hide, and app exit all -converge on presence cleanup. - -The aurora uses the Chrome extension's color and depth language but clips to the -current device cutout. Its border radius follows the rendered frame and adapts -to all device aspect ratios and panel widths. - -## Performance Profiles - -The stream profile is separate from the fallback rate: - -- **30 fps - Recommended:** selected by default on every new attach. -- **60 fps - High fluency:** user-selected and not persisted as the default. - -Selecting 60 fps displays this localized warning: - -> High fluency uses more processing and may warm up your computer or reduce the -> performance of other apps. - -Portuguese copy: - -> Alta fluidez usa mais processamento e pode aquecer o computador ou reduzir o -> desempenho de outros apps. - -The warning is inline and non-blocking. The option label itself communicates -`60 fps - high performance` so the cost is visible before selection. - -Renderer frame handling keeps only the newest pending frame and commits at most -once per animation frame. Stream source and FPS telemetry update on a slower -interval instead of scheduling independent React state updates for every image. -The backend keeps frame parsing bounded and drops superseded presentation work; -it does not drop input or annotation commands. - -No H.264 path, WDA downscaling, speculative adaptive bitrate, or physical-device -support is included in this iteration. These require separate measurement and -design. - -## Error and Lifecycle Behavior - -- WDA build or settings failure preserves the existing `simctl` visual fallback - and clearly disables interaction and component selection. -- Manual input reports a concise visible error when the session disappears. -- Stale accessibility snapshots and annotation captures are discarded when the - attached device generation changes. -- Detach stops frame, input, snapshot, and presence workers; deletes the WDA - session; terminates the WDA process tree; closes listeners; and cleans only - simulator annotation temp files owned by the session. -- Detach does not shut down a simulator that the user may still be using. -- App hide and exit retain the existing bounded-cleanup contract. -- Agent MCP turn completion removes presence even when the final action or panel - visibility changes concurrently. - -## Accessibility and Interaction Quality - -- Mode controls have visible labels or tooltips and pressed state. -- The simulator surface exposes its focus and operating mode to assistive - technology. -- Focus is never trapped; `Escape` always returns keyboard ownership to Verboo. -- Presence and annotation overlays are ignored by assistive technology. -- Reduced-motion behavior is preserved for aurora, cursor, and ripple effects. -- Pointer cancellation and window blur terminate an in-progress manual gesture. - -## Verification Strategy - -Implementation follows red-green-refactor with effect-based tests. - -### Backend tests - -- WDA settings are applied before the first MJPEG promotion. -- Omitting the 30 fps setting reproduces the 10 fps ceiling in the fake WDA - contract. -- Tap, drag, text, and key payloads map to the expected WDA endpoints. -- Normalized coordinates map correctly for portrait, landscape, and iPad sizes. -- Input commands serialize in request order. -- Accessibility trees are sanitized and smallest-element selection is stable. -- Annotation capture produces matching crop and full-frame generations. -- Presence generations reject late completion. -- Detach and bounded app cleanup stop tools, WDA, ports, and presence. - -### Renderer tests - -- `object-fit: contain` coordinate mapping excludes letterbox regions. -- Short pointer gestures tap; long gestures drag; cancellation does neither. -- Direct focus captures text and supported keys while preserving desktop - shortcuts and Escape. -- Interaction and both annotation modes are mutually exclusive. -- Element and free-area selections survive incoming frames without flicker. -- 60 fps warning copy and accessible semantics are present. -- Frame coalescing commits only the newest pending frame per animation frame. -- Aurora and cursor target the painted device cutout and honor reduced motion. -- Manual actions never render agent presence. - -### Real application proof - -Without calling the Verboo model: - -1. Attach a shutdown iPhone simulator and observe `simctl` warmup migrating to - WDA at the selected 30 fps profile. -2. Tap Safari, swipe between Home Screen pages, focus a text field, type text, - press Backspace and Enter, and release focus with Escape. -3. Select one accessibility element and one free rectangle; add both to chat and - verify distinct simulator annotation chips, crops, and full snapshots. -4. Drive an agent-tool tap, drag, and text action through the MCP seam and verify - the aurora, cursor motion, ripple, target correctness, and cleanup. -5. Switch to 60 fps, verify the warning, sustained visible rate, CPU behavior, - and responsiveness; return to 30 fps. -6. Detach and verify the WDA process, loopback ports, interaction, annotations, - and presence have stopped while the simulator remains usable. - -## Acceptance Criteria - -- The real panel sustains approximately 30 fps by default on the measured - simulator instead of remaining capped near 10 fps. -- A user can tap, drag, swipe, type, use supported keys, and release focus from - the embedded panel. -- A user can add both an accessibility element and a free area from the - simulator to the current chat. -- Agent actions use the same attached simulator and display device-adaptive - aurora and cursor feedback only for the duration of agent control. -- 60 fps is available with a clear, friendly performance warning. -- Frame rendering remains responsive and bounded at both profiles. -- Existing `simctl` fallback, device ownership, loopback security, and cleanup - behavior do not regress. diff --git a/docs/superpowers/specs/2026-08-11-whats-new-release-modal-design.md b/docs/superpowers/specs/2026-08-11-whats-new-release-modal-design.md deleted file mode 100644 index df8aedf4..00000000 --- a/docs/superpowers/specs/2026-08-11-whats-new-release-modal-design.md +++ /dev/null @@ -1,382 +0,0 @@ -# Versioned What's New Modal Design - -**Date:** 2026-08-11 - -**Target version:** `0.7.0-beta` - -**Status:** Approved design; implementation pending - -**Platforms:** macOS, Windows, and Linux - -## Summary - -Every tagged Verboo Code release displays one localized What's New modal the -first time that version runs in a user profile. This applies equally to a clean -installation, an installer downloaded directly from the repository release, -and an update installed by the in-app updater. Recognizing the modal suppresses -it for the same version. Installing a later tagged app version makes the modal -eligible again. - -The modal is app-release UI. A CLI-only update never triggers it and cannot -write its state. Local development builds do not consume the recognition state -for a tagged distribution build. - -Release copy lives in one versioned, bilingual catalog used by both the -renderer and the release workflow. Future version preparation scaffolds a new -catalog entry, while release verification rejects missing, incomplete, stale, -or mismatched entries. - -## Goals - -1. Show the current tagged app release once per user profile. -2. Cover clean installs, direct repository downloads, and in-app updates with - the same rule. -3. Never trigger from a CLI-only update, a same-version relaunch, or a - downgrade. -4. Keep release copy available offline in Portuguese and English. -5. Open the exact repository tag from a fixed, validated URL. -6. Make future bumps require release copy without requiring modal code changes. -7. Remain accessible, responsive, and visually consistent with the app. - -## Non-goals - -- Fetching or rendering arbitrary GitHub release Markdown at app startup. -- Generating product claims automatically from commit messages. -- Showing an archive of previous release notes inside the app. -- Showing one modal per skipped intermediate version. -- Coupling recognition state to the CLI updater or CLI installation state. -- Calling a tagged repository artifact an official Verboo product in the UI. - -## Chosen behavior - -The eligibility question is: - -> Has this user profile already recognized this tagged app version? - -The source of installation does not matter. - -| Scenario | Result | -| --- | --- | -| First launch of `0.7.0-beta` after in-app update | Show | -| First launch of `0.7.0-beta` from a direct download | Show | -| First launch of a clean `0.7.0-beta` install | Show | -| Relaunch after recognizing `0.7.0-beta` | Do not show | -| Install a later tagged app version | Show that current version once | -| Skip one or more app versions | Show only the installed current version | -| Install an older version than the recognized version | Do not show | -| Update only the Verboo CLI | Do not show | -| Clear all app data manually | Treat as a new profile and show again | - -Closing the process while the modal is still pending does not recognize the -release. The modal appears again on the next launch. - -## Tagged build contract - -The release workflow sets a compile-time repository tag for every distributed -artifact. The tag must be exactly `v`. The lifecycle service -enables automatic What's New eligibility only when this build tag is present -and matches the packaged app version. - -This contract has two effects: - -- artifacts produced by the tagged release workflow show the modal regardless - of whether they arrived through the updater or a direct download; -- ordinary local builds do not acknowledge or suppress the future tagged - release with the same semantic version. - -Local QA uses an explicit preview environment switch. Preview mode presents -the current catalog entry but never writes release recognition state. Tests -inject the same preview contract rather than pretending a development build is -a distributed release. - -The UI does not describe the build as official, independent, or a development -edition. The tag is an internal lifecycle assertion only. - -## Persistent lifecycle state - -The Rust lifecycle layer owns a small app-data file named -`release-state.json`: - -```json -{ - "schemaVersion": 1, - "acknowledgedVersion": "0.7.0-beta" -} -``` - -The state belongs to the desktop app. It is stored outside the app bundle so -normal updates preserve it, and outside every CLI-owned directory so the app -and CLI updaters remain independent. - -On startup the service: - -1. validates the embedded release tag against the running package version; -2. loads and validates the state file; -3. compares the current and acknowledged versions as semantic versions; -4. returns the current version and tag only when the current version is - eligible. - -No acknowledged version means the current tagged version is eligible. An equal -or newer acknowledged version suppresses the modal. A newer current version is -eligible. - -Recognition writes a temporary file in the same app-data directory and renames -it atomically. A corrupt record fails safely: the service logs a diagnostic, -repairs the record to the current version, and does not show a potentially -repeated modal. A persistence failure must never trap the user in the modal; -the modal closes for the current process, reports the non-fatal failure, and -may reappear on the next launch. - -## Release catalog and bump automation - -A repository-level JSON catalog is the single content source. Its schema is: - -```text -schemaVersion -releases - - pt-BR - title - summary - items[] - title - body - en-US - title - summary - items[] - title - body -``` - -The tag URL is not editable catalog content. It is derived from the validated -version as: - -```text -https://github.com/graseeel/verboo_app/releases/tag/v -``` - -This prevents release copy from injecting an arbitrary external destination. - -A release-preparation command accepts the next app version and scaffolds its -catalog entry. It does not invent highlights from Git history. The author or -release agent fills the bilingual editorial copy and reviews the product -claims. - -Release verification requires: - -- the tag, `package.json`, `Cargo.toml`, and `tauri.conf.json` versions to - match; -- a catalog entry for that exact version; -- both `pt-BR` and `en-US` content; -- non-empty title and summary values; -- four to six complete highlight items per locale; -- no placeholder markers; and -- a valid `v` repository tag. - -The release workflow reads the same entry to generate the GitHub release body -and the updater manifest summary. This removes the currently duplicated, -version-stale release text from the workflow. Adding a future version therefore -requires editorial content, but modal display, tag routing, one-time behavior, -and workflow formatting remain automatic. - -## Modal presentation - -The modal mounts only after mandatory startup and CLI-bootstrap blockers are -resolved. It overlays whichever app surface is otherwise active. - -Presentation: - -- a full-window dimmed and softly blurred backdrop; -- a centered, responsive card with the current version, title, summary, and - four to six highlights; -- a bounded scroll region on short windows; -- exactly two visible actions: **Learn more / Saiba mais** and - **Close / Fechar**; -- a short opacity-and-scale entrance using the existing motion language; and -- a reduced-motion path without scale movement. - -There is no close icon. Backdrop clicks do not dismiss the modal accidentally. -Escape is equivalent to Close. - -Learn more opens the exact derived tag URL in the system browser. A successful -open recognizes the version and closes the modal. If opening fails, the modal -stays open and shows a recoverable error. Close recognizes the version and -releases the app immediately. - -Accessibility requirements: - -- `role="dialog"`, `aria-modal="true"`, and labelled title/description; -- focus moves into the modal and starts on Close; -- Tab and Shift+Tab remain inside the modal; -- the background is inert while open; -- focus returns to its prior owner after close when that owner still exists; -- all content and action labels follow the current app locale; and -- contrast, scrolling, and keyboard behavior work at supported window sizes. - -## Startup and overlay precedence - -The modal must not compete with the managed Node/CLI bootstrap gate. Startup -order is: - -1. hydrate app configuration and settings; -2. resolve mandatory runtime/CLI preparation; -3. evaluate the pending tagged release; -4. show What's New before normal interactive modals. - -What's New blocks pointer and keyboard interaction with the app while visible, -but it does not start, cancel, or alter background update operations. It does -not emit an updater snapshot and does not share state with the sidebar update -card. - -## Approved `0.7.0-beta` content - -### Português (Brasil) - -**Title:** O Verboo Code 0.7.0-beta chegou - -**Summary:** Uma grande atualização para trabalhar com apps iOS, provedores -externos e uma instalação mais leve. - -1. **Simulador de iOS integrado — macOS** - - Abra iPhones e iPads ao lado da conversa, interaja com o app, use controles - do sistema e envie seleções ao chat. -2. **Várias contas Claude e Codex** - - Conecte contas adicionais, escolha qual conta cada conversa utiliza e - preserve o histórico visível ao trocar. -3. **Planos e limites no lugar certo** - - Consulte o plano, as janelas de uso e os horários de renovação diretamente - em Provedores. -4. **Atualizações independentes do CLI** - - O app e o CLI agora podem receber atualizações assinadas separadamente, - mantendo um único fluxo seguro de reinicialização. -5. **Instalação muito mais leve** - - O Node é baixado e verificado pelo próprio app no primeiro uso, sem depender - do Node do sistema e sem criar um aplicativo auxiliar no Dock. -6. **Uma experiência mais fluida** - - Carregamento paralelo de provedores, login mais robusto e transições - discretas deixam a inicialização mais agradável. - -### English (United States) - -**Title:** Verboo Code 0.7.0-beta is here - -**Summary:** A major update for working with iOS apps, external providers, and -a lighter installation. - -1. **Built-in iOS Simulator — macOS** - - Open iPhones and iPads beside the conversation, interact with your app, use - system controls, and send selections to chat. -2. **Multiple Claude and Codex accounts** - - Connect additional accounts, choose which account each conversation uses, - and keep the visible history when switching. -3. **Plans and limits where you need them** - - See your plan, usage windows, and reset times directly in Providers. -4. **Independent CLI updates** - - The app and CLI can now receive signed updates separately while sharing one - safe restart flow. -5. **A much lighter installation** - - Node is downloaded and verified by the app on first use, without relying on - system Node or creating a helper app in the Dock. -6. **A smoother experience** - - Parallel provider loading, more reliable sign-in, and subtle transitions - make startup feel better. - -## Error handling - -- Missing catalog content does not render an empty modal. Verification blocks - this condition in a tagged release; a local mismatch logs a diagnostic. -- A malformed or mismatched embedded tag disables automatic presentation. -- Failure to open Learn more keeps the modal available and does not - acknowledge the version. -- Failure to persist Close never blocks access to the app. -- Unknown future state fields are ignored; an unsupported state schema fails - safely without repeatedly interrupting startup. - -## Verification - -### Native lifecycle tests - -- no record plus tagged build shows the current version; -- the same acknowledged version does not show; -- a higher current semantic version shows; -- a downgrade does not show; -- an absent or mismatched build tag does not show; -- preview mode shows without writing; -- recognition survives a new service instance; -- corrupt state fails safely without repetition; and -- CLI update state cannot affect eligibility. - -### Mounted renderer tests - -- the modal renders the current locale and version; -- the background is inert and focus is trapped; -- Escape and Close acknowledge and dismiss; -- a backdrop click does not dismiss; -- Learn more opens the exact tag and acknowledges only after success; -- an opener failure remains visible and recoverable; -- reduced motion removes scale movement; -- a short viewport scrolls the content while keeping actions reachable; and -- the CLI bootstrap gate wins overlay precedence. - -### Release-contract tests - -- the current package version has complete PT/EN catalog content; -- the release workflow embeds the matching tag; -- the GitHub body and updater summary are generated from the catalog; -- stale hardcoded release highlights are absent from the workflow; and -- release preparation scaffolds a new entry but verification rejects unfilled - placeholders. - -### Packaged behavioral QA - -Build the app with an isolated profile and the tagged-release contract: - -1. first launch shows `0.7.0-beta`; -2. direct Close unlocks the app; -3. relaunch does not repeat the modal; -4. deleting only the process and reinstalling the same artifact preserves the - recognition record; -5. preview mode shows without changing the record; and -6. a fixture-level newer version becomes eligible again. - -Run the renderer, Rust, release-script, and cross-platform compile gates. The -packaged UI is exercised locally on macOS. Windows and Linux receive the same -state-machine and renderer coverage plus their existing CI build gates; the -handoff must state that real graphical packaged QA on those two operating -systems was not performed locally. - -## Alternatives considered - -### Renderer-only `localStorage` - -Rejected as the primary authority. It is easy to implement but can be cleared -or partitioned with WebView data, has weaker atomicity, and makes release state -less explicit than an app-owned lifecycle record. - -### Updater installation receipt - -Rejected as the eligibility source. It covers only in-app updates and misses -clean installations and installers downloaded directly from the repository. - -### Fetch GitHub release notes at startup - -Rejected. It adds a network dependency, rate-limit and offline behavior, -untrusted Markdown rendering, and single-language content to a startup path. - -### Generate highlights from commit history - -Rejected. Commit history is implementation-oriented and may include incomplete -or internal work. Release claims remain editorial, reviewed, and bilingual. diff --git a/extensions/verboo-chrome/src/agent/intentSignals.js b/extensions/verboo-chrome/src/agent/intentSignals.js index 8f0fff32..3603f4ea 100644 --- a/extensions/verboo-chrome/src/agent/intentSignals.js +++ b/extensions/verboo-chrome/src/agent/intentSignals.js @@ -2,15 +2,19 @@ * intentSignals.js — intent signals for the browser-tools decision * (ciclo Intenção+UX, FRENTE classificador). * - * Two NEW signals complement shouldOfferBrowserTools's verb list WITHOUT + * These signals complement shouldOfferBrowserTools's verb list WITHOUT * adding verbs (the verb list is whack-a-mole: every new verb a user * types outside it is a new miss): * - * 1. hasDeicticImperativeIntent — STRUCTURAL, any language: a + * - hasDeicticImperativeIntent — STRUCTURAL, any language: a * verb-first clause whose object is anchored to the CURRENT page * (deictic anchor + page noun). No verb list at all. * - * 2. hasBrowserUnavailableAdmission — SEMANTIC: the ASSISTANT's own + * - hasImperativeWithObject — STRUCTURAL fall-open when the turn has a + * controllable page under the panel: verb-first clause + article + + * concrete object, gated against questions/explanations/desires. + * + * - hasBrowserUnavailableAdmission — SEMANTIC: the ASSISTANT's own * reply admits it has no browser access ("o navegador não está * disponível", "I don't have access to the browser"). Used to * reclassify a conversation turn into a browser turn (L3). @@ -33,10 +37,6 @@ const DEICTIC_IMPERATIVE_RE = /^(?:(?:por\s+favor|please)\s+)?[a-z]+\s+(?:[a-z]+\s+){0,4}(?:o|a|os|as|um|uma|uns|umas|the|an)\s+(?:[a-z]+\s+){0,4}(?:esta|essa|desta|dessa|nesta|nessa|neste|nesse|deste|desse|this|that|here|there)\s+(?:pagina|page|aba|tab|site|tela|screen|janela|window|lista|list|formulario|form|secao|section|campo|field|planilha|spreadsheet)\b/i -/** - * @param {unknown} value - * @returns {boolean} - */ export function hasDeicticImperativeIntent(value) { const text = normalizeIntentText(value) if (!text) return false @@ -104,10 +104,6 @@ const DEICTIC_PAGE_ANCHOR_RE = const IMPERATIVE_WITH_OBJECT_RE = /^[a-z]+\s+(?:[a-z]+\s+){0,4}(?:o|a|os|as|um|uma|uns|umas|the|an)\s+[a-z]+\b/i -/** - * @param {unknown} value - * @returns {boolean} - */ export function hasImperativeWithObject(value) { const text = normalizeIntentText(value) if (!text) return false @@ -159,7 +155,6 @@ export function hasBrowserUnavailableAdmission(value) { return BROWSER_UNAVAILABLE_ADMISSION_PATTERNS.some((re) => re.test(text)) } -/** @param {unknown} value */ function normalizeIntentText(value) { return String(value ?? '') .normalize('NFD') diff --git a/extensions/verboo-chrome/src/agent/intentSignals.test.js b/extensions/verboo-chrome/src/agent/intentSignals.test.js index 7007fecb..2ed75cf9 100644 --- a/extensions/verboo-chrome/src/agent/intentSignals.test.js +++ b/extensions/verboo-chrome/src/agent/intentSignals.test.js @@ -15,7 +15,7 @@ import { hasImperativeWithObject, } from './intentSignals.js' -// ── L1: deictic imperative → browser tools ───────────────────────── +// L1: deictic imperative → browser tools. test('L1: deictic imperative opens browser tools (any language, no verb list)', () => { assert.equal(hasDeicticImperativeIntent('crie o produto desta página'), true) @@ -43,7 +43,7 @@ test('L1: genuine conversation stays conversation (no anchor / no page noun)', ( assert.equal(hasDeicticImperativeIntent('mande uma mensagem para ela'), false) }) -// ── L3: browser-unavailability admission (assistant reply) ───────── +// L3: browser-unavailability admission (assistant reply). test('L3: PT-BR admissions are detected (case-insensitive)', () => { assert.equal(hasBrowserUnavailableAdmission('O navegador não está disponível neste momento.'), true) @@ -80,7 +80,7 @@ test('L3: only the reply opening is scanned (300 chars)', () => { const leadingAdmission = 'o navegador não está disponível. ' + 'contexto. '.repeat(40) assert.equal(hasBrowserUnavailableAdmission(leadingAdmission), true) }) -// ── L2: imperative with concrete object (fall-open with a page) ──── +// L2: imperative with concrete object (fall-open with a page). test('L2: imperative with concrete object matches (no verb list)', () => { assert.equal(hasImperativeWithObject('crie o produto ethos'), true) @@ -104,7 +104,7 @@ test('L2: "me conte sobre esta página" stays out (sobre is not an article)', () assert.equal(hasImperativeWithObject('me explique sobre o assunto'), false) }) -// ── L2 PÓS-GATE: explanation + desire gates (Farol contra-examples) ─ +// L2 PÓS-GATE: explanation + desire gates (Farol contra-examples). test('L2 PÓS-GATE: direct explanation forms stay conversation (PT/EN)', () => { assert.equal(hasImperativeWithObject('explique a teoria'), false) @@ -158,7 +158,7 @@ test('L2 PÓS-GATE: COMMUNICATION imperatives stay browser (product decision)', assert.equal(hasImperativeWithObject('send a message to joão'), true) }) -// ── L2 PÓS-RE-GATE: knowledge family — 'de' optional + EN to know ── +// L2 PÓS-RE-GATE: knowledge family — 'de' optional + EN to know. test('L2 PÓS-RE-GATE: literal Farol forms — saber/conhecer stay conversation', () => { assert.equal(hasImperativeWithObject('quero saber o que é ethos'), false) @@ -170,7 +170,7 @@ test('L2 PÓS-RE-GATE: literal Farol forms — saber/conhecer stay conversation' assert.equal(hasImperativeWithObject('queria saber o preço'), false) }) -// ── T6-B (Ciclo dos Achados de Campo): dêitico vence o gate de conhecimento ── +// T6-B (Ciclo dos Achados de Campo): dêitico vence o gate de conhecimento. test('T6-B: âncora dêitica de página (deste/desta/this + substantivo) vence o DESIRE_GATE — a pergunta é sobre a página', () => { assert.equal(hasImperativeWithObject('quero saber o preço deste produto'), true) diff --git a/extensions/verboo-chrome/src/agent/loop.js b/extensions/verboo-chrome/src/agent/loop.js index cda89eb8..8e7e7198 100644 --- a/extensions/verboo-chrome/src/agent/loop.js +++ b/extensions/verboo-chrome/src/agent/loop.js @@ -643,7 +643,6 @@ async function runLlmAgentTurnWithinBudget({ } } - // Add assistant message (with tool_calls) to conversation. messages.push({ role: 'assistant', content: completion.content, @@ -842,7 +841,6 @@ async function runLlmAgentTurnWithinBudget({ } } - // Build text result for the conversation. // Tool role content remains a string. Vision pixels travel in a separate // user message after every tool response from this step has been added. let resultText = '' @@ -1081,7 +1079,6 @@ async function runLlmAgentTurnWithinBudget({ if (signal?.aborted) throw new Error('Agent turn cancelled') - // Reached max steps without text-only response. return { assistantMessage: looksPortuguese(userMessage) ? `Execução incompleta: alcancei o limite de ${MAX_AGENT_STEPS} etapas antes de concluir e verificar o pedido.` @@ -1090,7 +1087,6 @@ async function runLlmAgentTurnWithinBudget({ } } -// ── Helpers ────────────────────────────────────────────────── /** * Keep only bounded user/assistant text from the visible panel conversation. @@ -1252,8 +1248,6 @@ function isInterruptedBrowserResume(text, conversationHistory) { function hasPageInspectionIntent(text) { const pageReference = /\b(?:esta|essa|desta|dessa|nesta|nessa|neste|nesse|nestes|nesses|this|current)\s+(?:pagina|page|aba|tab|site|tela|screen|documento|document|html|dom)\b|\b(?:a|o|da|do|na|no)\s+(?:pagina|page|aba|tab|site|tela|screen|documento|document|html|dom)\s+(?:atual|aberta|aberto|aqui|current|inicial|home)\b/i - // Page inspection: the user asks to look at / read / describe what's - // on the page. // PÓS-GATE Farol: explain/define joined the inspection family so the // PAGE-anchored explanation path stays browser ("explique esta página", // "explain this page") while the topic explanation stays conversation @@ -1382,7 +1376,6 @@ export function requiresScreenshot(userMessage) { return /\b(?:tire|tirar|faca|fazer|take|capture)\b.{0,24}\b(?:um |uma |a )?(?:print|screenshot|screen shot|captura)\b/i.test(text) } -/** @param {unknown} value */ function normalizeIntentText(value) { return String(value ?? '') .normalize('NFD') @@ -1465,7 +1458,6 @@ function extractFindSelectors(findResult) { return [...text.matchAll(/selector="([^"]+)"/g)].map((match) => match[1]) } -/** @param {string} json */ function canonicalJson(json) { try { return JSON.stringify(sortJsonValue(JSON.parse(json))) @@ -1474,7 +1466,6 @@ function canonicalJson(json) { } } -/** @param {unknown} value */ function sortJsonValue(value) { if (Array.isArray(value)) return value.map(sortJsonValue) if (!value || typeof value !== 'object') return value @@ -1663,9 +1654,6 @@ export function looksPortuguese(text) { ) } -/** - * @param {string} text - */ function looksEnglish(text) { if (!text) return false return /\b(open|go to|search|play|put on|find|please|the|and|for|with|youtube|music|song|video)\b/i.test( diff --git a/extensions/verboo-chrome/src/agent/loop.test.js b/extensions/verboo-chrome/src/agent/loop.test.js index 6b46fc40..c9c6d0f6 100644 --- a/extensions/verboo-chrome/src/agent/loop.test.js +++ b/extensions/verboo-chrome/src/agent/loop.test.js @@ -8,7 +8,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { createRunQueue } from '../routines/runQueue.js' -// ── Save/restore original fetch ───────────────────────────── const origFetch = globalThis.fetch // Mock fetch: two sequential responses — first returns tool_call, second returns text. @@ -92,7 +91,6 @@ test('runLlmAgentTurn: one tool-call round-trip (navigate then text)', async () assert.equal(resultBroadcast.toolResult.success, true) assert.equal(typeof resultBroadcast.toolResult.durationMs, 'number') - // AGENT_THOUGHT broadcasts present. const thoughts = broadcastCalls.filter(b => b.type === 'agent:thought') assert.ok(thoughts.length >= 2) // Analyzing + Calling navigate assert.ok(thoughts.every(t => typeof t.text === 'string')) @@ -1280,14 +1278,13 @@ test('runLlmAgentTurn: early-stop after 5 consecutive failures of same tool', as getActiveTabMeta: async () => null, }) - // Stopped early with a friendly message, not burning all 200 steps. + // Stopped early with a friendly message, before exhausting MAX_AGENT_STEPS. assert.ok( result.assistantMessage.includes('try a different approach') || result.assistantMessage.includes('try a more specific instruction') || result.assistantMessage.includes('try a different'), `expected early-stop message, got: "${result.assistantMessage}"`, ) - // All tool results are failures. assert.ok(result.toolResults.every(r => r.success === false)) // Stopped well before 20 (5 fails + optional strategy-hint step). assert.ok(result.toolResults.length < 10, `expected <10 tools, got ${result.toolResults.length}`) @@ -1557,7 +1554,6 @@ test('B2: reclassified turn re-execution is routed through the queue and stays s ) }) -// ── G1-CHROME: discovery over guessing ───────────────────── // The model must discover a user-named target by READING the page (find // returns real clickable references) and click the REAL reference — never // guess CSS selectors from memory. Contrafactual: a nonexistent target @@ -1683,7 +1679,6 @@ test('G1: nonexistent named target ends honestly — zero guessed clicks', async } }) -// ── G2-CHROME: executed actions + empty final reply = COMPLETE ── test('G2: executed actions + empty reply — loop re-asks once and completes with a closing summary', async () => { const requestBodies = [] const executeCalls = [] @@ -1848,7 +1843,6 @@ test('G2: zero actions + empty reply remains an honest failure (model_returned_e } }) -// ── G3-CHROME: full-page extraction reaches the model, nothing truncated ── test('G3: long page extraction delivers the END of the page to the model', async () => { const requestBodies = [] const TAIL = 'CONCLUSAO-UNICA-DO-FIM-DA-PAGINA' @@ -1910,7 +1904,6 @@ test('G3: long page extraction delivers the END of the page to the model', async } }) -// ── T4 (Ciclo dos Achados de Campo): read_page truncado sinaliza NO RESULTADO ── test('T4: read_page truncado sinaliza NO RESULTADO (conteúdo truncado em N chars — use structured_extract)', async () => { const requestBodies = [] const longText = `LIVRO-1 ${'corpo intermediario. '.repeat(1200)} LIVRO-20-FINAL` @@ -1972,7 +1965,6 @@ test('T4: read_page truncado sinaliza NO RESULTADO (conteúdo truncado em N char } }) -// ── T4-SUSPICIOUS (ressalva N3 do gate CICLO-B): pior caso do wrap ── // Conteúdo que dispara os sinais do untrustedContent (instruction_override + // secret_exfiltration) → o wrap adiciona 327 chars (medido). O WRAP_OVERHEAD // (350) deve cobrir o pior caso real — o resultado NUNCA excede 4000. @@ -2034,7 +2026,6 @@ test('T4-SUSPICIOUS: read_page com conteúdo suspicious (wrap 327 chars) ainda r } }) -// ── BLOQUEIO CADINHO 1 (G1 reativo): o caminho do vídeo ──── // O defeito real é REATIVO: modelo chuta seletor → element-not-found // repetido → o loop injeta o STRATEGY_HINT → o modelo chama find → // clica na referência REAL descoberta. Asserção final no CLIQUE real. @@ -2143,7 +2134,6 @@ test('G1 reativo: guessed-click failures trigger the hint, then find → click o } }) -// ── BLOQUEIO CADINHO 2 (G2): portão = sucesso REAL, não length ── // CASO A do vídeo: 4 element-not-found + resposta vazia deve continuar // falha honesta — nunca "Concluído: 0 ações". test('G2 CASO A: 4 falhas (element not found) + empty reply stays an honest failure', async () => { @@ -2199,7 +2189,6 @@ test('G2 CASO A: 4 falhas (element not found) + empty reply stays an honest fail } }) -// ── FRENTE-C: directed format retry, honest failure, reclassify ────── test('runLlmAgentTurn: directed format retry when the model emits unsupported markup (R6)', async () => { const responses = [ @@ -2353,7 +2342,6 @@ test('runLlmAgentTurn: dropped tool names are fed back to the model next step (R } }) -// ── Intenção+UX: L1 deictic imperative + L3 admission reclassify ── test('shouldOfferBrowserTools: L1 deictic imperative opens tools; genuine conversation stays conversation', () => { // L1 positive — structural, any language, no verb list. @@ -2524,8 +2512,6 @@ test('runLlmAgentTurn: admission in a BROWSER turn does not reclassify (L3 bound } }) -// ── L2: imperative + controllable URL (fall-open) — guards 1-4 ───── -// // Evaluation order (documented): L1 (deictic anchor) runs first — it is // the strongest signal; L2 (imperative + controllable URL) second; the // verb list third; L3 (admission reclassify) is only reachable in turns @@ -2696,7 +2682,6 @@ test('shouldOfferBrowserTools: PÓS-RE-GATE — knowledge desires stay conversat assert.equal(shouldOfferBrowserTools('odeio esta página'), false) }) -// ── GENERALIZAÇÃO: R-V1 screenshot does not clear verification ───── test('runLlmAgentTurn: a screenshot after a mutation does NOT clear verification (R-V1)', async () => { const responses = [ @@ -2755,7 +2740,6 @@ test('runLlmAgentTurn: a screenshot after a mutation does NOT clear verification } }) -// ── GENERALIZAÇÃO: literal TodoMVC case — effect absent → honest failure ─ test('runLlmAgentTurn: TodoMVC literal — absent effect is reported as failure, not success', async () => { const responses = [ @@ -2817,7 +2801,6 @@ test('runLlmAgentTurn: TodoMVC literal — absent effect is reported as failure, } }) -// ── PÓS-CAMPO-3: repeated-failed-mutate block + hint mentions find ── test('runLlmAgentTurn: the EXACT same failing mutate is blocked on the 3rd repeat with find feedback', async () => { const requestBodies = [] @@ -2974,7 +2957,6 @@ test('runLlmAgentTurn: the fail-streak hint explicitly mentions find', async () } }) -// ── PÓS-CAMPO-4: automatic find recovery (round 4 literal case) ──── /** * PÓS-CAMPO-6 (A): the OpenAI ADJACENCY contract — every assistant message @@ -3270,7 +3252,6 @@ test('runLlmAgentTurn: click failures also trigger the auto-find (user wording a } }) -// ── PÓS-CAMPO-5: structural honesty (round 5 literal case) ───────── test('runLlmAgentTurn: round-5 literal — all-failed mutations REPLACE the fabricated success text', async () => { let fetchCount = 0 diff --git a/extensions/verboo-chrome/src/agent/routerClient.js b/extensions/verboo-chrome/src/agent/routerClient.js index 3f4117c2..28936f29 100644 --- a/extensions/verboo-chrome/src/agent/routerClient.js +++ b/extensions/verboo-chrome/src/agent/routerClient.js @@ -350,7 +350,6 @@ function parseJsonToolCalls(body, dropped = []) { return calls } -// ── Family normalization (FRENTE-C) ──────────────────────────────── // Conservative alias map for tool names other tools/models may emit for // the SAME operation. Only unambiguous names are mapped — anything diff --git a/extensions/verboo-chrome/src/agent/routerClient.test.js b/extensions/verboo-chrome/src/agent/routerClient.test.js index 5f8d2f1b..a0d37210 100644 --- a/extensions/verboo-chrome/src/agent/routerClient.test.js +++ b/extensions/verboo-chrome/src/agent/routerClient.test.js @@ -533,7 +533,7 @@ test('parseCompletionResponse: JSON computer action unwraps action and drops unk assert.ok(!(result.content ?? '').includes(' family (Ivo's literal fixtures) ────── +// FRENTE-C: family (Ivo's literal fixtures). test('parseCompletionResponse: Ivo fixture — computer/screenshot normalizes to screenshot', () => { const result = parseCompletionResponse({ diff --git a/extensions/verboo-chrome/src/agent/toolCatalog.js b/extensions/verboo-chrome/src/agent/toolCatalog.js index a11b3d73..0aeb233c 100644 --- a/extensions/verboo-chrome/src/agent/toolCatalog.js +++ b/extensions/verboo-chrome/src/agent/toolCatalog.js @@ -46,7 +46,6 @@ export function toToolCall(toolCall) { } } -/** @param {string} argStr */ function parseArguments(argStr) { try { const parsed = JSON.parse(argStr) diff --git a/extensions/verboo-chrome/src/auth/auth.js b/extensions/verboo-chrome/src/auth/auth.js index 90ef77d2..80bd30b9 100644 --- a/extensions/verboo-chrome/src/auth/auth.js +++ b/extensions/verboo-chrome/src/auth/auth.js @@ -42,7 +42,7 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000 * @property {string} [provider] */ -// ── chrome.storage.local with in-memory fallback (Node tests) ──── +// chrome.storage.local, falling back to an in-memory Map under Node tests. /** @type {Map} */ const memoryStore = new Map() @@ -99,7 +99,6 @@ async function storageRemove(keys) { for (const k of list) memoryStore.delete(k) } -// ── Public API ───────────────────────────────────────────── /** Load session from chrome.storage.local. @returns {Promise} */ export async function loadSession() { @@ -270,9 +269,6 @@ export async function loadModels(forceRefresh = false) { return models } -/** - * @param {string} modelId - */ export async function selectModel(modelId) { if (!modelId || typeof modelId !== 'string') { throw new Error('modelId is required') @@ -280,7 +276,6 @@ export async function selectModel(modelId) { await storageSet({ [SELECTED_MODEL_KEY]: modelId }) } -/** @returns {Promise} */ export async function getSelectedModelId() { try { const result = await storageGet(SELECTED_MODEL_KEY) @@ -358,7 +353,6 @@ export async function ensureFreshSession(options = {}) { return refreshSession(options.config ?? OAUTH_CONFIG, options.dependencies ?? {}) } -// ── Router fetch + normalize ───────────────────────────────── /** * @param {string} token diff --git a/extensions/verboo-chrome/src/background.integration.test.js b/extensions/verboo-chrome/src/background.integration.test.js index 5e994319..7764a455 100644 --- a/extensions/verboo-chrome/src/background.integration.test.js +++ b/extensions/verboo-chrome/src/background.integration.test.js @@ -357,7 +357,6 @@ test('(5) RED-DE-VERDADE: L2 (crie uma tarefa) + sourceWindowId + 1 janela → b const tab = { id: 2, windowId: 20, url: 'https://todomvc.com/', active: true, status: 'complete' } sh.state.windows = { 20: { activeTab: tab } } sh.state.tabsById = new Map([[tab.id, tab]]) - // Override tabs.query para devolver a aba também para currentWindow:true. const origQuery = sh.chrome.tabs.query sh.chrome.tabs.query = async (q) => { if (q.active === true && q.currentWindow === true) return [tab] diff --git a/extensions/verboo-chrome/src/background.js b/extensions/verboo-chrome/src/background.js index 9397c59f..fd3607a6 100644 --- a/extensions/verboo-chrome/src/background.js +++ b/extensions/verboo-chrome/src/background.js @@ -225,7 +225,6 @@ void disableGlobalVerbooPanel().catch((error) => { console.warn('[Verboo] Could not disable the global side panel:', error) }) -// ── Open side panel on toolbar click ────────────────────────────── chrome.action.onClicked.addListener(async (tab) => { if (tab?.id) { try { @@ -236,7 +235,6 @@ chrome.action.onClicked.addListener(async (tab) => { } }) -// ── Extension install / update ──────────────────────────────────── chrome.runtime.onInstalled.addListener((details) => { void installSelectionContextMenu().catch((error) => { console.error('[Verboo] Could not install the selected-text menu:', error) @@ -278,7 +276,6 @@ chrome.notifications?.onClicked?.addListener((notificationId) => { void restoreRoutineExecutionState() -// ── Pending approvals (toolCallId → resolver) ──────────────────── /** @type {Map void }>} */ const pendingApprovals = new Map() /** @type {Map>} */ @@ -286,7 +283,6 @@ const turnSiteGrants = new Map() /** @type {Map>} — abas criadas pelo agente no turno (close policy) */ const turnCreatedTabIds = new Map() -// ── Message router ──────────────────────────────────────────────── chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message || typeof message !== 'object') return false @@ -823,8 +819,6 @@ async function handleBrowserTool(toolCall) { ) } -// ── Agent loop ──────────────────────────────────────────────────── -// // Flow per turn: // 1. AGENT_TURN_STARTED // 2. AGENT_THOUGHT (planning) @@ -921,7 +915,6 @@ async function runAgentTurn( } }, 20_000) - /** @type {boolean} */ let terminalSent = false /** * Emit at most one COMPLETE or ERROR so the panel always leaves Working… @@ -1053,7 +1046,6 @@ async function runAgentTurn( if (browserToolsRequested && typeof presenceTabId === 'number') { try { await ensureVerbooTabGroup(presenceTabId) - // Frame + animated cursor from the first moment of control. await ensureAgentPresence(presenceTabId) } catch { // Non-controllable page or missing APIs — continue the turn. @@ -1621,9 +1613,6 @@ async function setRoutineApprovalState(runId, status) { broadcast({ type: MSG.ROUTINE_RUN_CHANGED, run: updated }) } -/** - * @param {string} turnId - */ function cancelTurn(turnId) { browserControlQueue.cancel(turnId) abortTurnController(turnId) diff --git a/extensions/verboo-chrome/src/controller/backgroundWorkspace.test.js b/extensions/verboo-chrome/src/controller/backgroundWorkspace.test.js index 0636a888..7195b9f0 100644 --- a/extensions/verboo-chrome/src/controller/backgroundWorkspace.test.js +++ b/extensions/verboo-chrome/src/controller/backgroundWorkspace.test.js @@ -98,7 +98,7 @@ test('turn lease changes target only after an explicit tabs action', async () => await assert.rejects(() => lease.selectTab(88, 9), /outside_workspace/) }) -// ── PÓS-CAMPO-6 (B): lease target revalidation + load wait ───────── +// PÓS-CAMPO-6 (B): lease target revalidation + load wait. const { createBackgroundWorkspaceManager } = await import('./backgroundWorkspace.js') @@ -210,7 +210,7 @@ test('PÓS-CAMPO-6: reset clears the lease storage', async () => { assert.equal(storage.session.verbooBackgroundWorkspace, undefined) }) -// ── PÓS-CAMPO-7: lease URL EQUALITY (stale-but-controllable lease) ── +// PÓS-CAMPO-7: lease URL EQUALITY (stale-but-controllable lease). test('PÓS-CAMPO-7: a stale-but-controllable lease is re-navigated to the user URL and the load is awaited', async () => { const { chromeApi, updates } = makeChromeApi({ diff --git a/extensions/verboo-chrome/src/controller/execute.js b/extensions/verboo-chrome/src/controller/execute.js index a5e15d61..dff5b4b9 100644 --- a/extensions/verboo-chrome/src/controller/execute.js +++ b/extensions/verboo-chrome/src/controller/execute.js @@ -94,7 +94,6 @@ export async function execute(toolCall, ctx) { return { ok: false, error: policy.reason, policy, toolCall: normalizedToolCall, policyHost } } - // Dispatch to the tool implementation. try { await ctx.onExecuting?.(normalizedToolCall) const result = await dispatch(normalizedToolCall, ctx) diff --git a/extensions/verboo-chrome/src/controller/protocol.js b/extensions/verboo-chrome/src/controller/protocol.js index a650e168..1b701939 100644 --- a/extensions/verboo-chrome/src/controller/protocol.js +++ b/extensions/verboo-chrome/src/controller/protocol.js @@ -40,7 +40,6 @@ import browserCatalog from './browserTools.js' -// ── Message types ────────────────────────────────────────── export const MSG = Object.freeze({ // Panel → Controller @@ -106,7 +105,6 @@ export const MSG = Object.freeze({ ROUTINE_SCHEDULE_CHANGED: 'routine:schedule_changed', }) -// ── Tool Call envelope ──────────────────────────────────── /** * Tool catalog version. Bumped when a new tool kind is added or when the @@ -128,8 +126,7 @@ export const MSG = Object.freeze({ * 3. Implement handler in src/controller/tools/.js * 4. Add dispatch case in src/controller/execute.js dispatch() * 5. Add unit test in src/controller/tools/.test.js - * 6. Bump CATALOG_VERSION, append a version-history line above - * 7. Update PRIVACY.md + PERMISSIONS.md + STORE_LISTING.md if the tool + * 6. Update PRIVACY.md + PERMISSIONS.md + STORE_LISTING.md if the tool * requires a new permission (AEGIS audit required for elevated tools) * * Multi-user: zero hardcoded path/user/token. accountId from session @@ -175,7 +172,7 @@ export const TOOL_RISK_MAP = Object.freeze(Object.fromEntries( BROWSER_TOOL_CATALOG.map((tool) => [tool.name, tool.risk]), )) -// ── Policy Decision (mirrors evaluateToolPolicy.js output) ── +// Mirrors evaluateToolPolicy.js output. /** * @typedef {Object} PolicyDecision @@ -185,7 +182,6 @@ export const TOOL_RISK_MAP = Object.freeze(Object.fromEntries( * @property {string} [hardBlockLabel] */ -// ── Tool Result ─────────────────────────────────────────── /** * @typedef {Object} ToolResult @@ -196,7 +192,6 @@ export const TOOL_RISK_MAP = Object.freeze(Object.fromEntries( * @property {number} durationMs */ -// ── Agent Turn envelope ─────────────────────────────────── /** * @typedef {Object} AgentTurnStart @@ -308,7 +303,6 @@ function validateToolParams(definition, params) { return null } -/** @param {unknown} value @param {string|undefined} type @param {string|undefined} itemType */ function matchesJsonType(value, type, itemType) { if (!type) return true if (type === 'integer') return Number.isInteger(value) @@ -319,7 +313,6 @@ function matchesJsonType(value, type, itemType) { return typeof value === type } -/** @param {string} name @param {Record} params */ function serializeCanonicalInput(name, params) { const entries = Object.entries(params) .sort(([a], [b]) => a.localeCompare(b)) @@ -327,7 +320,6 @@ function serializeCanonicalInput(name, params) { return [name, ...entries].join(' ') } -/** @param {{name: string; params: Record}} toolCall */ function resolvePolicyHost(toolCall) { if (toolCall.name === 'navigate') return httpHost(toolCall.params.url) if (toolCall.name === 'tabs' && toolCall.params.action === 'new') { @@ -336,7 +328,6 @@ function resolvePolicyHost(toolCall) { return '' } -/** @param {unknown} value */ function httpHost(value) { if (typeof value !== 'string') return '' try { diff --git a/extensions/verboo-chrome/src/controller/tools/gifRecording.js b/extensions/verboo-chrome/src/controller/tools/gifRecording.js index 4a530088..4a2d9ddf 100644 --- a/extensions/verboo-chrome/src/controller/tools/gifRecording.js +++ b/extensions/verboo-chrome/src/controller/tools/gifRecording.js @@ -43,12 +43,6 @@ export async function gifRecording(tool) { ) } -/** - * @param {unknown} value - * @param {number} min - * @param {number} max - * @param {number} fallback - */ function clamp(value, min, max, fallback) { if (typeof value !== 'number' || Number.isNaN(value)) return fallback return Math.max(min, Math.min(max, value)) diff --git a/extensions/verboo-chrome/src/controller/tools/networkReader.js b/extensions/verboo-chrome/src/controller/tools/networkReader.js index 1b422b66..8253ec36 100644 --- a/extensions/verboo-chrome/src/controller/tools/networkReader.js +++ b/extensions/verboo-chrome/src/controller/tools/networkReader.js @@ -67,7 +67,7 @@ function clampDuration(requested) { } /** - * @param {boolean} + * @returns {Promise} whether the debugger permission is available */ function hasDebuggerPermission() { try { diff --git a/extensions/verboo-chrome/src/controller/tools/screenshot.js b/extensions/verboo-chrome/src/controller/tools/screenshot.js index 46f5ae17..df7ec4d7 100644 --- a/extensions/verboo-chrome/src/controller/tools/screenshot.js +++ b/extensions/verboo-chrome/src/controller/tools/screenshot.js @@ -128,12 +128,10 @@ async function captureWithRetries(windowId) { ) } -/** @param {string} url */ function isCapturableUrl(url) { return /^https?:\/\//i.test(url) || /^file:\/\//i.test(url) } -/** @param {string} url */ function schemeOf(url) { try { return new URL(url).protocol.replace(':', '') diff --git a/extensions/verboo-chrome/src/controller/tools/serialize.test.js b/extensions/verboo-chrome/src/controller/tools/serialize.test.js index d8ae3ecb..9463685a 100644 --- a/extensions/verboo-chrome/src/controller/tools/serialize.test.js +++ b/extensions/verboo-chrome/src/controller/tools/serialize.test.js @@ -187,7 +187,7 @@ test('find: the injected func is self-contained — selectors derive via seriali } }) -// ── ROUND 9, blade 2: null page result fails honestly on every tool ── +// ROUND 9, blade 2: null page result fails honestly on every tool. test('ROUND 9 guard: type/click/find report an honest error (with tab identity) when the page result is null', async () => { // The func crashing in-page delivers [{ result: null }] — the round-9 @@ -221,7 +221,7 @@ test('ROUND 9 guard: type/click/find report an honest error (with tab identity) } }) -// ── SELECT: type on a resolves the option (text OR value). // Field evidence: clicking a synthetic