diff --git a/.github/scripts/pr-quality-state.test.cjs b/.github/scripts/pr-quality-state.test.cjs index 131205e4b54..cf68abc8276 100644 --- a/.github/scripts/pr-quality-state.test.cjs +++ b/.github/scripts/pr-quality-state.test.cjs @@ -795,6 +795,41 @@ describe("durable readiness re-attestation", () => { assert.equal(result.pending.checkpointAt, null); }); + it("accepts a delayed author event when the live head and body remain unchanged", () => { + const pending = { version: 1, headSha: HEAD_A, baseRef: "dev", generation: 2, phase: "await-clear", checkpointAt: CHECKPOINT }; + const result = advanceReattestation({ + pending, + legacy: false, + current: true, + readiness: readiness(0), + live: live(body0, { updatedAt: "2026-09-22T09:00:01.000Z" }), + event: authorEdit(body0, body4), + }); + assert.equal(result.pending.phase, "await-check"); + assert.equal(result.pending.checkpointAt, null); + }); + + it("rejects future author events and missing or invalid live timestamps", () => { + const pending = { version: 1, headSha: HEAD_A, baseRef: "dev", generation: 2, phase: "await-clear", checkpointAt: CHECKPOINT }; + for (const [name, liveUpdatedAt, eventUpdatedAt] of [ + ["future author event", LIVE_TIME, "2026-09-22T01:00:02.000Z"], + ["missing live timestamp", undefined, LIVE_TIME], + ["invalid live timestamp", "not-a-time", LIVE_TIME], + ]) { + const result = advanceReattestation({ + pending, + legacy: false, + current: true, + readiness: readiness(0), + live: live(body0, { updatedAt: liveUpdatedAt }), + event: authorEdit(body0, body4, { updatedAt: eventUpdatedAt }), + }); + assert.equal(result.pending.phase, "await-clear", name); + assert.equal(result.changed, false, name); + assert.equal(result.canComplete, false, name); + } + }); + it("rejects equal timestamps, title-only edits, and stale or reordered payloads", () => { const pending = { version: 1, headSha: HEAD_A, baseRef: "dev", generation: 2, phase: "await-clear", checkpointAt: CHECKPOINT }; const cases = [ diff --git a/.github/scripts/pr-readiness-reattest.cjs b/.github/scripts/pr-readiness-reattest.cjs index 59576cfb32b..776eaa1ce6b 100644 --- a/.github/scripts/pr-readiness-reattest.cjs +++ b/.github/scripts/pr-readiness-reattest.cjs @@ -115,6 +115,10 @@ function samePending(left, right) { function qualifyingAuthorBodyEdit({ live, event, checkpointAt }) { const checkpointMs = Date.parse(checkpointAt); const eventMs = Date.parse(event?.updatedAt ?? ""); + // GitHub can advance the live PR timestamp after the author event arrives. + // Do not cap the lag: a delayed event still proves this author's post-checkpoint + // edit when the exact body and head are unchanged at the live read. + const liveMs = Date.parse(live?.updatedAt ?? ""); return Boolean( event?.name === "pull_request_target" && event.action === "edited" && @@ -123,9 +127,8 @@ function qualifyingAuthorBodyEdit({ live, event, checkpointAt }) { event.headSha === live.headSha && typeof event.body === "string" && event.body === live.body && typeof event.previousBody === "string" && event.previousBody !== event.body && - event.updatedAt === live.updatedAt && - Number.isFinite(checkpointMs) && Number.isFinite(eventMs) && - eventMs > checkpointMs + Number.isFinite(checkpointMs) && Number.isFinite(eventMs) && Number.isFinite(liveMs) && + eventMs > checkpointMs && eventMs <= liveMs ); } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c8c1b458c1..af09564cd08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,7 @@ jobs: # step. A missing or malformed filter output must fail this job instead # of silently making every expensive job skip. ci: ${{ steps.scope.outputs.ci }} + desktop: ${{ steps.scope.outputs.desktop }} native: ${{ steps.matrices.outputs.native }} # Matrix include lists for keyring-smoke and npm-global-smoke, built and # shape-checked by the same validation step as `native`. @@ -265,6 +266,20 @@ jobs: - '.github/workflows/ci.yml' gui: - 'gui/**' + # Building both Linux package formats and booting their real payloads is + # substantially heavier than the Rust-only desktop-shell check. Keep it + # scoped to inputs that can change the packaged shell, dashboard or + # standalone sidecar. The workflow names itself so edits to this lane + # cannot skip their own E2E. + desktop: + - 'desktop/**' + - 'gui/**' + - 'src/**' + - 'scripts/build-standalone.ts' + - 'scripts/standalone-targets.ts' + - 'package.json' + - 'bun.lock' + - '.github/workflows/ci.yml' # The docs site is built by nothing else on a pull request. `ci` above # deliberately omits `docs-site/**` -- a prose edit has no business # starting the cross-platform suite -- and `deploy-docs.yml` triggers @@ -342,6 +357,7 @@ jobs: shell: bash env: CI_SCOPE: ${{ steps.filter.outputs.ci }} + DESKTOP_SCOPE: ${{ steps.filter.outputs.desktop }} run: | set -euo pipefail case "$CI_SCOPE" in @@ -353,6 +369,15 @@ jobs: exit 1 ;; esac + case "$DESKTOP_SCOPE" in + true|false) + printf 'desktop=%s\n' "$DESKTOP_SCOPE" >> "$GITHUB_OUTPUT" + ;; + *) + printf '::error::changes.outputs.desktop was %q, expected true or false\n' "$DESKTOP_SCOPE" + exit 1 + ;; + esac - name: Assert the native and matrix outputs are usable id: matrices @@ -1345,11 +1370,11 @@ jobs: desktop-shell: name: desktop shell needs: [changes, gates] - # Native-gated like platform-macos: the Rust shell is formatted, linted - # and tested only when native-capable paths changed. - if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.native == 'true') + # Native shell changes run the Rust checks; package-affecting changes also run the real Linux + # bundle acceptance. The aggregate gate below mirrors this union exactly. + if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && (needs.changes.outputs.native == 'true' || needs.changes.outputs.desktop == 'true')) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -1359,7 +1384,11 @@ jobs: - name: Install Tauri Linux dependencies run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf dbus-x11 xvfb xauth wmctrl xdotool openbox + + - name: Setup Bun for packaged E2E + if: needs.changes.outputs.desktop == 'true' + uses: ./.github/actions/setup-project-bun - name: Setup Rust uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master @@ -1385,6 +1414,79 @@ jobs: - name: Run Rust tests run: cargo test --manifest-path desktop/src-tauri/Cargo.toml + - name: Install packaged E2E dependencies + if: needs.changes.outputs.desktop == 'true' + run: | + bun install --frozen-lockfile + cd desktop + bun install --frozen-lockfile + + - name: Build dashboard and bundled sidecar + if: needs.changes.outputs.desktop == 'true' + run: | + bun run build:gui + bun desktop/scripts/prepare-sidecar.ts --target x86_64-unknown-linux-gnu + + # Build separately. One format failing must not delete or hide the other + # format's evidence, and neither verification artifact needs an updater key. + - name: Preserve the compiled Linux sidecar + if: needs.changes.outputs.desktop == 'true' + run: chmod +x desktop/scripts/appimage-patchelf.py + + - name: Build Linux AppImage + if: needs.changes.outputs.desktop == 'true' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-appimage-target + PATCHELF: ${{ github.workspace }}/desktop/scripts/appimage-patchelf.py + run: bunx tauri build --ci --bundles appimage --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Build Linux deb + if: needs.changes.outputs.desktop == 'true' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-deb-target + run: bunx tauri build --ci --bundles deb --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Stage isolated Linux bundles + if: needs.changes.outputs.desktop == 'true' + env: + APPIMAGE_BUNDLE: ${{ runner.temp }}/opencodex-appimage-target/release/bundle/appimage + DEB_BUNDLE: ${{ runner.temp }}/opencodex-deb-target/release/bundle/deb + BUNDLE_ROOT: ${{ runner.temp }}/opencodex-linux-bundles + run: | + set -euo pipefail + mkdir -p "$BUNDLE_ROOT/appimage" "$BUNDLE_ROOT/deb" + cp -a "$APPIMAGE_BUNDLE/." "$BUNDLE_ROOT/appimage/" + cp -a "$DEB_BUNDLE/." "$BUNDLE_ROOT/deb/" + chmod -R a-w "$BUNDLE_ROOT" + + - name: Run Linux packaged-shell E2E + if: needs.changes.outputs.desktop == 'true' + env: + REPORT_PATH: ${{ runner.temp }}/opencodex-linux-e2e/report.json + run: | + set -euo pipefail + mkdir -p "$(dirname "$REPORT_PATH")" + dbus-run-session -- xvfb-run -a -s '-screen 0 1440x900x24' bash -lc ' + openbox >"$RUNNER_TEMP/opencodex-openbox.log" 2>&1 & + wm_pid=$! + trap '\''kill "$wm_pid" 2>/dev/null || true'\'' EXIT + bun desktop/scripts/linux-packaged-e2e.ts \ + --bundle-root "$RUNNER_TEMP/opencodex-linux-bundles" \ + --report "$REPORT_PATH" \ + --version "$(jq -r .version package.json)" + ' + + - name: Upload Linux packaged-shell E2E report + if: always() && needs.changes.outputs.desktop == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-packaged-shell-e2e + path: ${{ runner.temp }}/opencodex-linux-e2e/report.json + if-no-files-found: warn + retention-days: 7 + ci: name: ci if: always() @@ -1414,6 +1516,7 @@ jobs: CHANGES_SETUP_ACTION: ${{ needs.changes.outputs.setup_action }} CHANGES_REMOTE_HELPER: ${{ needs.changes.outputs.remote_helper }} CHANGES_NATIVE: ${{ needs.changes.outputs.native }} + CHANGES_DESKTOP: ${{ needs.changes.outputs.desktop }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -1434,8 +1537,8 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_CI" != "true" ]; then scoped=not-requested fi - # platform-macos, widget and desktop-shell carry a compound - # condition: the ordinary scope gate AND the native path filter. + # platform-macos and widget carry the ordinary scope gate AND the native path filter. + # desktop-shell accepts that native set plus the package-E2E set. # This mirrors that expression exactly; where it disagrees with the # jobs' own `if:`, the gate fails by name instead of demanding # success from a job that was deliberately left unselected. @@ -1443,6 +1546,10 @@ jobs: if [ "$EVENT_NAME" != "pull_request" ] || { [ "$CHANGES_CI" = "true" ] && [ "$CHANGES_NATIVE" = "true" ]; }; then native=requested fi + desktop_shell=not-requested + if [ "$EVENT_NAME" != "pull_request" ] || { [ "$CHANGES_CI" = "true" ] && { [ "$CHANGES_NATIVE" = "true" ] || [ "$CHANGES_DESKTOP" = "true" ]; }; }; then + desktop_shell=requested + fi packaging=not-requested if [ "$CHANGES_PACKAGING" = "true" ]; then packaging=requested @@ -1499,8 +1606,9 @@ jobs: changes|select-windows-runner) echo requested ;; test|storage-policy|api-usage|gates|keyring-smoke|docker-smoke) echo "$scoped" ;; - platform-macos|widget|desktop-shell) + platform-macos|widget) echo "$native" ;; + desktop-shell) echo "$desktop_shell" ;; npm-global-smoke) echo "$packaging" ;; docs-site-build) echo "$docs" ;; structure-gate) echo "$structure" ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 498fa9246e7..8857697a6a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -414,6 +414,7 @@ jobs: # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. - name: Build desktop bundles + if: runner.os != 'Linux' working-directory: desktop env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -429,9 +430,48 @@ jobs: # diagnostics on the first attempt; Apple signing commands stay non-verbose. run: bunx tauri ${{ runner.os == 'Linux' && '--verbose' || '' }} build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config "${{ runner.os == 'Windows' && format('{0}/opencodex-msi.json', runner.temp) || '{}' }}" + # Tauri patches a bundle-type marker into the application binary for each Linux format. + # Keep each format in its own Cargo target so the deb cannot inherit the AppImage marker + # and linuxdeploy cannot mutate the binary later consumed by the deb build. + - name: Build Linux AppImage bundle + if: runner.os == 'Linux' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-appimage-target + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles appimage + + - name: Build Linux deb bundle + if: runner.os == 'Linux' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-deb-target + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles deb + + - name: Stage isolated Linux release bundles + if: runner.os == 'Linux' + shell: bash + env: + DESKTOP_TARGET: ${{ matrix.target }} + APPIMAGE_TARGET: ${{ runner.temp }}/opencodex-appimage-target + DEB_TARGET: ${{ runner.temp }}/opencodex-deb-target + run: | + set -euo pipefail + bundle_root="$RUNNER_TEMP/opencodex-linux-release-bundles" + mkdir -p "$bundle_root/appimage" "$bundle_root/deb" + cp -a "$APPIMAGE_TARGET/$DESKTOP_TARGET/release/bundle/appimage/." "$bundle_root/appimage/" + cp -a "$DEB_TARGET/$DESKTOP_TARGET/release/bundle/deb/." "$bundle_root/deb/" + chmod -R a-w "$bundle_root" + echo "DESKTOP_BUNDLE_ROOT=$bundle_root" >> "$GITHUB_ENV" + + # After the isolated AppImage exists, and against that staged copy: the default Cargo target + # holds no Linux bundle any more, so verifying there would fail or check a stale artifact. - name: Verify the packaged Linux sidecar if: runner.os == 'Linux' - run: bash desktop/scripts/verify-linux-sidecar.sh + run: bash desktop/scripts/verify-linux-sidecar.sh "$DESKTOP_BUNDLE_ROOT/appimage" - name: Rename release assets shell: bash @@ -439,10 +479,15 @@ jobs: RELEASE_VERSION: ${{ inputs.version }} DESKTOP_TARGET: ${{ matrix.target }} run: | - bun desktop/scripts/collect-release-assets.ts \ + args=( \ --version "$RELEASE_VERSION" \ --target "$DESKTOP_TARGET" \ - --out dist/release + --out dist/release \ + ) + if [[ -n "${DESKTOP_BUNDLE_ROOT:-}" ]]; then + args+=(--bundle-root "$DESKTOP_BUNDLE_ROOT") + fi + bun desktop/scripts/collect-release-assets.ts "${args[@]}" # After the bundle exists, not before: a sweep that runs first passes by finding nothing. - name: Verify every Mach-O in the bundle carries the release identity diff --git a/README.md b/README.md index 3e994459a57..f663fb97072 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,23 @@ Two commands, and every one of them runs any LLM you point it at.

Follow @claudeebum on X - Latest desktop release npm version license node version

-

- Download OpenCodex for macOS - Download OpenCodex for Windows - Download OpenCodex for Linux -

-

Desktop app (beta): macOS universal .dmg · Windows x64 .msi · Linux x86_64 .AppImage / .deb. Prefer the terminal? Install the CLI:

- ```bash npm install -g @bitkyc08/opencodex ocx start ``` +

+ Download for macOS (.dmg) + Download for Windows (.msi) + Download for Linux (.AppImage) + Download for Linux (.deb) +

+
@@ -90,7 +89,21 @@ account while existing threads stay pinned to the account that started them. ## Quick start -### Desktop app (beta) +### Personal install (CLI) + +```bash +npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically +ocx start # proxy + dashboard on localhost:10100 +``` + +Use `ocx service` to run it in the background. + +Open **http://localhost:10100** and configure everything in the web dashboard — add providers +(40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` +re-opens the dashboard at any time. + +
+Desktop app (beta) The desktop app is the same proxy and dashboard in a native window, with a tray and bundled `ocx`. It attaches to a proxy that is already running, or starts its bundled one, and the dashboard stays @@ -113,18 +126,7 @@ step needs macOS). The [Desktop App guide](https://opencodex.me/guides/desktop-a [macOS Menu Bar App guide](https://opencodex.me/guides/macos-menu-bar/) cover first launch, and [`AGENTS_INSTALL.md`](./AGENTS_INSTALL.md#where-things-are-installed) lists everything written to disk. -### Personal install (CLI) - -```bash -npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically -ocx start # proxy + dashboard on localhost:10100 -``` - -Use `ocx service` to run it in the background. - -Open **http://localhost:10100** and configure everything in the web dashboard — add providers -(40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` -re-opens the dashboard at any time. +
### ChatGPT account pool @@ -306,14 +308,15 @@ see the [installation docs](https://opencodex.me/getting-started/installation/).
Memory ownership details -OpenCodex tracks 36 categories of process-retained state. Each has a documented bound: +OpenCodex tracks process-retained state in the categories below. Each has a documented bound: -- **12 retained stores** (request log, debug rings, image cache, model cache, vision +- **14 retained stores** (request log, debug rings, image cache, model cache, vision descriptions, cursor blobs, responses continuation, etc.) are byte-accounted and - evicted by the app-owned memory budget (default 256 MiB). + evicted by the app-owned memory budget (default 256 MiB), except the native control replay + store, which is pinned and never evicted. - **4 observed buffers** (translator accumulators, image/OAuth/Grok tails) are monitored for in-flight byte pressure without eviction. -- **24 state-store registrations** handle expiry sweeps (60 s interval) and +- **28 state-store registrations** handle expiry sweeps (60 s interval) and config-generation reconciliation so stale provider/account keys are removed. - **Path and fingerprint memos** (workspace metadata, hardened identities, installation salts, mode-hint capabilities) use insertion-order LRU caps (8–128 entries). diff --git a/app/Sources/NativeTray/Popover.swift b/app/Sources/NativeTray/Popover.swift index 5659aa858ad..e3d930af7c2 100644 --- a/app/Sources/NativeTray/Popover.swift +++ b/app/Sources/NativeTray/Popover.swift @@ -33,6 +33,78 @@ private final class NativeTrayPopover: NSObject { } +@MainActor +private final class UpdateDotView: NSView { + weak var statusButton: NSStatusBarButton? + + init(button: NSStatusBarButton) { + statusButton = button + super.init(frame: button.bounds) + autoresizingMask = [.width, .height] + // AppKit keeps the template image and its highlighted tint. This view draws only + // the independent accent, without making the status button layer-backed. + wantsLayer = false + } + + required init?(coder: NSCoder) { nil } + override var isOpaque: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func layout() { + super.layout() + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + guard let button = statusButton else { return } + let imageRect = button.cell?.imageRect(forBounds: button.bounds) ?? button.bounds + let image = imageRect.isEmpty ? button.bounds : imageRect + let diameter: CGFloat = 7 + let dot = NSRect(x: min(bounds.maxX - diameter, image.maxX - 4), + y: max(bounds.minY, image.minY + 1), + width: diameter, height: diameter) + NSColor.windowBackgroundColor.setFill() + NSBezierPath(ovalIn: dot.insetBy(dx: -1.25, dy: -1.25)).fill() + NSColor(calibratedRed: 0.18, green: 0.48, blue: 0.97, alpha: 1).setFill() + NSBezierPath(ovalIn: dot).fill() + } +} + +@MainActor +private enum UpdateDot { + static weak var button: NSStatusBarButton? + static var view: UpdateDotView? + + static func set(_ item: NSStatusItem, visible: Bool) { + guard let next = item.button else { return } + if button !== next { + view?.removeFromSuperview() + view = nil + button = next + } + guard visible else { + view?.removeFromSuperview() + view = nil + return + } + if view == nil { + let overlay = UpdateDotView(button: next) + next.addSubview(overlay) + view = overlay + } + view?.frame = next.bounds + view?.needsDisplay = true + } +} + +@_cdecl("ocx_native_tray_update_dot") +@MainActor +public func nativeTrayUpdateDot(_ item: UnsafeMutableRawPointer?, _ show: Int32) { + guard Thread.isMainThread, let item else { return } + let statusItem = Unmanaged.fromOpaque(item).takeUnretainedValue() + UpdateDot.set(statusItem, visible: show != 0) +} + @_cdecl("ocx_native_tray_show") @MainActor public func nativeTrayShow(_ item: UnsafeMutableRawPointer?, _ toggle: Int32, _ callback: @escaping @convention(c) (Int32) -> Void) { diff --git a/assets/download-linux.svg b/assets/download-linux.svg deleted file mode 100644 index b2555c9a92f..00000000000 --- a/assets/download-linux.svg +++ /dev/null @@ -1,10 +0,0 @@ - - Download OpenCodex for Linux - - - - Download for - Linux - - .AppImage - diff --git a/assets/download-macos.svg b/assets/download-macos.svg deleted file mode 100644 index db9427bed9f..00000000000 --- a/assets/download-macos.svg +++ /dev/null @@ -1,10 +0,0 @@ - - Download OpenCodex for macOS - - - - Download for - macOS - - .dmg - diff --git a/assets/download-windows.svg b/assets/download-windows.svg deleted file mode 100644 index 28e2b1c6455..00000000000 --- a/assets/download-windows.svg +++ /dev/null @@ -1,10 +0,0 @@ - - Download OpenCodex for Windows - - - - Download for - Windows - - .msi - diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 55729b02c3f..c7f847542d7 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -36,7 +36,7 @@ import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; -import { detectInstallFromPath } from "../src/update/install-detection.mjs"; +import { detectInstallOwnershipFromPath } from "../src/update/install-detection.mjs"; import { pnpmOwnerInvocation, resolvePnpmGlobalOwner, @@ -71,7 +71,8 @@ try { } const require = createRequire(import.meta.url); const here = dirname(fileURLToPath(import.meta.url)); -const installMethod = detectInstallFromPath(here, { exists: existsSync }); +const installOwnership = detectInstallOwnershipFromPath(here, { exists: existsSync }); +const installMethod = installOwnership.installer; const cliPath = join(here, "..", "src", "cli", "index.ts"); const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT"; const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof="; @@ -924,6 +925,19 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") { process.exit(1); } +if (process.argv[2] === "update" && installMethod === "mise") { + if (installOwnership.owner) { + console.error( + `opencodex: this installation is externally managed by mise; update it with: mise upgrade ${installOwnership.owner.tool}`, + ); + } else { + console.error( + "opencodex: this installation appears to be managed by mise, but its ownership metadata is unreadable or inconsistent; repair the mise installation metadata before updating.", + ); + } + process.exit(1); +} + if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) { if (installMethod === "npm") runNpmSelfUpdate(); if (installMethod === "pnpm") runPnpmSelfUpdate(); diff --git a/desktop/package.json b/desktop/package.json index 5966c9235f2..7e469873090 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,6 +5,7 @@ "dev": "tauri dev", "build": "tauri build", "build:local": "bun scripts/build-local.ts", + "e2e:linux-packaged": "bun scripts/linux-packaged-e2e.ts", "icons": "bun scripts/generate-icons.ts", "icons:check": "bun scripts/generate-icons.ts --check", "prepare-sidecar": "bun scripts/prepare-sidecar.ts", diff --git a/desktop/scripts/appimage-patchelf.py b/desktop/scripts/appimage-patchelf.py index 4c7e73cd930..63e78ef7416 100644 --- a/desktop/scripts/appimage-patchelf.py +++ b/desktop/scripts/appimage-patchelf.py @@ -5,17 +5,51 @@ import sys +APPDIR_SIDECAR_TAIL = ( + "release", + "bundle", + "appimage", + "OpenCodex.AppDir", + "usr", + "bin", + "ocx", +) + + +def prepared_sidecar(root, candidate, target_root): + """Return the one prepared Linux CLI that the AppDir sidecar exactly mirrors.""" + try: + relative = candidate.resolve().relative_to(target_root.resolve()) + except ValueError: + return None + if tuple(relative.parts[-len(APPDIR_SIDECAR_TAIL):]) != APPDIR_SIDECAR_TAIL: + return None + prefix = relative.parts[:-len(APPDIR_SIDECAR_TAIL)] + if len(prefix) > 1: + return None + + binaries = root / "desktop/src-tauri/binaries" + candidates = sorted(path for path in binaries.glob("ocx-*-linux-gnu") if path.is_file()) + if prefix: + candidates = [path for path in candidates if path.name == f"ocx-{prefix[0]}"] + matches = [path for path in candidates if path.read_bytes() == candidate.read_bytes()] + return matches[0] if len(matches) == 1 else None + + def main(args): root = Path(__file__).resolve().parents[2] - triple = "x86_64-unknown-linux-gnu" - original = root / "desktop/src-tauri/binaries" / f"ocx-{triple}" - sidecar = root / "desktop/src-tauri/target" / triple / "release/bundle/appimage/OpenCodex.AppDir/usr/bin/ocx" - if len(args) == 3 and args[:2] == ["--set-rpath", "$ORIGIN/../lib"] and Path(args[2]).resolve() == sidecar.resolve(): + target_root = Path(os.environ.get("CARGO_TARGET_DIR", root / "desktop/src-tauri/target")) + sidecar = Path(args[2]) if len(args) == 3 else None + if ( + sidecar is not None + and args[:2] == ["--set-rpath", "$ORIGIN/../lib"] + and prepared_sidecar(root, sidecar, target_root) is not None + ): # linuxdeploy's nested GTK pass runs ldd again after patching. Its # patchelf rewrite breaks the compiled Bun ELF. This sidecar depends # only on host glibc libraries; it needs no AppDir library search path. # Never bless an already-modified binary or a different executable. - if sidecar.is_symlink() or original.read_bytes() != sidecar.read_bytes(): + if sidecar.is_symlink(): raise RuntimeError("AppImage sidecar differs from the prepared CLI") print("Preserving compiled ocx bytes (no AppDir RPATH required)", file=sys.stderr) return diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts index 2c97de39d44..1d1266283e0 100644 --- a/desktop/scripts/collect-release-assets.ts +++ b/desktop/scripts/collect-release-assets.ts @@ -42,6 +42,7 @@ export interface CollectReleaseAssetsOptions { target: string; out: string; repoRoot?: string; + bundleRoot?: string; } function findBundle(directory: string, kind: BundleKind): string { @@ -61,13 +62,17 @@ export function collectReleaseAssets(options: CollectReleaseAssetsOptions): stri const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../..")); const bundles = bundlesByTarget[options.target]; if (!bundles) throw new Error(`Unsupported desktop target: ${options.target}`); + const bundleRoot = resolve( + options.bundleRoot + ?? join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle"), + ); const output = resolve(options.out); mkdirSync(output, { recursive: true }); const written: string[] = []; for (const bundle of bundles) { const source = findBundle( - join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle", bundle.dir), + join(bundleRoot, bundle.dir), bundle.kind, ); const destinationName = `OpenCodex-${options.version}-${bundle.name}`; @@ -98,8 +103,11 @@ if (import.meta.main) { const version = argument("--version"); const target = argument("--target"); const out = argument("--out"); + const bundleRoot = argument("--bundle-root"); if (!version || !target || !out) { throw new Error("Usage: collect-release-assets.ts --version --target --out "); } - for (const path of collectReleaseAssets({ version, target, out })) console.log(`Wrote ${path}`); + const options: CollectReleaseAssetsOptions = { version, target, out }; + if (bundleRoot) options.bundleRoot = bundleRoot; + for (const path of collectReleaseAssets(options)) console.log(`Wrote ${path}`); } diff --git a/desktop/scripts/generate-icons.ts b/desktop/scripts/generate-icons.ts index 3acfdc8bb92..6ce6a00c264 100644 --- a/desktop/scripts/generate-icons.ts +++ b/desktop/scripts/generate-icons.ts @@ -70,6 +70,17 @@ const ICO_SIZES = [16, 32, 48, 64, 128, 256]; const TRAY_OUTPUT = "tray/icon.png"; const TRAY_SIZE = 44; const traySource = join(iconsDir, "tray", "icon.svg"); +const DOTTED_TRAY_OUTPUT = "tray/icon-update.png"; +const DOTTED_TRAY_SVG = ''; + +function renderDottedTray(target: string): void { + const dottedSvg = join(target, ".tray-update.svg"); + const base = readFileSync(traySource, "utf8"); + if (!base.includes("")) throw new Error("tray icon source is not SVG"); + writeFileSync(dottedSvg, base.replace("", DOTTED_TRAY_SVG + "")); + try { render(TRAY_SIZE, join(target, DOTTED_TRAY_OUTPUT), dottedSvg); } + finally { rmSync(dottedSvg, { force: true }); } +} /** Render at `size` from `from`, defaulting to the app icon vector. */ function render(size: number, out: string, from: string = source): void { @@ -112,6 +123,8 @@ function generateInto(target: string): { produced: string[]; icnsSkipped: boolea mkdirSync(join(target, "tray"), { recursive: true }); render(TRAY_SIZE, join(target, TRAY_OUTPUT), traySource); produced.push(TRAY_OUTPUT); + renderDottedTray(target); + produced.push(DOTTED_TRAY_OUTPUT); return { produced, icnsSkipped }; } diff --git a/desktop/scripts/linux-packaged-e2e.ts b/desktop/scripts/linux-packaged-e2e.ts new file mode 100644 index 00000000000..9ca92352668 --- /dev/null +++ b/desktop/scripts/linux-packaged-e2e.ts @@ -0,0 +1,566 @@ +#!/usr/bin/env bun +/** + * Hosted Linux packaged-shell acceptance. + * + * This is deliberately narrower than installed-gate.ts. It extracts, rather than + * installs, the AppImage and deb payloads so a hosted runner never mutates its package + * database or the runner account's real OpenCodex home. What it proves is the common + * packaged path: the real application executable and bundled resources can show a + * window in a session with no tray host, start their bundled sidecar, identify that + * runtime, and drain both processes when the only window closes. + * + * Real dpkg/AppImage installation, elevation, takeover, and in-place updates remain the + * responsibility of installed-gate.ts on an approved disposable GUI runner. + */ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + closeSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { createServer } from "node:net"; + +export type LinuxBundleFormat = "appimage" | "deb"; + +export interface LinuxE2eOptions { + bundleRoot: string; + reportPath: string; + version: string; +} + +export interface BundleArtifacts { + appimage: string; + deb: string; +} + +interface RuntimeRecord { + pid: number; + port: number; +} + +interface HealthObservation { + status: number; + body: Record; +} + +interface ReservedLoopbackPort { + port: number; + release: () => Promise; +} + +interface FormatReport { + format: LinuxBundleFormat; + artifact: string; + ok: boolean; + durationMs: number; + windowId?: string; + appPid?: number; + appExitCode?: number | null; + appExitSignal?: string | null; + runtimePid?: number; + runtimeVersion?: string; + configuredPort?: number; + readyMs?: number; + processTreeRssKiB?: number; + error?: string; + stdoutTail?: string[]; + stderrTail?: string[]; +} + +interface AcceptanceReport { + schema: "opencodex-linux-packaged-e2e/1"; + version: string; + startedAt: string; + finishedAt: string; + ok: boolean; + formats: FormatReport[]; +} + +const READY_DEADLINE_MS = 45_000; +const EXIT_DEADLINE_MS = 30_000; +const POLL_MS = 200; +const LOG_TAIL_LINES = 80; +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +function argument(argv: string[], name: string): string | undefined { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : undefined; +} + +export function parseArguments(argv: string[]): LinuxE2eOptions { + const bundleRoot = argument(argv, "--bundle-root"); + const reportPath = argument(argv, "--report"); + const version = argument(argv, "--version"); + if (!bundleRoot || !reportPath || !version) { + throw new Error("--bundle-root, --report and --version are required"); + } + if (!VERSION.test(version)) throw new Error("--version must be a strict semver"); + return { + bundleRoot: resolve(bundleRoot), + reportPath: resolve(reportPath), + version, + }; +} + +function files(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory) + .map(name => join(directory, name)) + .filter(path => statSync(path).isFile()); +} + +function exactlyOne(paths: string[], label: string): string { + if (paths.length !== 1) { + throw new Error(`expected exactly one ${label}, found ${paths.length}`); + } + return paths[0]!; +} + +export function locateArtifacts(bundleRoot: string): BundleArtifacts { + return { + appimage: exactlyOne( + files(join(bundleRoot, "appimage")).filter(path => path.endsWith(".AppImage")), + "AppImage", + ), + deb: exactlyOne( + files(join(bundleRoot, "deb")).filter(path => path.endsWith(".deb")), + "deb", + ), + }; +} + +function command( + file: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): void { + const result = spawnSync(file, args, { + cwd: options.cwd, + env: options.env, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "no output").trim(); + throw new Error(`${basename(file)} exited ${result.status ?? "without a status"}: ${detail}`); + } +} + +function executableFiles(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory) + .map(name => join(directory, name)) + .filter(path => { + const stat = statSync(path); + return stat.isFile() && (stat.mode & 0o111) !== 0; + }); +} + +export function extractedExecutable( + format: LinuxBundleFormat, + artifact: string, + destination: string, +): string { + mkdirSync(destination, { recursive: true }); + if (format === "appimage") { + command(artifact, ["--appimage-extract"], { cwd: destination }); + const appRun = join(destination, "squashfs-root", "AppRun"); + if (!existsSync(appRun)) throw new Error("AppImage extraction did not produce AppRun"); + return appRun; + } + + command("dpkg-deb", ["--extract", artifact, destination]); + const candidates = executableFiles(join(destination, "usr", "bin")); + return selectDebExecutable(candidates); +} + +export function selectDebExecutable(candidates: string[]): string { + // The package contains the desktop host and its `ocx` sidecar. The sidecar is deliberately + // executable, but it is not the process whose WebView/window lifecycle this acceptance owns. + return exactlyOne( + candidates.filter(candidate => basename(candidate) !== "ocx"), + "deb desktop executable under usr/bin", + ); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function reserveLoopbackPort(): Promise { + return await new Promise((resolvePort, reject) => { + const server = createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("could not reserve a temporary loopback port")); + return; + } + let released = false; + resolvePort({ + port: address.port, + release: async () => { + if (released) return; + released = true; + await new Promise((resolveClose, rejectClose) => { + server.close(error => error ? rejectClose(error) : resolveClose()); + }); + }, + }); + }); + }); +} + +async function waitFor(read: () => T | undefined | Promise, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await read(); + if (value !== undefined) return value; + await sleep(POLL_MS); + } + throw new Error(`condition did not settle within ${timeoutMs}ms`); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +export function readRuntimeRecord(path: string): RuntimeRecord | undefined { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record; + const pid = positiveInteger(parsed.pid); + const port = positiveInteger(parsed.port); + if (pid === undefined || port === undefined || port > 65_535) return undefined; + return { pid, port }; + } catch { + return undefined; + } +} + +export function assertRuntimeRecordPort(record: RuntimeRecord, configuredPort: number): RuntimeRecord { + if (record.port !== configuredPort) { + throw new Error( + `packaged runtime recorded port ${record.port}, expected isolated port ${configuredPort}`, + ); + } + return record; +} + +function processAlive(pid: number | undefined): boolean { + if (pid === undefined) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return typeof error === "object" && error !== null && "code" in error && error.code === "EPERM"; + } +} + +function processRows(): Array<{ pid: number; ppid: number; rssKiB: number }> { + const result = spawnSync("ps", ["-e", "-o", "pid=,ppid=,rss="], { encoding: "utf8" }); + if (result.status !== 0) return []; + return result.stdout + .trim() + .split(/\r?\n/u) + .map(line => line.trim().split(/\s+/u).map(Number)) + .filter(parts => parts.length === 3 && parts.every(Number.isFinite)) + .map(parts => ({ pid: parts[0]!, ppid: parts[1]!, rssKiB: parts[2]! })); +} + +export interface AppExit { + code: number | null; + signal: string | null; +} + +/** + * The close request goes through the window manager (EWMH _NET_CLOSE_WINDOW), the same path a + * person's close button takes. xdotool's windowclose destroys the X window instead, which can end + * the process without ever running Tauri's close/drain handling and still look like a clean exit. + */ +export function windowManagerCloseArgs(windowId: string): string[] { + const id = Number(windowId); + if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`invalid X11 window id: ${windowId}`); + return ["-i", "-c", `0x${id.toString(16)}`]; +} + +/** A graceful close exits 0 on its own; a signal or a nonzero code is a crash, not a drain. */ +export function assertCleanExit(exit: AppExit | undefined): AppExit { + if (!exit) throw new Error("desktop app did not exit after the close request"); + if (exit.signal !== null || exit.code !== 0) { + throw new Error(`desktop app exited with code ${exit.code ?? "none"} and signal ${exit.signal ?? "none"} instead of a clean close`); + } + return exit; +} + +export function processTreeRssKiB(rootPid: number, rows = processRows()): number { + const selected = new Set([rootPid]); + let changed = true; + while (changed) { + changed = false; + for (const row of rows) { + if (selected.has(row.ppid) && !selected.has(row.pid)) { + selected.add(row.pid); + changed = true; + } + } + } + return rows.filter(row => selected.has(row.pid)).reduce((sum, row) => sum + row.rssKiB, 0); +} + +function xdotoolWindow(): string | undefined { + // WebKit exposes an auxiliary `opencodex-desktop` X11 window before the titled top-level + // `OpenCodex` window. A loose match selected that helper and `windowclose` merely destroyed the + // web process surface, never exercising Tauri's close/drain path. + const result = spawnSync( + "xdotool", + ["search", "--onlyvisible", "--name", "^OpenCodex$"], + { encoding: "utf8" }, + ); + if (result.status !== 0) return undefined; + return result.stdout.trim().split(/\r?\n/u).find(Boolean); +} + +async function health(record: RuntimeRecord): Promise { + try { + const response = await fetch(`http://127.0.0.1:${record.port}/healthz`, { + signal: AbortSignal.timeout(1_000), + cache: "no-store", + }); + const body = await response.json(); + return typeof body === "object" && body !== null + ? { status: response.status, body: body as Record } + : undefined; + } catch { + return undefined; + } +} + +function tail(path: string): string[] { + try { + return readFileSync(path, "utf8").split(/\r?\n/u).filter(Boolean).slice(-LOG_TAIL_LINES); + } catch { + return []; + } +} + +async function stopGroup(child: ChildProcess): Promise { + if (!child.pid || !processAlive(child.pid)) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + try { + await waitFor(() => processAlive(child.pid) ? undefined : true, 5_000); + return; + } catch { + // Escalate only inside the detached process group this test created. + } + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } +} + +async function runFormat( + format: LinuxBundleFormat, + artifact: string, + version: string, + root: string, +): Promise { + const started = Date.now(); + const directory = join(root, format); + const extracted = join(directory, "payload"); + const home = join(directory, "home"); + const opencodexHome = join(home, ".opencodex"); + const codexHome = join(home, ".codex"); + const configHome = join(home, ".config"); + const cacheHome = join(home, ".cache"); + const dataHome = join(home, ".local", "share"); + for (const path of [home, opencodexHome, codexHome, configHome, cacheHome, dataHome]) { + mkdirSync(path, { recursive: true, mode: 0o700 }); + } + const stdoutPath = join(directory, "stdout.log"); + const stderrPath = join(directory, "stderr.log"); + mkdirSync(directory, { recursive: true }); + const stdout = openSync(stdoutPath, "w", 0o600); + const stderr = openSync(stderrPath, "w", 0o600); + let child: ChildProcess | undefined; + let runtimePid: number | undefined; + let configuredPort: number | undefined; + let reservedPort: ReservedLoopbackPort | undefined; + try { + const executable = extractedExecutable(format, artifact, extracted); + reservedPort = await reserveLoopbackPort(); + configuredPort = reservedPort.port; + writeFileSync( + join(opencodexHome, "config.json"), + `${JSON.stringify({ port: configuredPort }, null, 2)}\n`, + { mode: 0o600 }, + ); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: configHome, + XDG_CACHE_HOME: cacheHome, + XDG_DATA_HOME: dataHome, + OPENCODEX_HOME: opencodexHome, + CODEX_HOME: codexHome, + NO_PROXY: "127.0.0.1,localhost", + no_proxy: "127.0.0.1,localhost", + WEBKIT_DISABLE_COMPOSITING_MODE: "1", + }; + // Hold the listener while preparing the isolated home so no unrelated process can claim the + // selected port. Release it only at the spawn boundary; the packaged runtime can then bind it. + await reservedPort.release(); + reservedPort = undefined; + child = spawn(executable, [], { + cwd: dirname(executable), + env, + detached: true, + stdio: ["ignore", stdout, stderr], + }); + if (!child.pid) throw new Error("desktop app did not report a pid"); + const appPid = child.pid; + let appExit: AppExit | undefined; + child.once("exit", (code, signal) => { + appExit = { code, signal }; + }); + const windowId = await waitFor(xdotoolWindow, READY_DEADLINE_MS); + const recordPath = join(opencodexHome, "runtime-port.json"); + const record = assertRuntimeRecordPort( + await waitFor(() => readRuntimeRecord(recordPath), READY_DEADLINE_MS), + configuredPort, + ); + runtimePid = record.pid; + let lastHealth: HealthObservation | undefined; + let ready: Record; + try { + ready = await waitFor(async () => { + const observed = await health(record); + if (!observed) return undefined; + lastHealth = observed; + const body = observed.body; + return observed.status >= 200 && observed.status < 300 + && body.service === "opencodex" + && body.pid === record.pid + && body.port === record.port + && body.version === version + ? body + : undefined; + }, READY_DEADLINE_MS); + } catch { + const observed = lastHealth + ? `status ${lastHealth.status}, body ${JSON.stringify(lastHealth.body)}` + : "no readable /healthz response"; + throw new Error(`packaged runtime health identity did not become ready (${observed})`); + } + const readyMs = Date.now() - started; + const rssKiB = processTreeRssKiB(appPid); + + command("wmctrl", windowManagerCloseArgs(windowId)); + await waitFor( + () => appExit && !processAlive(runtimePid) ? true : undefined, + EXIT_DEADLINE_MS, + ); + const exit = assertCleanExit(appExit); + return { + format, + artifact: basename(artifact), + ok: true, + durationMs: Date.now() - started, + windowId, + appPid, + appExitCode: exit.code, + appExitSignal: exit.signal, + runtimePid, + runtimeVersion: typeof ready.version === "string" ? ready.version : undefined, + configuredPort, + readyMs, + processTreeRssKiB: rssKiB, + stdoutTail: tail(stdoutPath), + stderrTail: tail(stderrPath), + }; + } catch (error) { + return { + format, + artifact: basename(artifact), + ok: false, + durationMs: Date.now() - started, + ...(child?.pid ? { appPid: child.pid } : {}), + ...(runtimePid ? { runtimePid } : {}), + ...(configuredPort ? { configuredPort } : {}), + error: error instanceof Error ? error.message : String(error), + stdoutTail: tail(stdoutPath), + stderrTail: tail(stderrPath), + }; + } finally { + await reservedPort?.release(); + if (child) await stopGroup(child); + closeSync(stdout); + closeSync(stderr); + } +} + +export async function runAcceptance(options: LinuxE2eOptions): Promise { + if (process.platform !== "linux") throw new Error("Linux packaged E2E runs only on Linux"); + for (const dependency of ["dpkg-deb", "ps", "wmctrl", "xdotool"]) { + const probe = spawnSync("sh", ["-c", `command -v ${dependency}`]); + if (probe.status !== 0) throw new Error(`missing required command: ${dependency}`); + } + if (!process.env.DISPLAY) throw new Error("DISPLAY is required; run under Xvfb"); + + const artifacts = locateArtifacts(options.bundleRoot); + const root = mkdtempSync(join(tmpdir(), "opencodex-linux-e2e-")); + const startedAt = new Date().toISOString(); + let formats: FormatReport[] = []; + try { + formats = [ + await runFormat("appimage", artifacts.appimage, options.version, root), + await runFormat("deb", artifacts.deb, options.version, root), + ]; + } finally { + const report: AcceptanceReport = { + schema: "opencodex-linux-packaged-e2e/1", + version: options.version, + startedAt, + finishedAt: new Date().toISOString(), + ok: formats.length === 2 && formats.every(format => format.ok), + formats, + }; + mkdirSync(dirname(options.reportPath), { recursive: true }); + writeFileSync(options.reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); + rmSync(root, { recursive: true, force: true }); + } + return JSON.parse(readFileSync(options.reportPath, "utf8")) as AcceptanceReport; +} + +async function main(): Promise { + const options = parseArguments(process.argv.slice(2)); + const report = await runAcceptance(options); + for (const format of report.formats) { + console.log(`${format.ok ? "PASS" : "FAIL"} ${format.format}: ${format.error ?? `${format.readyMs}ms ready, ${format.processTreeRssKiB} KiB RSS`}`); + } + process.exitCode = report.ok ? 0 : 1; +} + +if (import.meta.main) { + main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/desktop/scripts/prepare-sidecar.ts b/desktop/scripts/prepare-sidecar.ts index 502127870dc..2403e130933 100644 --- a/desktop/scripts/prepare-sidecar.ts +++ b/desktop/scripts/prepare-sidecar.ts @@ -1,5 +1,6 @@ import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs"; import { join, resolve } from "node:path"; +import { adHocSignSidecar, shouldAdHocSignSidecar } from "./sidecar-signing"; const targetByTriple: Record = { "aarch64-apple-darwin": "bun-darwin-arm64", @@ -55,5 +56,9 @@ mkdirSync(binaries, { recursive: true }); mkdirSync(resources, { recursive: true }); const destination = join(binaries, `ocx-${triple}${target.startsWith("bun-windows-") ? ".exe" : ""}`); copyFileSync(executable, destination); +if (shouldAdHocSignSidecar(process.platform, target)) { + const signed = adHocSignSidecar(destination); + if (signed !== 0) process.exit(signed); +} cpSync(join(repoRoot, "gui", "dist"), resources, { recursive: true }); console.log(`Prepared ${destination}`); diff --git a/desktop/scripts/sidecar-signing.ts b/desktop/scripts/sidecar-signing.ts new file mode 100644 index 00000000000..a41642b3062 --- /dev/null +++ b/desktop/scripts/sidecar-signing.ts @@ -0,0 +1,31 @@ +// Ad-hoc signing of the prepared desktop sidecar on macOS. +// +// Bun's linker-signed standalone output is killed by macOS page validation +// (CODESIGNING "Invalid Page"), so the copied sidecar is resealed with an +// ad-hoc signature before Tauri bundles it. Only a macOS host preparing a +// bun-darwin-* target signs: a Mac cross-preparing a Linux or Windows sidecar +// must never run codesign on that file. Release builds re-sign the bundled +// binary with Developer ID afterwards; this step only has to leave a runnable +// input. + +export const CODESIGN_PATH = "/usr/bin/codesign"; + +export function shouldAdHocSignSidecar(hostPlatform: string, bunTarget: string): boolean { + return hostPlatform === "darwin" && bunTarget.startsWith("bun-darwin-"); +} + +export function adHocSignArgv(destination: string): string[] { + return [CODESIGN_PATH, "-s", "-", "-f", destination]; +} + +export type SidecarSignSpawn = (argv: string[]) => { exitCode: number | null }; + +const inheritSpawn: SidecarSignSpawn = (argv) => + Bun.spawnSync(argv, { stdout: "inherit", stderr: "inherit" }); + +/** Returns 0 on success, otherwise the nonzero exit code the caller should exit with. */ +export function adHocSignSidecar(destination: string, spawn: SidecarSignSpawn = inheritSpawn): number { + const result = spawn(adHocSignArgv(destination)); + if (result.exitCode === 0) return 0; + return result.exitCode ?? 1; +} diff --git a/desktop/scripts/verify-linux-sidecar.sh b/desktop/scripts/verify-linux-sidecar.sh index 88c5df2ba34..7695767ebe6 100644 --- a/desktop/scripts/verify-linux-sidecar.sh +++ b/desktop/scripts/verify-linux-sidecar.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash # Run only on a Linux packaging runner, against the completed AppImage. +# Usage: verify-linux-sidecar.sh [appimage-bundle-dir] +# The release workflow builds each Linux format in its own Cargo target and stages the AppImage +# into an isolated read-only directory, which it passes here; a local build keeps the default. set -euo pipefail root="$(cd "$(dirname "$0")/../.." && pwd)" -bundle="$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage" +bundle="${1:-$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage}" original="$root/desktop/src-tauri/binaries/ocx-x86_64-unknown-linux-gnu" shopt -s nullglob images=("$bundle"/*.AppImage) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 2a3f97a9619..a74fd7686bc 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2541,7 +2541,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.64.0-preview.20260923" +version = "2.65.0-preview.20260925" dependencies = [ "dbus", "reqwest 0.12.24", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 24d29642b75..67774337c65 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.64.0-preview.20260923" +version = "2.65.0-preview.20260925" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/capabilities/dashboard-zoom.json b/desktop/src-tauri/capabilities/dashboard-zoom.json new file mode 100644 index 00000000000..dc2b0734244 --- /dev/null +++ b/desktop/src-tauri/capabilities/dashboard-zoom.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "dashboard-zoom", + "description": "Page zoom hotkeys for the main window, including the loopback dashboard", + "windows": ["main"], + "remote": { + "urls": ["http://127.0.0.1:*"] + }, + "permissions": ["core:webview:allow-set-webview-zoom"] +} diff --git a/desktop/src-tauri/icons/tray/icon-update.png b/desktop/src-tauri/icons/tray/icon-update.png new file mode 100644 index 00000000000..9cd0fc910ed Binary files /dev/null and b/desktop/src-tauri/icons/tray/icon-update.png differ diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e9467013ebb..90955564f19 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -25,6 +25,12 @@ mod popup; #[cfg(target_os = "macos")] #[path = "native_tray.rs"] mod popup; +// The macOS build selects native_tray.rs as the popup module; compile the portable popup +// module's tests on macOS too so its navigation rules run on the maintainers' platform. +#[cfg(all(test, target_os = "macos"))] +#[allow(dead_code)] +#[path = "popup.rs"] +mod popup_portable_test; mod proxy; mod resolve; mod runtime_stop; @@ -135,9 +141,7 @@ impl Default for AppState { #[tauri::command] fn show_dashboard(app: tauri::AppHandle) { popup::hide(&app); - if let Some(window) = app.get_webview_window("main") { - window::show(&window); - } + startup::open_dashboard(&app); } #[tauri::command] @@ -188,13 +192,49 @@ fn decide_takeover(app: tauri::AppHandle, approved: bool) { } } +#[tauri::command] +async fn update_status( + window: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result { + window::require_update_page(&window)?; + Ok(updater::page_status(&app)) +} + +#[tauri::command] +async fn update_check( + window: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result { + window::require_update_page(&window)?; + let check_result = updater::check_and_show(&app).await; + check_result.map_err(|_| "the update check failed; try again".to_owned())?; + Ok(updater::page_status(&app)) +} + +#[tauri::command] +async fn update_install( + window: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result { + window::require_update_page(&window)?; + updater::install_pending(&app).await.map_err(|error| { + logging::log_once("updater install failed", &error); + "the update could not be installed; try again".to_owned() + }) +} + +#[tauri::command] +fn return_to_dashboard(window: tauri::WebviewWindow, app: tauri::AppHandle) -> Result<(), String> { + window::require_update_page(&window)?; + startup::return_to_dashboard(&app) +} + pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - if let Some(window) = app.get_webview_window("main") { - popup::hide(app); - window::show(&window); - } + popup::hide(app); + startup::open_dashboard(app); })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_process::init()) @@ -222,11 +262,21 @@ pub fn run() { startup_snapshot, startup_phases, retry_startup, - decide_takeover + decide_takeover, + update_status, + update_check, + update_install, + return_to_dashboard ]) .setup(|app| { app.manage(AppState::new()); app.manage(updater::PendingUpdate(Mutex::new(None))); + app.manage(updater::DesktopUpdateState::new( + app.package_info().version.to_string(), + )); + app.manage(updater::CheckGeneration::default()); + updater::start_ui_projection_worker(app.handle().clone()); + updater::start_snapshot_publisher(app.handle().clone()); app.manage(tray::TrayState::default()); app.manage(exit::ExitCoordinator::new()); app.manage(startup::Startup::new()); @@ -241,7 +291,24 @@ pub fn run() { .inner_size(1100.0, 720.0) .visible(false) .user_agent(&window::webview_user_agent()) + // Cmd on macOS, Ctrl elsewhere, with + / - / 0. WebView2 zooms natively; on + // macOS and Linux Tauri injects a keydown polyfill whose one IPC call is granted + // to the loopback dashboard by `capabilities/dashboard-zoom.json`. + .zoom_hotkeys_enabled(true) .on_navigation(window::navigation_allowed(app.handle().clone())) + // A hidden window still loads pages: wry builds this one with WebView2 + // IsVisible=false, and the bootstrap page navigates to the dashboard URL + // afterwards, so the eval that a later show or hide would rely on has nowhere + // to land during a reload. Re-sending the current state here is what keeps the + // GUI's answer correct across navigation. + .on_page_load(|window, payload| { + if matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) { + window::report_visibility( + &window, + window.is_visible().unwrap_or(false), + ); + } + }) .build()?; window::configure(&window); if startup::LaunchOrigin::detect() == startup::LaunchOrigin::User { @@ -260,6 +327,11 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building OpenCodex desktop shell") .run(|app, event| { + // Dock/Finder reopening an existing macOS app does not launch a second instance. + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Reopen { .. } = event { + show_dashboard(app.clone()); + } // Window close and the platform quit gesture arrive here as an exit request, and until // this handler existed they went straight through to a SIGKILL of the runtime. D2 makes // them hide; only the tray's Quit, and an update's coordinated restart, get past. diff --git a/desktop/src-tauri/src/native_tray.rs b/desktop/src-tauri/src/native_tray.rs index 1098f1d1d09..c1e17fdbdff 100644 --- a/desktop/src-tauri/src/native_tray.rs +++ b/desktop/src-tauri/src/native_tray.rs @@ -19,6 +19,7 @@ extern "C" { fn ocx_native_tray_hide(); fn ocx_native_tray_visible() -> i32; fn ocx_native_tray_update(bytes: *const u8, count: isize); + fn ocx_native_tray_update_dot(item: *mut c_void, show: i32); } static HOST: OnceLock = OnceLock::new(); @@ -75,6 +76,27 @@ fn present(app: &AppHandle, toggle: bool) -> tauri::Result<()> { }) } +pub fn set_update_dot(app: &AppHandle, _show: bool) { + let app = app.clone(); + let target = app.clone(); + let _ = target.run_on_main_thread(move || { + let Some(tray) = app.tray_by_id("main") else { + return; + }; + let pending = app + .try_state::() + .is_some_and(|state| state.update_pending.load(Ordering::Acquire)); + let _ = tray.with_inner_tray_icon(move |inner| { + if let Some(item) = inner.ns_status_item() { + let pointer = (&*item as *const _ as *mut c_void).cast(); + unsafe { + ocx_native_tray_update_dot(pointer, i32::from(pending)); + } + } + }); + }); +} + pub fn hide(app: &AppHandle) { stop_refresh(app); let _ = app.run_on_main_thread(|| unsafe { ocx_native_tray_hide() }); @@ -93,12 +115,16 @@ extern "C" fn native_event(event: i32) { return; }; if let Some(main) = app.get_webview_window("main") { + let session = app + .state::() + .session_id() + .to_string(); let path = if event == 4 { - "/?desktop=open#/usage/companion" + format!("/?desktop=open&desktop_session={session}#/usage/companion") } else { - "/?desktop=open#/usage" + format!("/?desktop=open&desktop_session={session}#/usage") }; - if let Ok(url) = proxy.endpoint().url(path).parse() { + if let Ok(url) = proxy.endpoint().url(&path).parse() { let _ = main.navigate(url); window::show(&main); } diff --git a/desktop/src-tauri/src/popup.rs b/desktop/src-tauri/src/popup.rs index 46bf0891bea..3f5d2ff087d 100644 --- a/desktop/src-tauri/src/popup.rs +++ b/desktop/src-tauri/src/popup.rs @@ -257,7 +257,11 @@ fn popup_navigation_allowed( hide(&app); if let Some(main) = app.get_webview_window("main") { window::show(&main); - let _ = main.navigate(url.clone()); + let session = app + .state::() + .session_id() + .to_string(); + let _ = main.navigate(dashboard_destination(url, &session)); } return false; } @@ -319,6 +323,14 @@ fn is_dashboard_url(url: &Url, endpoint: ProxyEndpoint) -> bool { && matches!(url.fragment(), Some("/usage") | Some("/usage/companion")) } +fn dashboard_destination(url: &Url, session: &str) -> Url { + let mut destination = url.clone(); + destination + .query_pairs_mut() + .append_pair("desktop_session", session); + destination +} + fn set_visibility(popup: &WebviewWindow, visible: bool) { let script = format!( "window.__OPENCODEX_TRAY_VISIBLE__ = {visible}; window.dispatchEvent(new CustomEvent('opencodex:tray-visibility', {{detail: {visible}}}));" @@ -381,6 +393,24 @@ mod tests { )); } + #[test] + fn dashboard_navigation_keeps_the_validated_fragment() { + for fragment in ["/usage", "/usage/companion"] { + let source: Url = ENDPOINT + .url(&format!("/?desktop=open#{fragment}")) + .parse() + .unwrap(); + assert!(is_dashboard_url(&source, ENDPOINT)); + let destination = dashboard_destination(&source, "session-123"); + assert_eq!( + destination.as_str(), + ENDPOINT.url(&format!( + "/?desktop=open&desktop_session=session-123#{fragment}" + )) + ); + } + } + #[test] fn initialization_script_matches_native_surface() { let expected_value = if VIBRANT_SURFACE { "on" } else { "off" }; diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs index 8e797918b1e..4c679fd701e 100644 --- a/desktop/src-tauri/src/proxy.rs +++ b/desktop/src-tauri/src/proxy.rs @@ -178,6 +178,26 @@ impl ProxyClient { self.request(Method::GET, path).await } + pub async fn post_desktop_snapshot(&self, body: &Value) -> Result<(), ProxyError> { + let token = self.authorised_token().await?; + let response = self + .client + .post(self.endpoint.url("/api/update/desktop-snapshot")) + .header("X-OpenCodex-API-Key", token) + .json(body) + .send() + .await + .map_err(|error| { + if error.is_connect() { + ProxyError::Unreachable + } else { + ProxyError::Decode(error) + } + })?; + let _ = decode(response).await?; + Ok(()) + } + async fn request(&self, method: Method, path: &str) -> Result { let response = self.send(&method, path, None).await?; if response.status() == StatusCode::UNAUTHORIZED { diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 3a693e37cda..afdb4453125 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -365,6 +365,18 @@ pub struct Startup { /// before `live`, and never held across an await. reporting: Mutex<()>, running: AtomicBool, + /// Whether this window has already left the bundled bootstrap surface. + /// + /// Explicit open actions can arrive repeatedly from the tray, the single-instance hook, and + /// the shell command. Navigating on every action would recreate the React application and + /// discard renderer state, so the transition is owned here and consumed exactly once per run. + dashboard_loaded: AtomicBool, + /// Whether a person asked for the dashboard during this run. + /// + /// An explicit open that arrives while startup is still running only shows the bootstrap page; + /// `finish` reads this after it has recorded Ready, and `open_dashboard` sets it before it + /// reads progress, so whichever of the two runs second sees the other and navigates. + dashboard_requested: AtomicBool, /// Which run the state belongs to. /// /// A run's deadline guard outlives the run it was started for, and a retry that begins before @@ -385,6 +397,8 @@ impl Startup { }), reporting: Mutex::new(()), running: AtomicBool::new(false), + dashboard_loaded: AtomicBool::new(false), + dashboard_requested: AtomicBool::new(false), generation: AtomicU64::new(0), registered: Mutex::new(None), } @@ -460,6 +474,33 @@ impl Startup { live.consent = ConsentState::Idle; live.reported.clear(); live.latest = Progress::new(Phase::NotStarted, 0); + self.dashboard_loaded.store(false, Ordering::SeqCst); + self.dashboard_requested.store(false, Ordering::SeqCst); + } + + fn should_navigate_dashboard(&self) -> bool { + !self.dashboard_loaded.swap(true, Ordering::SeqCst) + } + + /// Give the one navigation back when the WebView refused the script, so the next open retries. + fn navigation_failed(&self) { + self.dashboard_loaded.store(false, Ordering::SeqCst); + } + + fn request_dashboard(&self) { + self.dashboard_requested.store(true, Ordering::SeqCst); + } + + fn dashboard_requested(&self) -> bool { + self.dashboard_requested.load(Ordering::SeqCst) + } + + /// The dashboard URL once this run is Ready, otherwise nothing. + fn ready_dashboard(&self) -> Option { + let progress = self.latest(); + (progress.phase == Phase::Ready.id()) + .then_some(progress.dashboard) + .flatten() } /// Whether the run has already said how it ended. @@ -1370,7 +1411,12 @@ fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { app.try_state::() .is_some_and(|state| state.owns_runtime()), ); - let dashboard = endpoint.url("/#/usage"); + let path = format!( + "/?desktop_session={}#/usage", + app.state::() + .session_id() + ); + let dashboard = endpoint.url(&path); let mut progress = Progress::new(Phase::Ready, elapsed(started)); progress.dashboard = Some(dashboard.clone()); if !emit(app, progress, None) { @@ -1378,11 +1424,96 @@ fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { // terminal state stays and the window must not navigate away from it. return; } + app.state::().wake(); if let Some(window) = app.get_webview_window("main") { - // justified: replacing the bootstrap page with the dashboard is how this window has always - // navigated, and the string is a URL this process resolved, not anything a page supplied. - let _ = window.eval(format!("window.location.replace({dashboard:?})")); + let visible = window.is_visible().unwrap_or(true); + let startup = app.try_state::(); + let requested = startup + .as_ref() + .is_some_and(|startup| startup.dashboard_requested()); + if loads_dashboard_on_ready(LaunchOrigin::detect(), visible, requested) { + match startup { + Some(startup) => { + navigate_once(&startup, &dashboard, |url| navigate_dashboard(&window, url)); + } + None => { + navigate_dashboard(&window, &dashboard); + } + } + } + } +} + +/// Open the full dashboard only when a person asks for it. +/// +/// A hidden login launch deliberately leaves its WebView on the tiny bundled startup surface after +/// the runtime becomes ready. The tray, a second ordinary application launch, or the bootstrap +/// command reaches this function and pays the dashboard cost at that point. If startup is still in +/// progress the bootstrap is merely shown; `finish` observes the now-visible window and performs +/// the navigation once the endpoint is ready. +pub fn open_dashboard(app: &AppHandle) { + let startup = app.try_state::(); + let Some(window) = app.get_webview_window("main") else { + return; + }; + if let Some(startup) = startup { + // The request is recorded before progress is read; see `dashboard_requested`. + startup.request_dashboard(); + if let Some(dashboard) = startup.ready_dashboard() { + navigate_once(&startup, &dashboard, |url| navigate_dashboard(&window, url)); + } + } + crate::window::show(&window); +} + +pub fn return_to_dashboard(app: &AppHandle) -> Result<(), String> { + let startup = app.try_state::().ok_or("dashboard is not ready")?; + let dashboard = startup.ready_dashboard(); + let window = app + .get_webview_window("main") + .ok_or("dashboard window is unavailable")?; + return_ready_dashboard(dashboard.as_deref(), |url| navigate_dashboard(&window, url))?; + crate::window::show(&window); + Ok(()) +} + +fn return_ready_dashboard( + dashboard: Option<&str>, + navigate: impl FnOnce(&str) -> bool, +) -> Result<(), String> { + let dashboard = dashboard.ok_or("dashboard is not ready")?; + if !navigate(dashboard) { + return Err("dashboard could not be opened".into()); + } + Ok(()) +} + +fn loads_dashboard_on_ready(origin: LaunchOrigin, window_visible: bool, requested: bool) -> bool { + origin == LaunchOrigin::User || window_visible || requested +} + +/// Perform this run's single dashboard navigation through `navigate`. +/// +/// `navigate` reports whether the WebView accepted the script. Acceptance is not proof that the +/// page finished loading, but a refusal certainly left the bootstrap page in place, so the claim is +/// returned and the next explicit open tries again instead of being suppressed for the whole run. +fn navigate_once(startup: &Startup, dashboard: &str, navigate: impl FnOnce(&str) -> bool) -> bool { + if !startup.should_navigate_dashboard() { + return false; } + if navigate(dashboard) { + return true; + } + startup.navigation_failed(); + false +} + +fn navigate_dashboard(window: &tauri::WebviewWindow, dashboard: &str) -> bool { + // justified: replacing the bootstrap page with the dashboard is how this window has always + // navigated, and the string is a URL this process resolved, not anything a page supplied. + window + .eval(format!("window.location.replace({dashboard:?})")) + .is_ok() } #[allow(clippy::too_many_arguments)] @@ -1489,9 +1620,10 @@ fn elapsed(started: Instant) -> u64 { #[cfg(test)] mod tests { use super::{ - approval_still_current, attach_plan, claim_after_silence, shows_window, - stop_after_approval, unavailable, AttachPlan, ConsentState, Expiry, LaunchOrigin, Phase, - Progress, Startup, AUTOSTART_FLAG, DEADLINE, PHASES, POLL, + approval_still_current, attach_plan, claim_after_silence, loads_dashboard_on_ready, + navigate_once, return_ready_dashboard, shows_window, stop_after_approval, unavailable, + AttachPlan, ConsentState, Expiry, LaunchOrigin, Phase, Progress, Startup, AUTOSTART_FLAG, + DEADLINE, PHASES, POLL, }; use crate::claim::ClaimResult; use crate::ownership::{Claim, Consent, Owner, Recorded}; @@ -1773,6 +1905,123 @@ mod tests { )); } + #[test] + fn only_a_hidden_login_launch_defers_the_full_dashboard() { + assert!(loads_dashboard_on_ready(LaunchOrigin::User, false, false)); + assert!(loads_dashboard_on_ready(LaunchOrigin::User, true, false)); + assert!(loads_dashboard_on_ready( + LaunchOrigin::Autostart, + true, + false + )); + assert!(!loads_dashboard_on_ready( + LaunchOrigin::Autostart, + false, + false + )); + // An open that arrived during startup counts even if the queued show has not landed yet. + assert!(loads_dashboard_on_ready( + LaunchOrigin::Autostart, + false, + true + )); + } + + #[test] + fn explicit_dashboard_navigation_is_consumed_once_per_run() { + let startup = Startup::new(); + let mut navigations = Vec::new(); + assert!(navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |url| { + navigations.push(url.to_string()); + true + } + )); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |url| { + navigations.push(url.to_string()); + true + } + )); + assert_eq!( + navigations, + vec!["http://127.0.0.1:10100/#/usage".to_string()] + ); + + startup.restart(); + assert!(navigate_once( + &startup, + "http://127.0.0.1:10101/#/usage", + |_| true + )); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10101/#/usage", + |_| true + )); + } + + #[test] + fn a_refused_dashboard_navigation_is_retried_on_the_next_open() { + let startup = Startup::new(); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |_| false + )); + let mut attempts = 0; + assert!(navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |_| { + attempts += 1; + true + } + )); + assert_eq!(attempts, 1); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |_| true + )); + } + + #[test] + fn an_open_during_startup_is_remembered_until_the_run_restarts() { + let startup = Startup::new(); + assert!(!startup.dashboard_requested()); + assert_eq!(startup.ready_dashboard(), None); + startup.request_dashboard(); + assert!(startup.dashboard_requested()); + startup.restart(); + assert!(!startup.dashboard_requested()); + } + + #[test] + fn update_page_return_requires_a_ready_dashboard_and_retries_refused_navigation() { + assert_eq!( + return_ready_dashboard(None, |_| true).unwrap_err(), + "dashboard is not ready" + ); + assert_eq!( + return_ready_dashboard(Some("http://127.0.0.1:10100/#/usage"), |_| false).unwrap_err(), + "dashboard could not be opened" + ); + let mut visited = None; + assert!( + return_ready_dashboard(Some("http://127.0.0.1:10100/#/usage"), |url| { + visited = Some(url.to_owned()); + true + }) + .is_ok() + ); + assert_eq!(visited.as_deref(), Some("http://127.0.0.1:10100/#/usage")); + } + #[test] fn a_login_launch_hides_only_where_there_is_a_tray_to_hide_in() { assert!(!shows_window( diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index ef50233140c..479a5a90906 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -20,6 +20,7 @@ use tauri_plugin_opener::OpenerExt; pub struct TrayState { pub menu: Mutex>, pub installing: AtomicBool, + pub update_pending: AtomicBool, } #[derive(Clone)] @@ -34,10 +35,36 @@ impl Default for TrayState { Self { menu: Mutex::new(None), installing: AtomicBool::new(false), + update_pending: AtomicBool::new(false), } } } +#[cfg(any(not(target_os = "macos"), test))] +fn tray_icon_bytes(pending: bool) -> &'static [u8] { + if pending { + include_bytes!("../icons/tray/icon-update.png") + } else { + include_bytes!("../icons/tray/icon.png") + } +} + +fn apply_update_indicator(app: &AppHandle, pending: bool) { + #[cfg(target_os = "macos")] + popup::set_update_dot(app, pending); + #[cfg(not(target_os = "macos"))] + if let Some(tray) = app.tray_by_id("main") { + let image = + tauri::image::Image::from_bytes(tray_icon_bytes(pending)).expect("generated tray icon"); + let _ = tray.set_icon(Some(image)); + } +} + +fn update_pending(app: &AppHandle) -> bool { + app.try_state::() + .is_some_and(|state| state.update_pending.load(Ordering::Acquire)) +} + /// Build the tray. /// /// The proxy is not passed in. The tray is installed before a runtime has been resolved, so every @@ -162,9 +189,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let _ = popup::show(app, endpoint, anchor); } "open-dashboard" => { - if let Some(window) = app.get_webview_window("main") { - window::show(&window); - } + crate::startup::open_dashboard(app); } "open-browser" => { let Some(endpoint) = app @@ -194,31 +219,13 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { "check-updates" => { let app = app.clone(); tauri::async_runtime::spawn(async move { - updater::check_and_show(&app).await; + let _ = updater::check_and_show(&app).await; }); } "install-update" => { let app = app.clone(); tauri::async_runtime::spawn(async move { - let update = app - .state::() - .0 - .lock() - .ok() - .and_then(|mut pending| pending.take()); - let Some(update) = update else { - return; - }; - let version = update.version.clone(); - let retry_update = update.clone(); - set_installing(&app, &version); - if let Err(error) = updater::install(&app, update).await { - if let Ok(mut pending) = - app.state::().0.lock() - { - *pending = Some(retry_update); - } - set_install_failed(&app, &version); + if let Err(error) = updater::install_pending(&app).await { crate::logging::log_once("updater install failed", &error); } }); @@ -230,6 +237,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { }) .build(app)?; + apply_update_indicator(app, update_pending(app)); refresh(app, &tray); let tray = tray.clone(); let app = app.clone(); @@ -243,7 +251,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { else { continue; }; - refresh_title(&tray, &proxy); + refresh_title(&app, &tray, &proxy); tick += 1; if tick % 5 == 0 { widget::refresh(&proxy); @@ -260,7 +268,7 @@ fn refresh(app: &AppHandle, tray: &tauri::tray::TrayIcon) { else { return; }; - refresh_title(tray, &proxy); + refresh_title(app, tray, &proxy); widget::refresh(&proxy); } @@ -290,6 +298,7 @@ pub fn show_update_available(app: &AppHandle, version: &str) { let _ = menu.check_updates.set_enabled(true); let _ = menu.check_updates.set_text("Check for Updates…"); } + apply_update_indicator(app, true); } pub fn show_up_to_date(app: &AppHandle) { @@ -300,6 +309,7 @@ pub fn show_up_to_date(app: &AppHandle) { let _ = menu.check_updates.set_enabled(true); let _ = menu.install_update.set_enabled(false); } + apply_update_indicator(app, false); } pub fn is_installing(app: &AppHandle) -> bool { @@ -307,10 +317,7 @@ pub fn is_installing(app: &AppHandle) -> bool { .is_some_and(|state| state.installing.load(Ordering::Acquire)) } -fn set_installing(app: &AppHandle, version: &str) { - if let Some(state) = app.try_state::() { - state.installing.store(true, Ordering::Release); - } +pub fn show_installing(app: &AppHandle, version: &str) { if let Some(menu) = menu_handles(app) { let _ = menu .install_update @@ -320,14 +327,11 @@ fn set_installing(app: &AppHandle, version: &str) { } } -fn set_install_failed(app: &AppHandle, version: &str) { - if let Some(state) = app.try_state::() { - state.installing.store(false, Ordering::Release); - } - show_update_available(app, version); -} - -fn refresh_title(tray: &tauri::tray::TrayIcon, proxy: &ProxyClient) { +fn refresh_title(app: &AppHandle, tray: &tauri::tray::TrayIcon, proxy: &ProxyClient) { + #[cfg(target_os = "macos")] + let app = app.clone(); + #[cfg(not(target_os = "macos"))] + let _ = app; let proxy = proxy.clone(); let tray = tray.clone(); tauri::async_runtime::spawn(async move { @@ -340,6 +344,8 @@ fn refresh_title(tray: &tauri::tray::TrayIcon, proxy: &ProxyClient) { let quotas = proxy.quotas().await.unwrap_or(Value::Null); let title = render_title(&settings, &usage, "as); let _ = tray.set_title(title.as_deref()); + #[cfg(target_os = "macos")] + apply_update_indicator(&app, update_pending(&app)); }); } @@ -497,9 +503,18 @@ fn tray_anchor(app: &AppHandle) -> tauri::PhysicalPosition { #[cfg(test)] mod tests { - use super::render_title; + use super::{render_title, tray_icon_bytes}; use serde_json::json; + #[test] + fn dotted_tray_variant_is_distinct_and_both_variants_are_png() { + let normal = tray_icon_bytes(false); + let dotted = tray_icon_bytes(true); + assert_eq!(&normal[..8], b"\x89PNG\r\n\x1a\n"); + assert_eq!(&dotted[..8], b"\x89PNG\r\n\x1a\n"); + assert_ne!(normal, dotted); + } + #[test] fn icon_only_clears_the_title_but_a_template_and_unavailable_data_keep_their_meaning() { let usage = json!({"summary":{"requests":7,"totalTokens":12}}); diff --git a/desktop/src-tauri/src/updater.rs b/desktop/src-tauri/src/updater.rs index 456ffe76c5c..badd66e9431 100644 --- a/desktop/src-tauri/src/updater.rs +++ b/desktop/src-tauri/src/updater.rs @@ -1,10 +1,345 @@ use crate::{exit::RestartReadiness, logging, tray}; +use serde::Serialize; +use serde_json::to_value; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Manager}; use tauri_plugin_updater::{Update, UpdaterExt}; +use tokio::sync::watch; +use uuid::Uuid; + +#[derive(Clone)] +pub enum UiProjection { + Available(String), + Current, + Installing(String), +} + +#[derive(Clone)] +struct UiUpdate { + revision: u64, + projection: UiProjection, +} + +pub struct CheckGeneration { + latest_started: AtomicU64, + install_epoch: AtomicU64, + application: Mutex<()>, + latest_ui_revision: AtomicU64, + ui: watch::Sender>, +} + +impl Default for CheckGeneration { + fn default() -> Self { + let (ui, _) = watch::channel(None); + Self { + latest_started: AtomicU64::new(0), + install_epoch: AtomicU64::new(0), + application: Mutex::new(()), + latest_ui_revision: AtomicU64::new(0), + ui, + } + } +} + +impl CheckGeneration { + pub fn begin_if_not_installing( + &self, + installing: &std::sync::atomic::AtomicBool, + publish_checking: impl FnOnce(), + ) -> Option<(u64, u64)> { + let _guard = self + .application + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if installing.load(Ordering::Acquire) { + return None; + } + let generation = self.latest_started.fetch_add(1, Ordering::AcqRel) + 1; + let epoch = self.install_epoch.load(Ordering::Acquire); + publish_checking(); + Some((generation, epoch)) + } + + pub fn claim_install( + &self, + installing: &std::sync::atomic::AtomicBool, + pending_version: impl FnOnce() -> Option, + on_claim: impl FnOnce(), + ) -> InstallClaim { + let _guard = self + .application + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if installing.load(Ordering::Acquire) { + return InstallClaim::Busy; + } + let Some(version) = pending_version() else { + return InstallClaim::NoPending; + }; + if installing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return InstallClaim::Busy; + } + self.install_epoch.fetch_add(1, Ordering::AcqRel); + self.latest_ui_revision.fetch_add(1, Ordering::AcqRel); + on_claim(); + self.queue_ui(UiProjection::Installing(version)); + InstallClaim::Claimed + } + + pub fn epoch_is_current(&self, epoch: u64) -> bool { + self.install_epoch.load(Ordering::Acquire) == epoch + } + + pub fn apply_if_current(&self, generation: u64, apply: impl FnOnce() -> T) -> Option { + let _guard = self + .application + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.latest_started.load(Ordering::Acquire) != generation { + return None; + } + Some(apply()) + } + + pub fn inspect(&self, read: impl FnOnce() -> T) -> T { + let _guard = self + .application + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + read() + } + + // Call only inside application/inspect. This is an in-memory send, never a Tauri setter. + fn queue_ui(&self, projection: UiProjection) { + let revision = self.latest_ui_revision.fetch_add(1, Ordering::AcqRel) + 1; + self.ui.send_replace(Some(UiUpdate { + revision, + projection, + })); + } + + fn apply_ui_projection_if_current( + &self, + update: UiUpdate, + apply: impl FnOnce(UiProjection), + ) -> bool { + if update.revision != self.latest_ui_revision.load(Ordering::Acquire) { + return false; + } + apply(update.projection); + true + } +} + +pub fn start_ui_projection_worker(app: AppHandle) { + let mut receiver = app.state::().ui.subscribe(); + tauri::async_runtime::spawn(async move { + while receiver.changed().await.is_ok() { + let Some(update) = receiver.borrow_and_update().clone() else { + continue; + }; + app.state::() + .apply_ui_projection_if_current(update, |projection| match projection { + UiProjection::Available(version) => tray::show_update_available(&app, &version), + UiProjection::Current => tray::show_up_to_date(&app), + UiProjection::Installing(version) => tray::show_installing(&app, &version), + }); + } + }); +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSnapshot { + session_id: String, + current_version: String, + latest_version: Option, + available: bool, + checked_at_ms: Option, + phase: &'static str, +} + +pub struct DesktopUpdateState { + session_id: String, + tx: watch::Sender, +} + +impl DesktopUpdateState { + pub fn new(current_version: String) -> Self { + let session_id = Uuid::new_v4().to_string(); + let (tx, _) = watch::channel(DesktopSnapshot { + session_id: session_id.clone(), + current_version, + latest_version: None, + available: false, + checked_at_ms: None, + phase: "idle", + }); + Self { session_id, tx } + } + + pub fn session_id(&self) -> &str { + &self.session_id + } + + pub fn publish(&self, phase: &'static str, latest: Option, checked: Option) { + let previous = self.tx.borrow().clone(); + let next = DesktopSnapshot { + session_id: self.session_id.clone(), + current_version: previous.current_version, + available: latest.is_some(), + latest_version: latest, + checked_at_ms: checked, + phase, + }; + self.tx.send_replace(next); + } + + pub fn retain_phase(&self, phase: &'static str) { + let previous = self.tx.borrow().clone(); + self.publish(phase, previous.latest_version, previous.checked_at_ms); + } + + pub fn wake(&self) { + self.wake_with_before_notify(|| {}); + } + + fn wake_with_before_notify(&self, before_notify: impl FnOnce()) { + before_notify(); + self.tx.send_modify(|_| {}); + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64 +} + +pub fn start_snapshot_publisher(app: AppHandle) { + let mut receiver = app.state::().tx.subscribe(); + tauri::async_runtime::spawn(async move { + loop { + let snapshot = receiver.borrow_and_update().clone(); + if let Some(proxy) = app + .try_state::() + .and_then(|state| state.proxy()) + { + if let Ok(body) = to_value(&snapshot) { + let _ = proxy.post_desktop_snapshot(&body).await; + } + } + if matches!( + tokio::time::timeout(Duration::from_secs(60), receiver.changed()).await, + Ok(Err(_)) + ) { + break; + } + } + }); +} pub struct PendingUpdate(pub Mutex>); +#[derive(Debug, PartialEq, Eq)] +pub enum InstallClaim { + Claimed, + Busy, + NoPending, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageUpdateStatus { + pub current_version: String, + pub latest_version: Option, + pub available: bool, + pub installing: bool, + pub checking: bool, +} + +pub fn page_status(app: &AppHandle) -> PageUpdateStatus { + app.state::().inspect(|| { + let pending = app.state::(); + let pending = pending + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let latest_version = pending.as_ref().map(|update| update.version.clone()); + let installing = tray::is_installing(app); + let checking = app.state::().tx.borrow().phase == "checking"; + PageUpdateStatus { + current_version: env!("CARGO_PKG_VERSION").to_owned(), + available: latest_version.is_some(), + latest_version, + installing, + checking, + } + }) +} + +pub async fn install_pending(app: &AppHandle) -> Result { + let state = app.state::(); + let gate = app.state::(); + match gate.claim_install( + &state.installing, + || { + app.state::() + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|update| update.version.clone()) + }, + || app.state::().retain_phase("installing"), + ) { + InstallClaim::Claimed => {} + InstallClaim::Busy => return Err("an update is already installing".into()), + InstallClaim::NoPending => return Err("no update is ready to install".into()), + } + let pending = app.state::(); + let update = pending + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + let Some(update) = update else { + gate.inspect(|| { + state.installing.store(false, Ordering::Release); + app.state::().retain_phase("current"); + gate.queue_ui(UiProjection::Current); + }); + return Err("no update is ready to install".into()); + }; + let version = update.version.clone(); + let retry_update = update.clone(); + let result = install(app, update).await; + if let Err(error) = result { + gate.inspect(|| { + let pending = app.state::(); + *pending + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(retry_update); + state.installing.store(false, Ordering::Release); + state.update_pending.store(true, Ordering::Release); + app.state::() + .retain_phase("install-failed"); + gate.queue_ui(UiProjection::Available(version)); + }); + return Err(error); + } + state.installing.store(false, Ordering::Release); + Ok(page_status(app)) +} + /// The manifest key a Linux install must resolve, or None to keep the updater's default /// os-arch key (linux-x86_64, windows-x86_64, darwin-*). /// @@ -82,42 +417,391 @@ pub fn start_background_checks(app: AppHandle) { tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(30)).await; loop { - check_and_show(&app).await; + let _ = check_and_show(&app).await; tokio::time::sleep(std::time::Duration::from_secs(6 * 60 * 60)).await; } }); } -pub async fn check_and_show(app: &AppHandle) { - if tray::is_installing(app) { - return; - } - match check(app).await { - Ok(Some(update)) => { - if tray::is_installing(app) { - return; +pub async fn check_and_show(app: &AppHandle) -> Result<(), String> { + let gate = app.state::(); + let state = app.state::(); + let Some((generation, epoch)) = gate.begin_if_not_installing(&state.installing, || { + app.state::().retain_phase("checking"); + }) else { + return Ok(()); + }; + let answer = check(app).await; + let applied = gate.apply_if_current(generation, || { + if tray::is_installing(app) || !gate.epoch_is_current(epoch) { + return Ok(()); + } + match answer { + Ok(Some(update)) => { + let version = update.version.clone(); + if let Ok(mut pending) = app.state::().0.lock() { + *pending = Some(update); + } + app.state::().publish( + "available", + Some(version.clone()), + Some(now_ms()), + ); + state.update_pending.store(true, Ordering::Release); + gate.queue_ui(UiProjection::Available(version)); + Ok(()) } - let version = update.version.clone(); - if let Ok(mut pending) = app.state::().0.lock() { - *pending = Some(update); + Ok(None) => { + if let Ok(mut pending) = app.state::().0.lock() { + *pending = None; + } + app.state::() + .publish("current", None, Some(now_ms())); + state.update_pending.store(false, Ordering::Release); + gate.queue_ui(UiProjection::Current); + Ok(()) } - tray::show_update_available(app, &version); - } - Ok(None) => { - if let Ok(mut pending) = app.state::().0.lock() { - *pending = None; + Err(error) => { + app.state::().retain_phase("error"); + Err(error) } - tray::show_up_to_date(app); } - Err(error) => logging::log_once("updater check failed", &error), + }); + if let Some(Err(error)) = &applied { + logging::log_once("updater check failed", error); } + applied.unwrap_or(Ok(())) } #[cfg(test)] mod tests { - use super::{linux_updater_target, update_label}; + use super::{ + linux_updater_target, update_label, CheckGeneration, DesktopUpdateState, InstallClaim, + UiProjection, + }; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{mpsc, Arc}; use tauri_utils::config::BundleType; + #[test] + fn desktop_snapshot_serializes_the_bounded_wire_fields() { + let state = DesktopUpdateState::new("2.61.0".into()); + state.publish("available", Some("2.62.0".into()), Some(1_790_000_000_000)); + let value = serde_json::to_value(state.tx.borrow().clone()).unwrap(); + assert!(uuid::Uuid::parse_str(state.session_id()).is_ok()); + assert_eq!(value["sessionId"], state.session_id()); + assert_eq!(value["currentVersion"], "2.61.0"); + assert_eq!(value["latestVersion"], "2.62.0"); + assert_eq!(value["available"], true); + assert_eq!(value["checkedAtMs"], 1_790_000_000_000u64); + assert!(value["checkedAtMs"].as_u64().unwrap() >= 946_684_800_000); + assert_eq!(value["phase"], "available"); + assert_eq!(value.as_object().unwrap().len(), 6); + } + + #[test] + fn wake_preserves_a_snapshot_published_during_notification() { + let state = DesktopUpdateState::new("2.61.0".into()); + state.publish("checking", None, None); + state.wake_with_before_notify(|| { + state.publish("available", Some("2.62.0".into()), Some(123)); + }); + let snapshot = state.tx.borrow(); + assert_eq!(snapshot.phase, "available"); + assert_eq!(snapshot.latest_version.as_deref(), Some("2.62.0")); + assert_eq!(snapshot.checked_at_ms, Some(123)); + } + + #[test] + fn wake_notifies_without_changing_the_snapshot() { + let state = DesktopUpdateState::new("2.61.0".into()); + let mut receiver = state.tx.subscribe(); + let before = serde_json::to_value(receiver.borrow_and_update().clone()).unwrap(); + state.wake(); + assert!(receiver.has_changed().unwrap()); + let after = serde_json::to_value(receiver.borrow_and_update().clone()).unwrap(); + assert_eq!(after, before); + } + + #[test] + fn a_delayed_older_none_cannot_clear_a_newer_pending_update() { + let checks = CheckGeneration::default(); + let installing = AtomicBool::new(false); + let (older, _) = checks.begin_if_not_installing(&installing, || {}).unwrap(); + let (newer, _) = checks.begin_if_not_installing(&installing, || {}).unwrap(); + let mut pending: Option<&str> = None; + let mut phase = "checking"; + assert_eq!( + checks.apply_if_current(newer, || { + pending = Some("2.62.0"); + phase = "available"; + }), + Some(()) + ); + assert_eq!( + checks.apply_if_current(older, || { + pending = None; + phase = "current"; + }), + None + ); + assert_eq!(pending, Some("2.62.0")); + assert_eq!(phase, "available"); + let (third, _) = checks.begin_if_not_installing(&installing, || {}).unwrap(); + assert_eq!( + checks.apply_if_current(newer, || { + pending = None; + }), + None + ); + assert_eq!(pending, Some("2.62.0")); + assert_eq!( + checks.apply_if_current(third, || { + pending = None; + }), + Some(()) + ); + assert_eq!(pending, None); + } + + #[test] + fn checking_publication_rechecks_install_claim_inside_the_gate() { + let checks = CheckGeneration::default(); + let installing = AtomicBool::new(false); + assert_eq!( + checks.claim_install(&installing, || Some("2.66.0".into()), || {}), + InstallClaim::Claimed + ); + let mut published = false; + assert_eq!( + checks.begin_if_not_installing(&installing, || { + published = true; + }), + None + ); + assert!(!published); + } + + #[test] + fn install_claim_has_one_winner_and_can_retry_after_failure() { + let gate = CheckGeneration::default(); + let installing = AtomicBool::new(false); + assert_eq!( + gate.claim_install(&installing, || Some("2.66.0".into()), || {}), + InstallClaim::Claimed + ); + assert_eq!(gate.install_epoch.load(Ordering::Acquire), 1); + assert_eq!( + gate.claim_install(&installing, || Some("2.66.0".into()), || {}), + InstallClaim::Busy + ); + assert_eq!(gate.install_epoch.load(Ordering::Acquire), 1); + installing.store(false, Ordering::Release); + assert_eq!( + gate.claim_install(&installing, || Some("2.66.0".into()), || {}), + InstallClaim::Claimed + ); + assert_eq!(gate.install_epoch.load(Ordering::Acquire), 2); + } + + #[test] + fn install_click_without_pending_leaves_in_flight_check_valid() { + let gate = CheckGeneration::default(); + let installing = AtomicBool::new(false); + let (generation, epoch) = gate.begin_if_not_installing(&installing, || {}).unwrap(); + let revision = gate.latest_ui_revision.load(Ordering::Acquire); + let mut claimed_hook = false; + assert_eq!( + gate.claim_install( + &installing, + || None, + || { + claimed_hook = true; + } + ), + InstallClaim::NoPending + ); + assert!(!claimed_hook); + assert!(!installing.load(Ordering::Acquire)); + assert_eq!(gate.install_epoch.load(Ordering::Acquire), 0); + assert_eq!(gate.latest_ui_revision.load(Ordering::Acquire), revision); + assert!(gate.epoch_is_current(epoch)); + assert_eq!( + gate.apply_if_current(generation, || "current"), + Some("current") + ); + } + + #[test] + fn page_check_started_before_tray_check_cannot_override_it_in_either_completion_order() { + let gate = CheckGeneration::default(); + let installing = AtomicBool::new(false); + let mut pending = Some("previous"); + let mut phase = "available"; + + let (page, _) = gate + .begin_if_not_installing(&installing, || { + phase = "checking"; + }) + .unwrap(); + let (tray, _) = gate + .begin_if_not_installing(&installing, || { + phase = "checking"; + }) + .unwrap(); + assert_eq!( + gate.apply_if_current(page, || { + pending = None; + phase = "current"; + }), + None + ); + assert_eq!((pending, phase), (Some("previous"), "checking")); + assert_eq!( + gate.apply_if_current(tray, || { + pending = Some("tray"); + phase = "available"; + }), + Some(()) + ); + assert_eq!((pending, phase), (Some("tray"), "available")); + + let (page, _) = gate + .begin_if_not_installing(&installing, || { + phase = "checking"; + }) + .unwrap(); + let (tray, _) = gate + .begin_if_not_installing(&installing, || { + phase = "checking"; + }) + .unwrap(); + assert_eq!( + gate.apply_if_current(tray, || { + pending = Some("new tray"); + phase = "available"; + }), + Some(()) + ); + assert_eq!( + gate.apply_if_current(page, || { + pending = None; + phase = "current"; + }), + None + ); + assert_eq!((pending, phase), (Some("new tray"), "available")); + } + + #[test] + fn install_claim_cannot_land_between_check_guard_and_pending_tray_write() { + let gate = Arc::new(CheckGeneration::default()); + let installing = Arc::new(AtomicBool::new(false)); + let (generation, epoch) = gate.begin_if_not_installing(&installing, || {}).unwrap(); + let (attempt_tx, attempt_rx) = mpsc::channel(); + let (claimed_tx, claimed_rx) = mpsc::channel(); + let mut pending = None; + let mut tray_visible = false; + + let claim_thread = gate + .apply_if_current(generation, || { + assert!(!installing.load(Ordering::Acquire)); + assert!(gate.epoch_is_current(epoch)); + let claim_gate = Arc::clone(&gate); + let claim_flag = Arc::clone(&installing); + let thread = std::thread::spawn(move || { + attempt_tx.send(()).unwrap(); + claimed_tx + .send(claim_gate.claim_install( + &claim_flag, + || Some("signed update".into()), + || {}, + )) + .unwrap(); + }); + attempt_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(); + assert_eq!( + claimed_rx.recv_timeout(std::time::Duration::from_millis(25)), + Err(mpsc::RecvTimeoutError::Timeout) + ); + pending = Some("signed update"); + tray_visible = true; + assert!(!installing.load(Ordering::Acquire)); + thread + }) + .unwrap(); + assert_eq!((pending, tray_visible), (Some("signed update"), true)); + assert_eq!( + claimed_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(), + InstallClaim::Claimed + ); + claim_thread.join().unwrap(); + assert!(installing.load(Ordering::Acquire)); + assert!(!gate.epoch_is_current(epoch)); + } + + #[test] + fn status_read_completes_while_check_ui_setter_is_blocked() { + let gate = Arc::new(CheckGeneration::default()); + let installing = AtomicBool::new(false); + let (generation, _) = gate.begin_if_not_installing(&installing, || {}).unwrap(); + let mut pending = None; + assert_eq!( + gate.apply_if_current(generation, || { + pending = Some("signed update"); + gate.queue_ui(UiProjection::Available("2.66.0".into())); + }), + Some(()) + ); + let projected = gate.ui.borrow().clone().unwrap(); + let (setter_entered_tx, setter_entered_rx) = mpsc::channel(); + let (status_returned_tx, status_returned_rx) = mpsc::channel(); + let setter_gate = Arc::clone(&gate); + let setter = std::thread::spawn(move || { + setter_gate.apply_ui_projection_if_current(projected, |_| { + setter_entered_tx.send(()).unwrap(); + status_returned_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(); + }) + }); + setter_entered_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(); + let read_gate = Arc::clone(&gate); + let (read_tx, read_rx) = mpsc::channel(); + let reader = std::thread::spawn(move || { + read_tx.send(read_gate.inspect(|| "available")).unwrap(); + }); + assert_eq!( + read_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(), + "available" + ); + status_returned_tx.send(()).unwrap(); + reader.join().unwrap(); + assert!(setter.join().unwrap()); + assert_eq!(pending, Some("signed update")); + } + + #[test] + fn superseded_ui_projection_never_enters_its_setter() { + let gate = CheckGeneration::default(); + gate.inspect(|| gate.queue_ui(UiProjection::Current)); + let old = gate.ui.borrow().clone().unwrap(); + gate.inspect(|| gate.queue_ui(UiProjection::Available("2.66.0".into()))); + let newest = gate.ui.borrow().clone().unwrap(); + assert!(!gate.apply_ui_projection_if_current(old, |_| panic!("stale setter ran"))); + let mut applied = false; + assert!(gate.apply_ui_projection_if_current(newest, |_| applied = true)); + assert!(applied); + } + #[test] fn formats_update_menu_label() { assert_eq!(update_label("2.62.0"), "Install update v2.62.0"); diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index 81969f7189e..f53cf068261 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -70,23 +70,61 @@ pub fn navigation_allowed(app: AppHandle) -> impl Fn(&Url) -> bool { /// generally, nor a name that merely ends in it, is this origin. fn is_app_origin(url: &Url) -> bool { match url.scheme() { - "tauri" => true, + "tauri" => url.host_str() == Some("localhost") && url.port().is_none(), "http" => url.host_str() == Some("tauri.localhost") && url.port().is_none(), _ => false, } } +pub fn require_update_page(window: &WebviewWindow) -> Result<(), String> { + if window.label() != "main" { + return Err("update page unavailable".into()); + } + let url = window.url().map_err(|_| "update page unavailable")?; + if !is_update_page_url(&url) { + return Err("update page unavailable".into()); + } + Ok(()) +} + +fn is_update_page_url(url: &Url) -> bool { + is_app_origin(url) && url.path() == "/update.html" +} + pub fn show(window: &WebviewWindow) { let _ = window.show(); let _ = window.set_focus(); + report_visibility(window, true); apply_tray_policy(window.app_handle(), true); } pub fn hide(window: &WebviewWindow) { let _ = window.hide(); + report_visibility(window, false); apply_tray_policy(window.app_handle(), false); } +/// Tell the main window's page whether its host window is visible. +/// +/// Windows WebView2 does not flip `document.visibilityState` when the host window is hidden +/// (tauri issues #10592 and #6864), so the dashboard's pollers keep running while the app sits in +/// the tray; macOS WKWebView does flip it. Publishing the host's own answer gives the GUI one +/// signal on every platform instead of one that is correct on only some of them. +/// +/// Only the `main` window publishes: `exit::hide_windows` hides every window through `hide`, +/// and the tray popup carries its own equivalent bridge, so an unguarded report would claim the +/// dashboard was hidden because a popup was. A page that has not loaded yet simply misses the eval; +/// the page-load hook re-sends the current state. +pub fn report_visibility(window: &WebviewWindow, visible: bool) { + if window.label() != "main" { + return; + } + let script = format!( + "window.__OPENCODEX_HOST_VISIBLE__ = {visible}; window.dispatchEvent(new CustomEvent('opencodex:host-visibility', {{detail: {visible}}}));" + ); + let _ = window.eval(script); +} + #[cfg(target_os = "macos")] fn apply_tray_policy(app: &AppHandle, visible: bool) { let policy = if visible { @@ -107,7 +145,7 @@ pub fn set_tray_policy(app: &AppHandle, visible: bool) { #[cfg(test)] mod tests { - use super::{is_app_origin, webview_user_agent}; + use super::{is_app_origin, is_update_page_url, webview_user_agent}; use tauri::Url; fn url(value: &str) -> Url { @@ -144,6 +182,24 @@ mod tests { } } + #[test] + fn only_the_bundled_update_page_has_update_commands() { + for value in [ + "tauri://localhost/update.html", + "http://tauri.localhost/update.html", + ] { + assert!(is_update_page_url(&url(value)), "{value}"); + } + for value in [ + "http://127.0.0.1:10100/update.html", + "tauri://evil/update.html", + "tauri://localhost/index.html", + "http://tauri.localhost/update.html.evil", + ] { + assert!(!is_update_page_url(&url(value)), "{value}"); + } + } + #[test] fn webview_user_agent_marks_the_desktop_shell() { let user_agent = webview_user_agent(); @@ -157,4 +213,47 @@ mod tests { assert!(user_agent.contains("(X11; Linux x86_64)")); } } + + /// The zoom polyfill runs inside the loopback dashboard, which is a remote origin to Tauri. The + /// capability that lets it call `set_webview_zoom` is the only one reaching that origin, so it + /// stays pinned to this window, this origin and this one command. + #[test] + fn the_dashboard_reaches_only_the_zoom_command() { + let zoom: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/dashboard-zoom.json")) + .expect("dashboard-zoom capability is JSON"); + assert_eq!(zoom["windows"], serde_json::json!(["main"])); + assert_eq!( + zoom["remote"]["urls"], + serde_json::json!(["http://127.0.0.1:*"]) + ); + assert_eq!( + zoom["permissions"], + serde_json::json!(["core:webview:allow-set-webview-zoom"]) + ); + + // Tauri matches the origin with URLPattern; the dashboard is the loopback endpoint on + // whatever port it resolved to, and nothing beside it. + let pattern: tauri_utils::acl::RemoteUrlPattern = + "http://127.0.0.1:*".parse().expect("a URL pattern"); + let dashboard = crate::endpoint::ProxyEndpoint { + host: "127.0.0.1", + port: 10100, + } + .url("/#/usage"); + assert!(pattern.test(&url(&dashboard)), "{dashboard}"); + for value in [ + "http://localhost:10100/", + "https://127.0.0.1:10100/", + "http://127.0.0.2:10100/", + "http://example.com/", + ] { + assert!(!pattern.test(&url(value)), "{value}"); + } + + let default: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/default.json")) + .expect("default capability is JSON"); + assert!(default.get("remote").is_none()); + } } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 0e968a1e23f..65293228b2b 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.64.0-preview.20260923", + "version": "2.65.0-preview.20260925", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 7829f0d2970..ba46c917900 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -5,52 +5,109 @@ OpenCodex -
-

OpenCodex

-

Starting OpenCodex…

+
+
+ +

OpenCodex

+
+ +

Starting OpenCodex…

-
    - -