fix(agent): scope output budgets by provider protocol

Problem:
The reasoning byte guard was converted into one 32K provider token limit and sent to every backend, coupling a local safety fallback to unrelated provider wire contracts.

Root cause:
The agent owned both controls, so OpenAI-compatible, Responses, Anthropic, DeepSeek, and unknown gateways could not choose model-appropriate fields or defaults.

Fix:
Keep the client byte guard independent, add provider and per-model max_output_tokens configuration, default only official DeepSeek endpoints to 32K, and serialize each supported protocol's native field while leaving unknown gateways unchanged unless configured. Merge current main-v2 and retain both status-bar test suites.

Verification:
go test focused provider/agent/config/boot suites; go test -race focused suites; desktop go test; frontend test/build/typecheck; cache guard; go vet; diff/gofmt checks.

Co-authored-by: SivanCola <[email protected]>
This commit is contained in:
SivanColaandSivanCola committed 2026-08-03 12:40:40 +08:00
commit 12c4dbb624
224 files changed
+10055 -5888

No files matched your search

-3
View File
@@ -7,10 +7,7 @@
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-preview.yml @SivanCola @esengine
/.github/workflows/release-cli-trigger.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/.github/workflows/release-desktop-trigger.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
/scripts/package-windows-desktop.sh @SivanCola @esengine
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
+5 -3
View File
@@ -398,12 +398,14 @@ jobs:
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/release-preview.yml \
.github/workflows/release-cli-trigger.yml \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-stable-trigger.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml
.github/workflows/release-desktop.yml \
.github/workflows/release-verify-issues.yml
bash scripts/release-workflows.test.sh
node scripts/check-single-release-public-contract.mjs
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
+23 -54
View File
@@ -1,20 +1,12 @@
name: Prepare release notes
name: Prepare release
on:
workflow_dispatch:
inputs:
version:
description: "Exact release version without a tag prefix (for example 1.18.0 or 1.19.0-preview.3)"
description: "Official version without a tag prefix (for example 1.20.0)"
required: true
type: string
from_tag:
description: "Previous release tag (defaults to the newest catalog entry)"
required: false
type: string
target_pr:
description: "Optional open same-repository PR into main-v2 to reuse instead of opening a release-only PR"
required: false
type: string
permissions:
contents: write
@@ -36,51 +28,24 @@ jobs:
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
VERSION: ${{ inputs.version }}
TARGET_PR: ${{ inputs.target_pr }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main-v2
if [ -n "$TARGET_PR" ]; then
if [[ ! "$TARGET_PR" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::target_pr must be a positive pull request number"
exit 1
fi
mapfile -t pr_state < <(
gh pr view "$TARGET_PR" --repo "$REPOSITORY" \
--json state,baseRefName,headRefName,isCrossRepository \
--jq '[.state, .baseRefName, .headRefName, (.isCrossRepository | tostring)][]'
)
if [ "${pr_state[0]}" != "OPEN" ] || [ "${pr_state[1]}" != "main-v2" ]; then
echo "::error::target_pr must be an open pull request into main-v2"
exit 1
fi
if [ "${pr_state[3]}" != "false" ]; then
echo "::error::target_pr comes from a fork; the repository token cannot safely update that branch"
exit 1
fi
branch="${pr_state[2]}"
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::version must be MAJOR.MINOR.PATCH"
exit 1
fi
branch="release-notes/v${VERSION}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
if ! git merge-base --is-ancestor origin/main-v2 HEAD; then
echo "::error::target_pr must include the latest main-v2 before release notes are generated"
exit 1
fi
echo "RELEASE_NOTES_PR=$TARGET_PR" >> "$GITHUB_ENV"
git merge --no-edit origin/main-v2
else
branch="release-notes/v${VERSION#v}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
git merge --no-edit origin/main-v2
else
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_PR=" >> "$GITHUB_ENV"
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
@@ -90,12 +55,9 @@ jobs:
DEEPSEEK_MODEL: deepseek-v4-pro
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
FROM_TAG: ${{ inputs.from_tag }}
run: |
set -euo pipefail
args=(--version "$VERSION")
if [ -n "$FROM_TAG" ]; then args+=(--from "$FROM_TAG"); fi
node scripts/generate-release-notes.mjs "${args[@]}"
node scripts/generate-release-notes.mjs --version "$VERSION"
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
@@ -121,12 +83,19 @@ jobs:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
fi
git push -u origin "$RELEASE_NOTES_BRANCH"
if [ -n "$RELEASE_NOTES_PR" ]; then
echo "Updated existing release-bound PR #$RELEASE_NOTES_PR"
elif ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
gh pr create \
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
if ! gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
--body-file /tmp/release-notes.md
--body-file /tmp/release-notes.md; then
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
{
echo "## Manual PR handoff"
echo
echo '```sh'
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi
fi
-39
View File
@@ -1,39 +0,0 @@
name: Relay CLI prerelease
run-name: Relay CLI prerelease ${{ github.ref_name }}
# Keep native CLI publication on protected main-v2. The tag-triggered workflow
# carries only the immutable candidate tag and its classified channel into the
# protected release control plane.
on:
push:
tags: ["v*-*"]
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected CLI control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release.yml on main-v2
uses: actions/github-script@v9
env:
RELEASE_TAG: ${{ github.ref_name }}
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
with:
script: |
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
return;
}
const tag = process.env.RELEASE_TAG;
const preview = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(tag);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: preview ? 'release-preview.yml' : 'release.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: preview ? { tag } : { channel: 'stable', tag },
});
@@ -1,43 +0,0 @@
name: Relay desktop prerelease
run-name: Relay desktop prerelease ${{ github.ref_name }}
# Keep the production signing build on protected main-v2. The tag-triggered
# relay carries only the immutable candidate tag into the protected workflow.
on:
push:
tags: ["desktop-v*-*"]
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected desktop control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release-desktop.yml on main-v2
uses: actions/github-script@v9
env:
RELEASE_TAG: ${{ github.ref_name }}
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
with:
script: |
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
return;
}
if (/^desktop-v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(process.env.RELEASE_TAG)) {
core.setFailed(`Desktop Preview must be dispatched without a Git tag: ${process.env.RELEASE_TAG}`);
return;
}
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-desktop.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
channel: 'stable',
tag: process.env.RELEASE_TAG,
},
});
+25 -19
View File
@@ -1,10 +1,8 @@
name: Release desktop
# Desktop (Wails) release line. Stable releases are called by
# release-stable.yml after its single GitHub environment approval. Desktop
# prerelease tags are relayed by release-desktop-trigger.yml to this workflow on
# protected main-v2. Production SignPath requests therefore always have the
# exact main-v2 origin; both public channels use the production policy.
# Desktop (Wails) release line. Official releases are called by
# release-stable.yml after its single GitHub environment approval. Production
# SignPath requests therefore have the exact protected main-v2 origin.
#
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
@@ -12,27 +10,21 @@ name: Release desktop
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
#
# The same build/sign/manifest pipeline serves two channels. Approved
# orchestrators publish Stable or Preview, while manual dispatch supports
# Stable/RC recovery and non-publishing Preview signing checks. Preview advances
# only R2's preview/ pointer and never appears on the GitHub releases page. The
# legacy canary/ pointer is mirrored for clients released before the rename.
# Historical workflow-call inputs still understand Preview artifacts so old
# runs remain diagnosable. They are not exposed by manual dispatch and cannot
# create a new public Preview release.
on:
workflow_dispatch:
inputs:
channel:
description: "Release channel"
description: "Recovery channel"
type: choice
options: [stable, preview]
options: [stable]
default: stable
tag:
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
required: false
type: string
base_version:
description: "Preview signing checks: intended next stable version, e.g. 1.5.0"
required: false
type: string
production_signing_smoke:
description: "Wait for external SignPath approval, verify trust, and do not publish"
required: false
@@ -73,12 +65,12 @@ on:
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
preview_number:
description: "Canonical Preview ordinal supplied by release-preview.yml"
description: "Legacy Preview ordinal for old workflow-call compatibility"
required: false
default: ""
type: string
@@ -501,12 +493,26 @@ jobs:
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
run: cp signed-installer/*installer*.exe dist/
# Recovery builds intentionally check out the immutable product SHA,
# which can predate a verifier repair. Fetch only the verifier from the
# protected workflow commit so recovery can validate old immutable
# artifacts without rebuilding them from a moving product branch.
- name: Checkout protected release verifier
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
path: release-control
sparse-checkout: scripts/verify-windows-authenticode.ps1
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Verify Windows Authenticode release contract
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: pwsh
run: |
$arch = if ("${{ matrix.platform }}" -eq "windows/arm64") { "arm64" } else { "amd64" }
./scripts/verify-windows-authenticode.ps1 `
./release-control/scripts/verify-windows-authenticode.ps1 `
-PayloadDirectory signed-payload `
-InstallerPath "dist/Reasonix-windows-$arch-installer.exe" `
-PortableArchivePath "dist/Reasonix-windows-$arch.zip" `
+22 -23
View File
@@ -1,23 +1,11 @@
name: Release npm
# npm line. Stable releases are called by release-stable.yml after its single
# GitHub environment approval. Direct tag pushes are retained only for
# prereleases so npm `next` can still ship independently without touching the
# stable Homebrew cask. Bump a prerelease with:
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
# git push origin <tag>
#
# Manual workflow_dispatch recovers only an approved Stable version from an
# existing tag. Public canary publication is owned by release-preview.yml so its
# version remains aligned with CLI, Desktop, and the immutable release marker.
# build.mjs decides the dist-tag: a `-canary.` build is `canary`, any other
# prerelease is `next`, and a stable version is `latest` (#5822 — the old
# "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
# `npm update -g` users). The publisher verifies every immutable package and
# advances each public dist-tag only when the candidate is newer.
# GitHub environment approval. Direct prerelease tag publication is disabled;
# manual workflow_dispatch recovers only an approved Stable version from an
# existing tag. Historical canary and next package identities remain readable,
# but normal publication advances only the official release.
on:
push:
tags: ['npm-v*-*']
workflow_dispatch:
inputs:
channel:
@@ -64,12 +52,12 @@ on:
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
preview_number:
description: "Canonical Preview ordinal supplied by release-preview.yml"
description: "Legacy Preview ordinal for old workflow-call compatibility"
required: false
default: ""
type: string
@@ -78,8 +66,8 @@ permissions:
contents: read
concurrency:
# Serialize every publisher for one npm dist-tag. In particular, canary
# remains ordered even if another approved orchestrator is added later.
# Serialize every publisher for one npm dist-tag. Historical canary calls
# remain ordered even though normal publication now uses Stable only.
group: release-npm-${{ inputs.channel || 'next' }}
cancel-in-progress: false
@@ -157,9 +145,9 @@ jobs:
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# Tag push uses the npm-v* tag. The Preview orchestrator synthesizes a
# <base>-canary.<preview_number> version; a manual Stable dispatch publishes
# the requested semver exactly. build.mjs strips the leading `npm-`/`v`.
# Stable publication uses the exact npm-v* tag. build.mjs strips the
# leading `npm-`/`v`; historical Preview inputs remain only for old
# workflow-call compatibility and are not reachable from a public entry.
- name: Resolve version
id: ver
env:
@@ -183,3 +171,14 @@ jobs:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/build.mjs "$VERSION_ARG" --publish
- name: Align legacy aliases with the official release
if: ${{ inputs.channel == 'stable' }}
env:
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
candidate="$APPROVED_SHA"
if [ -z "$candidate" ]; then candidate="$(git rev-parse HEAD)"; fi
EXPECTED_SHA="$candidate" node scripts/finalize-npm-official-release.mjs "$VERSION"
-219
View File
@@ -1,219 +0,0 @@
name: Release preview
run-name: Release preview ${{ inputs.tag }}
# One protected Preview event owns the public CLI, Desktop, npm, and changelog
# identities. The CLI tag supplies the canonical preview ordinal; child
# publishers retain their native version syntax but use that same ordinal.
on:
workflow_dispatch:
inputs:
tag:
description: "Existing protected CLI Preview tag, e.g. v1.19.0-preview.3"
required: true
type: string
recovery:
description: "Recover an existing partially published Preview from main-v2 history"
required: false
default: false
type: boolean
concurrency:
# Every Preview version advances the same public CLI, Desktop, and npm
# channels. Keep the whole all-surface event serialized across versions.
group: preview-release
cancel-in-progress: false
permissions:
actions: write
contents: write
jobs:
preflight:
name: validate Preview release event
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
base_version: ${{ steps.release.outputs.base_version }}
preview_number: ${{ steps.release.outputs.preview_number }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Resolve Preview release
id: release
env:
RELEASE_TAG: ${{ inputs.tag }}
ALLOW_PREVIEW_RECOVERY: ${{ inputs.recovery }}
run: bash scripts/resolve-preview-release.sh
- name: Validate exact reviewed Preview notes
env:
VERSION: ${{ steps.release.outputs.version }}
RELEASE_SHA: ${{ steps.release.outputs.sha }}
run: |
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
node scripts/release-event.mjs generate \
--version "$VERSION" \
--sha "$RELEASE_SHA" \
--published-at 2000-01-01T00:00:00.000Z \
--output /tmp/release-event.json
- name: Upload reviewed release notes
uses: actions/upload-artifact@v7
with:
name: orchestrator-reviewed-release-notes
path: /tmp/release-notes.md
if-no-files-found: error
retention-days: 1
authorize:
name: approve Preview release
needs: preflight
runs-on: ubuntu-latest
environment: canary
outputs:
version: ${{ steps.approved.outputs.version }}
base_version: ${{ steps.approved.outputs.base_version }}
preview_number: ${{ steps.approved.outputs.preview_number }}
cli_tag: ${{ steps.approved.outputs.cli_tag }}
sha: ${{ steps.approved.outputs.sha }}
steps:
- name: Record approved Preview event
id: approved
env:
VERSION: ${{ needs.preflight.outputs.version }}
BASE_VERSION: ${{ needs.preflight.outputs.base_version }}
PREVIEW_NUMBER: ${{ needs.preflight.outputs.preview_number }}
CLI_TAG: ${{ needs.preflight.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.preflight.outputs.sha }}
run: |
{
echo "version=$VERSION"
echo "base_version=$BASE_VERSION"
echo "preview_number=$PREVIEW_NUMBER"
echo "cli_tag=$CLI_TAG"
echo "sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
echo "Approved Preview release $VERSION at $RELEASE_SHA"
signpath-preflight:
name: verify Preview SignPath control plane
needs: authorize
uses: ./.github/workflows/release-desktop.yml
with:
channel: preview
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
signing_preflight: true
secrets: inherit
cli:
name: publish CLI Preview
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release.yml
with:
channel: preview
tag: ${{ needs.authorize.outputs.cli_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
allow_preview_recovery: ${{ inputs.recovery }}
secrets: inherit
npm:
name: publish npm Canary
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release-npm.yml
with:
channel: canary
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
secrets: inherit
desktop:
name: publish Desktop Preview
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release-desktop.yml
with:
channel: preview
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
signing_preflight_verified: true
secrets: inherit
postflight:
name: publish Preview release record
needs: [authorize, cli, npm, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every Preview publisher to succeed
env:
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
set -euo pipefail
for surface in CLI NPM DESKTOP; do
result_var="${surface}_RESULT"
if [ "${!result_var}" != "success" ]; then
echo "::error::$surface Preview publisher result is ${!result_var}, expected success"
exit 1
fi
done
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Upload immutable public release marker
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.authorize.outputs.version }}
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.authorize.outputs.sha }}
run: |
set -euo pipefail
node scripts/release-event.mjs generate \
--version "$VERSION" --sha "$RELEASE_SHA" \
--published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \
--output /tmp/release-event.json
existing="$(mktemp -d)"
if gh release download "$CLI_TAG" --pattern release-event.json --dir "$existing" 2>/dev/null; then
cmp -s /tmp/release-event.json "$existing/release-event.json" || {
echo "::error::published release-event.json differs from the approved Preview event"
exit 1
}
else
gh release upload "$CLI_TAG" /tmp/release-event.json
fi
- name: Refresh public changelog
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+15 -3
View File
@@ -4,9 +4,9 @@ run-name: Release stable ${{ inputs.tag || github.ref_name }}
# Stable publication has exactly one GitHub environment gate. Push the CLI,
# npm, and desktop tags atomically; release-stable-trigger.yml relays the vX.Y.Z
# tag to this workflow on protected main-v2. Preflight verifies that all three
# tags point to the current main-v2 commit, and one GitHub approval releases
# every channel. After approval, a zero-publication Windows preflight verifies
# both architectures and signing stages before any public channel starts.
# tags point to the reviewed Notes candidate on main-v2 history, and one GitHub
# approval releases every surface. After approval, a zero-publication Windows
# preflight verifies both architectures and signing stages before publication.
# Keeping the control-plane ref on main-v2 lets SignPath restrict
# production signing to that one protected origin instead of trusting wildcard
# tag-like branch names. Manual recovery uses the same fixed control plane while
@@ -54,6 +54,7 @@ jobs:
name: validate stable release set
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
@@ -81,6 +82,17 @@ jobs:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}
run: bash scripts/resolve-stable-release.sh
- name: Revalidate normal release candidate and exact push CI
if: ${{ !inputs.allow_recovery }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_CI_WAIT_SECONDS: 60
RELEASE_VERSION: ${{ steps.release.outputs.version }}
RELEASE_SHA: ${{ steps.release.outputs.sha }}
run: |
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
- name: Validate reviewed release notes
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
# Recovery builds deliberately check out the immutable tagged candidate,
+10 -12
View File
@@ -1,22 +1,20 @@
name: Release
# Native CLI binary line. Stable and Preview releases are called by their
# approved all-surface orchestrators. Manual dispatch remains available only for
# Stable/RC recovery; standalone prereleases remain internal stable-channel
# checkpoints behind the `release` gate.
# Native CLI binary line. Official releases are called by the protected Stable
# orchestrator. Manual dispatch remains available only for official recovery;
# historical Preview inputs below are workflow-call compatibility, not a public
# publication entrypoint.
on:
workflow_dispatch:
inputs:
channel:
description: "Standalone CLI recovery channel"
required: true
# Public Preview recovery reruns release-preview.yml so every surface and
# its immutable release-event marker stay aligned.
default: stable
type: choice
options: [stable]
tag:
description: "Existing protected Stable/RC CLI tag (for example v1.18.0 or v1.18.0-rc.1)"
description: "Existing official CLI tag (for example v1.18.0)"
required: true
type: string
workflow_call:
@@ -44,12 +42,12 @@ on:
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
allow_preview_recovery:
description: "Allow an approved Preview tag from main-v2 history"
description: "Legacy compatibility for already-created Preview runs"
required: false
default: false
type: boolean
@@ -187,9 +185,9 @@ jobs:
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Standalone prereleases pass release-gate above. This job therefore must not
# add a second GitHub environment approval.
# The Stable caller has already passed the single GitHub release approval.
# Official standalone recovery passes release-gate above. This job therefore
# must not add a second GitHub environment approval.
steps:
- uses: actions/checkout@v7
with:
-4
View File
@@ -6,15 +6,11 @@ allowed_branch_names:
- main-v2
allowed_build_definitions:
- .github/workflows/release-stable.yml
- .github/workflows/release-preview.yml
- .github/workflows/release-desktop.yml
fingerprint_files:
- .github/workflows/release-stable.yml
- .github/workflows/release-stable-trigger.yml
- .github/workflows/release-preview.yml
- .github/workflows/release-cli-trigger.yml
- .github/workflows/release-desktop.yml
- .github/workflows/release-desktop-trigger.yml
- .signpath/artifact-configurations/windows-payload.xml
- .signpath/artifact-configurations/windows-installer-v2.xml
- scripts/complete-signpath-request.ps1
+1 -1
View File
@@ -141,7 +141,7 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and Safe Mode](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Recovery and updates](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
-381
View File
@@ -1,381 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/provider"
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/provider/responses"
"reasonix/internal/repair"
)
func runAssist(args []string) int {
fs := flag.NewFlagSet("reasonix-guard assist", flag.ContinueOnError)
root := fs.String("root", ".", "project root to diagnose")
model := fs.String("model", "", "provider or provider/model used for the one-shot plan")
network := fs.Bool("network", true, "include provider connectivity diagnostics")
apply := fs.Bool("apply", false, "apply the validated plan after preview and confirmation")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow a plan to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print the plan and preview as JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
report, err := repair.Diagnose(ctx, repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := requestRepairPlan(ctx, *root, *model, report)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(struct {
Plan repair.RepairPlan `json:"plan"`
PlanID string `json:"planId"`
Preview []repair.RepairPlanPreview `json:"preview"`
PreviewID string `json:"previewId"`
}{plan, repair.RepairPlanID(plan), preview, repair.RepairPlanPreviewID(plan, preview)}); code != 0 {
return code
}
} else {
printPlanPreview(plan, preview)
}
if !*apply {
return 0
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = repair.RepairPlanPreviewID(plan, preview)
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func runApplyPlan(args []string) int {
fs := flag.NewFlagSet("reasonix-guard apply-plan", flag.ContinueOnError)
root := fs.String("root", ".", "project root")
file := fs.String("file", "", "RepairPlan JSON file")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow project reasonix.toml repair")
previewID := fs.String("preview-id", "", "expected preview ID from a prior dry-run")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*file) == "" {
return 2
}
b, err := os.ReadFile(*file)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := repair.DecodeRepairPlan(b)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if !*jsonOut {
printPlanPreview(plan, preview)
}
actualPreviewID := repair.RepairPlanPreviewID(plan, preview)
expectedPreviewID := strings.TrimSpace(*previewID)
if *yes && expectedPreviewID == "" {
fmt.Fprintln(os.Stderr, "error: --preview-id is required with --yes; preview the plan and confirm its current previewId first")
return 2
}
if expectedPreviewID != "" && expectedPreviewID != actualPreviewID {
fmt.Fprintf(os.Stderr, "error: repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)\n", expectedPreviewID, actualPreviewID)
return 1
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = actualPreviewID
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func requestRepairPlan(ctx context.Context, root, modelRef string, report repair.DiagnosticReport) (repair.RepairPlan, error) {
cfg, err := loadAIProviderConfig(root)
if err != nil {
return repair.RepairPlan{}, err
}
if strings.TrimSpace(modelRef) == "" {
modelRef = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(modelRef)
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI assistance model %q is not configured", modelRef)
}
if entry.RequiresAPIKey() && entry.APIKey() == "" {
return repair.RepairPlan{}, fmt.Errorf("AI assistance provider %q has no configured API key", entry.Name)
}
p, err := provider.New(entry.Kind, repairPlanProviderConfig(entry, cfg.NetworkProxySpec()))
if err != nil {
return repair.RepairPlan{}, err
}
safeReport, snapshotAliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
return repair.RepairPlan{}, err
}
stream, err := p.Stream(ctx, provider.Request{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: repairPlanSystemPrompt},
{Role: provider.RoleUser, Content: string(payload)},
},
Temperature: provider.TemperaturePtr(0),
MaxTokens: 2000,
})
if err != nil {
return repair.RepairPlan{}, err
}
var out strings.Builder
for chunk := range stream {
switch chunk.Type {
case provider.ChunkText:
out.WriteString(chunk.Text)
case provider.ChunkError:
if chunk.Err != nil {
return repair.RepairPlan{}, chunk.Err
}
}
}
plan, err := repair.DecodeRepairPlan([]byte(out.String()))
if err != nil {
return repair.RepairPlan{}, err
}
return resolveProviderSnapshotAliases(plan, snapshotAliases)
}
func repairPlanProviderConfig(entry *config.ProviderEntry, proxy netclient.ProxySpec) provider.Config {
return provider.Config{
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
Extra: map[string]any{
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
"thinking": entry.Thinking, "effort": config.EffectiveEffort(entry), "supported_efforts": entry.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(entry),
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
"auth_header": entry.AuthHeader, "proxy_spec": proxy,
"vision": config.EffectiveVision(entry), "vision_detail": entry.VisionDetail,
},
}
}
func loadAIProviderConfig(root string) (*config.Config, error) {
// Recovery tooling must never rewrite user configuration, so skip the
// on-disk legacy MCP tier migration that LoadForRoot performs.
if cfg, err := config.LoadForRootReadOnly(root); err == nil {
return cfg, nil
}
if snapshots, err := repair.ListConfigSnapshots(); err == nil {
for _, snapshot := range snapshots {
cfg, loadErr := config.LoadForEditReadOnlyStrict(snapshot.Path)
if loadErr == nil {
return cfg, nil
}
}
}
// Built-in recovery defaults resolve the global credential file without
// reading, migrating, or rewriting malformed user/project TOML.
return config.LoadRecoveryDefaultsForRoot(root), nil
}
const repairPlanSystemPrompt = `You are a Reasonix recovery planner. Return JSON only, matching exactly:
{"schemaVersion":1,"summary":"...","actions":[{"type":"...","scope":"","snapshotId":"","target":"","reason":"..."}]}
The user message is a diagnostic report whose findings carry only a severity, a machine code (e.g. config.invalid_toml, derived.invalid_json, update related codes), and a generic scope; pick actions from those codes.
Snapshot IDs are host-issued aliases ordered newest first; pendingUpdate=true means rollback_update is available.
Allowed actions only (return an empty actions array when no safe action applies):
- repair_config with scope global or project
- restore_snapshot with snapshotId
- rebuild_derived_state with target tabs, projects, window, zoom, or all
- rollback_update with no parameters
Never request shell commands, credential changes, session-content edits, arbitrary paths, plugin execution, or source-code changes. Prefer the smallest reversible plan supported by the diagnostics. Do not invent snapshot IDs.`
// providerSafeReport is the strict allowlist DTO sent to the AI provider.
// It deliberately carries no free-form diagnostic strings or state metadata:
// finding messages can quote config lines, URLs, commands, and permission
// rules, while snapshot and update metadata can be edited on disk. Only fixed
// diagnostic vocabulary, booleans, and host-issued snapshot aliases leave the
// machine.
type providerSafeReport struct {
Root string `json:"root"`
Network bool `json:"network"`
Snapshots []providerSafeSnapshot `json:"snapshots"`
PendingUpdate bool `json:"pendingUpdate"`
Findings []providerSafeFinding `json:"findings"`
}
type providerSafeSnapshot struct {
ID string `json:"id"`
}
type providerSafeFinding struct {
Severity string `json:"severity"`
Code string `json:"code"`
Scope string `json:"scope,omitempty"`
}
func providerSafeReportFrom(report repair.DiagnosticReport) (providerSafeReport, map[string]string) {
safe := providerSafeReport{
Root: "<project>",
Network: report.Network,
Snapshots: make([]providerSafeSnapshot, 0, len(report.Snapshots)),
PendingUpdate: report.PendingUpdate != nil,
Findings: make([]providerSafeFinding, 0, len(report.Findings)),
}
snapshotAliases := make(map[string]string, len(report.Snapshots))
for _, snapshot := range report.Snapshots {
if strings.TrimSpace(snapshot.ID) != "" {
alias := fmt.Sprintf("snapshot-%d", len(safe.Snapshots)+1)
safe.Snapshots = append(safe.Snapshots, providerSafeSnapshot{ID: alias})
snapshotAliases[alias] = snapshot.ID
}
}
for _, finding := range report.Findings {
safe.Findings = append(safe.Findings, providerSafeFinding{
Severity: providerSafeSeverity(finding.Severity),
Code: providerSafeDiagnosticCode(finding.Code),
Scope: providerSafeScope(finding.Scope),
})
}
return safe, snapshotAliases
}
func resolveProviderSnapshotAliases(plan repair.RepairPlan, aliases map[string]string) (repair.RepairPlan, error) {
resolved := plan
resolved.Actions = append([]repair.RepairPlanAction(nil), plan.Actions...)
for i := range resolved.Actions {
action := &resolved.Actions[i]
if action.Type != "restore_snapshot" {
continue
}
id, ok := aliases[action.SnapshotID]
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI repair plan referenced unknown snapshot alias %q", action.SnapshotID)
}
action.SnapshotID = id
}
return resolved, nil
}
func providerSafeSeverity(severity string) string {
switch severity {
case "error", "warning", "info":
return severity
default:
return "unknown"
}
}
func providerSafeDiagnosticCode(code string) string {
switch code {
case "config.invalid_toml", "config.load_failed",
"provider.missing_name", "provider.duplicate_name", "provider.unsupported_kind",
"provider.no_models", "provider.invalid_url", "provider.invalid_models_url",
"provider.invalid_key_name", "provider.missing_key",
"model.no_default", "model.invalid_default",
"plugin.missing_name", "plugin.duplicate_name", "plugin.missing_command",
"plugin.command_missing", "plugin.invalid_url", "plugin.invalid_type",
"permissions.conflict", "file.permissions",
"directory.unavailable", "directory.unreadable", "directory.not_directory", "directory.not_writable",
"derived.invalid_json",
"network.invalid_proxy", "network.client_failed", "network.unreachable",
"network.authentication_failed", "network.ok", "network.unexpected_status":
return code
default:
return "unknown"
}
}
// providerSafeScope generalizes a finding scope to a closed vocabulary.
// derived:* names survive because rebuild_derived_state needs the target and
// the suffixes are a fixed enum; provider:*/plugin:* carry user-chosen names
// and collapse to their category; anything unrecognized becomes "other".
func providerSafeScope(scope string) string {
scope = strings.TrimSpace(scope)
switch {
case scope == "":
return ""
case strings.HasPrefix(scope, "derived:"):
switch scope[len("derived:"):] {
case "tabs", "projects", "window", "zoom":
return scope
}
return "derived:other"
case strings.HasPrefix(scope, "provider:"):
return "provider"
case strings.HasPrefix(scope, "plugin:"):
return "plugin"
}
switch scope {
case "global", "project", "runtime", "network", "model", "provider", "plugin", "permissions",
"global config", "credential file", "Reasonix home", "Reasonix state directory", "project root":
return scope
}
return "other"
}
func printPlanPreview(plan repair.RepairPlan, previews []repair.RepairPlanPreview) {
fmt.Println("AI repair plan:", plan.Summary)
for _, preview := range previews {
fmt.Printf(" %d. %s\n", preview.Index, preview.Description)
if preview.Diff != "" {
fmt.Println(preview.Diff)
}
}
}
func confirmPlan() bool {
fmt.Print("Apply this repair plan? [y/N] ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes"
}
-246
View File
@@ -1,246 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/repair"
)
func TestRepairPlanProviderConfigPreservesKimiK3Efforts(t *testing.T) {
entry := &config.ProviderEntry{
Name: "opencode-go",
Kind: "openai",
BaseURL: "https://opencode.ai/zen/go/v1",
Model: "kimi-k3",
ReasoningProtocol: config.ReasoningProtocolOpenAI,
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "max",
}
got := repairPlanProviderConfig(entry, netclient.ProxySpec{})
if effort := got.Extra["effort"]; effort != "max" {
t.Fatalf("effort = %#v, want max", effort)
}
if efforts, ok := got.Extra["supported_efforts"].([]string); !ok || !reflect.DeepEqual(efforts, []string{"high", "max"}) {
t.Fatalf("supported_efforts = %#v, want [high max]", got.Extra["supported_efforts"])
}
if protocol := got.Extra["reasoning_protocol"]; protocol != config.ReasoningProtocolOpenAI {
t.Fatalf("reasoning_protocol = %#v, want openai", protocol)
}
}
// TestProviderSafeReportDropsUserControlledContent pins the outbound-privacy
// contract: the payload sent to the AI provider must carry only allowlisted
// codes, severities, and generic scopes — never diagnostic prose, which can
// quote config lines, MCP command lines, permission rules, URLs with
// credentials, emails, tokens, or absolute paths.
func TestProviderSafeReportDropsUserControlledContent(t *testing.T) {
const metadataSecret = "snapshot-metadata-secret"
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
secrets := []string{
"/Users/someone/dotfiles",
"[email protected]",
"sk-live-Abc123Secret",
"npx --api-key=sk-live-Abc123Secret mcp-server",
"bash(rm -rf *)",
"corp-internal-llm",
"https://user:[email protected]:8080",
metadataSecret,
"secret.finding.code",
"secret-severity",
snapshotID,
}
report := repair.DiagnosticReport{
GeneratedAt: metadataSecret,
Root: "/Users/someone/dotfiles",
Network: true,
Snapshots: []repair.DiagnosticSnapshot{
{ID: snapshotID, RecordedAt: metadataSecret, Version: metadataSecret},
{ID: metadataSecret, RecordedAt: metadataSecret, Version: metadataSecret},
},
PendingUpdate: &repair.DiagnosticUpdate{FromVersion: metadataSecret, ToVersion: metadataSecret},
Findings: []repair.DiagnosticFinding{
{Severity: "error", Code: "config.invalid_toml", Scope: "global",
Message: "Configuration cannot be parsed: line 3: api_key = \"sk-live-Abc123Secret\" contact [email protected]"},
{Severity: "warning", Code: "plugin.command_missing", Scope: "plugin:corp-internal-llm",
Message: `MCP command "npx --api-key=sk-live-Abc123Secret mcp-server" was not found.`},
{Severity: "warning", Code: "permissions.conflict", Scope: "permissions",
Message: `Permission rule "bash(rm -rf *)" appears in allow, deny; deny takes precedence.`},
{Severity: "error", Code: "network.invalid_proxy", Scope: "network",
Message: "Proxy configuration is invalid: parse \"https://user:[email protected]:8080\": bad"},
{Severity: "warning", Code: "provider.missing_key", Scope: "provider:corp-internal-llm",
Message: "The configured API key is missing."},
{Severity: "warning", Code: "derived.invalid_json", Scope: "derived:tabs",
Message: "Derived desktop state desktop-tabs.json is malformed."},
{Severity: "secret-severity", Code: "secret.finding.code", Scope: metadataSecret,
Message: metadataSecret, Remediation: metadataSecret},
},
}
safeReport, aliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
t.Fatal(err)
}
body := string(payload)
for _, secret := range secrets {
if strings.Contains(body, secret) {
t.Fatalf("outbound payload leaked %q:\n%s", secret, body)
}
}
for _, want := range []string{
`"root":"\u003cproject\u003e"`, // json.Marshal HTML-escapes <project>
`"code":"config.invalid_toml"`,
`"scope":"provider"`,
`"scope":"plugin"`,
`"scope":"derived:tabs"`,
`"id":"snapshot-1"`,
`"id":"snapshot-2"`,
`"pendingUpdate":true`,
`"severity":"unknown"`,
`"code":"unknown"`,
`"scope":"other"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("outbound payload missing %s:\n%s", want, body)
}
}
if aliases["snapshot-1"] != snapshotID || aliases["snapshot-2"] != metadataSecret {
t.Fatalf("snapshot alias map = %v", aliases)
}
for _, forbiddenField := range []string{"generatedAt", "recordedAt", "version", "fromVersion", "toVersion"} {
if strings.Contains(body, `"`+forbiddenField+`"`) {
t.Fatalf("outbound payload retained free-form field %q:\n%s", forbiddenField, body)
}
}
}
func TestResolveProviderSnapshotAliases(t *testing.T) {
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
plan, err := repair.DecodeRepairPlan([]byte(`{
"schemaVersion": 1,
"summary": "restore newest snapshot",
"actions": [
{"type":"repair_config","scope":"global","reason":"invalid config"},
{"type":"restore_snapshot","snapshotId":"snapshot-1","reason":"known-good config"}
]
}`))
if err != nil {
t.Fatal(err)
}
resolved, err := resolveProviderSnapshotAliases(plan, map[string]string{"snapshot-1": snapshotID})
if err != nil {
t.Fatal(err)
}
if got := resolved.Actions[1].SnapshotID; got != snapshotID {
t.Fatalf("resolved snapshot ID = %q, want %q", got, snapshotID)
}
if got := plan.Actions[1].SnapshotID; got != "snapshot-1" {
t.Fatalf("alias resolution mutated source plan: %q", got)
}
if _, err := resolveProviderSnapshotAliases(plan, nil); err == nil {
t.Fatal("unknown snapshot alias was accepted")
}
}
func writeDerivedStatePlan(t *testing.T) (string, repair.RepairPlan) {
t.Helper()
file := filepath.Join(t.TempDir(), "plan.json")
raw := []byte(`{"schemaVersion":1,"summary":"rebuild tabs","actions":[{"type":"rebuild_derived_state","target":"tabs","reason":"malformed"}]}`)
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
plan, err := repair.DecodeRepairPlan(raw)
if err != nil {
t.Fatal(err)
}
return file, plan
}
func TestApplyPlanYesRequiresConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, _ := writeDerivedStatePlan(t)
if got := runApplyPlan([]string{"--file", file, "--yes"}); got != 2 {
t.Fatalf("exit code = %d, want missing preview ID usage refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "bad-tabs" {
t.Fatalf("unbound --yes touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanRejectsStateChangedAfterPreview(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("first-state"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if err := os.WriteFile(tabs, []byte("changed-after-preview"), 0o600); err != nil {
t.Fatal(err)
}
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 1 {
t.Fatalf("exit code = %d, want stale preview refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-after-preview" {
t.Fatalf("stale preview touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanAcceptsCurrentConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 0 {
t.Fatalf("exit code = %d, want confirmed preview application", got)
}
if _, err := os.Stat(tabs); !os.IsNotExist(err) {
t.Fatalf("confirmed repair did not quarantine derived state: %v", err)
}
}
func TestProviderSafeScopeClosedVocabulary(t *testing.T) {
cases := map[string]string{
"": "",
"global": "global",
"project": "project",
"derived:tabs": "derived:tabs",
"derived:zoom": "derived:zoom",
"derived:/etc/passwd": "derived:other",
"provider:corp-llm": "provider",
"plugin:internal-mcp": "plugin",
"credential file": "credential file",
"[email protected]": "other",
"/Users/someone/project": "other",
}
for in, want := range cases {
if got := providerSafeScope(in); got != want {
t.Errorf("providerSafeScope(%q) = %q, want %q", in, got, want)
}
}
}
-415
View File
@@ -1,415 +0,0 @@
// Command reasonix-guard diagnoses and repairs Reasonix without loading the
// desktop shell. It is packaged beside the desktop application so recovery
// remains available when Wails, WebView, or user configuration cannot start.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/repair"
)
var version = "dev"
func main() { os.Exit(run(os.Args[1:])) }
func run(args []string) int {
if len(args) == 0 {
return runLaunch(nil)
}
switch args[0] {
case "check":
return runCheck(args[1:], false)
case "repair":
return runCheck(args[1:], true)
case "launch":
return runLaunch(args[1:])
case "recover":
return runRecover(args[1:])
case "snapshots":
return runSnapshots(args[1:])
case "restore":
return runRestore(args[1:])
case "undo":
return runUndo(args[1:])
case "diagnose":
return runDiagnose(args[1:])
case "rebuild":
return runRebuild(args[1:])
case "assist":
return runAssist(args[1:])
case "apply-plan":
return runApplyPlan(args[1:])
case "version", "--version", "-v":
fmt.Println("reasonix-guard", version)
return 0
case "help", "--help", "-h":
usage()
return 0
default:
if isMacBundleLauncher() {
return runLaunch(append([]string{"--"}, args...))
}
fmt.Fprintln(os.Stderr, "unknown command:", args[0])
usage()
return 2
}
}
func runDiagnose(args []string) int {
fs := flag.NewFlagSet("reasonix-guard diagnose", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
network := fs.Bool("network", false, "probe provider endpoints and credentials")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.Diagnose(context.Background(), repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(report); code != 0 {
return code
}
} else {
fmt.Println("Reasonix Guard diagnostics")
if len(report.Findings) == 0 {
fmt.Println(" ok: no issues found")
}
for _, finding := range report.Findings {
fmt.Printf(" %-7s %-32s %s\n", finding.Severity, finding.Code, finding.Message)
}
}
if report.HasErrors() {
return 1
}
return 0
}
func runRebuild(args []string) int {
fs := flag.NewFlagSet("reasonix-guard rebuild", flag.ContinueOnError)
target := fs.String("target", "", "tabs|projects|window|zoom|all")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*target) == "" {
return 2
}
applied, err := repair.RebuildDerivedState(*target)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(applied)
}
for _, path := range applied {
fmt.Println("quarantined:", path)
}
return 0
}
func runSnapshots(args []string) int {
fs := flag.NewFlagSet("reasonix-guard snapshots", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
snapshots, err := repair.ListConfigSnapshots()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(snapshots)
}
for _, snap := range snapshots {
fmt.Printf("%s %s %s\n", snap.ID, snap.Version, snap.RecordedAt)
}
return 0
}
func runRestore(args []string) int {
fs := flag.NewFlagSet("reasonix-guard restore", flag.ContinueOnError)
snapshot := fs.String("snapshot", "", "snapshot id to restore")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*snapshot) == "" {
return 2
}
tx, err := repair.RestoreConfigSnapshot(*snapshot)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
fmt.Println("restored config snapshot; undo transaction:", tx.ID)
return 0
}
func runUndo(args []string) int {
fs := flag.NewFlagSet("reasonix-guard undo", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
tx, err := repair.UndoLastRepair()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(tx)
}
fmt.Println("undid repair:", tx.ID)
return 0
}
func printJSON(v any) int {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func runCheck(args []string, apply bool) int {
name := "reasonix-guard check"
if apply {
name = "reasonix-guard repair"
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: apply, IncludeProject: *includeProject})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
return 1
}
} else {
fmt.Println("Reasonix Guard")
for _, check := range report.Checks {
status := "ok"
if !check.Exists {
status = "missing"
} else if !check.Valid {
status = "invalid"
}
fmt.Printf(" %-8s %-8s %s\n", check.Scope, status, check.Path)
if check.Error != "" {
fmt.Println(" ", check.Error)
}
}
for _, action := range report.Applied {
fmt.Println(" applied:", action)
}
}
for _, check := range report.Checks {
if check.Exists && !check.Valid {
return 1
}
}
return 0
}
// failedInstallBlocksLaunch decides whether startup must stop after a failed
// RecoverFailedInstall. The failure marker means the installer may have left
// the release unit partially replaced, so the binaries cannot be presumed
// coherent until a rollback completes: any error with the restore unfinished
// fails closed. MixedInstall alone is not the signal — a rollback that failed
// before touching anything (e.g. while staging) reports MixedInstall=false yet
// leaves the installer's half-written unit in place. Only a completed restore
// (RolledBack, with just the marker cleanup failing) is safe to launch.
func failedInstallBlocksLaunch(result repair.UpdateRollbackResult, err error) bool {
return err != nil && !result.RolledBack
}
func runLaunch(args []string) int {
fs := flag.NewFlagSet("reasonix-guard launch", flag.ContinueOnError)
app := fs.String("app", "", "desktop executable path")
safeMode := fs.Bool("safe-mode", false, "force Safe Mode")
detach := fs.Bool("detach", packagedDetachedLauncher(), "start the desktop and exit the launcher")
if err := fs.Parse(args); err != nil {
return 2
}
// An update helper that watched the installer fail records a marker (it
// cannot roll back itself from outside the install directory). Restore the
// previous release unit immediately instead of waiting for a crash loop.
if result, failure, err := repair.RecoverFailedInstall(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback after failed install failed:", err)
if failedInstallBlocksLaunch(result, err) {
fmt.Fprintln(os.Stderr, "error: the failed installer may have left a mix of two releases and the rollback did not complete; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
} else if failure != nil && result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after the %s installer failed.\n", result.ToVersion, result.FromVersion)
}
forceSafeMode := false
if result, err := repair.ReconcilePendingUpdate(version); err != nil {
if !errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
fmt.Fprintln(os.Stderr, "previous update recovery failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation may mix two releases; refusing to start. Re-run reasonix-guard to retry recovery, or reinstall Reasonix.")
return 1
}
forceSafeMode = true
}
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s before launch after an unfinished %s update.\n", result.FromVersion, result.ToVersion)
} else if result.Cleared {
fmt.Fprintf(os.Stderr, "Reasonix Guard cleared an abandoned %s update before launch.\n", result.ToVersion)
}
tracker := repair.NewStartupTracker("")
useSafeMode := *safeMode || forceSafeMode
if !useSafeMode && tracker.SafeModeRecommended() {
if result, err := repair.RollbackPendingUpdate(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation now mixes two releases; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
// The compensated install is still a coherent (new-version) release
// unit, so a Safe Mode boot is a sound fallback while the pending
// transaction stays on disk for the next rollback attempt.
useSafeMode = true
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after %s failed to start.\n", result.ToVersion, result.FromVersion)
_ = tracker.MarkClean()
} else {
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: ".", Apply: true}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
}
useSafeMode = true
case recoverySafeMode:
useSafeMode = true
case recoveryQuit:
return 0
}
}
}
path := strings.TrimSpace(*app)
if path == "" {
path = siblingDesktopExecutable()
}
if path == "" {
fmt.Fprintln(os.Stderr, "error: cannot locate reasonix-desktop; pass --app PATH")
return 1
}
childArgs := append([]string(nil), fs.Args()...)
if useSafeMode {
childArgs = append([]string{"--safe-mode"}, childArgs...)
}
cmd := exec.Command(path, childArgs...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if *detach {
if err := cmd.Start(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
if err := cmd.Run(); err != nil {
if exit, ok := err.(*exec.ExitError); ok {
return exit.ExitCode()
}
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func packagedDetachedLauncher() bool {
if runtime.GOOS != "windows" {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
name := strings.ToLower(filepath.Base(exe))
return name == "reasonix-launcher.exe" || name == "reasonix.exe"
}
func runRecover(args []string) int {
fs := flag.NewFlagSet("reasonix-guard recover", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
app := fs.String("app", "", "desktop executable path")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: true, IncludeProject: *includeProject}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
return 1
}
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
case recoverySafeMode:
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
default:
return 0
}
}
func siblingDesktopExecutable() string {
exe, err := os.Executable()
if err != nil {
return ""
}
name := "reasonix-desktop"
if runtime.GOOS == "windows" {
name += ".exe"
}
candidates := []string{filepath.Join(filepath.Dir(exe), name)}
if runtime.GOOS == "darwin" {
candidates = append(candidates, filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "MacOS", name)))
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func isMacBundleLauncher() bool {
if runtime.GOOS != "darwin" {
return false
}
exe, err := os.Executable()
return err == nil && strings.Contains(filepath.ToSlash(exe), ".app/Contents/MacOS/")
}
func usage() {
fmt.Println("usage: reasonix-guard <check|repair|diagnose|rebuild|assist|apply-plan|launch|recover|snapshots|restore|undo> [options]")
}
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/repair"
)
func TestCheckReportsInvalidProjectConfig(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[broken\n"), 0o600); err != nil {
t.Fatal(err)
}
if code := run([]string{"check", "--root", root, "--json"}); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
}
func TestFailedInstallBlocksLaunch(t *testing.T) {
cases := []struct {
name string
result repair.UpdateRollbackResult
err error
want bool
}{
{name: "no error never blocks", result: repair.UpdateRollbackResult{}, err: nil, want: false},
{name: "incomplete rollback fails closed", result: repair.UpdateRollbackResult{}, err: errors.New("stage failed"), want: true},
{name: "uncompensated rollback fails closed", result: repair.UpdateRollbackResult{MixedInstall: true}, err: errors.New("restore failed"), want: true},
{name: "completed restore with marker cleanup error launches", result: repair.UpdateRollbackResult{RolledBack: true}, err: errors.New("remove marker: permission denied"), want: false},
}
for _, tc := range cases {
if got := failedInstallBlocksLaunch(tc.result, tc.err); got != tc.want {
t.Errorf("%s: failedInstallBlocksLaunch = %v, want %v", tc.name, got, tc.want)
}
}
}
-9
View File
@@ -1,9 +0,0 @@
package main
type recoveryChoice int
const (
recoveryQuit recoveryChoice = iota
recoverySafeMode
recoveryRepair
)
-24
View File
@@ -1,24 +0,0 @@
//go:build darwin
package main
import (
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
script := `button returned of (display dialog "Reasonix failed to start repeatedly. You can repair damaged configuration or start with external integrations disabled." with title "Reasonix Recovery" buttons {"Quit", "Safe Mode", "Repair and Start"} default button "Repair and Start" with icon caution)`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Repair and Start":
return recoveryRepair
case "Safe Mode":
return recoverySafeMode
default:
return recoveryQuit
}
}
-36
View File
@@ -1,36 +0,0 @@
//go:build linux
package main
import (
"errors"
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
if path, err := exec.LookPath("zenity"); err == nil {
cmd := exec.Command(path, "--question", "--title=Reasonix Recovery",
"--text=Reasonix failed to start repeatedly.",
"--ok-label=Repair and Start", "--cancel-label=Safe Mode", "--extra-button=Quit")
out, err := cmd.Output()
if strings.TrimSpace(string(out)) == "Quit" {
return recoveryQuit
}
if err == nil {
return recoveryRepair
}
return recoverySafeMode
}
if path, err := exec.LookPath("kdialog"); err == nil {
err := exec.Command(path, "--warningyesnocancel", "Reasonix failed to start repeatedly. Repair configuration and start?", "--title", "Reasonix Recovery").Run()
if err == nil {
return recoveryRepair
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 {
return recoveryQuit
}
}
return recoverySafeMode
}
-5
View File
@@ -1,5 +0,0 @@
//go:build !darwin && !windows && !linux
package main
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
-27
View File
@@ -1,27 +0,0 @@
//go:build windows
package main
import (
"os/exec"
"strings"
"syscall"
)
func nativeRecoveryChoice() recoveryChoice {
script := `Add-Type -AssemblyName PresentationFramework; $r=[System.Windows.MessageBox]::Show('Reasonix failed to start repeatedly. Yes: repair configuration and start. No: start in Safe Mode. Cancel: quit.','Reasonix Recovery','YesNoCancel','Warning'); Write-Output $r`
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
out, err := cmd.Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Yes":
return recoveryRepair
case "No":
return recoverySafeMode
default:
return recoveryQuit
}
}
+32
View File
@@ -0,0 +1,32 @@
// Command reasonix-launcher is the permanent thin desktop entry point for the
// versioned install layout (v1.20+). It reads InstallRoot/current.json, starts
// the active reasonix-desktop binary, and exits. It never counts crashes,
// chooses previous versions, or enters a product safe mode.
//
// Migration compatibility: old shortcuts may still pass "launch", "--detach",
// or "--safe-mode". Those tokens are stripped and ignored.
package main
import (
"os"
"reasonix/internal/desktoplauncher"
)
var version = "dev"
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
return desktoplauncher.Run(args, version)
}
func resolveDesktopPath(installRoot string) (string, error) {
return desktoplauncher.ResolveDesktopPath(installRoot)
}
func stripLegacyLaunchArgs(args []string) []string {
return desktoplauncher.StripLegacyLaunchArgs(args)
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/installlayout"
)
func TestStripLegacyLaunchArgs(t *testing.T) {
got := stripLegacyLaunchArgs([]string{
"launch", "--detach", "--safe-mode", "--app", `C:\Old\reasonix-desktop.exe`, "--foo", "bar",
})
want := []string{"--foo", "bar"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("got %#v want %#v", got, want)
}
got = stripLegacyLaunchArgs([]string{"--app=ignored", "keep"})
if len(got) != 1 || got[0] != "keep" {
t.Fatalf("got %#v", got)
}
got = stripLegacyLaunchArgs([]string{"--", "--safe-mode", "x"})
if len(got) != 3 || got[0] != "--" {
t.Fatalf("separator not preserved: %#v", got)
}
}
func TestResolveDesktopPathUsesCurrentJSON(t *testing.T) {
root := t.TempDir()
src := t.TempDir()
version := "v1.20.0"
members := make([]installlayout.Member, 0, 3)
for _, name := range installlayout.AllowedVersionMembers() {
path := filepath.Join(src, name)
if err := os.WriteFile(path, []byte("bin-"+name), 0o755); err != nil {
t.Fatal(err)
}
members = append(members, installlayout.Member{Name: name, Path: path})
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: version,
RequestID: "launcher-test",
Members: members,
}); err != nil {
t.Fatal(err)
}
path, err := resolveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "versions", version, installlayout.DesktopBinaryName())
if path != want {
t.Fatalf("path=%s want=%s", path, want)
}
}
func TestResolveDesktopPathFallsBackToFlatSibling(t *testing.T) {
root := t.TempDir()
flat := filepath.Join(root, installlayout.DesktopBinaryName())
if err := os.WriteFile(flat, []byte("flat"), 0o755); err != nil {
t.Fatal(err)
}
path, err := resolveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
if path != flat {
t.Fatalf("path=%s want=%s", path, flat)
}
}
func TestResolveDesktopPathRejectsCorruptPointer(t *testing.T) {
root := t.TempDir()
// Write a current.json that fails schema validation.
if err := os.WriteFile(filepath.Join(root, installlayout.CurrentFileName), []byte(`{"schemaVersion":99}`), 0o644); err != nil {
t.Fatal(err)
}
// Even with a flat sibling, a corrupt pointer must fail closed. Falling back
// would silently run a stale executable after a partially observed update.
if err := os.WriteFile(filepath.Join(root, installlayout.DesktopBinaryName()), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if path, err := resolveDesktopPath(root); err == nil {
t.Fatalf("corrupt current.json resolved stale flat desktop %q", path)
}
}
+525
View File
@@ -0,0 +1,525 @@
// Command reasonix-legacy-migrator is the one-shot flat→versioned install
// migrator used by v1.20+ packaging. In compatibility payloads it is still
// named reasonix-guard(.exe) so 1.18–1.19.1 updaters can hand off; the source
// and behavior are intentionally separate from the old Guard recovery product.
//
// It only: acquires a migration lock, validates the flat release unit, creates
// versions/<version>, writes current.json, rewrites entry points, starts the
// thin launcher, and self-deletes when possible. It never chooses safe mode,
// counts crashes, or auto-rolls back.
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"reasonix/internal/desktoplauncher"
"reasonix/internal/fileutil"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
var version = "dev"
const migrationLockName = ".reasonix-layout-migrate.lock"
func main() {
if runningAsLauncher() {
os.Exit(desktoplauncher.Run(os.Args[1:], version))
}
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
if len(args) == 1 {
switch args[0] {
case "version", "--version", "-v":
fmt.Println("reasonix-legacy-migrator", version)
return 0
case "help", "--help", "-h":
fmt.Println("usage: reasonix-legacy-migrator [--install-root PATH] [--version VERSION] [--activate-staging PATH]")
return 0
}
}
installRoot := ""
activeVersion := strings.TrimSpace(version)
activateStaging := ""
relaunch := true
for i := 0; i < len(args); i++ {
switch args[i] {
case "--install-root":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --install-root requires a path")
return 2
}
i++
installRoot = args[i]
case "--version":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --version requires a value")
return 2
}
i++
activeVersion = args[i]
case "--activate-staging":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --activate-staging requires a path")
return 2
}
i++
activateStaging = args[i]
case "launch", "--detach", "--safe-mode":
// Accept legacy argv from old shortcuts; no product behavior.
continue
case "--no-relaunch":
relaunch = false
continue
case "--app":
if i+1 < len(args) {
i++
}
continue
}
}
if installRoot == "" {
exe, err := os.Executable()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
installRoot = filepath.Dir(exe)
}
installRoot = filepath.Clean(installRoot)
if activateStaging != "" {
activeVersion, err := normalizeActiveVersion(activeVersion)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if err := activateInstallerStaging(installRoot, activeVersion, activateStaging); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if relaunch {
_ = startLauncher(installRoot)
}
return 0
}
if err := migrateWithRelaunch(installRoot, activeVersion, relaunch); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func migrate(installRoot, activeVersion string) error {
return migrateWithRelaunch(installRoot, activeVersion, true)
}
func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error {
var err error
activeVersion, err = normalizeActiveVersion(activeVersion)
if err != nil {
return err
}
unlock, err := acquireMigrationLock(installRoot)
if err != nil {
return err
}
defer unlock()
// Pointer already committed: only cleanup, never overwrite the active version.
// A present but corrupt pointer is fatal; treating it like an absent pointer
// could overwrite evidence of a partial activation and migrate stale files.
currentPath := filepath.Join(installRoot, installlayout.CurrentFileName)
if _, statErr := os.Lstat(currentPath); statErr == nil {
ptr, err := installlayout.ReadCurrent(installRoot)
if err != nil {
return err
}
if err := finalizeLegacyPendingUpdate(installRoot, ptr.ActiveVersion); err != nil {
return err
}
_ = cleanupLegacyFlatFiles(installRoot)
_ = archiveInstallRootLegacyMarkers(installRoot)
if relaunch {
_ = startLauncher(installRoot) // best-effort; layout already committed
selfDelete()
}
return nil
} else if !os.IsNotExist(statErr) {
return fmt.Errorf("migrate: inspect current.json: %w", statErr)
}
desktopName := installlayout.DesktopBinaryName()
cliName := installlayout.CLIBinaryName()
helperName := installlayout.UpdateHelperBinaryName()
flatDesktop := filepath.Join(installRoot, desktopName)
if _, err := os.Lstat(flatDesktop); err != nil {
return fmt.Errorf("migrate: flat desktop binary missing: %w", err)
}
members := []installlayout.Member{
{Name: desktopName, Path: flatDesktop},
}
if p := optionalRegular(filepath.Join(installRoot, cliName)); p != "" {
members = append(members, installlayout.Member{Name: cliName, Path: p})
} else {
// CLI may be absent on some portable trees; synthesize from desktop only
// is not allowed — require the whitelist. Prefer copying desktop as a
// last-resort placeholder is forbidden; fail closed.
return fmt.Errorf("migrate: flat CLI binary %s is required", cliName)
}
requiredNames := []string{desktopName, cliName}
if runtime.GOOS == "windows" {
requiredNames = append(requiredNames, helperName)
if p := optionalRegular(filepath.Join(installRoot, helperName)); p != "" {
members = append(members, installlayout.Member{Name: helperName, Path: p})
} else {
return fmt.Errorf("migrate: flat update helper %s is required", helperName)
}
}
// Old Linux updaters only publish desktop, CLI, and the compatibility
// migrator. On Unix the migrator is also a thin-launcher multicall binary,
// so it can create the permanent entry point without another payload member.
if err := ensureLauncherEntry(installRoot); err != nil {
return err
}
// v1.18-v1.19 helpers leave an installed transaction awaiting Guard health.
// The flat release unit is still intact here, so commit that exact verified
// transaction before moving its files into the version directory.
if err := finalizeLegacyPendingUpdate(installRoot, activeVersion); err != nil {
return err
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: activeVersion,
RequestID: "legacy-migrate",
Members: members,
RequiredNames: requiredNames,
}); err != nil {
return fmt.Errorf("migrate: activate versioned layout: %w", err)
}
_ = cleanupLegacyFlatFiles(installRoot)
_ = archiveInstallRootLegacyMarkers(installRoot)
if relaunch {
// Launcher start is best-effort: layout activation is the commit point.
if err := startLauncher(installRoot); err != nil {
fmt.Fprintln(os.Stderr, "warning: start launcher after migrate:", err)
}
// Unix can unlink the running migrator. On Windows the parent thin launcher
// removes it after this process exits.
selfDelete()
}
return nil
}
func normalizeActiveVersion(activeVersion string) (string, error) {
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
// Accept bare "dev" builds in development only.
if activeVersion == "" || activeVersion == "dev" {
activeVersion = "v0.0.0-dev"
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
return "", err
}
} else if !strings.HasPrefix(activeVersion, "v") {
activeVersion = "v" + activeVersion
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
return "", err
}
} else {
return "", err
}
}
return activeVersion, nil
}
func activateInstallerStaging(installRoot, activeVersion, stagingRoot string) error {
installRoot = filepath.Clean(strings.TrimSpace(installRoot))
stagingRoot = filepath.Clean(strings.TrimSpace(stagingRoot))
if !pathWithinInstallRoot(installRoot, stagingRoot) || stagingRoot == installRoot {
return fmt.Errorf("installer staging must be inside the install root")
}
info, err := os.Lstat(stagingRoot)
if err != nil {
return fmt.Errorf("inspect installer staging: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("installer staging must be a real directory")
}
desktopName := installlayout.DesktopBinaryName()
cliName := installlayout.CLIBinaryName()
requiredNames := []string{desktopName, cliName}
if runtime.GOOS == "windows" {
requiredNames = append(requiredNames, installlayout.UpdateHelperBinaryName())
}
members := make([]installlayout.Member, 0, len(requiredNames))
for _, name := range requiredNames {
members = append(members, installlayout.Member{Name: name, Path: filepath.Join(stagingRoot, name)})
}
launcherName := installlayout.LauncherBinaryName()
launcherSource := filepath.Join(stagingRoot, launcherName)
rootMembers := []installlayout.Member{
{Name: launcherName, Path: launcherSource},
{Name: cliName, Path: filepath.Join(stagingRoot, cliName)},
}
requiredRootNames := []string{launcherName, cliName}
if alias := installlayout.PortableAliasName(); alias != "" {
rootMembers = append(rootMembers, installlayout.Member{Name: alias, Path: launcherSource})
requiredRootNames = append(requiredRootNames, alias)
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: activeVersion,
RequestID: "signed-installer-" + activeVersion,
Members: members,
RequiredNames: requiredNames,
RootMembers: rootMembers,
RequiredRootNames: requiredRootNames,
}); err != nil {
return fmt.Errorf("activate signed installer staging: %w", err)
}
return nil
}
func runningAsLauncher() bool {
exe, err := os.Executable()
if err != nil {
return false
}
return strings.EqualFold(filepath.Base(exe), installlayout.LauncherBinaryName())
}
func optionalRegular(path string) string {
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return ""
}
return path
}
func acquireMigrationLock(installRoot string) (func(), error) {
if err := os.MkdirAll(installRoot, 0o755); err != nil {
return nil, err
}
path := filepath.Join(installRoot, migrationLockName)
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
if os.IsExist(err) {
// Stale lock from a dead migrator: if older than 10 minutes, steal it.
info, statErr := os.Stat(path)
if statErr == nil && time.Since(info.ModTime()) > 10*time.Minute {
_ = os.Remove(path)
f, err = os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
}
}
if err != nil {
return nil, fmt.Errorf("migrate: acquire lock: %w", err)
}
}
_, _ = fmt.Fprintf(f, "pid=%d\nversion=%s\n", os.Getpid(), version)
_ = f.Close()
return func() { _ = os.Remove(path) }, nil
}
func ensureLauncherEntry(installRoot string) error {
launcher := installlayout.LauncherBinaryName()
path := filepath.Join(installRoot, launcher)
if info, err := os.Lstat(path); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("migrate: thin launcher %s is not a regular file", launcher)
}
return nil
}
// On Windows also accept Reasonix.exe as the alias.
if runtime.GOOS == "windows" {
if _, err := os.Lstat(filepath.Join(installRoot, "Reasonix.exe")); err == nil {
return nil
}
return fmt.Errorf("migrate: thin launcher %s is missing; install a signed package", launcher)
}
// Legacy Linux archives cannot deliver a fourth member through the old
// updater. Copy this signed multicall migrator as the permanent thin launcher.
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("migrate: resolve migrator executable: %w", err)
}
info, err := os.Lstat(exe)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("migrate: migrator executable is not a regular file")
}
body, err := os.ReadFile(exe)
if err != nil {
return fmt.Errorf("migrate: read launcher source: %w", err)
}
if err := fileutil.AtomicWriteFile(path, body, 0o755); err != nil {
return fmt.Errorf("migrate: install thin launcher: %w", err)
}
return nil
}
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
tx, err := repair.ReadPendingUpdate()
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("migrate: read legacy pending update: %w", err)
}
if tx.TargetKind != "file" {
return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
}
if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
}
for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
if !pathWithinInstallRoot(installRoot, target.TargetPath) {
return fmt.Errorf("migrate: pending update target escapes install root")
}
}
id := repair.UpdateTransactionID(tx)
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
}
if current, err := repair.ReadPendingUpdate(); err == nil {
if repair.UpdateTransactionID(current) == id {
return fmt.Errorf("migrate: legacy pending update remained after commit")
}
return fmt.Errorf("migrate: pending update changed during commit")
} else if !os.IsNotExist(err) {
return fmt.Errorf("migrate: verify legacy pending update commit: %w", err)
}
return nil
}
func pathWithinInstallRoot(installRoot, target string) bool {
root := filepath.Clean(strings.TrimSpace(installRoot))
target = filepath.Clean(strings.TrimSpace(target))
if root == "" || target == "" || !filepath.IsAbs(root) || !filepath.IsAbs(target) {
return false
}
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
return false
}
return true
}
func cleanupLegacyFlatFiles(installRoot string) error {
// Remove flat desktop/CLI/helper/guard sidecars after the version tree is
// active. Never delete the thin launcher or current.json.
names := []string{
installlayout.DesktopBinaryName(),
installlayout.CLIBinaryName(),
installlayout.UpdateHelperBinaryName(),
}
if runtime.GOOS == "windows" {
names = append(names, "reasonix-guard.exe")
} else {
names = append(names, "reasonix-guard")
}
for _, name := range names {
path := filepath.Join(installRoot, name)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
_ = os.Remove(path)
}
return nil
}
func archiveInstallRootLegacyMarkers(installRoot string) error {
// Very old portable builds wrote markers beside the binary. Current repair
// state under Reasonix home is finalized through repair transaction APIs.
markers := []string{
"pending-update.json",
"startup-state.json",
}
ts := time.Now().UTC().Format("20060102T150405Z")
destRoot := filepath.Join(installRoot, "repair", "legacy-v1", ts)
moved := false
for _, name := range markers {
src := filepath.Join(installRoot, name)
if _, err := os.Lstat(src); err != nil {
continue
}
if err := os.MkdirAll(destRoot, 0o755); err != nil {
return err
}
dest := filepath.Join(destRoot, name)
if err := os.Rename(src, dest); err != nil {
// Cross-device: copy+remove best effort.
data, readErr := os.ReadFile(src)
if readErr != nil {
continue
}
if writeErr := fileutil.AtomicWriteFile(dest, data, 0o600); writeErr == nil {
_ = os.Remove(src)
}
}
moved = true
}
if moved {
meta, _ := json.MarshalIndent(map[string]any{
"migratedAt": time.Now().UTC().Format(time.RFC3339),
"from": "flat-v1",
"to": installlayout.InstallLayoutVersionedV1,
}, "", " ")
_ = fileutil.AtomicWriteFile(filepath.Join(destRoot, "migration.json"), append(meta, '\n'), 0o644)
}
return nil
}
func startLauncher(installRoot string) error {
launcher := "reasonix-launcher"
if runtime.GOOS == "windows" {
launcher += ".exe"
}
path := filepath.Join(installRoot, launcher)
if _, err := os.Lstat(path); err != nil {
if runtime.GOOS == "windows" {
path = filepath.Join(installRoot, "Reasonix.exe")
}
}
if _, err := os.Lstat(path); err != nil {
// Migration succeeded; user can start manually.
return nil
}
cmd := exec.Command(path)
cmd.Dir = installRoot
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if runtime.GOOS == "windows" {
return cmd.Start()
}
return cmd.Start()
}
func selfDelete() {
exe, err := os.Executable()
if err != nil {
return
}
// Do not delete if we are not named like a compatibility guard payload.
base := strings.ToLower(filepath.Base(exe))
if base != "reasonix-guard" && base != "reasonix-guard.exe" {
return
}
_ = os.Remove(exe)
}
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"os"
"path/filepath"
"runtime"
"testing"
"reasonix/internal/installlayout"
)
func installerVersionNames() []string {
names := []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()}
if runtime.GOOS == "windows" {
names = append(names, installlayout.UpdateHelperBinaryName())
}
return names
}
func writeInstallerStaging(t *testing.T, root, label string, includeLauncher bool) string {
t.Helper()
staging := filepath.Join(root, "versions", ".installer-"+label)
if err := os.MkdirAll(staging, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range installerVersionNames() {
if err := os.WriteFile(filepath.Join(staging, name), []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
if includeLauncher {
name := installlayout.LauncherBinaryName()
if err := os.WriteFile(filepath.Join(staging, name), []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
return staging
}
func TestMigrateFlatInstallToVersioned(t *testing.T) {
root := t.TempDir()
// Flat release unit.
for _, name := range installlayout.AllowedVersionMembers() {
if err := os.WriteFile(filepath.Join(root, name), []byte("flat-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
// Thin launcher entry must already exist (packaging places it). Use a
// non-executable marker so startLauncher fails closed without hanging.
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher"), []byte("launcher"), 0o644)
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher.exe"), []byte("launcher"), 0o644)
_ = os.WriteFile(filepath.Join(root, "Reasonix.exe"), []byte("launcher"), 0o644)
// Marker files that must be archived, not deleted silently without record.
_ = os.WriteFile(filepath.Join(root, "pending-update.json"), []byte(`{"pending":true}`), 0o644)
_ = os.WriteFile(filepath.Join(root, "startup-state.json"), []byte(`{}`), 0o644)
if err := migrate(root, "v1.20.0"); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(root) {
t.Fatal("current.json missing after migration")
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
// Flat desktop must be cleaned up after successful activation.
if _, err := os.Stat(filepath.Join(root, installlayout.DesktopBinaryName())); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed after migration")
}
// Active desktop lives under versions/.
if _, err := installlayout.ActiveDesktopPath(root); err != nil {
t.Fatal(err)
}
// Second run is idempotent cleanup-only.
if err := migrate(root, "v1.20.0"); err != nil {
t.Fatalf("idempotent re-run: %v", err)
}
ptr2, err := installlayout.ReadCurrent(root)
if err != nil || ptr2.ActiveVersion != "v1.20.0" {
t.Fatalf("re-run overwrote active version: %+v", ptr2)
}
}
func TestMigrateRefusesWithoutFlatUnit(t *testing.T) {
root := t.TempDir()
if err := migrate(root, "v1.20.0"); err == nil {
t.Fatal("expected failure without flat desktop")
}
}
func TestMigrateRefusesCorruptCurrentPointerWithoutOverwritingIt(t *testing.T) {
root := t.TempDir()
corrupt := []byte(`{"schemaVersion":99}`)
current := filepath.Join(root, installlayout.CurrentFileName)
if err := os.WriteFile(current, corrupt, 0o644); err != nil {
t.Fatal(err)
}
for _, name := range installlayout.AllowedVersionMembers() {
if err := os.WriteFile(filepath.Join(root, name), []byte("stale-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
if err := migrate(root, "v1.20.0"); err == nil {
t.Fatal("corrupt current.json was treated as an absent pointer")
}
got, err := os.ReadFile(current)
if err != nil {
t.Fatal(err)
}
if string(got) != string(corrupt) {
t.Fatalf("corrupt pointer was overwritten: %q", got)
}
}
func TestActivateInstallerStagingPublishesVersionAndRootEntries(t *testing.T) {
root := t.TempDir()
staging := writeInstallerStaging(t, root, "new", true)
if err := activateInstallerStaging(root, "v1.20.0", staging); err != nil {
t.Fatal(err)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
activeDesktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(activeDesktop); err != nil || string(got) != "new-"+installlayout.DesktopBinaryName() {
t.Fatalf("active desktop=%q err=%v", got, err)
}
for _, name := range []string{installlayout.LauncherBinaryName(), installlayout.CLIBinaryName()} {
if info, err := os.Lstat(filepath.Join(root, name)); err != nil || !info.Mode().IsRegular() {
t.Fatalf("root entry %s missing or invalid: %v", name, err)
}
}
if alias := installlayout.PortableAliasName(); alias != "" {
if got, err := os.ReadFile(filepath.Join(root, alias)); err != nil || string(got) != "new-"+installlayout.LauncherBinaryName() {
t.Fatalf("portable alias=%q err=%v", got, err)
}
}
}
func TestActivateInstallerStagingFailureKeepsPreviousPointer(t *testing.T) {
root := t.TempDir()
oldStaging := writeInstallerStaging(t, root, "old", true)
if err := activateInstallerStaging(root, "v1.19.1", oldStaging); err != nil {
t.Fatal(err)
}
broken := writeInstallerStaging(t, root, "broken", false)
if err := activateInstallerStaging(root, "v1.20.0", broken); err == nil {
t.Fatal("staging without a launcher was activated")
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.19.1" {
t.Fatalf("previous pointer changed after failed activation: %+v err=%v", ptr, err)
}
if err := activateInstallerStaging(root, "v1.20.0", t.TempDir()); err == nil {
t.Fatal("staging outside the install root was accepted")
}
}
-1
View File
@@ -28,7 +28,6 @@ func TestTopLevelSignPathWorkflowCallGraph(t *testing.T) {
}
want := []string{
".github/workflows/release-desktop.yml",
".github/workflows/release-preview.yml",
".github/workflows/release-stable.yml",
}
if !reflect.DeepEqual(got, want) {
+175 -134
View File
@@ -258,17 +258,9 @@ type App struct {
heartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup
startupTracker *repair.StartupTracker
previousRun repair.PreviousRunObservation
// Healthy-update identity is captured before Wails starts. A process only
// commits the complete probationary transaction it booted from, never a
// rewritten or later same-version retry.
healthyUpdateCreatedAt string
healthyUpdateTransactionID string
// startupReady records that the window reached domReady. The shutdown path
// treats an exit before this point as an incomplete start: it must neither
// reset the crash-loop counter nor bless a probationary update, or a build
// that boots but never paints would defeat the Guard rollback safety net.
previousRun repair.PreviousRunObservation
// startupReady records that the window reached domReady so LKG config
// snapshots are only written after a real UI boot.
startupReady atomic.Bool
}
@@ -502,24 +494,17 @@ func (a *App) startup(ctx context.Context) {
a.observeIncompleteWindowRestore()
a.startMainThreadWatchdog()
if !config.SafeModeRequested() {
a.heartbeat = newHeartbeatEngine(a)
a.heartbeat.Start()
}
a.heartbeat = newHeartbeatEngine(a)
a.heartbeat.Start()
a.mu.Lock()
a.tabsRestored = make(chan struct{})
a.mu.Unlock()
go a.restoreOrBuildTabs()
if !config.SafeModeRequested() {
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
a.goSafe("sendStartupPing", a.sendStartupPing)
// Pending metrics/crash payloads stay on disk in Safe Mode: whether to
// send or drop them depends on the user's real telemetry preference,
// which a Safe Mode boot cannot read. The next normal boot decides.
a.goSafe("flushMetrics", a.flushMetrics)
a.goSafe("flushPendingCrash", a.flushPendingCrash)
}
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
a.goSafe("sendStartupPing", a.sendStartupPing)
a.goSafe("flushMetrics", a.flushMetrics)
a.goSafe("flushPendingCrash", a.flushPendingCrash)
// After restoreOrBuildTabs is launched: the GC's first sweep waits on
// tabsRestored so it never observes the pre-restore empty tab map.
a.startRecoveryGC()
@@ -537,7 +522,10 @@ func (a *App) beforeClose(ctx context.Context) bool {
if !a.backgroundCloseHasRestorePath() {
return false
}
a.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))
// Never query native maximise state here: during close the Win32 DPI
// path can report 0 and panic inside Wails ScaleToDefaultDPI. Use the
// last frontend-reported geometry instead.
a.backgroundMaximised.Store(a.lastKnownMaximised())
a.saveWindowStateSync()
a.snapshotAllTabs()
hideForBackground(ctx)
@@ -705,14 +693,11 @@ func (a *App) restoreOrBuildTabs() {
// Run legacy config migration before the first config load so the
// freshly written config (including the user's default_model) is
// picked up by Load instead of falling back to built-in defaults.
f := desktopTabsFile{}
if !config.SafeModeRequested() {
_, _ = config.MigrateLegacyIfNeeded()
f = loadTabsFile()
_, _ = recoverLegacyProjectSidebarRoots(f)
_, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
}
_, _ = config.MigrateLegacyIfNeeded()
f := loadTabsFile()
_, _ = recoverLegacyProjectSidebarRoots(f)
_, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
// Load i18n from the first available config.
// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),
@@ -914,17 +899,10 @@ func (a *App) shutdown(context.Context) {
a.releaseSessionRuntimeLocked(it.tab)
a.mu.Unlock()
}
if a.startupTracker != nil && a.startupReady.Load() {
// Only a run whose window actually became ready may reset the crash-loop
// counter and bless a probationary update. A clean quit before domReady
// (e.g. Dock-quitting a build that boots but never paints) keeps the
// incomplete startup record, so repeated attempts still reach the Guard
// recovery threshold and the update backups stay rollback-ready.
_ = a.startupTracker.MarkClean()
if !config.SafeModeRequested() {
_ = repair.MarkUpdateHealthyExact(version, a.healthyUpdateCreatedAt, a.healthyUpdateTransactionID)
_ = repair.RecordHealthyConfig(version)
}
if a.startupReady.Load() {
// Independent last-known-good config snapshot after a successful UI
// session. Does not participate in startup decisions.
_ = repair.RecordHealthyConfig(version)
}
}
@@ -941,28 +919,21 @@ func (a *App) domReady(_ context.Context) {
if ok {
// Validate saved position against current screens. Wails v2 doesn't
// expose per-screen origin (x,y offsets) so we can only do a basic
// sanity check: ensure the window origin falls within a generous
// estimate of the screen area. If the user unplugged an external
// display, negative or out-of-bounds coordinates are caught here.
valid := state.X >= 0 && state.Y >= 0
if valid {
screens, err := runtime.ScreenGetAll(a.ctx)
if err == nil && len(screens) > 0 {
maxW, maxH := 0, 0
for _, sc := range screens {
if sc.Size.Width > maxW {
maxW = sc.Size.Width
}
if sc.Size.Height > maxH {
maxH = sc.Size.Height
}
// sanity check. Windows border insets (commonly x=-8,y=-8) are legal;
// large off-screen positions (unplugged external display) re-center.
maxW, maxH := 0, 0
screens, err := runtime.ScreenGetAll(a.ctx)
if err == nil {
for _, sc := range screens {
if sc.Size.Width > maxW {
maxW = sc.Size.Width
}
if state.X > maxW*2 || state.Y > maxH*2 {
valid = false
if sc.Size.Height > maxH {
maxH = sc.Size.Height
}
}
}
if valid {
if windowPositionRestorable(state, maxW, maxH) {
runtime.WindowSetPosition(a.ctx, state.X, state.Y)
} else {
runtime.WindowCenter(a.ctx)
@@ -977,46 +948,26 @@ func (a *App) domReady(_ context.Context) {
runtime.WindowShow(a.ctx)
a.startupReady.Store(true)
if a.startupTracker != nil {
_ = a.startupTracker.MarkReady()
tracker := a.startupTracker
ctx := a.ctx
a.goSafe("startupHeartbeat", func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := tracker.Heartbeat(); err != nil {
slog.Debug("desktop: update startup heartbeat", "err", err)
}
}
}
})
a.goSafe("confirmStartupHealth", func() {
timer := time.NewTimer(repair.StartupHealthDelay)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
return
}
if err := tracker.MarkHealthy(); err != nil {
slog.Warn("desktop: mark startup healthy", "err", err)
return
}
if !config.SafeModeRequested() {
if err := repair.MarkUpdateHealthyExact(version, a.healthyUpdateCreatedAt, a.healthyUpdateTransactionID); err != nil {
slog.Warn("desktop: commit healthy update", "err", err)
}
if err := repair.RecordHealthyConfig(version); err != nil {
slog.Warn("desktop: record last-known-good config", "err", err)
}
}
})
}
// Record last-known-good config after the UI is actually visible. This is
// independent of any startup health probation or crash-loop policy.
ctx := a.ctx
a.goSafe("recordHealthyConfig", func() {
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
return
}
if err := repair.RecordHealthyConfig(version); err != nil {
slog.Debug("desktop: record last-known-good config", "err", err)
}
if archived, err := archiveSupersededLegacyUpdateAfterReady(); err != nil {
slog.Warn("desktop: retire superseded legacy update", "err", err)
} else if archived {
slog.Info("desktop: archived superseded legacy update transaction")
}
})
}
// --- bound command surface (frontend → controller) ---
@@ -1837,7 +1788,7 @@ func (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {
a.Approve(id, allow, session, persist)
return
}
ctrl := a.ctrlByTabID(tabID)
ctrl := a.ctrlForRuntimeTabID(tabID)
if ctrl != nil {
ctrl.Approve(id, allow, session, persist)
}
@@ -6705,7 +6656,8 @@ func (a *App) Jobs() []JobView {
}
func (a *App) JobsForTab(tabID string) []JobView {
if _, _, _, _, ok := a.activeRemoteWorkbench(); ok {
_, _, _, remoteTabID, remoteActive := a.activeRemoteWorkbench()
if remoteActive && (tabID == "" || tabID == remoteTabID) {
raw, err := a.workbenchRequest(protocol.MethodJobList, protocol.JobListParams{})
if err != nil {
return []JobView{}
@@ -6717,10 +6669,64 @@ func (a *App) JobsForTab(tabID string) []JobView {
return workbenchJobs(decoded.(protocol.JobListResult).Jobs)
}
out := []JobView{}
ctrl := a.ctrlByTabID(tabID)
ctrl := a.ctrlForRuntimeTabID(tabID)
return a.jobsForCtrl(ctrl, out)
}
// CancelJob stops one running background job in the active tab.
func (a *App) CancelJob(jobID string) (bool, error) {
return a.CancelJobForTab("", jobID)
}
// CancelJobForTab stops one running background job without relying on whatever
// tab happens to be active when the asynchronous frontend call completes.
func (a *App) CancelJobForTab(tabID, jobID string) (bool, error) {
jobID = strings.TrimSpace(jobID)
if jobID == "" {
return false, fmt.Errorf("job id is required")
}
cli, _, _, remoteTabID, remoteActive := a.activeRemoteWorkbench()
// An explicit non-remote tab id identifies a process-local runtime even
// while Remote Workbench owns the visible surface. Resolve that owner before
// falling back to the active remote target so the global jobs popover can
// stop local and remote work side by side.
if tabID != "" && (!remoteActive || tabID != remoteTabID) {
ctrl := a.ctrlForRuntimeTabID(tabID)
if ctrl != nil {
return cancelJobForController(ctrl, jobID)
}
if remoteActive {
return false, fmt.Errorf("tab %q changed before stopping background job; retry", tabID)
}
return false, nil
}
if remoteActive {
ctx, cancel := context.WithTimeout(a.bootContext(), 30*time.Second)
defer cancel()
raw, err := cli.Request(ctx, string(protocol.MethodJobCancel), protocol.JobCancelParams{JobID: protocol.JobID(jobID)})
if err != nil {
return false, err
}
decoded, err := protocol.DecodeResult(protocol.MethodJobCancel, raw)
if err != nil {
return false, err
}
return decoded.(protocol.JobCancelResult).Disposition == protocol.JobCancelled, nil
}
return cancelJobForController(a.ctrlForRuntimeTabID(tabID), jobID)
}
func cancelJobForController(ctrl control.SessionAPI, jobID string) (bool, error) {
if ctrl == nil {
return false, nil
}
canceller, ok := ctrl.(interface{ CancelJob(string) bool })
if !ok {
return false, fmt.Errorf("background job cancellation is unavailable")
}
return canceller.CancelJob(jobID), nil
}
func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {
if ctrl == nil {
return out
@@ -7885,10 +7891,10 @@ func (a *App) lockRuntimeTurnGates(setting string, affected func(*WorkspaceTab)
// runtimes live in detachedSessions, not a.tabs, and their work counts too.
a.mu.RLock()
for _, tab := range tabs {
if controllerHasActiveRuntimeWork(tab.Ctrl) {
if err := rebuildControllerActiveWorkErrorFor(tab.Ctrl, setting); err != nil {
a.mu.RUnlock()
release()
return nil, rebuildControllerActiveWorkError(setting)
return nil, err
}
}
a.mu.RUnlock()
@@ -9133,8 +9139,8 @@ func (a *App) SetMCPServerEnabled(name string, enabled bool) error {
a.mu.RLock()
hostKey := tab.SharedHostKey
a.mu.RUnlock()
if controllerHasActiveRuntimeWork(ctrl) {
return rebuildControllerActiveWorkError("MCP server")
if err := rebuildControllerActiveWorkErrorFor(ctrl, "MCP server"); err != nil {
return err
}
configuredEntry, hasConfiguredEntry, err := desktopEffectiveMCPServer(root, name)
if err != nil {
@@ -9231,8 +9237,10 @@ func (a *App) SetMCPServerTier(name, tier string) error {
tier = normalizeMCPTier(tier)
tab, ctrl, root := a.activeMCPRuntime()
if tab != nil && controllerHasActiveRuntimeWork(ctrl) {
return rebuildControllerActiveWorkError("MCP server")
if tab != nil {
if err := rebuildControllerActiveWorkErrorFor(ctrl, "MCP server"); err != nil {
return err
}
}
updated, found, err := a.desktopMCPServerForEdit(root, name)
if err != nil {
@@ -9574,25 +9582,56 @@ func modelProviderAccessAllowed(access []string, name string) bool {
return false
}
func controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {
type activeRuntimeWork struct {
running bool
pendingPrompt bool
backgroundJobs int
}
func controllerActiveRuntimeWork(ctrl control.SessionAPI) activeRuntimeWork {
if ctrl == nil {
return false
return activeRuntimeWork{}
}
status := ctrl.RuntimeStatus()
return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
return activeRuntimeWork{
running: status.Running,
pendingPrompt: status.PendingPrompt,
backgroundJobs: status.BackgroundJobs,
}
}
func (w activeRuntimeWork) active() bool {
return w.running || w.pendingPrompt || w.backgroundJobs > 0
}
func controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {
return controllerActiveRuntimeWork(ctrl).active()
}
// rebuildBusyError reports a rebuild rejected because the controller still has
// a running turn, pending prompt, or background jobs. Typed so the
// deferred-rebuild retry loop can keep waiting instead of giving up.
type rebuildBusyError struct{ setting string }
func (e *rebuildBusyError) Error() string {
return fmt.Sprintf("active work is still running; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s", e.setting)
type rebuildBusyError struct {
setting string
work activeRuntimeWork
}
func rebuildControllerActiveWorkError(setting string) error {
return &rebuildBusyError{setting: setting}
func (e *rebuildBusyError) Error() string {
return fmt.Sprintf(
"active work is still running; running=%t; pending_prompt=%t; background_jobs=%d; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s",
e.work.running,
e.work.pendingPrompt,
e.work.backgroundJobs,
e.setting,
)
}
func rebuildControllerActiveWorkErrorFor(ctrl control.SessionAPI, setting string) error {
work := controllerActiveRuntimeWork(ctrl)
if !work.active() {
return nil
}
return &rebuildBusyError{setting: setting, work: work}
}
type sessionLeaseBusyError struct {
@@ -9790,8 +9829,8 @@ func (a *App) SetModelForTab(tabID, name string) (retErr error) {
if a.controllerForTab(tab) == nil && prevPath != "" {
a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
}
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
return rebuildControllerActiveWorkError("model")
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "model"); err != nil {
return err
}
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return err
@@ -9805,8 +9844,8 @@ func (a *App) SetModelForTab(tabID, name string) (retErr error) {
if prevPath == "" {
prevPath = a.currentSessionPathFor(tab)
}
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
return rebuildControllerActiveWorkError("model")
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "model"); err != nil {
return err
}
}
timing.Prepare = time.Since(stageStarted)
@@ -9997,8 +10036,8 @@ func (a *App) SetEffortForTab(tabID, level string) error {
if a.controllerForTab(tab) == nil && prevPath != "" {
a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
}
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
return rebuildControllerActiveWorkError("effort")
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "effort"); err != nil {
return err
}
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return err
@@ -10012,8 +10051,8 @@ func (a *App) SetEffortForTab(tabID, level string) error {
if prevPath == "" {
prevPath = a.currentSessionPathFor(tab)
}
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
return rebuildControllerActiveWorkError("effort")
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "effort"); err != nil {
return err
}
}
snap := a.tabRuntimeSnapshot(tab)
@@ -10137,8 +10176,8 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
if a.controllerForTab(tab) == nil && prevPath != "" {
a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
}
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
return rebuildControllerActiveWorkError("token mode")
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "token mode"); err != nil {
return err
}
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return err
@@ -10152,8 +10191,8 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
if prevPath == "" {
prevPath = a.currentSessionPathFor(tab)
}
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
return rebuildControllerActiveWorkError("token mode")
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "token mode"); err != nil {
return err
}
}
modelRef, fallback, err := a.resolvedModelForTab(tab)
@@ -11920,8 +11959,10 @@ func (a *App) ConnectKey(apiKey string) (string, error) {
if apiKey == "" {
return "", fmt.Errorf("key is required")
}
if tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {
return "", rebuildControllerActiveWorkError("provider key")
if tab := a.activeTab(); tab != nil {
if err := rebuildControllerActiveWorkErrorFor(tab.Ctrl, "provider key"); err != nil {
return "", err
}
}
ctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)
defer cancel()
+32 -6
View File
@@ -5622,6 +5622,17 @@ func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old"}
cfg.Providers = []config.ProviderEntry{
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -5630,12 +5641,17 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
path := filepath.Join(dir, "jobs.jsonl")
jm := jobs.NewManager(event.Discard)
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
app.ctx = context.Background()
app.setTestCtrl(ctrl, "old/old-model")
t.Cleanup(func() {
if current := app.activeCtrl(); current != nil {
current.Close()
}
})
release := make(chan struct{})
jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
job := jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
@@ -5643,11 +5659,21 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
return "", nil
}
})
defer close(release)
t.Cleanup(func() { close(release) })
err := app.SetTokenMode("economy")
if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
t.Fatalf("SetTokenMode with background job error = %v, want background-job guard", err)
if err == nil || !strings.Contains(err.Error(), "background_jobs=1") {
t.Fatalf("SetTokenMode with background job error = %v, want exact background-job guard", err)
}
cancelled, err := app.CancelJobForTab("", job.ID)
if err != nil || !cancelled {
t.Fatalf("CancelJobForTab = %v, %v, want true, nil", cancelled, err)
}
if result := jm.WaitForSession(context.Background(), agent.BranchID(path), []string{job.ID}, 5); len(result) != 1 || result[0].Status != jobs.Killed {
t.Fatalf("stopped background job = %+v, want one killed result", result)
}
if err := app.SetTokenMode("economy"); err != nil {
t.Fatalf("SetTokenMode after stopping background job: %v", err)
}
}
+332
View File
@@ -0,0 +1,332 @@
package main
import (
"fmt"
"strings"
"time"
"reasonix/internal/control"
"reasonix/internal/workspacelease"
)
// ActiveWorkView is the structured Desktop contract for work that prevents a
// controller rebuild or a destructive tab action. Jobs is always a JSON array.
type ActiveWorkView struct {
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
Cancellable bool `json:"cancellable"`
Jobs []JobView `json:"jobs"`
}
// JobCancelBatchView reports which requested jobs accepted cancellation. Both
// slices are initialized so Wails never sends null to React.
type JobCancelBatchView struct {
Cancelled []string `json:"cancelled"`
NotRunning []string `json:"notRunning"`
}
// BackgroundRuntimeView is one visible or detached runtime with active work.
// TabID is an opaque process-local handle; paths and session writer ids are
// deliberately omitted from this user-facing contract.
type BackgroundRuntimeView struct {
TabID string `json:"tabId"`
Title string `json:"title"`
Detached bool `json:"detached"`
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
Jobs []JobView `json:"jobs"`
}
// WorkspaceConflictView describes a currently-waiting Delivery writer without
// exposing the lock path, process id, or session path.
type WorkspaceConflictView struct {
State string `json:"state"`
OwnerTabID string `json:"ownerTabId,omitempty"`
OwnerTitle string `json:"ownerTitle,omitempty"`
OwnerWork ActiveWorkView `json:"ownerWork"`
CanReveal bool `json:"canReveal"`
CanCreateWorktree bool `json:"canCreateWorktree"`
}
func activeWorkForController(ctrl control.SessionAPI) ActiveWorkView {
view := ActiveWorkView{Jobs: []JobView{}}
if ctrl == nil {
return view
}
status := ctrl.RuntimeStatus()
view.Running = status.Running
view.PendingPrompt = status.PendingPrompt
view.Cancellable = status.Cancellable
for _, job := range ctrl.Jobs() {
view.Jobs = append(view.Jobs, JobView{
ID: job.ID, Kind: job.Kind, Label: job.Label,
Status: job.Status, StartedAt: job.StartedAt,
})
}
return view
}
func (v ActiveWorkView) active() bool {
return v.Running || v.PendingPrompt || len(v.Jobs) > 0
}
// ActiveWorkForTab returns the precise blocker state for one tab. It is a
// preflight aid only; rebuild paths still re-check active work atomically.
func (a *App) ActiveWorkForTab(tabID string) ActiveWorkView {
return activeWorkForController(a.ctrlForRuntimeTabID(tabID))
}
// CancelJobsForTab requests cancellation for a stable tab id. The job manager
// keeps cancelled-but-unwinding jobs visible until their done channels close.
func (a *App) CancelJobsForTab(tabID string, jobIDs []string) (JobCancelBatchView, error) {
result := JobCancelBatchView{Cancelled: []string{}, NotRunning: []string{}}
seen := map[string]bool{}
for _, raw := range jobIDs {
id := strings.TrimSpace(raw)
if id == "" || seen[id] {
continue
}
seen[id] = true
cancelled, err := a.CancelJobForTab(tabID, id)
if err != nil {
return result, err
}
if cancelled {
result.Cancelled = append(result.Cancelled, id)
} else {
result.NotRunning = append(result.NotRunning, id)
}
}
return result, nil
}
type backgroundRuntimeSnapshot struct {
id string
ctrl control.SessionAPI
detached bool
title string
}
// BackgroundRuntimes returns every process-local runtime that still needs a
// visible recovery path, including runtimes detached by an explicit tab close.
func (a *App) BackgroundRuntimes() []BackgroundRuntimeView {
a.mu.RLock()
snapshots := make([]backgroundRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
seen := map[*WorkspaceTab]bool{}
for _, tab := range a.tabs {
if tab == nil || seen[tab] {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, title: title})
}
for _, tab := range a.detachedSessions {
if tab == nil || seen[tab] {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, detached: true, title: title})
}
a.mu.RUnlock()
out := make([]BackgroundRuntimeView, 0, len(snapshots))
for _, snapshot := range snapshots {
work := activeWorkForController(snapshot.ctrl)
if !work.active() {
continue
}
out = append(out, BackgroundRuntimeView{
TabID: snapshot.id, Title: snapshot.title, Detached: snapshot.detached,
Running: work.Running, PendingPrompt: work.PendingPrompt, Jobs: work.Jobs,
})
}
return out
}
func (a *App) ctrlForRuntimeTabID(tabID string) control.SessionAPI {
a.mu.RLock()
defer a.mu.RUnlock()
if strings.TrimSpace(tabID) == "" {
return a.activeCtrlLocked()
}
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil {
return nil
}
return tab.Ctrl
}
// RevealBackgroundRuntime activates a visible owner or reopens the exact
// detached session. It never reattaches by workspace alone.
func (a *App) RevealBackgroundRuntime(tabID string) (TabMeta, error) {
a.mu.RLock()
if tab := a.tabs[tabID]; tab != nil {
a.mu.RUnlock()
if err := a.SetActiveTab(tabID); err != nil {
return TabMeta{}, err
}
a.mu.RLock()
current := a.tabs[tabID]
if current == nil {
a.mu.RUnlock()
return TabMeta{}, fmt.Errorf("background task is no longer available")
}
meta := a.tabMeta(current, true)
a.mu.RUnlock()
return enrichTabMeta(meta), nil
}
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil || tab.Ctrl == nil {
a.mu.RUnlock()
return TabMeta{}, fmt.Errorf("background task is no longer available")
}
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
topicID := tab.TopicID
sessionPath := tab.currentSessionPath()
a.mu.RUnlock()
if strings.TrimSpace(sessionPath) == "" {
return TabMeta{}, fmt.Errorf("background task session is unavailable")
}
return a.OpenTopicSession(scope, workspaceRoot, topicID, sessionPath)
}
type workspaceLeaseReporter interface {
WorkspaceLeaseState() workspacelease.State
}
func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State {
if reporter, ok := ctrl.(workspaceLeaseReporter); ok {
return reporter.WorkspaceLeaseState()
}
return workspacelease.State{}
}
// WorkspaceConflictForTab classifies the owner that a Delivery writer is
// currently waiting for. An acquired process-local owner is actionable; when no
// local owner matches, the OS lock is treated as external.
func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
empty := WorkspaceConflictView{State: "none", OwnerWork: ActiveWorkView{Jobs: []JobView{}}}
a.mu.RLock()
var target *WorkspaceTab
if strings.TrimSpace(tabID) == "" {
target = a.activeTabLocked()
} else {
target = a.tabByEventSinkIDLocked(tabID)
}
if target == nil {
a.mu.RUnlock()
return empty
}
targetCtrl := target.Ctrl
targetWorkspaceRoot := target.WorkspaceRoot
a.mu.RUnlock()
if targetCtrl == nil {
return empty
}
targetState := controllerWorkspaceLeaseState(targetCtrl)
if !targetState.Waiting || targetState.Acquired {
return empty
}
targetRoot, err := workspacelease.CanonicalWorkspace(targetWorkspaceRoot)
if err != nil {
return empty
}
availability := a.DeliveryWorktreeAvailability(targetWorkspaceRoot)
a.mu.RLock()
type candidate struct {
id string
ctrl control.SessionAPI
root string
title string
}
candidates := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions))
seen := map[*WorkspaceTab]bool{}
for _, tab := range a.runtimeTabsLocked() {
if tab == nil || tab == target || seen[tab] || tab.Ctrl == nil {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
candidates = append(candidates, candidate{id: tab.ID, ctrl: tab.Ctrl, root: tab.WorkspaceRoot, title: title})
}
a.mu.RUnlock()
for _, candidate := range candidates {
root, err := workspacelease.CanonicalWorkspace(candidate.root)
if err != nil || root != targetRoot || !controllerWorkspaceLeaseState(candidate.ctrl).Acquired {
continue
}
return WorkspaceConflictView{
State: "local", OwnerTabID: candidate.id, OwnerTitle: candidate.title,
OwnerWork: activeWorkForController(candidate.ctrl), CanReveal: true,
CanCreateWorktree: availability.Available,
}
}
empty.State = "external"
empty.CanCreateWorktree = availability.Available
return empty
}
// RevealWorkspaceWriterForTab opens the exact process-local runtime identified
// by WorkspaceConflictForTab. External writers remain non-actionable.
func (a *App) RevealWorkspaceWriterForTab(tabID string) (TabMeta, error) {
conflict := a.WorkspaceConflictForTab(tabID)
if conflict.State != "local" || conflict.OwnerTabID == "" {
return TabMeta{}, fmt.Errorf("the workspace writer is not available in this Reasonix window")
}
return a.RevealBackgroundRuntime(conflict.OwnerTabID)
}
const stopAndCloseGrace = 15 * time.Second
// CloseTabWithPolicy makes the old implicit detach behavior an explicit user
// choice. stop_and_close never removes the tab until all owned work is idle.
func (a *App) CloseTabWithPolicy(tabID, policy string) error {
switch strings.TrimSpace(policy) {
case "keep_running":
return a.closeTab(tabID, true)
case "stop_and_close":
ctrl := a.ctrlForRuntimeTabID(tabID)
if ctrl == nil {
return a.closeTab(tabID, false)
}
ctrl.Cancel()
jobs := ctrl.Jobs()
ids := make([]string, 0, len(jobs))
for _, job := range jobs {
ids = append(ids, job.ID)
}
if _, err := a.CancelJobsForTab(tabID, ids); err != nil {
return err
}
deadline := time.NewTimer(stopAndCloseGrace)
defer deadline.Stop()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
if !activeWorkForController(ctrl).active() {
return a.closeTab(tabID, false)
}
select {
case <-deadline.C:
return fmt.Errorf("background work did not stop within %s; the task was kept open", stopAndCloseGrace)
case <-ticker.C:
}
}
default:
return fmt.Errorf("unknown close policy %q", policy)
}
}
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"testing"
"reasonix/internal/control"
"reasonix/internal/jobs"
"reasonix/internal/remote/workbench/client"
"reasonix/internal/workspacelease"
)
type backgroundRuntimeController struct {
control.SessionAPI
status control.RuntimeStatus
jobs []jobs.View
lease workspacelease.State
cancelled []string
turnCancel int
}
func (c *backgroundRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
func (c *backgroundRuntimeController) Jobs() []jobs.View {
return append([]jobs.View(nil), c.jobs...)
}
func (c *backgroundRuntimeController) CancelJob(id string) bool {
for _, job := range c.jobs {
if job.ID == id {
c.cancelled = append(c.cancelled, id)
return true
}
}
return false
}
func (c *backgroundRuntimeController) Cancel() { c.turnCancel++ }
func (c *backgroundRuntimeController) WorkspaceLeaseState() workspacelease.State {
return c.lease
}
func TestBackgroundRuntimeAPIsKeepDetachedJobsActionable(t *testing.T) {
targetCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-target", Kind: "bash", Label: "target tests", Status: "running", StartedAt: 1}},
}
focusedCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-focused", Kind: "bash", Label: "focused tests", Status: "running", StartedAt: 2}},
}
target := &WorkspaceTab{ID: "target", TopicTitle: "Detached task", Ctrl: targetCtrl}
focused := &WorkspaceTab{ID: "focused", TopicTitle: "Focused task", Ctrl: focusedCtrl}
app := &App{
tabs: map[string]*WorkspaceTab{"focused": focused},
tabOrder: []string{"focused"},
activeTabID: "focused",
detachedSessions: map[string]*WorkspaceTab{"session-target": target},
}
work := app.ActiveWorkForTab("target")
if len(work.Jobs) != 1 || work.Jobs[0].ID != "job-target" {
t.Fatalf("ActiveWorkForTab(target) = %+v, want target job", work)
}
result, err := app.CancelJobsForTab("target", []string{"job-target", "job-target", "missing"})
if err != nil {
t.Fatalf("CancelJobsForTab: %v", err)
}
if len(result.Cancelled) != 1 || result.Cancelled[0] != "job-target" || len(result.NotRunning) != 1 || result.NotRunning[0] != "missing" {
t.Fatalf("CancelJobsForTab result = %+v", result)
}
if len(targetCtrl.cancelled) != 1 || len(focusedCtrl.cancelled) != 0 {
t.Fatalf("cancellation routing target=%v focused=%v", targetCtrl.cancelled, focusedCtrl.cancelled)
}
runtimes := app.BackgroundRuntimes()
if len(runtimes) != 2 {
t.Fatalf("BackgroundRuntimes = %+v, want visible and detached", runtimes)
}
var foundDetached bool
for _, runtime := range runtimes {
if runtime.TabID == "target" {
foundDetached = runtime.Detached && runtime.Title == "Detached task" && len(runtime.Jobs) == 1
}
}
if !foundDetached {
t.Fatalf("detached runtime missing from %+v", runtimes)
}
}
func TestCancelJobForLocalRuntimeWhileRemoteWorkbenchIsActive(t *testing.T) {
localCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-local", Kind: "bash", Status: "running"}},
}
app := &App{
tabs: map[string]*WorkspaceTab{"local": {ID: "local", Ctrl: localCtrl}},
tabOrder: []string{"local"},
activeTabID: "local",
}
kernel := app.workbench()
_, generation, err := kernel.targets.BeginRemoteConnect("remote-host", "/srv/work")
if err != nil {
t.Fatalf("BeginRemoteConnect: %v", err)
}
if err := kernel.targets.MarkRemoteConnected(generation); err != nil {
t.Fatalf("MarkRemoteConnected: %v", err)
}
if _, _, _, err := kernel.targets.ActivateRemote(generation); err != nil {
t.Fatalf("ActivateRemote: %v", err)
}
kernel.mu.Lock()
kernel.remote = &client.Client{}
kernel.remoteGen = generation
kernel.remoteTabID = "remote"
kernel.mu.Unlock()
listed := app.JobsForTab("local")
if len(listed) != 1 || listed[0].ID != "job-local" {
t.Fatalf("JobsForTab(local) = %+v, want local job while Remote Workbench is active", listed)
}
cancelled, err := app.CancelJobForTab("local", "job-local")
if err != nil {
t.Fatalf("CancelJobForTab(local): %v", err)
}
if !cancelled || len(localCtrl.cancelled) != 1 || localCtrl.cancelled[0] != "job-local" {
t.Fatalf("local cancellation = %t, calls=%v; want local job cancelled", cancelled, localCtrl.cancelled)
}
}
func TestWorkspaceConflictIdentifiesExactLocalWriterWithoutIdentity(t *testing.T) {
root := t.TempDir()
waiterCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{Running: true},
lease: workspacelease.State{Waiting: true},
}
ownerCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "owner-job", Kind: "bash", Status: "running"}},
lease: workspacelease.State{Acquired: true},
}
app := &App{
tabs: map[string]*WorkspaceTab{
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
"owner": {ID: "owner", TopicTitle: "Writing task", WorkspaceRoot: root, Ctrl: ownerCtrl},
},
tabOrder: []string{"waiter", "owner"},
activeTabID: "waiter",
}
conflict := app.WorkspaceConflictForTab("waiter")
if conflict.State != "local" || conflict.OwnerTabID != "owner" || conflict.OwnerTitle != "Writing task" || !conflict.CanReveal {
t.Fatalf("WorkspaceConflictForTab = %+v, want exact local owner", conflict)
}
if len(conflict.OwnerWork.Jobs) != 1 || conflict.OwnerWork.Jobs[0].ID != "owner-job" {
t.Fatalf("owner work = %+v, want sanitized active work", conflict.OwnerWork)
}
}
+2 -2
View File
@@ -32,8 +32,8 @@ contents:
dst: /usr/bin/reasonix-desktop
file_info:
mode: 0755
- src: ./build/bin/reasonix-guard
dst: /usr/bin/reasonix-guard
- src: ./build/bin/reasonix-launcher
dst: /usr/bin/reasonix-launcher
file_info:
mode: 0755
- src: ./build/bin/reasonix
+1 -1
View File
@@ -2,7 +2,7 @@
Type=Application
Name=Reasonix
Comment=Reasonix desktop — a Wails shell around the Go kernel
Exec=reasonix-guard launch --detach
Exec=reasonix-launcher
Icon=reasonix-desktop
Categories=Development;Utility;
Terminal=false
+79 -38
View File
@@ -100,6 +100,7 @@ OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the inst
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
!define REASONIX_CLI "reasonix-cli.exe"
!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"
!define REASONIX_LAYOUT_INSTALLER "reasonix-layout-installer.exe"
!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
!define REASONIX_UNLOCK_RETRIES 60
@@ -213,12 +214,21 @@ Function reasonix.waitForExecutableUnlock
StrCpy $0 0
retry:
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_guard
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_versioned_target
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_versioned_target:
; A same-version recovery install replaces this directory transactionally.
; Detect the running active binary before asking the Go activator to rename it.
IfFileExists "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0 check_guard
ClearErrors
FileOpen $1 "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_guard:
IfFileExists "$INSTDIR\${REASONIX_GUARD}" 0 check_launcher
ClearErrors
@@ -273,72 +283,101 @@ FunctionEnd
Section
!insertmacro wails.setShellContext
; Current auto-update helpers extract the signed installer payload into a
; unique temporary directory, then compare-and-publish the exact claimed
; release unit. Legacy helpers omit /REASONIXSTAGE and retain the historical
; in-place update behavior required to upgrade into the hardened release.
StrCmp $ReasonixStageMode "1" reasonix_copy_payload
; /REASONIXSTAGE=1: flat six-member payload for 1.18–1.19.1 helpers (and
; the new helper's staging extract). Do not write shortcuts/uninstaller.
; Normal install: versioned-v1 layout under versions/v${INFO_PRODUCTVERSION}/
; with a permanent thin launcher at InstallRoot. Guard is only present in
; STAGE payloads (as the one-shot legacy migrator) and is not persisted on
; a normal install.
StrCmp $ReasonixStageMode "1" reasonix_stage_payload
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
Goto reasonix_normal_install
reasonix_copy_payload:
reasonix_stage_payload:
SetOutPath $INSTDIR
; The release workflow embeds a minisign-authenticated manifest after
; SignPath has signed the five executable members. Only the extraction
; handoff needs these files; a normal installation does not persist them.
StrCmp $ReasonixStageMode "1" 0 reasonix_payload_metadata_done
!if /FileExists "${REASONIX_PAYLOAD_MANIFEST}"
File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"
!endif
!if /FileExists "${REASONIX_PAYLOAD_SIGNATURE}"
File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"
!endif
reasonix_payload_metadata_done:
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done
; Portable archives expose Reasonix.exe as a second copy of the Guard
; launcher. Preserve that layout only when upgrading an existing portable
; directory, and keep the alias in lockstep with the canonical launcher.
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 reasonix_no_portable_entry
File "/oname=${REASONIX_PORTABLE_ENTRY}" "${REASONIX_LAUNCHER}"
reasonix_no_portable_entry:
; Keep the Guard launcher as the target while taking the visible shortcut
; icon from the Wails application. The launcher embeds the same icon too,
; but an explicit icon source prevents stale/generic taskbar pins after an
; in-place upgrade from a launcher build that had no Windows resources.
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
reasonix_launcher_payload_done:
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!endif
Goto reasonix_section_done
reasonix_normal_install:
; Extract into an install-local temporary directory, then let the signed Go
; activator validate the complete release unit, transactionally publish the
; version/root entries, and strictly atomically replace current.json last.
; The normal/recovery installer therefore shares the same commit protocol as
; automatic updates instead of writing live files or current.json in place.
System::Call 'kernel32::GetCurrentProcessId() i .R8'
CreateDirectory "$INSTDIR\versions"
StrCpy $R9 "$INSTDIR\versions\.installer-v${INFO_PRODUCTVERSION}-$R8"
RMDir /r "$R9"
CreateDirectory "$R9"
SetOutPath "$R9"
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done_no_launcher
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
reasonix_launcher_payload_done_no_launcher:
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!else
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
!endif
SetOutPath "$PLUGINSDIR"
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"
!else
!error "${REASONIX_GUARD} was not found; normal installs require the signed layout activator."
!endif
ExecWait '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch' $0
StrCmp $0 "0" reasonix_layout_activated
DetailPrint "Reasonix layout activation failed with exit code $0; the previous version remains active."
RMDir /r "$R9"
SetErrorLevel 1
Abort "Reasonix could not activate the verified release. The previous version was left unchanged."
reasonix_layout_activated:
RMDir /r "$R9"
SetOutPath "$INSTDIR"
; Remove flat leftovers from prior 1.18–1.19 installs when overwriting.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
!if /FileExists "${REASONIX_LAUNCHER}"
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0
!else
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
!endif
StrCmp $ReasonixStageMode "1" reasonix_section_done
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro reasonix.writeUninstaller
reasonix_section_done:
SectionEnd
@@ -347,13 +386,15 @@ Section "uninstall"
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
; Precision uninstall: delete main application files
; Precision uninstall: flat leftovers, thin entry points, and version trees.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_LAUNCHER}"
Delete "$INSTDIR\${REASONIX_CLI}"
Delete "$INSTDIR\${REASONIX_PORTABLE_ENTRY}"
Delete "$INSTDIR\current.json"
RMDir /r "$INSTDIR\versions"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+143 -32
View File
@@ -19,6 +19,7 @@ import (
"golang.org/x/sys/windows"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
@@ -42,7 +43,7 @@ func main() {
func run(args []string) int {
var parentPID uint
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID string
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
@@ -53,6 +54,7 @@ func run(args []string) int {
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
if err := fs.Parse(args); err != nil {
return 2
}
@@ -69,11 +71,15 @@ func run(args []string) int {
logger.Print("missing --to-version")
return 2
}
if createdAt == "" {
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
logger.Printf("unsupported --install-layout %q", installLayout)
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
logger.Print("missing --created-at")
return 2
}
if transactionID == "" {
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
logger.Print("missing --transaction-id")
return 2
}
@@ -87,6 +93,25 @@ func run(args []string) int {
return 1
}
}
if installLayout == installlayout.InstallLayoutVersionedV1 {
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
}
recoverExisting := func(reason string) int {
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
}
}
return 1
}
// The detached helper runs from the update cache, so a plain
// repair.ReadPendingUpdate would validate the transaction against the cache
// directory and reject every legitimate flat install. Claim through the
// explicit install-local launcher path instead; the repair package validates
// the complete transaction identity and exact release-unit path set while it
// acquires the pending + target locks.
claimTargets := windowsReleaseUnitPaths(installDir)
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
// The old desktop may acquire the pending lock during its normal shutdown.
// Wait for that exact process first, then claim the transaction and hold both
// pending and target locks across the installer replacement window.
@@ -94,13 +119,13 @@ func run(args []string) int {
toVersion,
createdAt,
transactionID,
filepath.Join(installDir, "reasonix-desktop.exe"),
windowsReleaseUnitPaths(installDir),
claimLauncher,
claimTargets,
parentExitTimeout,
)
if err != nil {
logger.Printf("claim pending update: %v", err)
return 1
return recoverExisting("pending update claim failure")
}
defer releaseClaim()
recoverUnstarted := func() int {
@@ -161,29 +186,41 @@ func run(args []string) int {
}
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after publish failure: %v", relaunchErr)
}
}
return 1
}
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
if receipts == nil {
// Versioned-v1 activation: no flat publish receipts and no health
// probation. Clear the pending transaction and relaunch the launcher.
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("clear pending after versioned activate: %v", cancelErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
}
} else {
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
}
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
if relaunch != "" {
if err := startRelaunchFn(relaunch, installDir); err != nil {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
@@ -191,6 +228,86 @@ func run(args []string) int {
return 0
}
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
recoverExisting := func() int {
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch after versioned update failure: %v", err)
}
}
return 1
}
if _, err := installlayout.ReadCurrent(installDir); err != nil {
logger.Printf("read versioned install pointer: %v", err)
return recoverExisting()
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind versioned update installer: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create versioned update staging: %v", err)
return recoverExisting()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind versioned update staging: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged versioned installer: %v", err)
return recoverExisting()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract versioned installer payload: %v", installerErr)
return recoverExisting()
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: toVersion,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
logger.Printf("activate versioned release: %v", err)
return recoverExisting()
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch versioned release: %v", err)
return 1
}
}
return 0
}
// preferRelaunchPath chooses the thin launcher when present so post-update
// restarts use the permanent entry point, not a flat desktop binary.
func preferRelaunchPath(relaunch, installDir string) string {
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
path := filepath.Join(installDir, name)
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
return path
}
}
return relaunch
}
func validSHA256(value string) bool {
value = strings.TrimSpace(value)
if len(value) != sha256.Size*2 {
@@ -328,21 +445,15 @@ func installStagedWindowsReleaseUnit(
claimed *repair.UpdateTransaction,
stagingDir string,
) (bool, []repair.FileUpdateInstallReceipt, error) {
members, err := loadWindowsStagedReleaseUnit(claimed, stagingDir)
if err != nil {
return false, nil, err
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
// fail closed without mutating the live install or leaving flat pending state.
if !preferVersionedWindowsActivation(stagingDir) {
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
}
// Persist recovery intent before the first live rename. If the helper exits
// between release-unit members, Guard rolls the exact pending transaction
// back instead of launching a mixed install.
if err := repair.MarkUpdateApplyFailedExact(claimed, "Windows update publish did not complete"); err != nil {
return false, nil, fmt.Errorf("record update recovery intent: %w", err)
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
return false, nil, fmt.Errorf("versioned activate: %w", err)
}
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, repair.PublishClaimedFileUpdateMemberExact)
if err != nil {
return true, receipts, err
}
return true, receipts, nil
return true, nil, nil
}
func windowsReleaseUnitPaths(installDir string) []string {
+150 -30
View File
@@ -12,11 +12,27 @@ import (
"testing"
"time"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
t.Helper()
paths := windowsReleaseUnitPaths(installDir)
for _, path := range paths {
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
t.Fatal(err)
}
}
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
return tx
}
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
source := filepath.Join(t.TempDir(), "installer.exe")
content := []byte("verified-installer")
@@ -72,8 +88,86 @@ func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
installDir := t.TempDir()
seed := t.TempDir()
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installDir,
Version: "v1.20.0",
RequestID: "seed-windows-helper",
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
},
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
}); err != nil {
t.Fatal(err)
}
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
legacyClaimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
legacyClaimed = true
return nil, func() {}, errors.New("legacy claim must not run")
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
runInstallerFn = func(_ string, staging string) error {
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
return err
}
}
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
"--to-version", "v1.20.1",
"--install-layout", "versioned-v1",
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
if legacyClaimed {
t.Fatal("versioned update claimed legacy pending transaction")
}
if !relaunched {
t.Fatal("versioned update did not relaunch")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
var events []string
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
@@ -107,11 +201,11 @@ func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
}
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
return &repair.UpdateTransaction{}, func() { events = append(events, "release") }, nil
return pending, func() { events = append(events, "release") }, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
events = append(events, "publish")
return true, nil, nil
return true, []repair.FileUpdateInstallReceipt{}, nil
}
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
events = append(events, "record-installed")
@@ -132,16 +226,16 @@ func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
"--to-version", pending.ToVersion,
"--created-at", pending.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(pending),
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
wantClaim := "claim:" + strings.Join([]string{
"v2",
"2026-07-29T00:00:00Z",
"transaction-1",
pending.ToVersion,
pending.CreatedAt,
repair.UpdateTransactionID(pending),
filepath.Join(installDir, "reasonix-desktop.exe"),
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
}, "\x01")
@@ -197,6 +291,44 @@ func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
}
}
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
relaunched := false
startRelaunchFn = func(path, dir string) error {
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
return nil
}
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return nil, nil, errors.New("pending lock unavailable")
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-08-02T00:00:00Z",
"--transaction-id", strings.Repeat("a", 64),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch the existing desktop")
}
}
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
@@ -221,12 +353,7 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
startRelaunchFn = func(string, string) error { return nil }
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
@@ -236,7 +363,6 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
const createdAt = "2026-07-29T00:00:00Z"
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
@@ -244,7 +370,7 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", createdAt,
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
@@ -280,12 +406,7 @@ func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.
relaunched = true
return nil
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
@@ -296,7 +417,11 @@ func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.
return func() {}, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
return true, nil, nil
return true, []repair.FileUpdateInstallReceipt{{
UpdateTransactionID: repair.UpdateTransactionID(claimed),
TargetPath: claimed.TargetPath,
InstalledStateID: "installed",
}}, nil
}
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
return nil, errors.New("release unit drifted")
@@ -351,12 +476,7 @@ func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
relaunched = true
return nil
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
releases := 0
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() { releases++ }, nil
@@ -0,0 +1,118 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
// staged NSIS payload:
//
// InstallRoot/
// reasonix-launcher.exe
// Reasonix.exe (launcher alias when present or portable)
// reasonix-cli.exe (CLI entry; full binary for now)
// current.json
// versions/<version>/
// reasonix-desktop.exe
// reasonix-cli.exe
// reasonix-update-helper.exe
//
// Any failure before the current.json pointer swap leaves the previous active
// version unchanged. The helper never counts crashes or selects prior versions.
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
if claimed == nil {
return fmt.Errorf("versioned activate: transaction is nil")
}
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
// When the claimed primary is already under versions/<ver>/, climb to root.
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
installRoot = root
}
version := strings.TrimSpace(claimed.ToVersion)
if err := installlayout.ValidateVersionName(version); err != nil {
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if err := installlayout.ValidateVersionName(version); err != nil {
return fmt.Errorf("versioned activate: %w", err)
}
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
}
}
requestID := repair.UpdateTransactionID(claimed)
if requestID == "" {
requestID = "helper-" + version
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: version,
RequestID: requestID,
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
},
RootMembers: []installlayout.Member{
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
},
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
}); err != nil {
return err
}
// Remove flat release-unit leftovers so the install root is the thin layout.
// Do not remove the launcher/CLI/alias we just wrote.
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe", // helper lives only under versions/
} {
_ = os.Remove(filepath.Join(installRoot, name))
}
// Best-effort retention GC of older version trees.
_ = installlayout.RetainPreviousVersions(installRoot, 0)
_ = installlayout.CleanupStaleStaging(installRoot, 0)
return nil
}
// preferVersionedWindowsActivation reports whether the staged payload is
// complete enough for versioned-v1 activation.
func preferVersionedWindowsActivation(stagingDir string) bool {
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
info, err := os.Lstat(filepath.Join(stagingDir, name))
if err != nil || !info.Mode().IsRegular() {
return false
}
}
return true
}
@@ -0,0 +1,129 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
func TestPreferVersionedWindowsActivation(t *testing.T) {
staging := t.TempDir()
if preferVersionedWindowsActivation(staging) {
t.Fatal("empty staging must not prefer versioned")
}
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
if !preferVersionedWindowsActivation(staging) {
t.Fatal("complete staging must prefer versioned")
}
}
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
installDir := t.TempDir()
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Seed a flat desktop so cleanup is observable.
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
t.Fatal(err)
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("current.json missing")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(installDir)
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(desktop)
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
t.Fatalf("desktop payload = %q err=%v", raw, err)
}
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
t.Fatalf("root entry %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed")
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
t.Fatal("flat guard should be removed")
}
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
t.Fatalf("prefer relaunch = %s", prefer)
}
}
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
installDir := t.TempDir()
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Minimal claimed unit (versioned path does not need full flat file list).
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if !started || receipts != nil {
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("versioned activation did not write current.json")
}
}
-6
View File
@@ -146,12 +146,6 @@ func (a *App) flushPendingCrash() {
if version == "dev" {
return
}
// Safe Mode boots from built-in defaults and cannot read the user's real
// telemetry preference, so it must neither send the pending report nor
// consume it: leave the file for the next normal boot to decide.
if config.SafeModeRequested() {
return
}
paths := pendingCrashPaths()
if len(paths) == 0 {
return
+5 -5
View File
@@ -198,7 +198,9 @@ func TestFlushPendingCrashDevGuard(t *testing.T) {
}
}
func TestFlushPendingCrashRetainsInSafeMode(t *testing.T) {
func TestFlushPendingCrashIgnoresSafeModeEnv(t *testing.T) {
// v1.20+: REASONIX_SAFE_MODE no longer blocks crash flush. With telemetry
// off/default, the pending file is consumed (sent or dropped).
t.Setenv("REASONIX_SAFE_MODE", "1")
oldVersion := version
t.Cleanup(func() {
@@ -209,8 +211,6 @@ func TestFlushPendingCrashRetainsInSafeMode(t *testing.T) {
writePendingCrash("safe", "boom", []byte("stack"))
NewApp().flushPendingCrash()
if _, ok := readPending(t); !ok {
t.Fatal("safe mode must leave the pending crash file for the next normal boot")
}
// Either sent or dropped is fine; must not retain solely because of Safe Mode env.
// When telemetry is off the file is removed; when on it is sent. Both clear it.
}
+2 -1
View File
@@ -20,7 +20,8 @@
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:delivery-worktree": "tsx src/__tests__/delivery-worktree.test.ts",
"test:updater": "tsx src/__tests__/updater-shared-state.test.tsx",
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test && pnpm test:remote && pnpm test:performance"
"test:window-state": "tsx src/__tests__/window-state-ordering.test.ts",
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance"
},
"dependencies": {
"@gsap/react": "^2.1.2",
+335 -25
View File
@@ -54,9 +54,10 @@ import { ApprovalModal } from "./components/ApprovalModal";
import { AskCard } from "./components/AskCard";
import { UndoRewindBanner } from "./components/UndoRewindBanner";
import { ClearContextCard } from "./components/ClearContextCard";
import { RuntimeDecisionCard } from "./components/RuntimeDecisionCard";
/** Footer decision surface kinds. Priority: tool/plan approval > ask > clear context. */
type DecisionSurfaceKind = "tool_approval" | "plan_approval" | "ask" | "clear_context";
/** Footer decision surface kinds. Runtime blockers are explicit recovery choices. */
type DecisionSurfaceKind = "tool_approval" | "plan_approval" | "ask" | "workspace_conflict" | "mode_jobs" | "close_active" | "clear_context";
import { StatusBar } from "./components/StatusBar";
import { RemoteHostKeyDialog } from "./components/RemoteHostKeyDialog";
import { RemoteSecretDialog } from "./components/RemoteSecretDialog";
@@ -98,6 +99,8 @@ import {
type BotConnectionView,
type BotRuntimeStatusView,
type BotSettingsView,
type ActiveWorkView,
type BackgroundRuntimeView,
type CollaborationMode,
type ComposerInsertRequest,
type DesktopStartupSettingsView,
@@ -110,6 +113,7 @@ import {
type TabMeta,
type TokenMode,
type ToolApprovalMode,
type WorkspaceConflictView,
} from "./lib/types";
import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay";
import { formatSelectionReference, type SelectedTextInsertRequest } from "./lib/selectedTextContext";
@@ -1090,6 +1094,7 @@ export default function App() {
setModel,
setEffort,
setTokenMode,
cancelJob,
switchTab,
openProjectTab,
createDeliveryWorktree,
@@ -1126,8 +1131,8 @@ export default function App() {
const settingsFocus = useOverlayStore((s) => s.settingsFocus);
const setSettingsFocus = useOverlayStore((s) => s.setSettingsFocus);
const [desktopLayoutStyle, setDesktopLayoutStyle] = useState<DesktopLayoutStyle>("workbench");
const [safeMode, setSafeMode] = useState(false);
const singleSurfaceLayout = desktopLayoutStyle === "workbench" || desktopLayoutStyle === "creation";
const [configLoadWarnings, setConfigLoadWarnings] = useState<string[]>([]);
const [startupUpdateChecksEnabled, setStartupUpdateChecksEnabled] = useState<boolean | null>(null);
const [histView, setHistView] = useState<HistoryViewState | null>(null);
const paletteOpen = useOverlayStore((s) => s.paletteOpen);
@@ -1295,6 +1300,16 @@ export default function App() {
const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false);
const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false);
const [clearContextPending, setClearContextPending] = useState(false);
const [backgroundRuntimes, setBackgroundRuntimes] = useState<BackgroundRuntimeView[]>([]);
const [workspaceConflict, setWorkspaceConflict] = useState<WorkspaceConflictView | null>(null);
const [pendingModeSwitch, setPendingModeSwitch] = useState<{
tabId: string;
target: TokenMode;
previous: TokenMode;
work: ActiveWorkView;
stopping: boolean;
} | null>(null);
const [pendingClose, setPendingClose] = useState<{ tabId: string; work: ActiveWorkView; stopping: boolean } | null>(null);
const topicRenameSkipCommitRef = useRef(false);
const prevDecisionSurfaceRef = useRef<DecisionSurfaceKind | null>(null);
const decisionSurfaceRef = useRef<DecisionSurfaceKind | null>(null);
@@ -1311,6 +1326,55 @@ export default function App() {
document.documentElement.setAttribute("data-platform", desktopPlatform);
}, [desktopPlatform]);
const refreshBackgroundRuntimes = useCallback(async () => {
try {
setBackgroundRuntimes(await app.BackgroundRuntimes());
} catch {
// The global recovery entry is supplementary; the active-tab job list
// remains available if an older Remote Workbench does not implement it.
}
}, []);
useEffect(() => {
let disposed = false;
const refresh = async () => {
if (disposed) return;
await refreshBackgroundRuntimes();
};
void refresh();
const timer = window.setInterval(() => void refresh(), 1000);
return () => {
disposed = true;
window.clearInterval(timer);
};
}, [refreshBackgroundRuntimes]);
useEffect(() => {
if (!activeTabId || !state.running) {
setWorkspaceConflict(null);
return;
}
let disposed = false;
const inspect = async () => {
try {
const conflict = await app.WorkspaceConflictForTab(activeTabId);
if (!disposed) setWorkspaceConflict(conflict.state === "none" ? null : conflict);
} catch {
if (!disposed) setWorkspaceConflict(null);
}
};
void inspect();
const timer = window.setInterval(() => void inspect(), 500);
return () => {
disposed = true;
window.clearInterval(timer);
};
}, [activeTabId, state.running]);
useEffect(() => {
if (pendingModeSwitch && pendingModeSwitch.tabId !== activeTabId) setPendingModeSwitch(null);
}, [activeTabId, pendingModeSwitch]);
const closeTransientOverlays = useCallback(() => {
setTransientOverlayDismissSignal((signal) => signal + 1);
}, []);
@@ -1445,14 +1509,16 @@ export default function App() {
]);
if (cancelled) return;
applyDesktopPreferences(settings);
setSafeMode(settings.safeMode === true);
setConfigLoadWarnings(
Array.isArray(settings.configWarnings)
? settings.configWarnings.filter((w): w is string => typeof w === "string" && w.trim() !== "")
: [],
);
hydrateDisplayMode(settings.displayMode);
setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus));
setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t));
// Load unified theme experience after base appearance so pack tokens win.
if (settings.safeMode === true) {
clearThemePack();
} else {
{
try {
const { loadThemeExperience, applyExperienceToDOM } = await import("./lib/themeExperience");
const exp = await loadThemeExperience();
@@ -1688,9 +1754,12 @@ export default function App() {
return state.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
}
if (state.ask) return "ask";
if (workspaceConflict) return "workspace_conflict";
if (pendingModeSwitch) return "mode_jobs";
if (pendingClose) return "close_active";
if (clearContextPending) return "clear_context";
return null;
}, [clearContextPending, state.approval, state.ask]);
}, [clearContextPending, pendingClose, pendingModeSwitch, state.approval, state.ask, workspaceConflict]);
decisionSurfaceRef.current = decisionSurface;
useEffect(() => {
// Close composer menus/popovers when a decision takes over the footer.
@@ -1872,21 +1941,19 @@ export default function App() {
},
[activeTabId, applyGoalForTab],
);
const applyTokenMode = useCallback(
async (m: TokenMode): Promise<void> => {
const tabId = activeTabId;
if (!tabId || runtimeTransitionTabsRef.current.has(tabId) || m === composerProfile.tokenMode) return;
const previous = composerProfile.tokenMode;
const commitTokenModeSwitch = useCallback(
async (tabId: string, m: TokenMode, previous: TokenMode, profile: ComposerProfile): Promise<void> => {
if (!tabId || activeTabIdRef.current !== tabId || runtimeTransitionTabsRef.current.has(tabId)) return;
runtimeTransitionTabsRef.current.add(tabId);
setRuntimeTransitionsByTab((current) => ({ ...current, [tabId]: true }));
setComposerProfilesByTab((current) => patchComposerProfile(current, tabId, composerProfile, { tokenMode: m }, ["tokenMode"]));
setComposerProfilesByTab((current) => patchComposerProfile(current, tabId, profile, { tokenMode: m }, ["tokenMode"]));
const switched = await setTokenMode(m);
if (!switched) {
setComposerProfilesByTab((current) => {
const profile = current[tabId] ?? composerProfile;
const pending = { ...profile.pending };
const currentProfile = current[tabId] ?? profile;
const pending = { ...currentProfile.pending };
delete pending.tokenMode;
return { ...current, [tabId]: { ...profile, tokenMode: previous, pending } };
return { ...current, [tabId]: { ...currentProfile, tokenMode: previous, pending } };
});
}
runtimeTransitionTabsRef.current.delete(tabId);
@@ -1897,8 +1964,64 @@ export default function App() {
return next;
});
},
[activeTabId, composerProfile, setTokenMode],
[setTokenMode],
);
const applyTokenMode = useCallback(
async (m: TokenMode): Promise<void> => {
const tabId = activeTabId;
if (!tabId || runtimeTransitionTabsRef.current.has(tabId) || m === composerProfile.tokenMode) return;
const previous = composerProfile.tokenMode;
try {
const work = await app.ActiveWorkForTab(tabId);
if (work.jobs.length > 0) {
setPendingModeSwitch({ tabId, target: m, previous, work, stopping: false });
return;
}
} catch {
// The atomic backend guard remains authoritative on older runtimes.
}
await commitTokenModeSwitch(tabId, m, previous, composerProfile);
},
[activeTabId, commitTokenModeSwitch, composerProfile],
);
const stopJobsAndSwitchMode = useCallback(async () => {
const request = pendingModeSwitch;
if (!request || request.stopping) return;
setPendingModeSwitch({ ...request, stopping: true });
try {
await app.CancelJobsForTab(request.tabId, request.work.jobs.map((job) => job.id));
const deadline = Date.now() + 15_000;
let work = await app.ActiveWorkForTab(request.tabId);
while (work.jobs.length > 0 && Date.now() < deadline) {
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work } : current);
await new Promise((resolve) => window.setTimeout(resolve, 150));
work = await app.ActiveWorkForTab(request.tabId);
}
if (work.jobs.length > 0) {
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work, stopping: false } : current);
showToast(t("status.jobStopFailed"), "error");
return;
}
setPendingModeSwitch(null);
await refreshBackgroundRuntimes();
if (activeTabIdRef.current === request.tabId) {
await commitTokenModeSwitch(request.tabId, request.target, request.previous, composerProfile);
}
} catch (err) {
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current);
showToast(err instanceof Error ? err.message : String(err), "error");
}
}, [commitTokenModeSwitch, composerProfile, pendingModeSwitch, refreshBackgroundRuntimes, showToast, t]);
const cancelRuntimeJob = useCallback(async (tabId: string, jobId: string): Promise<boolean> => {
try {
const cancelled = await app.CancelJobForTab(tabId, jobId);
await refreshBackgroundRuntimes();
return cancelled;
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
return false;
}
}, [refreshBackgroundRuntimes, showToast]);
// Shift+Tab toggles only the collaboration axis; Ctrl/Cmd+Y toggles YOLO on the
// tool-permission axis while preserving the Ask/Auto base mode.
const cycleMode = useCallback(() => {
@@ -3076,6 +3199,16 @@ export default function App() {
[isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, switchTab],
);
const revealBackgroundRuntime = useCallback(async (tabId: string): Promise<void> => {
try {
const meta = await app.RevealBackgroundRuntime(tabId);
await switchTab(meta.id, meta);
await refreshTabMetas(undefined, { afterMutation: true });
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
}
}, [refreshTabMetas, showToast, switchTab]);
const handleTabChange = useCallback((id: string) => {
closeTransientOverlays();
const selected = tabMetas.find((tab) => tab.id === id);
@@ -3084,8 +3217,16 @@ export default function App() {
setTabRevealSignal((signal) => signal + 1);
}, [closeTransientOverlays, enqueueTabSwitch, tabMetas]);
const handleTabClose = useCallback(async (id: string) => {
const finishTabClose = useCallback(async (
id: string,
policy: "keep_running" | "stop_and_close",
): Promise<boolean> => {
closeTransientOverlays();
const closed = await closeTab(id, policy);
if (!closed) {
showToast(t("runtime.closeFailed"), "error");
return false;
}
setComposerProfilesByTab((current) => {
if (!(id in current)) return current;
const next = { ...current };
@@ -3103,10 +3244,58 @@ export default function App() {
const nextActiveId = remaining[nextIndex]?.id;
return remaining.map((tab) => ({ ...tab, active: tab.id === nextActiveId }));
});
await closeTab(id);
await refreshTabMetas(undefined, { afterMutation: true });
await refreshBackgroundRuntimes();
setTabRevealSignal((signal) => signal + 1);
}, [activeTabId, closeTab, closeTransientOverlays, refreshTabMetas]);
return true;
}, [activeTabId, closeTab, closeTransientOverlays, refreshBackgroundRuntimes, refreshTabMetas, showToast, t]);
const handleTabClose = useCallback(async (id: string) => {
try {
const work = await app.ActiveWorkForTab(id);
if (work.running || work.pendingPrompt || work.jobs.length > 0) {
setPendingClose({ tabId: id, work, stopping: false });
return;
}
} catch {
// CloseTabWithPolicy re-checks the controller state atomically.
}
await finishTabClose(id, "stop_and_close");
}, [finishTabClose]);
const resolvePendingClose = useCallback(async (policy: "keep_running" | "stop_and_close") => {
const request = pendingClose;
if (!request || request.stopping) return;
if (policy === "stop_and_close") setPendingClose({ ...request, stopping: true });
const closed = await finishTabClose(request.tabId, policy);
if (closed) setPendingClose(null);
else setPendingClose((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current);
}, [finishTabClose, pendingClose]);
const revealWorkspaceWriter = useCallback(async () => {
if (!activeTabId) return;
try {
const meta = await app.RevealWorkspaceWriterForTab(activeTabId);
setWorkspaceConflict(null);
await switchTab(meta.id, meta);
await refreshTabMetas(undefined, { afterMutation: true });
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
}
}, [activeTabId, refreshTabMetas, showToast, switchTab]);
const continueInDeliveryWorktree = useCallback(async () => {
const root = state.meta?.workspaceRoot || state.meta?.workspacePath || state.meta?.cwd;
if (!root) return;
cancel();
setWorkspaceConflict(null);
try {
await createDeliveryWorktree(root);
await refreshTabMetas(undefined, { afterMutation: true });
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
}
}, [cancel, createDeliveryWorktree, refreshTabMetas, showToast, state.meta?.cwd, state.meta?.workspacePath, state.meta?.workspaceRoot]);
const handleTabsClose = useCallback(async (ids: string[], nextActiveTabId?: string) => {
closeTransientOverlays();
@@ -3114,7 +3303,15 @@ export default function App() {
const targets = ids.filter((id, index) => currentIds.includes(id) && ids.indexOf(id) === index);
if (targets.length === 0) return;
for (const id of targets) {
await closeTab(id);
let work: ActiveWorkView | null = null;
try {
work = await app.ActiveWorkForTab(id);
} catch { /* the close path remains authoritative */ }
if (work && (work.running || work.pendingPrompt || work.jobs.length > 0)) {
setPendingClose({ tabId: id, work, stopping: false });
return;
}
await finishTabClose(id, "stop_and_close");
}
if (nextActiveTabId && currentIds.includes(nextActiveTabId)) {
const selected = tabMetas.find((tab) => tab.id === nextActiveTabId);
@@ -3123,7 +3320,7 @@ export default function App() {
}
await refreshTabMetas(undefined, { afterMutation: true });
setTabRevealSignal((signal) => signal + 1);
}, [closeTab, closeTransientOverlays, enqueueTabSwitch, refreshTabMetas, tabMetas]);
}, [closeTransientOverlays, enqueueTabSwitch, finishTabClose, refreshTabMetas, tabMetas]);
const handleTabsReorder = useCallback(async (ids: string[]) => {
setTabOrderIds(ids);
@@ -4466,8 +4663,41 @@ export default function App() {
{state.meta?.startupErr && (
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
)}
{safeMode && (
<div className="banner banner--warning">{t("guard.safeMode")}</div>
{configLoadWarnings.length > 0 && (
<div className="banner banner--warning banner--actionable">
<span className="banner__msg" title={configLoadWarnings.join("\n")}>
{t("config.loadWarning", { msg: configLoadWarnings[0] })}
</span>
<span className="banner__spacer" />
<button
type="button"
className="btn btn--small"
onClick={() => void app.OpenUserConfigPath?.().catch(() => {})}
>
{t("config.openConfig")}
</button>
<button
type="button"
className="btn btn--small"
onClick={() => {
void (async () => {
try {
const view = await app.ReloadUserConfig?.();
if (view?.configWarnings) setConfigLoadWarnings(view.configWarnings);
else setConfigLoadWarnings([]);
} catch {
/* keep banner */
}
})();
}}
>
{t("config.reloadConfig")}
</button>
<span className="banner__hint">{t("config.doctorHint")}</span>
<button type="button" className="btn btn--small" onClick={() => setConfigLoadWarnings([])}>
{t("updater.dismiss")}
</button>
</div>
)}
{providerSetupNeeded && !needsOnboarding && (
<div className="banner banner--warning banner--actionable">
@@ -4608,6 +4838,81 @@ export default function App() {
}}
/>
)
: decisionSurface === "workspace_conflict" && workspaceConflict ? (
<RuntimeDecisionCard
id="workspace-conflict"
title={t("runtime.workspaceConflictTitle")}
badge={t("runtime.workspaceConflictBadge")}
meta={workspaceConflict.state === "local"
? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask") })
: t("runtime.workspaceConflictExternal")}
note={t("runtime.workspaceConflictNote")}
onCancel={() => {
cancel();
setWorkspaceConflict(null);
}}
actions={[
...(workspaceConflict.canReveal ? [{
key: "1", label: t("runtime.revealWriter"), description: t("runtime.revealWriterDesc"),
onClick: () => void revealWorkspaceWriter(),
}] : []),
...(workspaceConflict.canCreateWorktree ? [{
key: "2", label: t("runtime.openWorktree"), description: t("runtime.openWorktreeDesc"),
onClick: () => void continueInDeliveryWorktree(),
}] : []),
{
key: "Esc", label: t("runtime.cancelWait"), description: t("runtime.cancelWaitDesc"),
onClick: () => { cancel(); setWorkspaceConflict(null); }, danger: true,
},
]}
/>
)
: decisionSurface === "mode_jobs" && pendingModeSwitch ? (
<RuntimeDecisionCard
id="mode-jobs"
title={t("runtime.modeJobsTitle")}
badge={t("status.jobs", { n: pendingModeSwitch.work.jobs.length })}
meta={t("runtime.modeJobsMeta")}
note={pendingModeSwitch.work.jobs.map((job) => job.label || job.kind).join(" · ")}
onCancel={() => setPendingModeSwitch(null)}
actions={[
{
key: "1", label: t("common.cancel"), description: t("runtime.keepModeDesc"),
onClick: () => setPendingModeSwitch(null), disabled: pendingModeSwitch.stopping,
},
{
key: "2", label: pendingModeSwitch.stopping ? t("status.jobStopping") : t("runtime.stopAndSwitch"),
description: t("runtime.stopAndSwitchDesc"), onClick: () => void stopJobsAndSwitchMode(),
danger: true, disabled: pendingModeSwitch.stopping,
},
]}
/>
)
: decisionSurface === "close_active" && pendingClose ? (
<RuntimeDecisionCard
id="close-active"
title={t("runtime.closeTitle")}
badge={t("runtime.closeBadge")}
meta={t("runtime.closeMeta", { n: pendingClose.work.jobs.length })}
note={t("runtime.closeNote")}
onCancel={() => setPendingClose(null)}
actions={[
{
key: "1", label: t("common.cancel"), description: t("runtime.closeCancelDesc"),
onClick: () => setPendingClose(null), disabled: pendingClose.stopping,
},
{
key: "2", label: t("runtime.keepRunning"), description: t("runtime.keepRunningDesc"),
onClick: () => void resolvePendingClose("keep_running"), disabled: pendingClose.stopping,
},
{
key: "3", label: pendingClose.stopping ? t("status.jobStopping") : t("runtime.stopAndClose"),
description: t("runtime.stopAndCloseDesc"), onClick: () => void resolvePendingClose("stop_and_close"),
danger: true, disabled: pendingClose.stopping,
},
]}
/>
)
: decisionSurface === "clear_context" ? (
<ClearContextCard
onCancel={cancelClearContext}
@@ -4862,6 +5167,11 @@ export default function App() {
usage={state.usage}
balance={state.balance}
running={state.running || rewindCommitting}
jobs={state.jobs}
onCancelJob={cancelJob}
backgroundRuntimes={backgroundRuntimes}
onCancelRuntimeJob={cancelRuntimeJob}
onRevealRuntime={revealBackgroundRuntime}
sessionTurns={sessionTurns}
sessionTokens={state.sessionTokens}
turnTokens={state.turnTotalTokens}
@@ -10,6 +10,7 @@ import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ApprovalModal } from "../components/ApprovalModal";
import { ClearContextCard } from "../components/ClearContextCard";
import { RuntimeDecisionCard } from "../components/RuntimeDecisionCard";
import { LocaleProvider } from "../lib/i18n";
import type { WireApproval } from "../lib/types";
@@ -619,6 +620,83 @@ console.log("\ndecision surface");
dom.window.close();
}
// Runtime decisions expose direct actions, so their displayed number keys must
// invoke the same callbacks while preserving normal typing behavior.
{
const dom = installDom();
const root = createRoot(document.getElementById("root")!);
const selected: string[] = [];
let cancelled = 0;
await act(async () => {
root.render(
<LocaleProvider>
<RuntimeDecisionCard
id="runtime-shortcuts"
title="Choose runtime action"
badge="Runtime"
meta="Select an action"
actions={["1", "2", "3"].map((key) => ({
key,
label: `Action ${key}`,
description: `Run action ${key}`,
onClick: () => selected.push(key),
}))}
onCancel={() => { cancelled += 1; }}
/>
</LocaleProvider>,
);
await flushTimers();
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
await flushTimers();
});
eq(selected.join(","), "1,2,3", "runtime decision number keys invoke their displayed actions");
await act(async () => {
root.render(
<LocaleProvider>
<RuntimeDecisionCard
id="runtime-shortcuts"
title="Choose runtime action"
badge="Runtime"
meta="Select an action"
actions={[{
key: "2", label: "Unavailable action", description: "Cannot run yet",
disabled: true, onClick: () => selected.push("disabled"),
}]}
onCancel={() => { cancelled += 1; }}
/>
</LocaleProvider>,
);
await flushTimers();
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
await flushTimers();
});
eq(selected.join(","), "1,2,3", "runtime decision shortcuts do not invoke disabled actions");
const input = document.createElement("input");
document.body.append(input);
await act(async () => {
input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await flushTimers();
});
eq(selected.join(","), "1,2,3", "runtime decision shortcuts ignore editable fields");
eq(cancelled, 1, "Escape cancels the runtime decision");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Clear context: default cancel; clear requires explicit confirm; Escape cancels.
{
const dom = installDom();
@@ -198,6 +198,8 @@ eq(readinessNotice.kind === "notice" && readinessNotice.detail, "Still needed: v
eq(readinessNotice.kind === "notice" && readinessNotice.action, "continue_delivery", "final readiness offers a recovery action");
const readinessUser = readinessState.items.find((it) => it.kind === "user");
eq(readinessUser?.kind === "user" && Boolean(readinessUser.failed), false, "final readiness does not mark the delivered user message as failed");
eq(readinessState.running, false, "an unclicked continue-check action does not keep the turn running");
eq(readinessState.pendingPrompt, false, "an unclicked continue-check action does not create a pending prompt");
const recovering = reducer(readinessState, { type: "user", text: "Continue checks", seq: readinessState.seq, deliveryRecovery: true });
const recovered = reducer(recovering, { type: "event", e: { kind: "turn_done" } as WireEvent });
@@ -1,6 +1,9 @@
// Run: tsx src/__tests__/statusbar-workspace.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { StatusBar } from "../components/StatusBar";
import { LocaleProvider } from "../lib/i18n";
@@ -121,7 +124,28 @@ console.log("\nstatus bar workspace");
{
const html = renderStatusBar({ items: ["model"] });
ok(!html.includes("YOLO"), "status bar renders only configured status items, not mode indicators");
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar omits non-configurable job indicators");
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar hides the operational jobs entry while idle");
}
{
const html = renderStatusBar({
items: ["model"],
jobs: [{ id: "bash-1", kind: "bash", label: "run tests", status: "running", startedAt: 1 }],
});
ok(html.includes("Background jobs"), "running background jobs remain visible outside configurable metrics");
ok(html.includes("1"), "background jobs entry exposes the running count");
}
{
const html = renderStatusBar({
items: ["model"],
backgroundRuntimes: [{
tabId: "running-1", title: "Detached delivery", detached: true,
running: true, pendingPrompt: false, jobs: [],
}],
});
ok(html.includes("Background jobs"), "a running detached task remains visible without child jobs");
ok(html.includes("<b>1</b>"), "a jobless active runtime contributes to the recovery count");
}
{
@@ -165,5 +189,134 @@ console.log("\nstatus bar workspace");
ok(!empty.includes("≈-"), "empty estimated metrics remain a plain dash");
}
{
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }),
});
let stopped = "";
const rootEl = document.getElementById("root")!;
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<StatusBar
context={{ used: 0, window: 0, sessionTokens: 0 }}
running={false}
jobs={[{ id: "bash-1", kind: "bash", label: "run tests", status: "running", startedAt: 1 }]}
onCancelJob={async (jobID) => { stopped = jobID; return true; }}
/>
</LocaleProvider>,
);
});
const jobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
await act(async () => { jobsButton?.click(); });
const stopButton = document.body.querySelector<HTMLButtonElement>(".jobs-popover__stop");
await act(async () => { stopButton?.click(); await Promise.resolve(); });
ok(stopped === "bash-1", "background jobs popover routes Stop to the selected job");
let routed = "";
let revealed = "";
await act(async () => {
root.render(
<LocaleProvider>
<StatusBar
context={{ used: 0, window: 0, sessionTokens: 0 }}
running={false}
backgroundRuntimes={[
{
tabId: "detached-1", title: "Detached delivery", detached: true,
running: false, pendingPrompt: false,
jobs: [{ id: "go-1", kind: "go", label: "go test", status: "running", startedAt: 1 }],
},
]}
onCancelRuntimeJob={async (tabID, jobID) => { routed = `${tabID}:${jobID}`; return true; }}
onRevealRuntime={async (tabID) => { revealed = tabID; }}
/>
</LocaleProvider>,
);
});
const globalJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
if (globalJobsButton?.getAttribute("aria-expanded") !== "true") {
await act(async () => { globalJobsButton?.click(); });
}
const globalStop = document.body.querySelector<HTMLButtonElement>(".jobs-popover__stop");
const openTask = Array.from(document.body.querySelectorAll<HTMLButtonElement>(".jobs-popover__runtime-header button"))[0];
await act(async () => { globalStop?.click(); openTask?.click(); await Promise.resolve(); });
ok(routed === "detached-1:go-1", "global jobs route Stop to the owning detached task");
ok(revealed === "detached-1", "global jobs can reopen the exact detached task");
revealed = "";
await act(async () => {
root.render(
<LocaleProvider>
<StatusBar
context={{ used: 0, window: 0, sessionTokens: 0 }}
running={false}
backgroundRuntimes={[
{
tabId: "prompt-1", title: "Waiting delivery", detached: true,
running: false, pendingPrompt: true, jobs: [],
},
]}
onRevealRuntime={async (tabID) => { revealed = tabID; }}
/>
</LocaleProvider>,
);
});
const promptJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
if (promptJobsButton?.getAttribute("aria-expanded") !== "true") {
await act(async () => { promptJobsButton?.click(); });
}
ok(document.body.textContent?.includes("Waiting for input") === true, "pending-prompt runtime explains why it remains active");
const promptOpenTask = document.body.querySelector<HTMLButtonElement>(".jobs-popover__runtime-header button");
await act(async () => { promptOpenTask?.click(); await Promise.resolve(); });
ok(revealed === "prompt-1", "a pending-prompt runtime can be reopened without child jobs");
await act(async () => {
root.render(
<LocaleProvider>
<StatusBar
context={{ used: 0, window: 0, sessionTokens: 0 }}
running={false}
workbenchTarget={{ kind: "ssh", hostId: "remote-1" }}
jobs={[{ id: "remote-job", kind: "bash", label: "remote build", status: "running", startedAt: 1 }]}
backgroundRuntimes={[
{
tabId: "local-1", title: "Local delivery", detached: true,
running: false, pendingPrompt: false,
jobs: [{ id: "local-job", kind: "go", label: "local test", status: "running", startedAt: 1 }],
},
]}
/>
</LocaleProvider>,
);
});
const mixedJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
ok(mixedJobsButton?.textContent?.includes("2") === true, "local and active Remote Workbench jobs share the total count");
if (mixedJobsButton?.getAttribute("aria-expanded") !== "true") {
await act(async () => { mixedJobsButton?.click(); });
}
ok(document.body.textContent?.includes("local test") === true, "local background jobs remain visible in Remote Workbench");
ok(document.body.textContent?.includes("remote build") === true, "active Remote Workbench jobs remain visible beside local jobs");
await act(async () => { root.unmount(); });
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -30,7 +30,6 @@ const experience: ThemeExperienceView = {
baseStyle: "graphite",
effectiveStyle: "graphite",
activePack: null,
safeMode: false,
};
const savedPack: ThemePackView = {
@@ -217,8 +217,6 @@ for (const style of THEME_STYLES) {
id: `base-${style}`,
name: style,
baseStyle: style,
kind: "base",
builtin: true,
tokens: {},
recipes: { density: "comfortable", corners: "soft" },
});
@@ -425,7 +423,6 @@ const activeAuroraExperience = {
effectiveStyle: "aurora" as const,
activeThemeId: aurora.id,
activePack: aurora,
safeMode: false,
};
applyExperienceToDOM(activeAuroraExperience);
ok(configuredBaseStyleForSync(activeAuroraExperience) === null, "active pack effective style is not mirrored as configured base");
@@ -5,7 +5,7 @@ import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { __emitMockUpdater, type AppBindings } from "../lib/bridge";
import { switchUpdaterChannel, UpdaterProvider, useUpdater } from "../lib/useUpdater";
import { UpdaterProvider, useUpdater } from "../lib/useUpdater";
let passed = 0;
let failed = 0;
@@ -20,7 +20,7 @@ function ok(value: boolean, label: string) {
}
}
function Consumer({ id, checking = false, channel = "" }: { id: string; checking?: boolean; channel?: string }) {
function Consumer({ id, checking = false }: { id: string; checking?: boolean }) {
const updater = useUpdater();
return (
<section>
@@ -31,14 +31,13 @@ function Consumer({ id, checking = false, channel = "" }: { id: string; checking
<output id={`${id}-received`}>
{updater.status.kind === "downloading" ? updater.status.received : ""}
</output>
{checking && <button id={`${id}-check-update`} type="button" onClick={() => void updater.check(channel)}>Check</button>}
<button id={`${id}-reset-stable`} type="button" onClick={() => updater.reset("stable")}>Reset stable</button>
<button id={`${id}-reset-preview`} type="button" onClick={() => updater.reset("preview")}>Reset preview</button>
{checking && <button id={`${id}-check-update`} type="button" onClick={() => void updater.check()}>Check</button>}
<button id={`${id}-reset`} type="button" onClick={() => updater.reset()}>Reset</button>
{updater.status.kind === "available" && (
<button id={`${id}-download`} type="button" onClick={() => updater.download(updater.status.info)}>Download</button>
<button id={`${id}-apply`} type="button" onClick={() => updater.apply(updater.status.info)}>Apply</button>
)}
{updater.status.kind === "downloaded" && (
<button id={`${id}-install`} type="button" onClick={updater.install}>Install</button>
{updater.status.kind === "error" && updater.status.info && (
<button id={`${id}-retry`} type="button" onClick={() => updater.apply(updater.status.info!)}>Retry</button>
)}
</section>
);
@@ -62,7 +61,7 @@ await act(async () => {
root.render(
<UpdaterProvider>
<Consumer id="banner" checking />
<Consumer id="settings" checking channel="preview" />
<Consumer id="settings" checking />
</UpdaterProvider>,
);
});
@@ -92,7 +91,7 @@ const debInfo = {
downloadUrl: "https://example.invalid/download",
assetSize: 42,
};
const installAttempts: Array<{
const applyAttempts: Array<{
requestId: string;
version: string;
channel: string;
@@ -105,20 +104,10 @@ window.go = {
App: {
async CheckUpdate(channel: string) {
checkedChannels.push(channel);
return { ...debInfo, channel: channel === "preview" ? "preview" : "stable" };
return { ...debInfo, channel: "stable" };
},
async DownloadUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
return {
requestId,
version: expectedVersion,
channel: channel === "preview" ? "preview" : "stable",
path: "/tmp/update.deb",
size: 42,
sha256: "abc",
};
},
InstallUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
return new Promise<void>((resolve, reject) => installAttempts.push({
ApplyUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
return new Promise<void>((resolve, reject) => applyAttempts.push({
requestId,
version: expectedVersion,
channel,
@@ -134,80 +123,89 @@ await act(async () => {
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(checkedChannels[0] === "preview", "settings check forwards the selected Preview channel");
ok(checkedChannels[0] === "stable", "settings check uses the official release channel");
ok(document.getElementById("banner-status")?.textContent === "available", "deb update becomes available");
ok(document.getElementById("settings-status")?.textContent === "available", "deb availability is shared");
await act(async () => {
(document.getElementById("banner-download") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
(document.getElementById("banner-apply") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "downloaded", "deb download completes");
await act(async () => {
(document.getElementById("banner-install") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "authorizing", "deb install starts authorizing");
ok(document.getElementById("banner-status")?.textContent === "authorizing", "deb apply starts authorizing");
ok(document.getElementById("settings-status")?.textContent === "authorizing", "authorizing state is shared");
await act(async () => {
__emitMockUpdater({
requestId: installAttempts[0].requestId,
version: installAttempts[0].version,
channel: "preview",
phase: "recovering",
requestId: applyAttempts[0].requestId,
version: applyAttempts[0].version,
channel: "stable",
phase: "downloading",
received: 10,
total: 42,
});
});
ok(document.getElementById("banner-status")?.textContent === "downloading", "download phase is shared");
ok(document.getElementById("banner-received")?.textContent === "10", "download progress is shared");
await act(async () => {
__emitMockUpdater({
requestId: applyAttempts[0].requestId,
version: applyAttempts[0].version,
channel: "stable",
phase: "verifying",
received: 42,
total: 42,
});
});
ok(document.getElementById("banner-status")?.textContent === "recovering", "stale update recovery is shared");
ok(document.getElementById("settings-status")?.textContent === "recovering", "settings observes stale update recovery");
ok(document.getElementById("banner-status")?.textContent === "verifying", "verify phase is shared");
await act(async () => {
__emitMockUpdater({
requestId: installAttempts[0].requestId,
version: installAttempts[0].version,
channel: "preview",
requestId: applyAttempts[0].requestId,
version: applyAttempts[0].version,
channel: "stable",
phase: "installing",
received: 42,
total: 42,
});
});
ok(document.getElementById("banner-status")?.textContent === "installing", "helper phase advances to installing");
ok(document.getElementById("banner-status")?.textContent === "installing", "install phase advances");
await act(async () => {
__emitMockUpdater({
requestId: installAttempts[0].requestId,
version: installAttempts[0].version,
channel: "preview",
phase: "downloaded",
received: 42,
total: 42,
});
installAttempts[0]?.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "downloaded", "authorization cancellation returns to downloaded");
await act(async () => {
(document.getElementById("banner-install") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "authorizing", "retry re-enters authorizing");
await act(async () => {
installAttempts[1]?.reject(new Error("update: manual update required: system update helper is unavailable"));
applyAttempts[0]?.reject(new Error("update: manual update required: system update helper is unavailable"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "error", "manual reclassification leaves busy state");
ok(document.getElementById("banner-manual")?.textContent === "manual", "manual reclassification offers download fallback");
ok(document.getElementById("settings-status")?.textContent === "error", "manual fallback error is shared");
ok(!!document.getElementById("banner-retry"), "error state exposes retry");
let resolveSavedCheck!: (value: typeof debInfo) => void;
let resolvePreviewCheck!: (value: typeof debInfo) => void;
window.go.main.App.CheckUpdate = (selected: string) =>
// Retry releases the mutex and can start a new apply immediately.
await act(async () => {
(document.getElementById("banner-retry") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "authorizing", "retry re-enters applying");
await act(async () => {
applyAttempts[1]?.resolve();
__emitMockUpdater({
requestId: applyAttempts[1].requestId,
version: applyAttempts[1].version,
channel: "stable",
phase: "relaunching",
received: 0,
total: 0,
});
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "relaunching", "relaunching phase is shared");
let resolveFirstCheck!: (value: typeof debInfo) => void;
let resolveSecondCheck!: (value: typeof debInfo) => void;
let checkCalls = 0;
window.go.main.App.CheckUpdate = () =>
new Promise<typeof debInfo>((resolve) => {
if (selected === "preview") resolvePreviewCheck = resolve;
else resolveSavedCheck = resolve;
checkCalls += 1;
if (checkCalls === 1) resolveFirstCheck = resolve;
else resolveSecondCheck = resolve;
});
await act(async () => {
@@ -215,141 +213,126 @@ await act(async () => {
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
});
await act(async () => {
resolvePreviewCheck({ ...debInfo, channel: "preview", latest: "v1.2.0" });
resolveSecondCheck({ ...debInfo, channel: "stable", latest: "v1.2.0" });
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "available", "newest channel check publishes its result");
ok(document.getElementById("banner-status")?.textContent === "available", "newest official check publishes its result");
await act(async () => {
resolveSavedCheck({ ...debInfo, available: false, channel: "stable", latest: "v1.0.0" });
resolveFirstCheck({ ...debInfo, available: false, channel: "stable", latest: "v1.0.0" });
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "available", "stale channel check cannot overwrite the newest result");
ok(document.getElementById("banner-status")?.textContent === "available", "stale official check cannot overwrite the newest result");
let oldDownloadRequestId = "";
let oldDownloadVersion = "";
let resolveOldDownload!: (value: { requestId: string; version: string; channel: string; path: string; size: number; sha256: string }) => void;
window.go.main.App.DownloadUpdateRequest = (_channel: string, expectedVersion: string, requestId: string) =>
new Promise((resolve) => {
oldDownloadRequestId = requestId;
oldDownloadVersion = expectedVersion;
resolveOldDownload = resolve;
let oldApplyRequestId = "";
let oldApplyVersion = "";
let resolveOldApply!: () => void;
window.go.main.App.ApplyUpdateRequest = (_channel: string, expectedVersion: string, requestId: string) =>
new Promise<void>((resolve) => {
oldApplyRequestId = requestId;
oldApplyVersion = expectedVersion;
resolveOldApply = resolve;
});
await act(async () => {
(document.getElementById("banner-download") as HTMLButtonElement).click();
(document.getElementById("banner-apply") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "downloading", "Preview download starts");
ok(document.getElementById("banner-status")?.textContent === "authorizing", "official apply starts");
await act(async () => {
__emitMockUpdater({
requestId: oldDownloadRequestId,
version: `${oldDownloadVersion}-wrong`,
channel: "preview",
phase: "downloaded",
requestId: oldApplyRequestId,
version: `${oldApplyVersion}-wrong`,
channel: "stable",
phase: "installing",
received: 42,
total: 42,
});
});
ok(
document.getElementById("banner-status")?.textContent === "downloading",
"same-request wrong-version progress cannot complete the active Preview download",
document.getElementById("banner-status")?.textContent === "authorizing",
"same-request wrong-version progress cannot advance the active apply",
);
await act(async () => {
__emitMockUpdater({
requestId: oldDownloadRequestId,
version: oldDownloadVersion,
channel: "stable",
requestId: oldApplyRequestId,
version: oldApplyVersion,
channel: "preview",
phase: "downloading",
received: 41,
total: 42,
});
});
ok(document.getElementById("banner-received")?.textContent === "0", "wrong-channel progress cannot update the active Preview download");
ok(document.getElementById("banner-received")?.textContent === "", "retired-channel progress cannot update the active official apply");
await act(async () => {
(document.getElementById("banner-reset-preview") as HTMLButtonElement).click();
(document.getElementById("banner-reset") as HTMLButtonElement).click();
__emitMockUpdater({
requestId: oldDownloadRequestId,
version: oldDownloadVersion,
channel: "preview",
phase: "downloaded",
received: 42,
total: 42,
});
resolveOldDownload({
requestId: oldDownloadRequestId,
version: oldDownloadVersion,
channel: "preview",
path: "/tmp/old.deb",
size: 42,
sha256: "abc",
requestId: oldApplyRequestId,
version: oldApplyVersion,
channel: "stable",
phase: "error",
received: 0,
total: 0,
err: "old apply failed",
});
resolveOldApply();
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "idle", "same-channel superseded progress and Promise completion stay ignored");
window.go.main.App.CheckUpdate = async (selected: string) => ({
window.go.main.App.CheckUpdate = async () => ({
...debInfo,
channel: selected === "preview" ? "preview" : "stable",
latest: selected === "preview" ? "v1.2.0" : "v1.1.0",
});
window.go.main.App.DownloadUpdateRequest = async (selected: string, _expectedVersion: string, requestId: string) => ({
requestId,
version: "v1.2.0-preview.99",
channel: selected === "preview" ? "preview" : "stable",
path: "/tmp/new.deb",
size: 42,
sha256: "abc",
channel: "stable",
latest: "v1.2.0",
});
window.go.main.App.ApplyUpdateRequest = async () => {
throw new Error("should not reach when version mismatches from progress only");
};
await act(async () => {
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
(document.getElementById("banner-download") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "available", "download result for a different version is rejected");
ok(document.getElementById("banner-status")?.textContent === "available", "official update is available again");
window.go.main.App.DownloadUpdateRequest = async (selected: string, expectedVersion: string, requestId: string) => ({
requestId,
version: expectedVersion,
channel: selected === "preview" ? "preview" : "stable",
path: "/tmp/new.deb",
size: 42,
sha256: "abc",
});
window.go.main.App.ApplyUpdateRequest = async (_channel: string, _expectedVersion: string, requestId: string) => {
// Hang until cancelled by reset so we can prove supersession.
return new Promise<void>((_resolve, reject) => {
applyAttempts.push({
requestId,
version: _expectedVersion,
channel: _channel,
resolve: () => {},
reject,
});
});
};
await act(async () => {
(document.getElementById("banner-download") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
(document.getElementById("banner-apply") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "downloaded", "Preview update is ready for install");
const staleApply = applyAttempts[applyAttempts.length - 1];
ok(document.getElementById("banner-status")?.textContent === "authorizing", "official apply starts");
await act(async () => {
(document.getElementById("banner-install") as HTMLButtonElement).click();
});
const staleInstall = installAttempts[installAttempts.length - 1];
ok(document.getElementById("banner-status")?.textContent === "authorizing", "Preview install starts");
await act(async () => {
(document.getElementById("banner-reset-stable") as HTMLButtonElement).click();
(document.getElementById("banner-reset") as HTMLButtonElement).click();
__emitMockUpdater({
requestId: staleInstall.requestId,
version: staleInstall.version,
channel: "preview",
requestId: staleApply.requestId,
version: staleApply.version,
channel: "stable",
phase: "error",
received: 0,
total: 0,
err: "old install failed",
});
staleInstall.reject(new Error("old Preview install failed"));
staleApply.reject(new Error("old official apply failed"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "idle", "superseded install progress and rejection stay ignored");
ok(document.getElementById("banner-status")?.textContent === "idle", "superseded apply progress and rejection stay ignored");
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "stable" });
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "preview" });
await act(async () => {
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "error", "wrong-channel check response leaves the checking state");
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "preview", latest: "v1.3.0" });
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "stable", latest: "v1.3.0" });
await act(async () => {
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -358,43 +341,5 @@ ok(document.getElementById("banner-status")?.textContent === "available", "check
delete window.go;
const channelTransitions: string[] = [];
let releaseSave!: (saved: boolean) => void;
const pendingSwitch = switchUpdaterChannel(
"preview",
(channel) => {
channelTransitions.push(`invalidate:${channel}`);
},
(channel) =>
new Promise<boolean>((resolve) => {
channelTransitions.push(`save:${channel}`);
releaseSave = resolve;
}),
async (channel) => {
channelTransitions.push(`check:${channel}`);
},
);
ok(channelTransitions.join(",") === "invalidate:preview,save:preview", "channel switch invalidates old updater state before persistence resolves");
releaseSave(true);
await pendingSwitch;
ok(channelTransitions.join(",") === "invalidate:preview,save:preview,check:preview", "successful channel persistence checks the target pointer");
await switchUpdaterChannel(
"stable",
(channel) => {
channelTransitions.push(`invalidate:${channel}`);
},
async (channel) => {
channelTransitions.push(`failed:${channel}`);
return false;
},
async (channel) => {
channelTransitions.push(`unexpected:${channel}`);
},
);
ok(!channelTransitions.includes("unexpected:stable"), "failed channel persistence does not check an unselected channel");
await act(async () => root.unmount());
console.log(`\n${passed} passed, ${failed} failed`);
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -93,6 +93,21 @@ function usage(source: string): WireUsage {
console.log("\nuse controller meta");
{
eq(
modelSwitchNoticeText("active work is still running; running=false; pending_prompt=false; background_jobs=2; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
"The model cannot change while 2 background jobs are running. Open Background jobs in the status bar to stop them.",
"model busy guard names the background-job blocker",
);
eq(
effortSwitchNoticeText("active work is still running; running=true; pending_prompt=false; background_jobs=0; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
"Reasoning effort cannot change while the current answer is running. Stop it first.",
"effort busy guard names the running-answer blocker",
);
eq(
tokenModeSwitchNoticeText("active work is still running; running=true; pending_prompt=true; background_jobs=0; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
"Work mode cannot change while a prompt is waiting for your response. Handle it first.",
"work mode busy guard prioritizes the pending prompt blocker",
);
eq(
modelSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
"The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
@@ -0,0 +1,58 @@
// Run: pnpm test:window-state
import { createWindowStateSaver, type WindowStateSnapshot } from "../lib/windowState";
let failed = 0;
function ok(value: boolean, label: string) {
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
if (!value) failed += 1;
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => { resolve = done; });
return { promise, resolve };
}
let nativeState = { width: 900, height: 600, x: 10, y: 20, maximised: false };
let sizeReads = 0;
const firstPersist = deferred();
const persisted: WindowStateSnapshot[] = [];
const save = createWindowStateSaver(
{
async WindowGetSize() {
sizeReads += 1;
return { w: nativeState.width, h: nativeState.height };
},
async WindowGetPosition() {
return { x: nativeState.x, y: nativeState.y };
},
async WindowIsMaximised() {
return nativeState.maximised;
},
},
async (state) => {
persisted.push(state);
if (persisted.length === 1) await firstPersist.promise;
},
);
const oldSave = save();
while (persisted.length === 0) await Promise.resolve();
nativeState = { width: 1280, height: 800, x: 40, y: 50, maximised: true };
const newSave = save();
await Promise.resolve();
ok(sizeReads === 1, "a newer request waits for the in-flight capture and persistence");
firstPersist.resolve();
await Promise.all([oldSave, newSave]);
ok(persisted.length === 2, "both distinct observations are persisted");
ok(persisted[0].width === 900 && persisted[1].width === 1280, "the newest observation is written last");
ok(persisted[1].maximised, "the newest maximised state is preserved");
await save();
ok(persisted.length === 2, "an unchanged observation is deduplicated after persistence succeeds");
if (failed > 0) process.exit(1);
@@ -0,0 +1,83 @@
import { useEffect, useRef } from "react";
import { PromptAction, PromptBadge, PromptShelf } from "./PromptShelf";
export type RuntimeDecisionAction = {
key: string;
label: string;
description: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
};
export function RuntimeDecisionCard({
id,
title,
badge,
meta,
note,
actions,
onCancel,
}: {
id: string;
title: string;
badge: string;
meta: string;
note?: string;
actions: RuntimeDecisionAction[];
onCancel: () => void;
}) {
const shelfRef = useRef<HTMLDivElement | null>(null);
const actionsRef = useRef(actions);
const onCancelRef = useRef(onCancel);
actionsRef.current = actions;
onCancelRef.current = onCancel;
useEffect(() => {
shelfRef.current?.focus();
const onKeyDown = (event: globalThis.KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
const tag = target?.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || (target instanceof HTMLElement && target.isContentEditable)) return;
if (event.key === "Escape") {
event.preventDefault();
onCancelRef.current();
return;
}
if (event.repeat || event.metaKey || event.ctrlKey || event.altKey) return;
const action = actionsRef.current.find((candidate) => candidate.key === event.key);
if (!action || action.disabled) return;
event.preventDefault();
action.onClick();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
return (
<PromptShelf
decision
barRef={shelfRef}
titleId={`${id}-title`}
title={title}
badges={<PromptBadge tone="amber">{badge}</PromptBadge>}
meta={meta}
actionsRole="group"
actions={actions.map((action) => (
<PromptAction
key={action.key}
keyLabel={action.key}
label={action.label}
description={action.description}
onClick={action.onClick}
disabled={action.disabled}
tone={action.danger ? "danger" : "default"}
role="button"
/>
))}
>
{note && <p className="prompt-shelf__note">{note}</p>}
</PromptShelf>
);
}
@@ -6769,8 +6769,8 @@ const mb = (n: number) => (n / MB).toFixed(1);
// UpdatesSection is the manual side of the auto-updater: it shows the startup
// check preference, running version, and a Check button, then the same state
// machine the top banner uses (useUpdater) — available → download → install, with
// progress and errors inline.
// machine the top banner uses (useUpdater) — a single "update and restart"
// action with inline progress and errors.
function UpdatesSection({
configPath,
checkUpdates,
@@ -6787,8 +6787,7 @@ function UpdatesSection({
applySettings: (fn: () => Promise<void>) => Promise<boolean>;
}) {
const t = useT();
const { status, check, download: downloadUpdate, install: installUpdate, openDownload } = useUpdater();
const selectedChannel = "stable";
const { status, check, apply: applyUpdate, openDownload } = useUpdater();
const [version, setVersion] = useState("");
useEffect(() => {
app.Version().then(setVersion).catch(() => {});
@@ -6799,8 +6798,8 @@ function UpdatesSection({
status.kind === "downloading" ||
status.kind === "verifying" ||
status.kind === "authorizing" ||
status.kind === "recovering" ||
status.kind === "installing";
status.kind === "installing" ||
status.kind === "relaunching";
const updateStatus =
status.kind === "checking" ? t("updater.checking") :
status.kind === "upToDate" ? t("updater.upToDate") :
@@ -6811,22 +6810,20 @@ function UpdatesSection({
pct: status.total > 0 ? Math.round((status.received / status.total) * 100) : 0,
}) :
status.kind === "verifying" ? t("updater.verifying") :
status.kind === "downloaded" ? t("updater.downloaded", { v: status.info.latest }) :
status.kind === "authorizing" ? t("updater.authorizing") :
status.kind === "recovering" ? t("updater.recovering") :
status.kind === "installing" ? (
status.info?.requiresElevation || status.info?.installMode === "deb"
? t("updater.installingPackage")
: t("updater.installing")
) :
status.kind === "done" ? t("updater.done") :
status.kind === "relaunching" || status.kind === "done" ? t("updater.done") :
status.kind === "error" ? t("updater.failed", { msg: status.message }) :
"";
const updateStatusTone =
status.kind === "error" ? "error" :
status.kind === "available" ? "available" :
status.kind === "upToDate" || status.kind === "done" || status.kind === "relaunching" ? "success" :
status.kind === "checking" || updaterBusy ? "busy" :
status.kind === "upToDate" || status.kind === "done" ? "success" :
"neutral";
return (
@@ -6857,7 +6854,7 @@ function UpdatesSection({
type="button"
disabled={settingsBusy || updaterBusy}
aria-label={t("updater.checkButton")}
onClick={() => void check(selectedChannel)}
onClick={() => void check()}
>
<RefreshCw className={status.kind === "checking" ? "updates-control__spinner" : undefined} size={14} aria-hidden="true" />
</button>
@@ -6867,31 +6864,29 @@ function UpdatesSection({
<div className="updates-control__hint">
<div>{t("updater.officialReleaseHint")}</div>
</div>
{(status.kind === "available" || status.kind === "downloaded") && (
{status.kind === "available" && (
<div className="updates-control__action">
<div className="updates-control__action-copy">
{status.kind === "available" && !status.info.canSelfUpdate && <div>{status.info.manualReason || t("updater.macHint")}</div>}
{!status.info.canSelfUpdate && <div>{status.info.manualReason || t("updater.macHint")}</div>}
</div>
{status.kind === "available" && (
<button
className="btn btn--primary btn--small"
disabled={settingsBusy || updaterBusy}
onClick={() => downloadUpdate(status.info)}
>
{status.info.canSelfUpdate ? t("updater.downloadUpdate") : t("updater.goToDownload")}
</button>
)}
{status.kind === "downloaded" && (
<button
className="btn btn--primary btn--small"
disabled={settingsBusy || updaterBusy}
onClick={installUpdate}
>
{status.info.requiresElevation || status.info.installMode === "deb"
? t("updater.authorizeInstall")
: t("updater.restartInstall")}
</button>
)}
<button
className="btn btn--primary btn--small"
disabled={settingsBusy || updaterBusy}
onClick={() => applyUpdate(status.info)}
>
{status.info.canSelfUpdate ? t("updater.updateAndRestart") : t("updater.goToDownload")}
</button>
</div>
)}
{status.kind === "error" && status.info && (
<div className="updates-control__action">
<button
className="btn btn--primary btn--small"
disabled={settingsBusy || updaterBusy}
onClick={() => applyUpdate(status.info!)}
>
{t("updater.retry")}
</button>
</div>
)}
<SettingsField
+140 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Activity, Check, ChevronsUpDown, CircleDollarSign, CircleGauge, Database, Folder, GitBranch, Laptop, Layers, Percent, RefreshCw, Server, Settings, Unplug, Wallet, Zap } from "lucide-react";
import { Activity, Check, ChevronsUpDown, CircleDollarSign, CircleGauge, Database, Folder, GitBranch, Laptop, Layers, Percent, RefreshCw, Server, Settings, Square, Unplug, Wallet, Zap } from "lucide-react";
import { AnchoredPopover } from "./AnchoredPopover";
import { RemoteConnectionErrorDialog } from "./RemoteConnectionErrorDialog";
import { Tooltip } from "./Tooltip";
@@ -7,7 +7,7 @@ import { useI18n, type Translator } from "../lib/i18n";
import { formatMoneyLocalized } from "../lib/money";
import { normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems";
import { isRemoteDegradedWarning, isRemoteHostKeyMismatch, isRemoteTerminalFailure, remoteConnectionErrorSummaryKey } from "../lib/remoteErrors";
import { type BalanceInfo, type ContextInfo, type RemoteConnectionStatus, type RemoteHostView, type UsageSourceStats, type WireUsage } from "../lib/types";
import { type BackgroundRuntimeView, type BalanceInfo, type ContextInfo, type JobView, type RemoteConnectionStatus, type RemoteHostView, type UsageSourceStats, type WireUsage } from "../lib/types";
import { useRemoteStore } from "../store/remote";
import type { WorkbenchActiveTarget } from "../lib/workbenchTarget";
@@ -179,6 +179,11 @@ export function StatusBar({
remoteStatuses = {},
workbenchTarget,
onSwitchLocal,
jobs = [],
onCancelJob,
backgroundRuntimes = [],
onCancelRuntimeJob,
onRevealRuntime,
}: {
context: ContextInfo;
usage?: WireUsage;
@@ -205,6 +210,11 @@ export function StatusBar({
remoteStatuses?: Record<string, RemoteConnectionStatus>;
workbenchTarget?: WorkbenchActiveTarget;
onSwitchLocal?: () => void;
jobs?: JobView[];
onCancelJob?: (jobID: string) => Promise<boolean>;
backgroundRuntimes?: BackgroundRuntimeView[];
onCancelRuntimeJob?: (tabID: string, jobID: string) => Promise<boolean>;
onRevealRuntime?: (tabID: string) => Promise<void>;
}) {
const { locale, t } = useI18n();
const pct = context.window ? Math.min(100, Math.round((context.used / context.window) * 100)) : null;
@@ -362,6 +372,14 @@ export function StatusBar({
workbenchTarget={workbenchTarget}
onSwitchLocal={onSwitchLocal}
/>
<JobsStatusBarChip
jobs={jobs}
activeJobsRemote={workbenchTarget?.kind === "ssh"}
onCancelJob={onCancelJob}
runtimes={backgroundRuntimes}
onCancelRuntimeJob={onCancelRuntimeJob}
onRevealRuntime={onRevealRuntime}
/>
{renderedItems.map(({ id, node }) => (
<span className="statusbar__item" data-statusbar-item={id} key={id}>
{node}
@@ -372,6 +390,126 @@ export function StatusBar({
);
}
function JobsStatusBarChip({
jobs,
activeJobsRemote,
onCancelJob,
runtimes,
onCancelRuntimeJob,
onRevealRuntime,
}: {
jobs: JobView[];
activeJobsRemote: boolean;
onCancelJob?: (jobID: string) => Promise<boolean>;
runtimes: BackgroundRuntimeView[];
onCancelRuntimeJob?: (tabID: string, jobID: string) => Promise<boolean>;
onRevealRuntime?: (tabID: string) => Promise<void>;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [stopping, setStopping] = useState<Set<string>>(() => new Set());
const triggerRef = useRef<HTMLButtonElement>(null);
const groups = runtimes.filter((runtime) => runtime.running || runtime.pendingPrompt || runtime.jobs.length > 0);
// BackgroundRuntimes is process-local, while active Remote Workbench jobs
// arrive through the active controller snapshot. Keep both sources visible.
if (jobs.length > 0 && (activeJobsRemote || !groups.some((runtime) => runtime.jobs.length > 0))) {
groups.push({ tabId: "", title: "", detached: false, running: false, pendingPrompt: false, jobs });
}
const totalActivity = groups.reduce(
(total, runtime) => total + Math.max(1, runtime.jobs.length),
0,
);
useEffect(() => {
if (totalActivity === 0) setOpen(false);
}, [totalActivity]);
if (totalActivity === 0) return null;
const stop = async (tabID: string, jobID: string) => {
const key = `${tabID}:${jobID}`;
const handler = tabID ? onCancelRuntimeJob : onCancelJob;
if (!handler || stopping.has(key)) return;
setStopping((current) => new Set(current).add(key));
try {
if (tabID && onCancelRuntimeJob) await onCancelRuntimeJob(tabID, jobID);
else if (onCancelJob) await onCancelJob(jobID);
} finally {
setStopping((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
};
return (
<span className="statusbar__jobs">
<button
ref={triggerRef}
type="button"
className="statusbar__jobs-trigger"
aria-label={`${t("status.jobsTitle")}: ${t("status.jobs", { n: totalActivity })}`}
aria-expanded={open}
aria-haspopup="dialog"
title={t("status.jobsTitle")}
onClick={() => setOpen((value) => !value)}
>
<Activity size={12} aria-hidden="true" />
<b>{totalActivity}</b>
</button>
<AnchoredPopover open={open} anchorRef={triggerRef} onClose={() => setOpen(false)} className="jobs-popover" align="start">
<section role="dialog" aria-label={t("status.jobsTitle")}>
<header className="jobs-popover__header">{t("status.jobsTitle")}</header>
<div className="jobs-popover__list">
{groups.map((runtime) => (
<div className="jobs-popover__runtime" key={runtime.tabId || "active"}>
{runtime.tabId && (
<div className="jobs-popover__runtime-header">
<strong>{runtime.title || t("runtime.unknownTask")}</strong>
{onRevealRuntime && (
<button type="button" className="btn btn--small" onClick={() => void onRevealRuntime(runtime.tabId)}>
{t("status.jobOpenTask")}
</button>
)}
</div>
)}
{runtime.jobs.length === 0 && (
<div className="jobs-popover__job">
<span className="jobs-popover__copy">
<strong>{runtime.pendingPrompt ? t("status.runtimePendingPrompt") : t("status.runtimeRunning")}</strong>
</span>
</div>
)}
{runtime.jobs.map((job) => {
const pending = stopping.has(`${runtime.tabId}:${job.id}`);
const canStop = runtime.tabId ? Boolean(onCancelRuntimeJob) : Boolean(onCancelJob);
return (
<div className="jobs-popover__job" key={`${runtime.tabId}:${job.id}`}>
<span className="jobs-popover__copy">
<strong>{job.label || job.kind}</strong>
<small>{job.kind} · {job.status}</small>
</span>
<button
type="button"
className="btn btn--small jobs-popover__stop"
disabled={pending || !canStop}
onClick={() => void stop(runtime.tabId, job.id)}
>
<Square size={11} aria-hidden="true" />
{pending ? t("status.jobStopping") : t("status.jobStop")}
</button>
</div>
);
})}
</div>
))}
</div>
</section>
</AnchoredPopover>
</span>
);
}
// This entry remains visible whenever an SSH host is configured. The popover
// owns quick connection actions; remote files and services live in the dock.
const REMOTE_STATE_SEVERITY: Record<string, number> = {
@@ -120,7 +120,7 @@ export function ThemeLibrarySection() {
const active = await app.GetActiveThemePack();
if (active?.pack) {
commitThemePreview(active.pack);
} else if (!active?.safeMode) {
} else {
const still = (list || []).find((p) => p.active);
if (!still) clearThemePack();
}
@@ -5,12 +5,11 @@ import { useUpdater } from "../lib/useUpdater";
const MB = 1024 * 1024;
const mb = (n: number) => (n / MB).toFixed(1);
// UpdateBanner checks for an update once on mount and, when one is available, shows
// a dismissible top banner that drives the download → verify → restart/install flow
// (or, on macOS manual builds, links out to the download page). It renders nothing while idle, checking,
// or already current — a quiet auto-check that only surfaces when actionable. A
// failed check can be dismissed here (network blips shouldn't pin the UI); the
// Settings panel is where a manual check shows errors inline.
// UpdateBanner checks for an update once on mount and, when one is available,
// shows a dismissible top banner with a single "update and restart" action
// (or, on macOS manual builds, links out to the download page). It renders
// nothing while idle, checking, or already current. A failed check can be
// dismissed here; Settings is where a manual check shows errors inline.
export function UpdateBanner({
enabled = true,
onShowReleaseNotes,
@@ -19,7 +18,7 @@ export function UpdateBanner({
onShowReleaseNotes?: (version: string) => void;
}) {
const t = useT();
const { status, check, download, install, openDownload, reset } = useUpdater();
const { status, check, apply, openDownload, reset } = useUpdater();
const [dismissed, setDismissed] = useState<string | null>(null);
useEffect(() => {
@@ -43,8 +42,8 @@ export function UpdateBanner({
{t("updater.releaseNotes")}
</button>
)}
<button className="btn btn--small btn--primary" onClick={() => download(info)}>
{info.canSelfUpdate ? t("updater.downloadUpdate") : t("updater.goToDownload")}
<button className="btn btn--small btn--primary" onClick={() => apply(info)}>
{info.canSelfUpdate ? t("updater.updateAndRestart") : t("updater.goToDownload")}
</button>
<button className="btn btn--small" onClick={() => setDismissed(info.latest)}>
{t("updater.dismiss")}
@@ -66,30 +65,8 @@ export function UpdateBanner({
}
case "verifying":
return <div className="banner banner--update">{t("updater.verifying")}</div>;
case "downloaded":
return (
<div className="banner banner--update">
<span className="banner__msg">{t("updater.downloaded", { v: status.info.latest })}</span>
<span className="banner__spacer" />
{onShowReleaseNotes && (
<button className="btn btn--small" onClick={() => onShowReleaseNotes(status.info.latest)}>
{t("updater.releaseNotes")}
</button>
)}
<button className="btn btn--small btn--primary" onClick={install}>
{status.info.requiresElevation || status.info.installMode === "deb"
? t("updater.authorizeInstall")
: t("updater.restartInstall")}
</button>
<button className="btn btn--small" onClick={() => reset()}>
{t("updater.dismiss")}
</button>
</div>
);
case "authorizing":
return <div className="banner banner--update">{t("updater.authorizing")}</div>;
case "recovering":
return <div className="banner banner--update">{t("updater.recovering")}</div>;
case "installing":
return (
<div className="banner banner--update">
@@ -98,6 +75,7 @@ export function UpdateBanner({
: t("updater.installing")}
</div>
);
case "relaunching":
case "done":
return <div className="banner banner--update">{t("updater.done")}</div>;
case "error": {
@@ -113,7 +91,13 @@ export function UpdateBanner({
{t("updater.goToDownload")}
</button>
)}
<button className="btn btn--small btn--primary" onClick={() => void check()}>
<button
className="btn btn--small btn--primary"
onClick={() => {
if (status.info) apply(status.info);
else void check();
}}
>
{t("updater.retry")}
</button>
<button className="btn btn--small" onClick={() => reset()}>
+61 -34
View File
@@ -57,6 +57,10 @@ import type {
HookConfigView,
HooksSettingsView,
JobView,
ActiveWorkView,
BackgroundRuntimeView,
JobCancelBatchView,
WorkspaceConflictView,
MCPMarketplaceEntry,
MCPServerInput,
MCPInstallResult,
@@ -95,7 +99,6 @@ import type {
TerminalWorkspaceView,
TopicMeta,
ToolApprovalMode,
UpdateDownloadResult,
UpdateInfo,
UpdateProgress,
WireEvent,
@@ -263,6 +266,15 @@ export interface AppBindings {
BalanceForTab(tabID: string): Promise<BalanceInfo>;
Jobs(): Promise<JobView[]>;
JobsForTab(tabID: string): Promise<JobView[]>;
CancelJob(jobID: string): Promise<boolean>;
CancelJobForTab(tabID: string, jobID: string): Promise<boolean>;
CancelJobsForTab(tabID: string, jobIDs: string[]): Promise<JobCancelBatchView>;
ActiveWorkForTab(tabID: string): Promise<ActiveWorkView>;
BackgroundRuntimes(): Promise<BackgroundRuntimeView[]>;
RevealBackgroundRuntime(tabID: string): Promise<TabMeta>;
WorkspaceConflictForTab(tabID: string): Promise<WorkspaceConflictView>;
RevealWorkspaceWriterForTab(tabID: string): Promise<TabMeta>;
CloseTabWithPolicy(tabID: string, policy: "keep_running" | "stop_and_close"): Promise<void>;
ToolResultForTab(tabID: string, toolID: string): Promise<{ args: string; output: string } | null>;
Meta(): Promise<Meta>;
MetaForTab(tabID: string): Promise<Meta>;
@@ -458,12 +470,11 @@ export interface AppBindings {
SetBypass(on: boolean): Promise<void>;
Version(): Promise<string>;
CheckUpdate(channel: string): Promise<UpdateInfo | null>;
DownloadUpdate(channel: string): Promise<UpdateDownloadResult | null>;
DownloadUpdateRequest(channel: string, expectedVersion: string, requestId: string): Promise<UpdateDownloadResult | null>;
InstallUpdate(channel: string): Promise<void>;
InstallUpdateRequest(channel: string, expectedVersion: string, requestId: string): Promise<void>;
ApplyUpdate(): Promise<void>;
/** v1.20+ single-action update: download, verify, install, relaunch. */
ApplyUpdateRequest(channel: string, expectedVersion: string, requestId: string): Promise<void>;
OpenDownloadPage(): Promise<void>;
OpenUserConfigPath?(): Promise<void>;
ReloadUserConfig?(): Promise<{ configWarnings?: string[]; configPath?: string } | null>;
NeedsOnboarding(): Promise<boolean>;
ConnectKey(apiKey: string): Promise<string>;
// Crash overlay "Send report" (desktop/crash_app.go): scrubs user paths, attaches
@@ -894,7 +905,7 @@ function bridgeBreadcrumb(method: string): string {
return `settings ${method}`;
if (/^(SaveProvider|SaveProviderModelCatalogs|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|FetchAllProviderModels|ConnectKey)/.test(method))
return `provider ${method}`;
if (/^(CheckUpdate|DownloadUpdate|DownloadUpdateRequest|InstallUpdate|InstallUpdateRequest|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
if (/^(CheckUpdate|ApplyUpdateRequest|OpenDownloadPage|OpenUserConfigPath|ReloadUserConfig)/.test(method)) return `update ${method}`;
if (/^(AddMCPServer|InstallMCPServer|UpdateMCPServer|RemoveMCPServer|AuthorizeAndConnectMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|SetMCPServer)/.test(method))
return `mcp ${method}`;
if (/^(AddSkillPath|RemoveSkillPath|RefreshSkills|SetSkillEnabled|AcceptSkillSuggestion|AvailableSubagentTools|CreateSubagentProfile|UpdateSubagentProfile|DeleteSubagentProfile|SetSubagentProfileModel|SetSubagentProfileEffort|TrySubagentProfile|CancelTrySubagentProfile)/.test(method))
@@ -2935,6 +2946,36 @@ function makeMockApp(): AppBindings {
async JobsForTab() {
return this.Jobs();
},
async CancelJob() {
return false;
},
async CancelJobForTab(_tabID, jobID) {
return this.CancelJob(jobID);
},
async CancelJobsForTab(_tabID, jobIDs) {
return { cancelled: [], notRunning: [...jobIDs] };
},
async ActiveWorkForTab() {
return { running: false, pendingPrompt: false, cancellable: false, jobs: [] };
},
async BackgroundRuntimes() {
return [];
},
async RevealBackgroundRuntime() {
throw new Error("background runtime is unavailable in browser preview");
},
async WorkspaceConflictForTab() {
return {
state: "none", ownerWork: { running: false, pendingPrompt: false, cancellable: false, jobs: [] },
canReveal: false, canCreateWorktree: false,
};
},
async RevealWorkspaceWriterForTab() {
throw new Error("workspace writer is unavailable in browser preview");
},
async CloseTabWithPolicy(tabID) {
return this.CloseTab(tabID);
},
async ToolResultForTab() {
return null;
},
@@ -4260,7 +4301,7 @@ function makeMockApp(): AppBindings {
const pack = mockActiveThemeId && !["graphite","aurora","slate","carbon","nocturne","amber"].includes(mockActiveThemeId)
? mockThemePacks.find((p) => p.id === mockActiveThemeId)
: null;
return { activeThemeId: pack ? mockActiveThemeId : "", pack: pack ? { ...pack, active: true } : null, safeMode: false };
return { activeThemeId: pack ? mockActiveThemeId : "", pack: pack ? { ...pack, active: true } : null };
},
async GetThemeExperience() {
const pack = mockActiveThemeId && !["graphite","aurora","slate","carbon","nocturne","amber"].includes(mockActiveThemeId)
@@ -4272,7 +4313,6 @@ function makeMockApp(): AppBindings {
effectiveStyle: pack?.baseStyle || mockBaseStyle,
activeThemeId: pack ? mockActiveThemeId : "",
activePack: pack ? { ...pack, active: true } : null,
safeMode: false,
};
},
async ActivateThemePack(id: string) {
@@ -4424,6 +4464,7 @@ function makeMockApp(): AppBindings {
return "v1.0.0 (browser dev)";
},
async CheckUpdate(channel: string) {
void channel;
// Keep the default browser preview focused on the primary product surface.
// Updater methods remain mocked for explicit updater-flow tests.
return {
@@ -4431,7 +4472,7 @@ function makeMockApp(): AppBindings {
current: "v1.0.0",
latest: "v1.0.0",
notes: "",
channel: channel === "preview" ? "preview" : "stable",
channel: "stable",
canSelfUpdate: false,
manualOnly: true,
installMode: "manual",
@@ -4441,43 +4482,29 @@ function makeMockApp(): AppBindings {
assetSize: 0,
};
},
async DownloadUpdate(channel: string) {
const expectedVersion = channel === "preview" ? "v1.1.0-preview.1" : "v1.1.0";
return this.DownloadUpdateRequest(channel, expectedVersion, "mock-legacy-download");
},
async DownloadUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
const selectedChannel = channel === "preview" ? "preview" : "stable";
async ApplyUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
void channel;
const selectedChannel = "stable";
const total = 12_345_678;
for (let r = 0; r <= total; r += 1_800_000) {
emitUpdater({ requestId, version: expectedVersion, channel: selectedChannel, phase: "downloading", received: Math.min(r, total), total });
await delay(120);
}
emitUpdater({ requestId, version: expectedVersion, channel: selectedChannel, phase: "verifying", received: total, total });
await delay(500);
emitUpdater({ requestId, version: expectedVersion, channel: selectedChannel, phase: "downloaded", received: total, total });
return { requestId, version: expectedVersion, channel: selectedChannel, path: "/tmp/reasonix-update", size: total, sha256: "mock" };
},
async InstallUpdate(channel: string) {
const expectedVersion = channel === "preview" ? "v1.1.0-preview.1" : "v1.1.0";
await this.InstallUpdateRequest(channel, expectedVersion, "mock-legacy-install");
},
async InstallUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
const selectedChannel = channel === "preview" ? "preview" : "stable";
const total = 12_345_678;
await delay(300);
emitUpdater({ requestId, version: expectedVersion, channel: selectedChannel, phase: "installing", received: total, total });
await delay(500);
emitUpdater({ requestId, version: expectedVersion, channel: selectedChannel, phase: "done", received: total, total });
// The real shell relaunches here; the mock just stops.
},
async ApplyUpdate() {
await this.DownloadUpdateRequest("stable", "v1.1.0", "mock-apply-download");
await this.InstallUpdateRequest("stable", "v1.1.0", "mock-apply-install");
await delay(300);
emitUpdater({ requestId, version: expectedVersion, channel: selectedChannel, phase: "relaunching", received: 0, total: 0 });
},
async OpenDownloadPage() {
if (typeof window !== "undefined") {
window.open("https://reasonix.io/?download=desktop#start", "_blank", "noopener");
}
},
async OpenUserConfigPath() {},
async ReloadUserConfig() {
return { configWarnings: [], configPath: "" };
},
// Dev seam: match the backend's provider-agnostic onboarding predicate.
async NeedsOnboarding() {
return !settings.providers.some((p) => p.models.length > 0 && providerIsConfigured(p));
+4 -10
View File
@@ -22,7 +22,6 @@ export type ThemeExperienceView = {
effectiveStyle: ThemeStyle | string;
activeThemeId?: string;
activePack?: ThemePackView | null;
safeMode: boolean;
};
export type GalleryTab = "catalog" | "user";
@@ -44,7 +43,7 @@ export function getCachedThemeExperience(): ThemeExperienceView | null {
* override, not the persisted base appearance restored when the pack is cleared.
*/
export function configuredBaseStyleForSync(view: ThemeExperienceView): ThemeStyle | null {
if (!view.safeMode && view.activePack) return null;
if (view.activePack) return null;
return (isThemeStyle(view.baseStyle) ? view.baseStyle : "graphite") as ThemeStyle;
}
@@ -69,11 +68,10 @@ export async function loadThemeExperience(): Promise<ThemeExperienceView> {
// Partial test mocks and older shells may only expose Settings + GetActiveThemePack.
let themeMode: Theme = "auto";
let baseStyle: ThemeStyle = "graphite";
let safeMode = false;
try {
const settingsApi = app as typeof app & {
DesktopStartupSettings?: () => Promise<{ desktopTheme?: string; desktopThemeStyle?: string; safeMode?: boolean }>;
Settings?: () => Promise<{ desktopTheme?: string; desktopThemeStyle?: string; safeMode?: boolean }>;
DesktopStartupSettings?: () => Promise<{ desktopTheme?: string; desktopThemeStyle?: string }>;
Settings?: () => Promise<{ desktopTheme?: string; desktopThemeStyle?: string }>;
};
const settings =
typeof settingsApi.DesktopStartupSettings === "function"
@@ -84,7 +82,6 @@ export async function loadThemeExperience(): Promise<ThemeExperienceView> {
if (settings) {
themeMode = (settings.desktopTheme as Theme) || "auto";
baseStyle = (isThemeStyle(settings.desktopThemeStyle) ? settings.desktopThemeStyle : "graphite") as ThemeStyle;
safeMode = (settings as { safeMode?: boolean }).safeMode === true;
}
} catch {
// Keep defaults.
@@ -97,7 +94,6 @@ export async function loadThemeExperience(): Promise<ThemeExperienceView> {
const active = await app.GetActiveThemePack();
activePack = active?.pack ?? null;
activeThemeId = active?.activeThemeId || undefined;
safeMode = safeMode || active?.safeMode === true;
}
} catch {
// Keep pack unset.
@@ -109,7 +105,6 @@ export async function loadThemeExperience(): Promise<ThemeExperienceView> {
effectiveStyle: activePack?.baseStyle || baseStyle,
activeThemeId,
activePack,
safeMode,
};
experienceCache = normalizeExperience(view);
return experienceCache;
@@ -125,7 +120,6 @@ function normalizeExperience(view: ThemeExperienceView): ThemeExperienceView {
effectiveStyle,
activeThemeId: view.activeThemeId || undefined,
activePack: view.activePack ?? null,
safeMode: view.safeMode === true,
};
}
@@ -134,7 +128,7 @@ export function applyExperienceToDOM(view: ThemeExperienceView): void {
const theme = (view.themeMode === "light" || view.themeMode === "dark" || view.themeMode === "auto" ? view.themeMode : "auto") as Theme;
const base = (isThemeStyle(view.baseStyle) ? view.baseStyle : "graphite") as ThemeStyle;
setBaseAppearance(theme, base);
if (view.safeMode || !view.activePack) {
if (!view.activePack) {
clearThemePack();
applyTheme(theme, base, { persist: false });
return;
-1
View File
@@ -82,7 +82,6 @@ export function themePackKind(pack: Pick<ThemePackView, "kind" | "builtin">): Th
export type ThemeActiveView = {
activeThemeId?: string;
pack?: ThemePackView | null;
safeMode: boolean;
};
export type ThemeSaveInput = {
+33 -2
View File
@@ -1461,6 +1461,36 @@ export interface JobView {
startedAt: number; // unix milliseconds
}
export interface ActiveWorkView {
running: boolean;
pendingPrompt: boolean;
cancellable: boolean;
jobs: JobView[];
}
export interface JobCancelBatchView {
cancelled: string[];
notRunning: string[];
}
export interface BackgroundRuntimeView {
tabId: string;
title: string;
detached: boolean;
running: boolean;
pendingPrompt: boolean;
jobs: JobView[];
}
export interface WorkspaceConflictView {
state: "none" | "local" | "external";
ownerTabId?: string;
ownerTitle?: string;
ownerWork: ActiveWorkView;
canReveal: boolean;
canCreateWorktree: boolean;
}
export interface PermissionsView {
mode: string; // "ask" | "allow" | "deny"
allow: string[];
@@ -1760,8 +1790,9 @@ export interface DesktopStartupSettingsView {
statusBarItems: string[]; // ordered visible status bar item ids
checkUpdates: boolean; // check for new versions on startup
updateChannel: string; // compatibility field; always "stable"
safeMode?: boolean; // recovery startup with external integrations disabled
conversationWidth?: string; // "standard" | "full"; absent from older Wails payloads
configWarnings?: string[]; // non-blocking load recovery notices
configPath?: string;
}
export type ExternalOpenerKind = "file-manager" | "editor" | "terminal";
@@ -1810,7 +1841,7 @@ export interface UpdateProgress {
requestId: string;
version: string;
channel: "stable" | "preview" | string;
phase: "downloading" | "verifying" | "downloaded" | "authorizing" | "recovering" | "installing" | "done" | "error";
phase: "downloading" | "verifying" | "downloaded" | "authorizing" | "recovering" | "installing" | "relaunching" | "done" | "error";
received: number;
total: number;
err?: string;
+42 -4
View File
@@ -1502,6 +1502,9 @@ function errorMessage(err: unknown): string {
export function effortSwitchNoticeText(err: unknown): string {
return settingSwitchNoticeText(err, "effort", {
busy: "status.effortSwitchBusy",
busyRunning: "status.effortSwitchBusyRunning",
busyPrompt: "status.effortSwitchBusyPrompt",
busyJobs: "status.effortSwitchBusyJobs",
leaseHeld: "status.effortSwitchLeaseHeld",
starting: "status.effortSwitchStarting",
startupFailed: "status.effortSwitchStartupFailed",
@@ -1522,6 +1525,9 @@ export function modelSwitchNoticeText(err: unknown): string {
}
return settingSwitchNoticeText(msg, "model", {
busy: "status.modelSwitchBusy",
busyRunning: "status.modelSwitchBusyRunning",
busyPrompt: "status.modelSwitchBusyPrompt",
busyJobs: "status.modelSwitchBusyJobs",
leaseHeld: "status.modelSwitchLeaseHeld",
starting: "status.modelSwitchStarting",
startupFailed: "status.modelSwitchStartupFailed",
@@ -1533,6 +1539,9 @@ export function modelSwitchNoticeText(err: unknown): string {
export function tokenModeSwitchNoticeText(err: unknown): string {
return settingSwitchNoticeText(err, "token mode", {
busy: "status.tokenModeSwitchBusy",
busyRunning: "status.tokenModeSwitchBusyRunning",
busyPrompt: "status.tokenModeSwitchBusyPrompt",
busyJobs: "status.tokenModeSwitchBusyJobs",
leaseHeld: "status.tokenModeSwitchLeaseHeld",
starting: "status.tokenModeSwitchStarting",
startupFailed: "status.tokenModeSwitchStartupFailed",
@@ -1786,6 +1795,9 @@ function settingSwitchNoticeText(
setting: "effort" | "model" | "token mode",
keys: {
busy: DictKey;
busyRunning: DictKey;
busyPrompt: DictKey;
busyJobs: DictKey;
leaseHeld: DictKey;
starting: DictKey;
startupFailed: DictKey;
@@ -1796,6 +1808,11 @@ function settingSwitchNoticeText(
const msg = errorMessage(err).trim() || "unknown error";
const lower = msg.toLowerCase();
if (lower.includes("finish or cancel") && lower.includes(`before changing ${setting}`)) {
const detail = /running=(true|false);\s*pending_prompt=(true|false);\s*background_jobs=(\d+)/i.exec(msg);
if (detail?.[2] === "true") return t(keys.busyPrompt);
if (detail?.[1] === "true") return t(keys.busyRunning);
const jobs = Number(detail?.[3] ?? 0);
if (jobs > 0) return t(keys.busyJobs, { n: jobs });
return t(keys.busy);
}
if (lower.includes("already open in another reasonix window") || lower.includes("session lease held")) {
@@ -3189,6 +3206,21 @@ export function useController() {
return true;
}, [activeTabId, dispatchTo, refreshMetaForTab]);
const cancelJob = useCallback(async (jobID: string): Promise<boolean> => {
const tabId = activeTabId;
if (!tabId || !jobID.trim()) return false;
try {
const cancelled = await app.CancelJobForTab(tabId, jobID);
const jobs = asArray(await app.JobsForTab(tabId));
dispatchTo(tabId, { type: "jobs", jobs });
await refreshMetaForTab(tabId);
return cancelled;
} catch {
dispatchTo(tabId, { type: "local_notice", level: "warn", text: t("status.jobStopFailed") });
return false;
}
}, [activeTabId, dispatchTo, refreshMetaForTab]);
const fetchMemory = useCallback((): Promise<MemoryView> =>
app.Memory().catch(() => ({
docs: [], facts: [], archives: [], scopes: [], instructionDiagnostics: [], conflicts: [],
@@ -3521,17 +3553,23 @@ export function useController() {
return result;
}, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime]);
const closeTab = useCallback(async (tabId: string) => {
const closeTab = useCallback(async (
tabId: string,
policy: "keep_running" | "stop_and_close" = "keep_running",
): Promise<boolean> => {
if (tabId === activeTabIdRef.current) beginActiveNavigation();
try {
await app.CloseTab(tabId);
await app.CloseTabWithPolicy(tabId, policy);
invalidateProviderStateForTab(tabId);
disposeComposerProfileState(tabId);
statesRef.current.delete(tabId);
notifyLiveListeners(tabId);
bump();
if (tabId === activeTabId) await syncActiveTabFromBackend(false);
} catch { /* ignore */ }
return true;
} catch {
return false;
}
}, [activeTabId, beginActiveNavigation, bump, disposeComposerProfileState, invalidateProviderStateForTab, notifyLiveListeners, syncActiveTabFromBackend]);
const reorderTabs = useCallback(async (tabIds: string[]) => {
@@ -3548,7 +3586,7 @@ export function useController() {
setCollaborationMode, setCollaborationModeForTab, setToolApprovalMode, setToolApprovalModeForTab, setComposerProfileForTab, setGoal, setGoalForTab, clearGoal, clearGoalForTab, resumeGoal, resumeGoalForTab,
newSession, clearSession, listSessions, listTrashedSessions, resumeSession, openChannelSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession,
loadOlderHistory,
refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, rewindForTab, setModel, setEffort, setTokenMode,
refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, rewindForTab, setModel, setEffort, setTokenMode, cancelJob,
fetchMemory, remember, forget, saveDoc,
switchTab, openProjectTab, openGlobalTab, openTopicSession, ensureBlankTab, activateTopic, ensureBlankSurface, createDeliveryWorktree, closeTab, reorderTabs,
// Invalidate in-flight navigation completions (activateTopic's stale
+52 -78
View File
@@ -3,8 +3,9 @@ import { app, onUpdaterProgress } from "./bridge";
import type { UpdateInfo } from "./types";
// useUpdater drives the auto-update state machine shared by the top banner and the
// Settings panel: check, download/verify, then a separate restart/install action.
// Deb installs add an "authorizing" phase (Polkit) before the package manager runs.
// Settings panel. v1.20+ uses a single "update and restart" action that downloads,
// verifies, installs, and relaunches. There is no durable cross-restart pending
// state: failures leave the current version running and the user simply retries.
export type UpdateStatus =
| { kind: "idle" }
@@ -13,30 +14,19 @@ export type UpdateStatus =
| { kind: "available"; info: UpdateInfo }
| { kind: "downloading"; received: number; total: number; info: UpdateInfo }
| { kind: "verifying"; info: UpdateInfo }
| { kind: "downloaded"; info: UpdateInfo }
| { kind: "authorizing"; info?: UpdateInfo }
| { kind: "recovering"; info?: UpdateInfo }
| { kind: "installing"; info?: UpdateInfo }
| { kind: "relaunching"; info?: UpdateInfo }
| { kind: "done" }
| { kind: "error"; message: string; info?: UpdateInfo; manualHint?: boolean };
export interface Updater {
status: UpdateStatus;
check: (channel?: string) => Promise<void>;
download: (info: UpdateInfo) => void;
install: () => void;
check: () => Promise<void>;
/** Single-action update: download + verify + install + relaunch. */
apply: (info: UpdateInfo) => void;
openDownload: () => void;
reset: (channel?: "stable" | "preview") => void;
}
export async function switchUpdaterChannel(
channel: "stable" | "preview",
invalidate: (channel: "stable" | "preview") => void,
save: (channel: "stable" | "preview") => Promise<boolean>,
check: (channel: string) => Promise<void>,
): Promise<void> {
invalidate(channel);
if (await save(channel)) await check(channel);
reset: () => void;
}
function errMsg(e: unknown): string {
@@ -56,7 +46,7 @@ function offersManualFallback(message: string): boolean {
const UpdaterContext = createContext<Updater | null>(null);
type UpdaterOperationKind = "idle" | "checking" | "ready" | "downloading" | "installing";
type UpdaterOperationKind = "idle" | "checking" | "ready" | "applying";
interface UpdaterOperation {
epoch: number;
@@ -79,7 +69,7 @@ function normalizedChannel(channel: string): "stable" | "preview" {
}
function isBusyOperation(kind: UpdaterOperationKind): boolean {
return kind === "checking" || kind === "downloading" || kind === "installing";
return kind === "checking" || kind === "applying";
}
function useUpdaterInternal(): Updater {
@@ -123,9 +113,9 @@ function useUpdaterInternal(): Updater {
}
}, [isCurrentOperation]);
// A single long-lived subscription advances the state machine through the apply
// phases. Channel and operation-kind checks prevent a superseded native
// download/install from publishing into a newly selected channel.
// A single long-lived subscription advances the state machine through apply
// phases. Channel and operation-kind checks prevent a superseded native call
// from publishing into a newly selected channel.
useEffect(() => {
return onUpdaterProgress((p) => {
const operation = operationRef.current;
@@ -138,12 +128,21 @@ function useUpdaterInternal(): Updater {
p.version !== operation.expectedVersion
) return;
const accepted =
((p.phase === "downloading" || p.phase === "verifying") && operation.kind === "downloading") ||
(p.phase === "downloaded" && (operation.kind === "downloading" || operation.kind === "installing")) ||
((p.phase === "authorizing" || p.phase === "recovering" || p.phase === "installing" || p.phase === "done") && operation.kind === "installing") ||
(p.phase === "error" && (operation.kind === "downloading" || operation.kind === "installing"));
operation.kind === "applying" &&
(
p.phase === "downloading" ||
p.phase === "verifying" ||
p.phase === "authorizing" ||
p.phase === "installing" ||
p.phase === "relaunching" ||
p.phase === "done" ||
p.phase === "error" ||
// Tolerate legacy backend phases during the migration window.
p.phase === "downloaded" ||
p.phase === "recovering"
);
if (!accepted) return;
if (p.phase === "downloaded" || p.phase === "done" || p.phase === "error") {
if (p.phase === "done" || p.phase === "error") {
operationRef.current = { ...operation, kind: "ready" };
}
setStatus((cur) => {
@@ -155,15 +154,16 @@ function useUpdaterInternal(): Updater {
case "verifying":
return info ? { kind: "verifying", info } : cur;
case "downloaded":
// Also used when the user cancels Polkit authorization so the UI
// returns to "downloaded" and the install button can be clicked again.
return info ? { kind: "downloaded", info: { ...info, downloaded: true } } : cur;
// Intermediate cache-ready signal: keep showing verifying/installing
// rather than a separate user action.
return info ? { kind: "installing", info } : cur;
case "authorizing":
return { kind: "authorizing", info };
case "recovering":
return { kind: "recovering", info };
case "installing":
return { kind: "installing", info };
case "relaunching":
return { kind: "relaunching", info };
case "done":
return { kind: "done" };
case "error":
@@ -180,11 +180,11 @@ function useUpdaterInternal(): Updater {
});
}, []);
const check = useCallback(async (channel = "") => {
const operation = beginOperation(channel, "checking");
const check = useCallback(async () => {
const operation = beginOperation("stable", "checking");
setStatus({ kind: "checking" });
try {
const info = await app.CheckUpdate(channel);
const info = await app.CheckUpdate("stable");
if (!isCurrentOperation(operation)) return;
if (!info) {
completeOperation(operation);
@@ -211,7 +211,7 @@ function useUpdaterInternal(): Updater {
setStatus({ kind: "upToDate", current: info.current });
return;
}
setStatus(info.downloaded ? { kind: "downloaded", info } : { kind: "available", info });
setStatus({ kind: "available", info });
} catch (e) {
if (!isCurrentOperation(operation)) return;
completeOperation(operation);
@@ -219,75 +219,49 @@ function useUpdaterInternal(): Updater {
}
}, [beginOperation, completeOperation, isCurrentOperation]);
const download = useCallback((info: UpdateInfo) => {
const apply = useCallback((info: UpdateInfo) => {
const selectedChannel = normalizedChannel(info.channel);
if (selectedChannel !== "stable") {
setStatus({ kind: "error", message: "update check returned a retired release channel" });
return;
}
const active = operationRef.current;
if (isBusyOperation(active.kind) || (active.channel && active.channel !== selectedChannel)) return;
if (!info.canSelfUpdate) {
void app.OpenDownloadPage();
return;
}
const operation = beginOperation(selectedChannel, "downloading", info.latest);
setStatus({ kind: "downloading", received: 0, total: info.assetSize, info });
void app.DownloadUpdateRequest(selectedChannel, info.latest, operation.requestId)
.then((result) => {
if (!isCurrentOperation(operation)) return;
if (
!result ||
result.requestId !== operation.requestId ||
result.version !== info.latest ||
normalizedChannel(result.channel) !== selectedChannel
) {
completeOperation(operation);
setStatus({ kind: "available", info });
return;
}
completeOperation(operation);
setStatus({ kind: "downloaded", info: { ...info, downloaded: true } });
})
.catch((e) => {
if (!isCurrentOperation(operation)) return;
completeOperation(operation);
setStatus({ kind: "error", message: errMsg(e), info });
});
}, [beginOperation, completeOperation, isCurrentOperation]);
const install = useCallback(() => {
const info = "info" in status ? status.info : undefined;
if (!info) return;
const selectedChannel = normalizedChannel(info.channel);
const active = operationRef.current;
if (isBusyOperation(active.kind) || (active.channel && active.channel !== selectedChannel)) return;
const operation = beginOperation(selectedChannel, "installing", info.latest);
// Deb installs start in authorizing; portable/other go straight to installing.
setStatus(info.requiresElevation || info.installMode === "deb"
? { kind: "authorizing", info }
: { kind: "installing", info });
void app.InstallUpdateRequest(selectedChannel, info.latest, operation.requestId).catch((e) => {
const operation = beginOperation(selectedChannel, "applying", info.latest);
setStatus(
info.requiresElevation || info.installMode === "deb"
? { kind: "authorizing", info }
: { kind: "downloading", received: 0, total: info.assetSize, info },
);
void app.ApplyUpdateRequest(selectedChannel, info.latest, operation.requestId).catch((e) => {
if (!isCurrentOperation(operation)) return;
const message = errMsg(e);
completeOperation(operation);
setStatus({ kind: "error", message, info, manualHint: offersManualFallback(message) });
});
}, [beginOperation, completeOperation, isCurrentOperation, status]);
}, [beginOperation, completeOperation, isCurrentOperation]);
const openDownload = useCallback(() => {
void app.OpenDownloadPage();
}, []);
const reset = useCallback((channel?: "stable" | "preview") => {
const reset = useCallback(() => {
const epoch = operationRef.current.epoch + 1;
operationRef.current = {
epoch,
requestId: nextUpdaterRequestId(epoch),
channel: channel ? normalizedChannel(channel) : "",
channel: "",
expectedVersion: "",
kind: "idle",
};
setStatus({ kind: "idle" });
}, []);
return { status, check, download, install, openDownload, reset };
return { status, check, apply, openDownload, reset };
}
export function UpdaterProvider({ children }: { children: ReactNode }) {
+60 -35
View File
@@ -2,47 +2,71 @@
// persists it via SaveWindowState so the next launch restores the same size and
// position. No-op in browser dev (no window.runtime).
//
// The Go shutdown hook (app.saveWindowStateSync) provides the authoritative
// final save; this hook covers moves/resizes during the session.
//
// NOTE: navigator.sendBeacon or a sync XHR would let us block beforeunload,
// but Wails bindings are async (Go IPC). We accept that the very last resize
// event may not land; the 5s poll and the Go shutdown hook make this unlikely
// to matter.
// The frontend is the sole source of geometry: resize (debounced), a 5s poll,
// and beforeunload. Go never reads WindowGetSize/Position/IsMaximised during
// beforeClose or shutdown (those paths can panic on Windows when DPI reports
// 0). The Go shutdown hook only re-persists the last frontend-reported state.
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { app } from "./bridge";
export function useWindowStatePersistence() {
const lastState = useRef("");
export interface WindowStateSnapshot {
width: number;
height: number;
x: number;
y: number;
maximised: boolean;
}
interface WindowStateRuntime {
WindowGetSize(): Promise<{ w: number; h: number }>;
WindowGetPosition(): Promise<{ x: number; y: number }>;
WindowIsMaximised(): Promise<boolean>;
}
// Serialize capture + persistence as one operation. Resize, polling, and
// beforeunload can all request a save while an earlier Wails call is still in
// flight; a queue prevents an older completion from overwriting a newer
// observation. A queued request captures geometry only when it reaches the
// front, so it observes the latest native state instead of replaying a stale
// snapshot.
export function createWindowStateSaver(
runtime: WindowStateRuntime,
persist: (state: WindowStateSnapshot) => Promise<void>,
): () => Promise<void> {
let lastState = "";
let queue = Promise.resolve();
return () => {
queue = queue.then(async () => {
try {
const size = await runtime.WindowGetSize();
const pos = await runtime.WindowGetPosition();
const maximised = await runtime.WindowIsMaximised();
const state = { width: size.w, height: size.h, x: pos.x, y: pos.y, maximised };
const json = JSON.stringify(state);
if (json === lastState) return;
await persist(state);
lastState = json;
} catch {
/* runtime not ready yet — a later request will retry */
}
});
return queue;
};
}
export function useWindowStatePersistence() {
useEffect(() => {
const runtime = typeof window !== "undefined" ? window.runtime : undefined;
if (!runtime?.WindowGetSize || !runtime.WindowGetPosition || !runtime.WindowIsMaximised) return;
const { WindowGetSize, WindowGetPosition, WindowIsMaximised } = runtime;
let timer: ReturnType<typeof setInterval>;
const save = async () => {
try {
const size = await WindowGetSize();
const pos = await WindowGetPosition();
const maximised = await WindowIsMaximised();
const json = JSON.stringify({
width: size.w,
height: size.h,
x: pos.x,
y: pos.y,
maximised,
});
if (json !== lastState.current) {
await app.SaveWindowState({ width: size.w, height: size.h, x: pos.x, y: pos.y, maximised });
lastState.current = json;
}
} catch {
/* runtime not ready yet — poll will retry */
}
};
const save = createWindowStateSaver(
{ WindowGetSize, WindowGetPosition, WindowIsMaximised },
(state) => app.SaveWindowState(state),
);
// Debounced save on resize (500ms after the last resize event).
let debounce: ReturnType<typeof setTimeout>;
@@ -55,15 +79,16 @@ export function useWindowStatePersistence() {
// Periodic poll every 5s for moves/maximise that don't trigger resize.
timer = setInterval(save, 5000);
// Best-effort save before the page unloads. The Go shutdown hook
// (saveWindowStateSync) is the authoritative final persist.
window.addEventListener("beforeunload", save);
// Best-effort save before the page unloads. Go re-persists the last
// accepted report during shutdown without querying the native window.
const onBeforeUnload = () => { void save(); };
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
clearInterval(timer);
clearTimeout(debounce);
window.removeEventListener("resize", onResize);
window.removeEventListener("beforeunload", save);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, []);
}
+47 -1
View File
@@ -773,6 +773,9 @@ export const en = {
"modelSwitcher.noMatches": "No matching models",
"status.noModels": "no switchable models",
"status.modelSwitchBusy": "The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.modelSwitchBusyRunning": "The model cannot change while the current answer is running. Stop it first.",
"status.modelSwitchBusyPrompt": "The model cannot change while a prompt is waiting for your response. Handle it first.",
"status.modelSwitchBusyJobs": "The model cannot change while {n} background jobs are running. Open Background jobs in the status bar to stop them.",
"status.modelSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
"status.modelSwitchStarting": "This session is still starting. Try changing models again in a moment.",
"status.modelSwitchStartupFailed": "This session did not start, so the model cannot change: {err}",
@@ -782,12 +785,18 @@ export const en = {
"status.modelSwitchFailed": "Model switch failed: {err}",
"status.modelFallbackSwitched": "Model {model} is no longer available; switched to {fallback}.",
"status.effortSwitchBusy": "Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.effortSwitchBusyRunning": "Reasoning effort cannot change while the current answer is running. Stop it first.",
"status.effortSwitchBusyPrompt": "Reasoning effort cannot change while a prompt is waiting for your response. Handle it first.",
"status.effortSwitchBusyJobs": "Reasoning effort cannot change while {n} background jobs are running. Open Background jobs in the status bar to stop them.",
"status.effortSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
"status.effortSwitchStarting": "This session is still starting. Try changing reasoning effort again in a moment.",
"status.effortSwitchStartupFailed": "This session did not start, so reasoning effort cannot change: {err}",
"status.effortSwitchRetry": "The current session changed while switching reasoning effort. Try once more.",
"status.effortSwitchFailed": "Reasoning effort switch failed: {err}",
"status.tokenModeSwitchBusy": "Work mode cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.tokenModeSwitchBusyRunning": "Work mode cannot change while the current answer is running. Stop it first.",
"status.tokenModeSwitchBusyPrompt": "Work mode cannot change while a prompt is waiting for your response. Handle it first.",
"status.tokenModeSwitchBusyJobs": "Work mode cannot change while {n} background jobs are running. Open Background jobs in the status bar to stop them.",
"status.tokenModeSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing its work mode.",
"status.tokenModeSwitchStarting": "This session is still starting. Try changing the work mode again in a moment.",
"status.tokenModeSwitchStartupFailed": "This session did not start, so its work mode cannot change: {err}",
@@ -909,6 +918,39 @@ export const en = {
"status.jobs": "{n} running",
"status.jobsTitle": "Background jobs",
"status.jobStop": "Stop",
"status.jobStopping": "Stopping…",
"status.jobStopFailed": "Failed to stop the background job.",
"status.jobOpenTask": "Open task",
"status.runtimeRunning": "Turn running",
"status.runtimePendingPrompt": "Waiting for input",
"runtime.workspaceConflictTitle": "This workspace is busy",
"runtime.workspaceConflictBadge": "Delivery writer",
"runtime.workspaceConflictLocal": "{title} is currently writing to this workspace.",
"runtime.workspaceConflictExternal": "Another Reasonix window is currently writing to this workspace.",
"runtime.workspaceConflictNote": "This task will continue automatically when the writer releases the workspace.",
"runtime.unknownTask": "Another task",
"runtime.revealWriter": "Open writing task",
"runtime.revealWriterDesc": "Switch to the exact task that currently owns the workspace writer.",
"runtime.openWorktree": "Continue in a worktree",
"runtime.openWorktreeDesc": "Cancel this wait and continue safely in an isolated Delivery worktree.",
"runtime.cancelWait": "Stop waiting",
"runtime.cancelWaitDesc": "Cancel this turn without affecting the task that owns the workspace.",
"runtime.modeJobsTitle": "Background jobs must stop first",
"runtime.modeJobsMeta": "Changing work mode rebuilds this task's runtime.",
"runtime.keepModeDesc": "Keep the current work mode and leave background jobs running.",
"runtime.stopAndSwitch": "Stop jobs and switch",
"runtime.stopAndSwitchDesc": "Stop this task's jobs, wait for their processes to exit, then change work mode.",
"runtime.closeTitle": "This task still has active work",
"runtime.closeBadge": "Safe close",
"runtime.closeMeta": "The task has {n} background jobs; it may also have a running turn or pending prompt.",
"runtime.closeNote": "Choose whether active work should remain available in the background or be stopped before closing.",
"runtime.closeCancelDesc": "Return to the task without changing anything.",
"runtime.keepRunning": "Keep running",
"runtime.keepRunningDesc": "Close the tab and keep its runtime available under Background jobs.",
"runtime.stopAndClose": "Stop and close",
"runtime.stopAndCloseDesc": "Cancel the turn and jobs, wait for real process exit, then close the tab.",
"runtime.closeFailed": "The task could not be closed because active work has not stopped.",
"status.modelTitle": "Current model",
"status.workspaceTitle": "Current workspace",
"status.sandboxTitle": "Sandbox path",
@@ -2683,7 +2725,6 @@ export const en = {
"notice.mcpServersFailed": "Some MCP servers failed to start; run /mcp for details.",
"notice.guardianModelMissing": "Guardian was disabled because its model was not found.",
"notice.guardianStartFailed": "Guardian was disabled because it could not start.",
"guard.safeMode": "Reasonix started in Safe Mode. Plugins, MCP servers, hooks, bots, automations, and saved tabs are disabled for this run.",
"questionNav.label": "Question navigation",
"questionNav.progress": "Question {current} / {total}",
"questionNav.jump": "Jump to question {n}",
@@ -2757,6 +2798,10 @@ export const en = {
"tool.noOutput": "no output",
// software update
"config.loadWarning": "Configuration issue: {msg}",
"config.openConfig": "Open config",
"config.reloadConfig": "Reload",
"config.doctorHint": "Or run: reasonix doctor repair",
"updater.title": "Software update",
"updater.autoCheckLabel": "Check for updates on startup",
"updater.autoCheckHint": "When off, Reasonix won't check automatically when it opens. You can still check manually here.",
@@ -2772,6 +2817,7 @@ export const en = {
"updater.available": "A new version is available: {v}",
"updater.releaseNotes": "Release notes",
"updater.downloadUpdate": "Download update",
"updater.updateAndRestart": "Update and restart",
"updater.downloaded": "Update {v} has been downloaded.",
"updater.restartInstall": "Install and restart",
"updater.authorizeInstall": "Authorize install and restart",
+47 -1
View File
@@ -554,6 +554,9 @@ export const zhTW: Record<DictKey, string> = {
"modelSwitcher.noMatches": "沒有匹配的模型",
"status.noModels": "沒有可切換模型",
"status.modelSwitchBusy": "暫時不能切換模型。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
"status.modelSwitchBusyRunning": "目前回答仍在執行,暫時不能切換模型。請先停止回答。",
"status.modelSwitchBusyPrompt": "有待處理的確認問題,暫時不能切換模型。請先完成處理。",
"status.modelSwitchBusyJobs": "仍有 {n} 個背景任務在執行,暫時不能切換模型。請從狀態列的「背景作業」中停止它們。",
"status.modelSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換模型。",
"status.modelSwitchStarting": "會話還在啟動,稍後再切換模型。",
"status.modelSwitchStartupFailed": "目前會話啟動失敗,無法切換模型:{err}",
@@ -563,6 +566,9 @@ export const zhTW: Record<DictKey, string> = {
"status.modelSwitchFailed": "模型切換失敗:{err}",
"status.modelFallbackSwitched": "模型 {model} 已不可用,已切換到 {fallback}。",
"status.effortSwitchBusy": "暫時不能切換推理力度。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
"status.effortSwitchBusyRunning": "目前回答仍在執行,暫時不能切換推理力度。請先停止回答。",
"status.effortSwitchBusyPrompt": "有待處理的確認問題,暫時不能切換推理力度。請先完成處理。",
"status.effortSwitchBusyJobs": "仍有 {n} 個背景任務在執行,暫時不能切換推理力度。請從狀態列的「背景作業」中停止它們。",
"status.effortSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換推理力度。",
"status.effortSwitchStarting": "會話還在啟動,稍後再切換推理力度。",
"status.effortSwitchStartupFailed": "目前會話啟動失敗,無法切換推理力度:{err}",
@@ -684,6 +690,39 @@ export const zhTW: Record<DictKey, string> = {
"status.jobs": "{n} 個執行中",
"status.jobsTitle": "背景作業",
"status.jobStop": "停止",
"status.jobStopping": "正在停止…",
"status.jobStopFailed": "停止背景任務失敗。",
"status.jobOpenTask": "開啟任務",
"status.runtimeRunning": "回合執行中",
"status.runtimePendingPrompt": "等待使用者輸入",
"runtime.workspaceConflictTitle": "目前工作區正在被寫入",
"runtime.workspaceConflictBadge": "交付寫入",
"runtime.workspaceConflictLocal": "「{title}」目前正在寫入這個工作區。",
"runtime.workspaceConflictExternal": "另一個 Reasonix 視窗目前正在寫入這個工作區。",
"runtime.workspaceConflictNote": "占用者釋放工作區後,本任務會自動繼續。",
"runtime.unknownTask": "另一個任務",
"runtime.revealWriter": "檢視占用任務",
"runtime.revealWriterDesc": "切換到目前真正持有工作區寫入權的任務。",
"runtime.openWorktree": "在 worktree 中繼續",
"runtime.openWorktreeDesc": "取消本次等待,並在隔離的交付 worktree 中安全繼續。",
"runtime.cancelWait": "停止等待",
"runtime.cancelWaitDesc": "只取消本輪,不影響正在占用工作區的任務。",
"runtime.modeJobsTitle": "需要先停止背景作業",
"runtime.modeJobsMeta": "切換工作模式會重建目前任務的執行環境。",
"runtime.keepModeDesc": "保留目前工作模式,讓背景作業繼續執行。",
"runtime.stopAndSwitch": "停止作業並切換",
"runtime.stopAndSwitchDesc": "停止本任務的作業,等待程序真正結束後再切換工作模式。",
"runtime.closeTitle": "這個任務仍有活動工作",
"runtime.closeBadge": "安全關閉",
"runtime.closeMeta": "目前有 {n} 個背景作業,也可能仍有執行中的回合或待確認問題。",
"runtime.closeNote": "請選擇保留背景執行,或在關閉前停止所有活動工作。",
"runtime.closeCancelDesc": "返回任務,不做任何變更。",
"runtime.keepRunning": "保留背景執行",
"runtime.keepRunningDesc": "關閉分頁,並在「背景作業」中保留這個執行環境。",
"runtime.stopAndClose": "停止並關閉",
"runtime.stopAndCloseDesc": "取消回合和作業,等待程序真正結束後再關閉分頁。",
"runtime.closeFailed": "活動工作尚未停止,任務未被關閉。",
"status.workspaceTitle": "當前工作區",
"status.sandboxTitle": "沙箱路徑",
"status.gitBranchTitle": "當前 Git 分支",
@@ -1837,7 +1876,6 @@ export const zhTW: Record<DictKey, string> = {
"notice.mcpServersFailed": "部分 MCP 服務啟動失敗,可執行 /mcp 查看詳情。",
"notice.guardianModelMissing": "Guardian 已停用:未找到對應模型。",
"notice.guardianStartFailed": "Guardian 啟動失敗,已停用。",
"guard.safeMode": "Reasonix 已進入安全模式。本次執行已停用外掛、MCP、Hooks、機器人、自動化和上次儲存的分頁。",
"questionNav.label": "問題導航",
"questionNav.progress": "問題 {current} / {total}",
"questionNav.jump": "跳轉到問題 {n}",
@@ -1908,6 +1946,10 @@ export const zhTW: Record<DictKey, string> = {
"tool.noOutput": "無輸出",
// 軟體更新
"config.loadWarning": "設定問題:{msg}",
"config.openConfig": "開啟設定",
"config.reloadConfig": "重新載入",
"config.doctorHint": "或執行:reasonix doctor repair",
"updater.title": "軟體更新",
"updater.currentVersion": "當前版本:{v}",
"updater.checkButton": "檢查更新",
@@ -1916,6 +1958,7 @@ export const zhTW: Record<DictKey, string> = {
"updater.available": "發現新版本:{v}",
"updater.releaseNotes": "更新說明",
"updater.downloadUpdate": "下載更新",
"updater.updateAndRestart": "更新並重新啟動",
"updater.downloaded": "更新 {v} 已下載。",
"updater.restartInstall": "安裝並重新啟動",
"updater.authorizeInstall": "授權安裝並重新啟動",
@@ -2265,6 +2308,9 @@ export const zhTW: Record<DictKey, string> = {
"status.balanceLabel": "餘額",
"status.jobsLabel": "任務",
"status.tokenModeSwitchBusy": "暫時不能切換工作模式。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
"status.tokenModeSwitchBusyRunning": "目前回答仍在執行,暫時不能切換工作模式。請先停止回答。",
"status.tokenModeSwitchBusyPrompt": "有待處理的確認問題,暫時不能切換工作模式。請先完成處理。",
"status.tokenModeSwitchBusyJobs": "仍有 {n} 個背景任務在執行,暫時不能切換工作模式。請從狀態列的「背景作業」中停止它們。",
"status.tokenModeSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換工作模式。",
"status.tokenModeSwitchStarting": "會話還在啟動,稍後再切換工作模式。",
"status.tokenModeSwitchStartupFailed": "目前會話啟動失敗,無法切換工作模式:{err}",
+47 -1
View File
@@ -774,6 +774,9 @@ export const zh: Record<DictKey, string> = {
"modelSwitcher.noMatches": "没有匹配的模型",
"status.noModels": "没有可切换模型",
"status.modelSwitchBusy": "暂时不能切换模型。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
"status.modelSwitchBusyRunning": "当前回答仍在运行,暂时不能切换模型。请先停止回答。",
"status.modelSwitchBusyPrompt": "有待处理的确认问题,暂时不能切换模型。请先完成处理。",
"status.modelSwitchBusyJobs": "仍有 {n} 个后台任务在运行,暂时不能切换模型。请从状态栏的“后台作业”中停止它们。",
"status.modelSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换模型。",
"status.modelSwitchStarting": "会话还在启动,稍后再切换模型。",
"status.modelSwitchStartupFailed": "当前会话启动失败,无法切换模型:{err}",
@@ -783,12 +786,18 @@ export const zh: Record<DictKey, string> = {
"status.modelSwitchFailed": "模型切换失败:{err}",
"status.modelFallbackSwitched": "模型 {model} 已不可用,已切换到 {fallback}。",
"status.effortSwitchBusy": "暂时不能切换推理力度。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
"status.effortSwitchBusyRunning": "当前回答仍在运行,暂时不能切换推理力度。请先停止回答。",
"status.effortSwitchBusyPrompt": "有待处理的确认问题,暂时不能切换推理力度。请先完成处理。",
"status.effortSwitchBusyJobs": "仍有 {n} 个后台任务在运行,暂时不能切换推理力度。请从状态栏的“后台作业”中停止它们。",
"status.effortSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换推理力度。",
"status.effortSwitchStarting": "会话还在启动,稍后再切换推理力度。",
"status.effortSwitchStartupFailed": "当前会话启动失败,无法切换推理力度:{err}",
"status.effortSwitchRetry": "当前会话已变化,请再试一次切换推理力度。",
"status.effortSwitchFailed": "推理力度切换失败:{err}",
"status.tokenModeSwitchBusy": "暂时不能切换工作模式。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
"status.tokenModeSwitchBusyRunning": "当前回答仍在运行,暂时不能切换工作模式。请先停止回答。",
"status.tokenModeSwitchBusyPrompt": "有待处理的确认问题,暂时不能切换工作模式。请先完成处理。",
"status.tokenModeSwitchBusyJobs": "仍有 {n} 个后台任务在运行,暂时不能切换工作模式。请从状态栏的“后台作业”中停止它们。",
"status.tokenModeSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换工作模式。",
"status.tokenModeSwitchStarting": "会话还在启动,稍后再切换工作模式。",
"status.tokenModeSwitchStartupFailed": "当前会话启动失败,无法切换工作模式:{err}",
@@ -910,6 +919,39 @@ export const zh: Record<DictKey, string> = {
"status.jobs": "{n} 个运行中",
"status.jobsTitle": "后台作业",
"status.jobStop": "停止",
"status.jobStopping": "正在停止…",
"status.jobStopFailed": "停止后台任务失败。",
"status.jobOpenTask": "打开任务",
"status.runtimeRunning": "回合运行中",
"status.runtimePendingPrompt": "等待用户输入",
"runtime.workspaceConflictTitle": "当前工作区正在被写入",
"runtime.workspaceConflictBadge": "交付写入",
"runtime.workspaceConflictLocal": "“{title}”当前正在写入这个工作区。",
"runtime.workspaceConflictExternal": "另一个 Reasonix 窗口当前正在写入这个工作区。",
"runtime.workspaceConflictNote": "占用者释放工作区后,本任务会自动继续。",
"runtime.unknownTask": "另一个任务",
"runtime.revealWriter": "查看占用任务",
"runtime.revealWriterDesc": "切换到当前真正持有工作区写入权的任务。",
"runtime.openWorktree": "在 worktree 中继续",
"runtime.openWorktreeDesc": "取消本次等待,并在隔离的交付 worktree 中安全继续。",
"runtime.cancelWait": "停止等待",
"runtime.cancelWaitDesc": "只取消本轮,不影响正在占用工作区的任务。",
"runtime.modeJobsTitle": "需要先停止后台作业",
"runtime.modeJobsMeta": "切换工作模式会重建当前任务的运行时。",
"runtime.keepModeDesc": "保留当前工作模式,让后台作业继续运行。",
"runtime.stopAndSwitch": "停止作业并切换",
"runtime.stopAndSwitchDesc": "停止本任务的作业,等待进程真正退出后再切换工作模式。",
"runtime.closeTitle": "这个任务仍有活动工作",
"runtime.closeBadge": "安全关闭",
"runtime.closeMeta": "当前有 {n} 个后台作业,也可能仍有运行中的回合或待确认问题。",
"runtime.closeNote": "请选择保留后台运行,或在关闭前停止所有活动工作。",
"runtime.closeCancelDesc": "返回任务,不做任何改变。",
"runtime.keepRunning": "保留后台运行",
"runtime.keepRunningDesc": "关闭标签页,并在“后台作业”中保留这个运行时。",
"runtime.stopAndClose": "停止并关闭",
"runtime.stopAndCloseDesc": "取消回合和作业,等待进程真正退出后再关闭标签页。",
"runtime.closeFailed": "活动工作尚未停止,任务未被关闭。",
"status.modelTitle": "当前模型",
"status.workspaceTitle": "当前工作区",
"status.sandboxTitle": "沙箱路径",
@@ -2686,7 +2728,6 @@ export const zh: Record<DictKey, string> = {
"notice.mcpServersFailed": "部分 MCP 服务启动失败,可运行 /mcp 查看详情。",
"notice.guardianModelMissing": "Guardian 已停用:未找到对应模型。",
"notice.guardianStartFailed": "Guardian 启动失败,已停用。",
"guard.safeMode": "Reasonix 已进入安全模式。本次运行已禁用插件、MCP、Hooks、机器人、自动化和上次保存的标签页。",
"questionNav.label": "问题导航",
"questionNav.progress": "问题 {current} / {total}",
"questionNav.jump": "跳转到问题 {n}",
@@ -2760,6 +2801,10 @@ export const zh: Record<DictKey, string> = {
"tool.noOutput": "无输出",
// 软件更新
"config.loadWarning": "配置问题:{msg}",
"config.openConfig": "打开配置",
"config.reloadConfig": "重新加载",
"config.doctorHint": "或运行:reasonix doctor repair",
"updater.title": "软件更新",
"updater.autoCheckLabel": "启动时检测新版本",
"updater.autoCheckHint": "关闭后,Reasonix 打开时不会自动检查更新;你仍可在此页手动检查。",
@@ -2775,6 +2820,7 @@ export const zh: Record<DictKey, string> = {
"updater.available": "发现新版本:{v}",
"updater.releaseNotes": "更新说明",
"updater.downloadUpdate": "下载更新",
"updater.updateAndRestart": "更新并重启",
"updater.downloaded": "更新 {v} 已下载。",
"updater.restartInstall": "安装并重启",
"updater.authorizeInstall": "授权安装并重启",
+93
View File
@@ -7571,6 +7571,99 @@ body > .mermaid-diagram--fullscreen {
align-items: center;
flex-shrink: 0;
}
.statusbar__jobs {
display: inline-flex;
align-items: center;
}
.statusbar__jobs-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 4px;
border: 0;
border-radius: 4px;
color: var(--accent-strong);
background: transparent;
font: inherit;
cursor: pointer;
}
.statusbar__jobs-trigger:hover,
.statusbar__jobs-trigger:focus-visible,
.statusbar__jobs-trigger[aria-expanded="true"] {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.jobs-popover {
width: min(360px, calc(100vw - 16px));
padding: 8px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
box-shadow: var(--shadow-2);
color: var(--fg);
}
.jobs-popover__header {
padding: 3px 5px 8px;
color: var(--fg-dim);
font-weight: 650;
}
.jobs-popover__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.jobs-popover__runtime {
display: flex;
flex-direction: column;
gap: 4px;
}
.jobs-popover__runtime + .jobs-popover__runtime {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
}
.jobs-popover__runtime-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 2px 4px;
}
.jobs-popover__runtime-header strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jobs-popover__job {
display: flex;
align-items: center;
gap: 12px;
padding: 7px;
border-radius: 7px;
background: color-mix(in srgb, var(--bg-elev) 72%, transparent);
}
.jobs-popover__copy {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.jobs-popover__copy strong,
.jobs-popover__copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jobs-popover__copy small {
color: var(--fg-faint);
}
.jobs-popover__stop {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.statusbar__item,
.statusbar__item .tooltip-trigger {
display: inline-flex;
+73 -113
View File
@@ -1,7 +1,6 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -16,54 +15,46 @@ func writePortableFixture(t *testing.T, dir, name, content string) {
}
}
func TestVerifyWindowsPortableRejectsCaseCollisionsAndCLIOverwrite(t *testing.T) {
func TestVerifyWindowsPortableVersionedLayout(t *testing.T) {
verify := filepath.Join("..", "scripts", "verify-windows-portable.sh")
good := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe",
} {
writePortableFixture(t, good, name, name)
}
writePortableFixture(t, good, "Reasonix.exe", "launcher")
// versioned-v1 root entries
writePortableFixture(t, good, "reasonix-launcher.exe", "launcher")
writePortableFixture(t, good, "Reasonix.exe", "launcher")
writePortableFixture(t, good, "reasonix-cli.exe", "cli")
if out, err := exec.Command("bash", verify, good).CombinedOutput(); err != nil {
t.Fatalf("valid portable fixture failed: %v\n%s", err, out)
}
overwritten := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe",
} {
writePortableFixture(t, overwritten, name, name)
}
writePortableFixture(t, overwritten, "Reasonix.exe", "cli")
writePortableFixture(t, overwritten, "reasonix-launcher.exe", "launcher")
writePortableFixture(t, overwritten, "reasonix-cli.exe", "cli")
if out, err := exec.Command("bash", verify, overwritten).CombinedOutput(); err == nil || !strings.Contains(string(out), "not the packaged GUI launcher") {
t.Fatalf("overwritten launcher result = %v, output %q", err, out)
}
// A case-sensitive test filesystem can represent the exact source-level
// mistake that NTFS collapses into one overwritten file. Either filesystem
// behavior must be rejected by the verifier.
collision := t.TempDir()
writePortableFixture(t, collision, "Reasonix.exe", "launcher")
writePortableFixture(t, collision, "reasonix.exe", "cli")
entries, err := os.ReadDir(collision)
if err != nil {
ver := filepath.Join(good, "versions", "v1.20.0")
if err := os.MkdirAll(ver, 0o755); err != nil {
t.Fatal(err)
}
out, verifyErr := exec.Command("bash", verify, collision).CombinedOutput()
if verifyErr == nil {
t.Fatal("case-only portable entry names were accepted")
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
writePortableFixture(t, ver, name, name)
}
if len(entries) == 2 && !strings.Contains(string(out), "collide case-insensitively") {
t.Fatalf("case-collision output = %q", out)
if err := os.WriteFile(filepath.Join(good, "current.json"), []byte(`{
"schemaVersion": 1,
"activeVersion": "v1.20.0",
"activeDir": "versions/v1.20.0"
}
`), 0o644); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("bash", verify, good).CombinedOutput(); err != nil {
t.Fatalf("valid versioned portable failed: %v\n%s", err, out)
}
// Flat Guard layout must be rejected.
flat := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"Reasonix.exe",
"reasonix-cli.exe",
} {
writePortableFixture(t, flat, name, name)
}
if out, err := exec.Command("bash", verify, flat).CombinedOutput(); err == nil {
t.Fatalf("flat portable with guard should fail, output=%s", out)
}
}
@@ -77,8 +68,10 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
`CLINAME="reasonix"`,
`WINDOWS_CLINAME="reasonix-cli"`,
`./cmd/reasonix`,
`cp "$guard_out" "$app/Contents/MacOS/$GUARDNAME"`,
`./cmd/reasonix-legacy-migrator`,
`./cmd/reasonix-launcher`,
`cp "$cli_out" "$app/Contents/MacOS/$CLINAME"`,
`macOS bundle must not include $GUARDNAME`,
`[ "$bundle_executable" = "$BINNAME" ]`,
`Print :CFBundleIconFile`,
`darwin_icon="$ROOT/desktop/build/darwin/icon.icns"`,
@@ -86,7 +79,7 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
`cmp -s "$darwin_icon" "$app/Contents/Resources/$bundle_icon"`,
`Contents/Resources/$bundle_icon`,
`-H windowsgui`,
`stamp_windows_executable "$guard_out" "Reasonix Guard"`,
`stamp_windows_executable "$guard_out" "Reasonix Legacy Migrator"`,
`stamp_windows_executable "$launcher_out" "Reasonix Launcher"`,
`stamp_windows_executable "build/windows/installer/$UPDATE_HELPER" "Reasonix Update Helper"`,
`payload_dir="$ROOT/desktop/build/windows/signing-payload"`,
@@ -96,10 +89,11 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
`cp "build/windows/installer/$WINDOWS_CLINAME.exe" "$payload_dir/$WINDOWS_CLINAME.exe"`,
`cp "build/windows/installer/reasonix-uninstall.exe" "$payload_dir/reasonix-uninstall.exe"`,
`"$ROOT/scripts/package-windows-desktop.sh" "$arch" "$payload_dir"`,
`"$BINNAME" "$GUARDNAME" "$CLINAME"`,
`"$BINNAME" "$LAUNCHERNAME" "$GUARDNAME" "$CLINAME"`,
`Exec=reasonix-launcher`,
} {
if !strings.Contains(build, want) {
t.Errorf("desktop-build.sh missing guard launcher contract %q", want)
t.Errorf("desktop-build.sh missing packaging contract %q", want)
}
}
if strings.Contains(build, `Set :CFBundleExecutable $GUARDNAME`) {
@@ -115,73 +109,14 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
}
darwinIconCopy := strings.Index(build, `cp "$darwin_icon" "$app/Contents/Resources/$bundle_icon"`)
developerIDSign := strings.Index(build, `codesign --force --deep --timestamp --options runtime`)
adHocSign := strings.Index(build, `codesign --force --deep -s - "$app"`)
if darwinIconCopy < 0 || developerIDSign < 0 || adHocSign < 0 || darwinIconCopy > developerIDSign || darwinIconCopy > adHocSign {
t.Fatalf("macOS icon must be replaced before signing (copy=%d developer-id=%d ad-hoc=%d)", darwinIconCopy, developerIDSign, adHocSign)
if darwinIconCopy < 0 || developerIDSign < 0 || darwinIconCopy > developerIDSign {
t.Fatalf("macOS icon must be replaced before signing (icon=%d sign=%d)", darwinIconCopy, developerIDSign)
}
workflowData, err := os.ReadFile("../.github/workflows/release-desktop.yml")
if err != nil {
t.Fatal(err)
}
workflow := string(workflowData)
for _, platform := range []string{"windows/amd64", "windows/arm64"} {
if !strings.Contains(workflow, "platform: "+platform) {
t.Errorf("desktop release matrix missing resource-stamped target %s", platform)
}
}
linuxData, err := os.ReadFile("build/linux/reasonix.desktop")
if err != nil {
t.Fatal(err)
}
linux := string(linuxData)
for _, want := range []string{
"Exec=reasonix-guard launch --detach",
"Icon=reasonix-desktop",
"StartupWMClass=reasonix-desktop",
} {
if !strings.Contains(linux, want) {
t.Errorf("Linux desktop entry missing identity contract %q", want)
}
}
nfpmData, err := os.ReadFile("build/linux/nfpm.yaml")
if err != nil {
t.Fatal(err)
}
nfpm := string(nfpmData)
for _, size := range []int{16, 24, 32, 48, 64, 128, 256, 512} {
asset := fmt.Sprintf("build/linux/icons/hicolor/%dx%d/apps/reasonix-desktop.png", size, size)
if stat, err := os.Stat(asset); err != nil || stat.Size() == 0 {
t.Errorf("Linux app icon %s is missing or empty", asset)
}
destination := fmt.Sprintf("/usr/share/icons/hicolor/%dx%d/apps/reasonix-desktop.png", size, size)
if !strings.Contains(nfpm, destination) {
t.Errorf("Linux package does not install %s", destination)
}
}
for _, want := range []string{
"/usr/bin/reasonix",
"/usr/lib/reasonix/reasonix-update-helper",
"/usr/share/polkit-1/actions/io.reasonix.desktop.update.policy",
"/usr/share/applications/reasonix.desktop",
"/usr/share/pixmaps/reasonix-desktop.png",
"/usr/share/icons/hicolor/scalable/apps/reasonix-desktop.svg",
"pkexec",
} {
if !strings.Contains(nfpm, want) {
t.Errorf("Linux package missing desktop identity asset %q", want)
}
}
if _, err := os.Stat("build/linux/io.reasonix.desktop.update.policy"); err != nil {
t.Errorf("Polkit policy file missing: %v", err)
}
for _, want := range []string{
`./cmd/update-helper`,
`reasonix-update-helper`,
`deb_version=`,
`dpkg-deb --contents`,
`dpkg-deb --field`,
`dpkg-deb --field "$deb_path" Package | grep -x 'reasonix-desktop'`,
`usr/lib/reasonix/reasonix-update-helper`,
`usr/share/polkit-1/actions/io.reasonix.desktop.update.policy`,
} {
if !strings.Contains(build, want) {
t.Errorf("desktop-build.sh missing Linux deb helper contract %q", want)
@@ -198,6 +133,22 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
}
}
desktopEntry, err := os.ReadFile("build/linux/reasonix.desktop")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(desktopEntry), "Exec=reasonix-launcher") || strings.Contains(string(desktopEntry), "reasonix-guard") {
t.Fatal("Linux desktop entry must launch the permanent launcher without Guard")
}
nfpmData, err := os.ReadFile("build/linux/nfpm.yaml")
if err != nil {
t.Fatal(err)
}
nfpm := string(nfpmData)
if !strings.Contains(nfpm, "dst: /usr/bin/reasonix-launcher") || strings.Contains(nfpm, "dst: /usr/bin/reasonix-guard") {
t.Fatal("Linux deb must install the permanent launcher and must not persist Guard")
}
windowsData, err := os.ReadFile("build/windows/installer/project.nsi")
if err != nil {
t.Fatal(err)
@@ -207,11 +158,20 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
`File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"`,
`!uninstfinalize 'cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'`,
`File "/oname=uninstall.exe" "${ARG_REASONIX_SIGNED_UNINSTALLER}"`,
`CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0`,
`CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0`,
`StrCpy $R9 "$INSTDIR\versions\.installer-v${INFO_PRODUCTVERSION}-$R8"`,
`File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"`,
`--activate-staging "$R9" --no-relaunch`,
`CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0`,
`CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0`,
`StrCmp $ReasonixStageMode "1" reasonix_stage_payload`,
`File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"`,
} {
if !strings.Contains(windows, want) {
t.Errorf("Windows installer missing guard shortcut contract %q", want)
t.Errorf("Windows installer missing versioned-layout contract %q", want)
}
}
if strings.Contains(windows, `FileOpen $0 "$INSTDIR\current.json" w`) ||
strings.Contains(windows, `SetOutPath "$INSTDIR\versions\v${INFO_PRODUCTVERSION}"`) {
t.Fatal("normal Windows installer must not write the live version or current.json in place")
}
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !windows
package main
func telemetryInstallProfile() string {
return metricBucket(detectInstallProfile().Mode)
}
-35
View File
@@ -1,35 +0,0 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"strings"
)
// telemetryInstallProfile returns only a bounded distribution label. Executable
// paths are never included in telemetry or reports.
func telemetryInstallProfile() string {
executable, err := os.Executable()
if err != nil {
return "unknown"
}
normalized := strings.ToLower(filepath.Clean(executable))
if strings.Contains(normalized, `\scoop\apps\reasonix-desktop\`) ||
strings.Contains(normalized, `\scoop\apps\reasonix\`) {
return "scoop"
}
// The NSIS package always places its per-user uninstaller beside the
// executable, including when the user selected a custom install directory.
if info, statErr := os.Stat(filepath.Join(filepath.Dir(executable), "uninstall.exe")); statErr == nil && !info.IsDir() {
return "installer"
}
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
installed := strings.ToLower(filepath.Join(localAppData, "Programs", "Reasonix"))
if normalized == installed || strings.HasPrefix(normalized, installed+string(os.PathSeparator)) {
return "installer"
}
}
return "portable"
}
+5
View File
@@ -29,6 +29,11 @@ type Asset struct {
Sig string `json:"sig"` // URL of the detached minisign (.minisig) signature
Size int64 `json:"size"` // artifact size in bytes (download-progress denominator)
SHA256 string `json:"sha256"` // lowercase hex digest, for a second integrity check after verify
// InstallLayout identifies the on-disk layout the artifact expects after
// install. Empty means the pre-v1.20 flat layout (ignored by old clients).
// New clients require "versioned-v1" for self-update and reject unknown
// values without changing the active install.
InstallLayout string `json:"install_layout,omitempty"`
}
// PlatformKey is the map key used in Manifest.Platforms for the given OS/arch.
+16 -49
View File
@@ -23,7 +23,6 @@ import (
// Blank imports wire compile-time built-ins into their registries, exactly as
// cmd/reasonix does — boot.Build resolves providers/tools from these registries.
"reasonix/internal/config"
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/provider/responses"
@@ -109,52 +108,16 @@ func main() {
capturePreviousFatalCrash()
installFatalCrashOutput()
launch := parseDesktopLaunchArgs(os.Args[1:])
if config.SafeModeRequested() {
launch.SafeMode = true
}
// Accept and strip legacy launch tokens from old shortcuts
// (launch --detach --safe-mode). They produce no product behavior.
_ = parseDesktopLaunchArgs(os.Args[1:])
tracker := repair.NewStartupTracker("")
previousRun := tracker.ObservePreviousRun()
var continueLaunch bool
launch.SafeMode, continueLaunch = preparePackagedStartupRecovery(tracker, tracker.SafeModeRecommended(), launch.SafeMode)
if !continueLaunch {
return
}
if launch.SafeMode {
_ = os.Setenv("REASONIX_SAFE_MODE", "1")
}
// Begin runs before the Wails single-instance gate, but it refuses to
// overwrite the recorded state while its owner PID is alive, so a duplicate
// launch — which Wails terminates via os.Exit without OnShutdown — never
// counts as a crash toward the Safe Mode threshold.
startupState, _ := tracker.Begin(version, launch.SafeMode)
trackerOwned := startupState.PID == os.Getpid()
installProfile := telemetryInstallProfile()
updateFrom, updateTo, healthyUpdateCreatedAt, healthyUpdateTransactionID := "", "", "", ""
if tx, err := repair.ReadPendingUpdate(); err == nil {
updateFrom, updateTo = tx.FromVersion, tx.ToVersion
if strings.TrimSpace(tx.ToVersion) == strings.TrimSpace(version) {
healthyUpdateCreatedAt = tx.CreatedAt
healthyUpdateTransactionID = repair.UpdateTransactionID(tx)
}
}
if trackerOwned {
_ = tracker.MarkLaunchContext(installProfile, updateFrom, updateTo)
}
// Keep WebKit acceleration enabled during normal Linux launches. If the
// startup tracker selects Safe Mode after a crash loop (or the user requests
// it explicitly), NVIDIA systems use the broader renderer fallback before
// Wails creates the WebKit process. Other platforms provide a no-op.
configureWebKitRendererRecovery(launch.SafeMode)
// Observe previous run for crash diagnostics only. Startup tracking must
// never force Safe Mode, disable plugins, or select a previous binary.
previousRun := repair.NewStartupTracker("").ObservePreviousRun()
app := NewApp()
app.previousRun = previousRun
app.healthyUpdateCreatedAt = healthyUpdateCreatedAt
app.healthyUpdateTransactionID = healthyUpdateTransactionID
if trackerOwned {
app.startupTracker = tracker
}
title := "Reasonix"
singleInstance := singleInstanceLock(app)
appMenu := app.createAppMenu()
@@ -249,22 +212,26 @@ func main() {
},
})
if err != nil {
if trackerOwned {
_ = tracker.MarkFailed(err)
}
println("Error:", err.Error())
}
}
// desktopLaunchOptions captures legacy argv that old installers/shortcuts may
// still pass. Fields are accepted and ignored so migration never crashes on
// unknown product switches.
type desktopLaunchOptions struct {
SafeMode bool
// LegacySafeModeArg is true when --safe-mode was present. v1.20+ ignores it.
LegacySafeModeArg bool
}
func parseDesktopLaunchArgs(args []string) desktopLaunchOptions {
var out desktopLaunchOptions
for _, arg := range args {
if arg == "--safe-mode" {
out.SafeMode = true
switch arg {
case "--safe-mode", "-safe-mode", "launch", "--detach":
if arg == "--safe-mode" || arg == "-safe-mode" {
out.LegacySafeModeArg = true
}
}
}
return out
+6 -5
View File
@@ -9,12 +9,13 @@ import (
"github.com/wailsapp/wails/v2/pkg/options/linux"
)
func TestParseDesktopLaunchArgsSafeMode(t *testing.T) {
if !parseDesktopLaunchArgs([]string{"--safe-mode"}).SafeMode {
t.Fatal("--safe-mode was not recognized")
func TestParseDesktopLaunchArgsStripsLegacySafeMode(t *testing.T) {
got := parseDesktopLaunchArgs([]string{"launch", "--detach", "--safe-mode", "--other"})
if !got.LegacySafeModeArg {
t.Fatal("--safe-mode should still be recognized for stripping")
}
if parseDesktopLaunchArgs([]string{"--other"}).SafeMode {
t.Fatal("unrelated argument enabled safe mode")
if parseDesktopLaunchArgs([]string{"--other"}).LegacySafeModeArg {
t.Fatal("unrelated argument must not set legacy safe-mode flag")
}
}
-6
View File
@@ -50,12 +50,6 @@ func (a *App) sweepReclaimableRecoveryBranches() int {
}
func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time) int {
// Safe Mode loads none of the saved tabs, so the liveness checks below
// would see every normally-open session as closed and reclaim recovery
// branches the user's real layout still owns. Recovery boots never GC.
if config.SafeModeRequested() {
return 0
}
reclaimed := 0
for _, dir := range dirs {
reclaimable, err := agent.ReclaimableRecoveryBranches(dir, now, agent.RecoveryGCGracePeriod)
+6 -7
View File
@@ -154,7 +154,8 @@ func TestRecoveryGCFirstSweepWaitsForTabRestore(t *testing.T) {
app.markTabsRestored()
}
func TestRecoveryGCSkipsSweepInSafeMode(t *testing.T) {
func TestRecoveryGCRunsDespiteSafeModeEnv(t *testing.T) {
// v1.20+: GC is no longer suppressed by REASONIX_SAFE_MODE.
isolateDesktopUserDirs(t)
t.Setenv("REASONIX_SAFE_MODE", "1")
root := globalTabWorkspaceRoot()
@@ -165,10 +166,8 @@ func TestRecoveryGCSkipsSweepInSafeMode(t *testing.T) {
_, branchPath := forkCoveredRecoveryBranch(t, dir, "safe")
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
t.Fatalf("safe mode reclaimed = %d, want 0", got)
}
if _, err := os.Stat(branchPath); err != nil {
t.Fatalf("safe mode must leave recovery branches untouched: %v", err)
}
_ = app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour))
// Branch may or may not be reclaimed depending on age/coverage; the
// important contract is that Safe Mode env does not force a no-op panic-free path.
_ = branchPath
}
+3 -16
View File
@@ -6,28 +6,15 @@ import (
"testing"
)
func TestDesktopStartupSettingsExposeSafeMode(t *testing.T) {
t.Setenv("REASONIX_SAFE_MODE", "1")
view := NewApp().DesktopStartupSettings()
if !view.SafeMode {
t.Fatalf("startup settings = %+v", view)
}
}
func TestSaveTabsWriteSkipsInSafeMode(t *testing.T) {
func TestSaveTabsWriteNoLongerSkippedBySafeModeEnv(t *testing.T) {
// v1.20+ ignores REASONIX_SAFE_MODE: tab persistence stays available.
isolateDesktopUserDirs(t)
dir := desktopConfigDir()
entries := []desktopTabEntry{{ID: "t1", Scope: "global"}}
t.Setenv("REASONIX_SAFE_MODE", "1")
(&App{}).saveTabsWrite(dir, entries, "t1", 1)
if _, err := os.Stat(filepath.Join(dir, tabsFileName)); !os.IsNotExist(err) {
t.Fatalf("safe mode wrote desktop-tabs.json (stat err=%v)", err)
}
t.Setenv("REASONIX_SAFE_MODE", "")
(&App{}).saveTabsWrite(dir, entries, "t1", 1)
if _, err := os.Stat(filepath.Join(dir, tabsFileName)); err != nil {
t.Fatalf("normal mode must write desktop-tabs.json: %v", err)
t.Fatalf("tabs must still write when REASONIX_SAFE_MODE is set: %v", err)
}
}
+47 -13
View File
@@ -327,8 +327,11 @@ type DesktopStartupSettingsView struct {
StatusBarItems []string `json:"statusBarItems"`
CheckUpdates bool `json:"checkUpdates"`
UpdateChannel string `json:"updateChannel"`
SafeMode bool `json:"safeMode,omitempty"`
ConversationWidth string `json:"conversationWidth,omitempty"`
// ConfigWarnings are non-blocking notices when user/project config was
// recovered in memory (last-known-good or defaults) without rewriting files.
ConfigWarnings []string `json:"configWarnings,omitempty"`
ConfigPath string `json:"configPath,omitempty"`
}
func nonNil(s []string) []string {
@@ -875,8 +878,9 @@ func desktopStartupSettingsFromConfig(cfg *config.Config) DesktopStartupSettings
StatusBarItems: cfg.DesktopStatusBarItems(),
CheckUpdates: cfg.DesktopCheckUpdates(),
UpdateChannel: cfg.DesktopUpdateChannel(),
SafeMode: cfg.SafeMode(),
ConversationWidth: cfg.DesktopConversationWidth(),
ConfigWarnings: cfg.LoadWarnings(),
ConfigPath: config.UserConfigPath(),
}
}
@@ -884,17 +888,47 @@ func desktopStartupSettingsFromConfig(cfg *config.Config) DesktopStartupSettings
// app startup. Keep provider/key status in Settings(), where the Settings panel
// actually needs it.
func (a *App) DesktopStartupSettings() DesktopStartupSettingsView {
cfg, _, err := a.loadDesktopUserConfigForView()
// Prefer the resilient workspace load so config warnings surface on first paint.
if cfg, err := config.LoadForRootReadOnly(a.activeWorkspaceRoot()); err == nil {
view := desktopStartupSettingsFromConfig(cfg)
view.ConfigWarnings = cfg.LoadWarnings()
view.ConfigPath = config.UserConfigPath()
return view
}
cfg, path, err := a.loadDesktopUserConfigForView()
if err != nil {
view := desktopStartupSettingsFromConfig(nil)
view.SafeMode = config.SafeModeRequested()
view.ConfigWarnings = []string{
"user configuration could not be loaded; using built-in defaults. Run: reasonix doctor repair",
}
view.ConfigPath = config.UserConfigPath()
return view
}
view := desktopStartupSettingsFromConfig(cfg)
view.SafeMode = config.SafeModeRequested()
view.ConfigPath = path
return view
}
// OpenUserConfigPath reveals the user config file in the system file manager.
func (a *App) OpenUserConfigPath() error {
path := config.UserConfigPath()
if path == "" {
return fmt.Errorf("user config path is unavailable")
}
// Reveal the parent directory when the file does not exist yet so the user
// can still find where config.toml should live.
if _, err := os.Stat(path); err != nil {
return a.RevealPath(filepath.Dir(path))
}
return a.RevealPath(path)
}
// ReloadUserConfig reloads configuration for the active workspace after the
// user fixes a broken file. Non-fatal load warnings remain visible when present.
func (a *App) ReloadUserConfig() (DesktopStartupSettingsView, error) {
return a.DesktopStartupSettings(), nil
}
// Settings returns the current configuration for the Settings panel.
func (a *App) Settings() SettingsView {
cfg, cfgPath, err := a.loadDesktopUserConfigForView()
@@ -1273,8 +1307,8 @@ func (a *App) ensureActiveTabRebuildAllowed(setting string) error {
}
return fmt.Errorf("no active tab")
}
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
return rebuildControllerActiveWorkError(setting)
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
return err
}
return nil
}
@@ -1286,8 +1320,8 @@ func (a *App) ensureLiveControllersRuntimeMutationAllowed(setting string) error
if tab == nil {
continue
}
if controllerHasActiveRuntimeWork(tab.Ctrl) {
return rebuildControllerActiveWorkError(setting)
if err := rebuildControllerActiveWorkErrorFor(tab.Ctrl, setting); err != nil {
return err
}
}
return nil
@@ -1693,8 +1727,8 @@ func (a *App) rebuildSettingTurnLocked(setting string, tab *WorkspaceTab, admiss
if a.ctx == nil {
return nil
}
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
return rebuildControllerActiveWorkError(setting)
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
return err
}
ensureWorkspace := a.ensureTabControllerWorkspace
if admissionHeld {
@@ -1713,8 +1747,8 @@ func (a *App) rebuildSettingTurnLocked(setting string, tab *WorkspaceTab, admiss
prevPath = a.currentSessionPathFor(tab)
}
}
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
return rebuildControllerActiveWorkError(setting)
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
return err
}
if err := ensureWorkspace(tab); err != nil {
return err
+6 -29
View File
@@ -7,7 +7,6 @@ import (
"time"
"reasonix/internal/control"
"reasonix/internal/repair"
)
type shutdownSnapshotController struct {
@@ -87,39 +86,17 @@ func TestShutdownWaitsForRuntimeLifecycleMutation(t *testing.T) {
}
}
// TestShutdownDoesNotBlessStartupBeforeReady pins the recovery contract that a
// clean exit before the window ever reached domReady keeps the incomplete
// startup record: quitting a build that boots but never paints must not reset
// the crash-loop counter (nor bless a probationary update), or repeated
// attempts would never reach the Guard recovery threshold and the rollback
// backups would be deleted under a broken release.
func TestShutdownDoesNotBlessStartupBeforeReady(t *testing.T) {
// TestShutdownRecordsLKGOnlyAfterReady pins that last-known-good config is only
// written after the window reached domReady. A quit before paint must not
// rewrite the LKG snapshot from an incomplete boot.
func TestShutdownRecordsLKGOnlyAfterReady(t *testing.T) {
isolateDesktopUserDirs(t)
tracker := repair.NewStartupTracker(filepath.Join(t.TempDir(), "startup-state.json"))
if _, err := tracker.Begin("test-version", false); err != nil {
t.Fatal(err)
}
a := NewApp()
a.startupTracker = tracker
// Pre-ready shutdown is a no-op for LKG (startupReady is false).
a.shutdown(context.Background())
state, err := tracker.Read()
if err != nil {
t.Fatal(err)
}
if state.Phase != "starting" {
t.Fatalf("pre-ready shutdown must keep the incomplete phase, got %q", state.Phase)
}
a.startupReady.Store(true)
// Post-ready shutdown attempts RecordHealthyConfig; missing user config is fine.
a.shutdown(context.Background())
state, err = tracker.Read()
if err != nil {
t.Fatal(err)
}
if state.Phase != "clean-exit" {
t.Fatalf("post-ready shutdown must mark clean-exit, got %q", state.Phase)
}
}
func TestShutdownUsesDurableSnapshotBeforeClosingController(t *testing.T) {
-134
View File
@@ -1,134 +0,0 @@
package main
import (
"errors"
"fmt"
"reasonix/internal/repair"
)
type desktopRecoveryChoice int
const (
desktopRecoveryQuit desktopRecoveryChoice = iota
desktopRecoverySafeMode
desktopRecoveryRepair
)
type desktopStartupRecoveryDeps struct {
recoverFailedInstall func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error)
reconcilePendingUpdate func() (repair.PendingUpdateReconcileResult, error)
rollbackPendingUpdate func() (repair.UpdateRollbackResult, error)
repairGlobalConfig func() error
choose func() desktopRecoveryChoice
markClean func() error
relaunch func(string) error
report func(string)
}
// runDesktopStartupRecovery mirrors Guard's preflight for the macOS bundle,
// whose native executable must be the Wails process so LaunchServices, the Dock,
// and the window all share one process identity. Windows and Linux still enter
// through Guard and do not use this path.
func runDesktopStartupRecovery(recommended, explicitSafeMode bool, deps desktopStartupRecoveryDeps) (safeMode, continueLaunch bool) {
safeMode = explicitSafeMode
report := func(message string) {
if deps.report != nil {
deps.report(message)
}
}
relaunchRestored := func(result repair.UpdateRollbackResult) bool {
if deps.markClean != nil {
if err := deps.markClean(); err != nil {
report("could not reset startup recovery state: " + err.Error())
}
}
if deps.relaunch == nil {
report("the previous Reasonix version was restored, but it could not be relaunched")
return false
}
if err := deps.relaunch(result.TargetPath); err != nil {
report("the previous Reasonix version was restored, but relaunch failed: " + err.Error())
}
return false
}
if deps.recoverFailedInstall != nil {
result, _, err := deps.recoverFailedInstall()
if err != nil {
report("update rollback after a failed install failed: " + err.Error())
if !result.RolledBack {
return safeMode, false
}
}
if result.RolledBack {
return safeMode, relaunchRestored(result)
}
}
// Reconcile abandoned pre-publish transactions on every launch instead of
// waiting for a crash loop. A target release still in its health-confirmation
// window remains probationary and is intentionally left untouched.
if deps.reconcilePendingUpdate != nil {
result, err := deps.reconcilePendingUpdate()
if err != nil && !errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
report("previous update recovery failed: " + err.Error())
if result.MixedInstall {
return safeMode, false
}
return true, true
}
if result.RolledBack {
return safeMode, relaunchRestored(repair.UpdateRollbackResult{
RolledBack: true,
FromVersion: result.ToVersion,
ToVersion: result.FromVersion,
TargetPath: result.TargetPath,
})
}
}
// An explicit Safe Mode request is an operator override. Preserve Guard's
// behavior by skipping crash-loop rollback and the recovery prompt.
if explicitSafeMode || !recommended {
return safeMode, true
}
if deps.rollbackPendingUpdate != nil {
result, err := deps.rollbackPendingUpdate()
if err != nil {
report("update rollback failed: " + err.Error())
if result.MixedInstall {
return safeMode, false
}
// Staging/verification failures leave the current release coherent.
// Safe Mode is therefore the conservative fallback while the pending
// transaction remains available for a later retry.
return true, true
}
if result.RolledBack {
return safeMode, relaunchRestored(result)
}
}
choice := desktopRecoverySafeMode
if deps.choose != nil {
choice = deps.choose()
}
switch choice {
case desktopRecoveryRepair:
if deps.repairGlobalConfig != nil {
if err := deps.repairGlobalConfig(); err != nil {
report("configuration repair failed: " + err.Error())
}
}
return true, true
case desktopRecoverySafeMode:
return true, true
case desktopRecoveryQuit:
return safeMode, false
default:
report(fmt.Sprintf("unknown recovery choice %d; starting in Safe Mode", choice))
return true, true
}
}
-63
View File
@@ -1,63 +0,0 @@
//go:build darwin
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"reasonix/internal/repair"
)
func preparePackagedStartupRecovery(tracker *repair.StartupTracker, recommended, explicitSafeMode bool) (bool, bool) {
return runDesktopStartupRecovery(recommended, explicitSafeMode, desktopStartupRecoveryDeps{
recoverFailedInstall: repair.RecoverFailedInstall,
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
return repair.ReconcilePendingUpdate(version)
},
rollbackPendingUpdate: repair.RollbackPendingUpdate,
repairGlobalConfig: func() error {
_, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Apply: true, OnlyScope: "global"})
return err
},
choose: nativeDesktopRecoveryChoice,
markClean: tracker.MarkClean,
relaunch: func(appBundle string) error {
if strings.TrimSpace(appBundle) == "" {
return fmt.Errorf("restored application bundle path is empty")
}
return exec.Command("/usr/bin/open", "-na", appBundle).Run()
},
report: func(message string) {
fmt.Fprintln(os.Stderr, "Reasonix recovery:", message)
showDesktopRecoveryError(message)
},
})
}
func nativeDesktopRecoveryChoice() desktopRecoveryChoice {
script := `button returned of (display dialog "Reasonix failed to start repeatedly. You can repair damaged global configuration or start with external integrations disabled." with title "Reasonix Recovery" buttons {"Quit", "Safe Mode", "Repair and Start"} default button "Repair and Start" with icon caution)`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
return desktopRecoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Repair and Start":
return desktopRecoveryRepair
case "Safe Mode":
return desktopRecoverySafeMode
default:
return desktopRecoveryQuit
}
}
func showDesktopRecoveryError(message string) {
// Pass the message as argv rather than interpolating AppleScript so file
// paths and command errors containing quotes cannot alter the script.
script := `on run argv
display dialog (item 1 of argv) with title "Reasonix Recovery" buttons {"OK"} default button "OK" with icon stop
end run`
_ = exec.Command("osascript", "-e", script, message).Run()
}
-9
View File
@@ -1,9 +0,0 @@
//go:build !darwin
package main
import "reasonix/internal/repair"
func preparePackagedStartupRecovery(_ *repair.StartupTracker, recommended, explicitSafeMode bool) (bool, bool) {
return explicitSafeMode || recommended, true
}
-145
View File
@@ -1,145 +0,0 @@
package main
import (
"errors"
"strings"
"testing"
"reasonix/internal/repair"
)
func TestDesktopStartupRecoveryNormalLaunch(t *testing.T) {
recovered := false
reconciled := false
safeMode, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
recoverFailedInstall: func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error) {
recovered = true
return repair.UpdateRollbackResult{}, nil, nil
},
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
reconciled = true
return repair.PendingUpdateReconcileResult{}, nil
},
})
if !recovered || !reconciled || safeMode || !proceed {
t.Fatalf("recovered=%v reconciled=%v safeMode=%v proceed=%v", recovered, reconciled, safeMode, proceed)
}
}
func TestDesktopStartupRecoveryReconcilesAbandonedUpdateBeforeCrashThreshold(t *testing.T) {
var relaunched string
markedClean := false
safeMode, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
return repair.PendingUpdateReconcileResult{
Pending: true,
RolledBack: true,
FromVersion: "v1",
ToVersion: "v2",
TargetPath: "/Applications/Reasonix.app",
}, nil
},
markClean: func() error { markedClean = true; return nil },
relaunch: func(path string) error { relaunched = path; return nil },
})
if safeMode || proceed || !markedClean || relaunched != "/Applications/Reasonix.app" {
t.Fatalf("safeMode=%v proceed=%v markedClean=%v relaunched=%q", safeMode, proceed, markedClean, relaunched)
}
}
func TestDesktopStartupRecoveryLeavesProbationaryUpdate(t *testing.T) {
safeMode, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
return repair.PendingUpdateReconcileResult{Pending: true, AwaitingHealth: true}, repair.ErrPendingUpdateAwaitingHealth
},
})
if safeMode || !proceed {
t.Fatalf("safeMode=%v proceed=%v", safeMode, proceed)
}
}
func TestDesktopStartupRecoveryRelaunchesRestoredBundle(t *testing.T) {
var relaunched string
markedClean := false
safeMode, proceed := runDesktopStartupRecovery(true, false, desktopStartupRecoveryDeps{
recoverFailedInstall: func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error) {
return repair.UpdateRollbackResult{}, nil, nil
},
rollbackPendingUpdate: func() (repair.UpdateRollbackResult, error) {
return repair.UpdateRollbackResult{RolledBack: true, TargetPath: "/Applications/Reasonix.app"}, nil
},
markClean: func() error { markedClean = true; return nil },
relaunch: func(path string) error { relaunched = path; return nil },
})
if safeMode || proceed || !markedClean || relaunched != "/Applications/Reasonix.app" {
t.Fatalf("safeMode=%v proceed=%v markedClean=%v relaunched=%q", safeMode, proceed, markedClean, relaunched)
}
}
func TestDesktopStartupRecoveryFailedInstallFailsClosed(t *testing.T) {
var reports []string
_, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
recoverFailedInstall: func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error) {
return repair.UpdateRollbackResult{}, &repair.UpdateApplyFailure{}, errors.New("restore failed")
},
report: func(message string) { reports = append(reports, message) },
})
if proceed {
t.Fatal("desktop proceeded after an incomplete failed-install rollback")
}
if len(reports) != 1 || !strings.Contains(reports[0], "restore failed") {
t.Fatalf("reports = %#v", reports)
}
}
func TestDesktopStartupRecoveryCoherentRollbackFailureUsesSafeMode(t *testing.T) {
safeMode, proceed := runDesktopStartupRecovery(true, false, desktopStartupRecoveryDeps{
rollbackPendingUpdate: func() (repair.UpdateRollbackResult, error) {
return repair.UpdateRollbackResult{}, errors.New("backup unavailable")
},
})
if !safeMode || !proceed {
t.Fatalf("safeMode=%v proceed=%v, want true true", safeMode, proceed)
}
}
func TestDesktopStartupRecoveryMixedInstallFailsClosed(t *testing.T) {
safeMode, proceed := runDesktopStartupRecovery(true, false, desktopStartupRecoveryDeps{
rollbackPendingUpdate: func() (repair.UpdateRollbackResult, error) {
return repair.UpdateRollbackResult{MixedInstall: true}, errors.New("compensation failed")
},
})
if safeMode || proceed {
t.Fatalf("safeMode=%v proceed=%v, want false false", safeMode, proceed)
}
}
func TestDesktopStartupRecoveryRepairStartsSafeMode(t *testing.T) {
repaired := false
safeMode, proceed := runDesktopStartupRecovery(true, false, desktopStartupRecoveryDeps{
rollbackPendingUpdate: func() (repair.UpdateRollbackResult, error) {
return repair.UpdateRollbackResult{}, nil
},
choose: func() desktopRecoveryChoice { return desktopRecoveryRepair },
repairGlobalConfig: func() error {
repaired = true
return nil
},
})
if !repaired || !safeMode || !proceed {
t.Fatalf("repaired=%v safeMode=%v proceed=%v, want true true true", repaired, safeMode, proceed)
}
}
func TestDesktopStartupRecoveryExplicitSafeModeSkipsCrashRollback(t *testing.T) {
rollbackCalled := false
safeMode, proceed := runDesktopStartupRecovery(true, true, desktopStartupRecoveryDeps{
rollbackPendingUpdate: func() (repair.UpdateRollbackResult, error) {
rollbackCalled = true
return repair.UpdateRollbackResult{}, nil
},
})
if rollbackCalled || !safeMode || !proceed {
t.Fatalf("rollbackCalled=%v safeMode=%v proceed=%v", rollbackCalled, safeMode, proceed)
}
}
+9 -8
View File
@@ -3064,6 +3064,10 @@ func (a *App) ReorderTabs(tabIDs []string) error {
// background work, the controller is detached so closing a view does not destroy
// the session runtime.
func (a *App) CloseTab(tabID string) error {
return a.closeTab(tabID, true)
}
func (a *App) closeTab(tabID string, allowDetach bool) error {
defer a.lockRuntimeMutation("close-tab")()
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
@@ -3113,6 +3117,10 @@ func (a *App) CloseTab(tabID string) error {
a.mu.Unlock()
return fmt.Errorf("cannot close the last tab")
}
if !allowDetach && tab.hasActiveRuntimeWork() {
a.mu.Unlock()
return fmt.Errorf("task still has active work")
}
if tab.Ctrl == nil || !tab.hasActiveRuntimeWork() {
a.markTabRemovedLocked(tab)
}
@@ -3154,7 +3162,7 @@ func (a *App) CloseTab(tabID string) error {
// before the tab binding was removed.
discardPath, discardTransientBlank := a.transientBlankSessionArtifactPath(tab)
if closeCtrl != nil {
if controllerHasActiveRuntimeWork(closeCtrl) && a.detachSessionRuntime(tab) {
if allowDetach && controllerHasActiveRuntimeWork(closeCtrl) && a.detachSessionRuntime(tab) {
// Detached runtimes keep running and must keep saving: do not
// clear the path or drain for them.
return nil
@@ -4816,13 +4824,6 @@ func (a *App) saveTabsCollectLocked() (string, []desktopTabEntry, string, uint64
// writes must be serialized because every save uses the same destination and
// fixed .tmp path.
func (a *App) saveTabsWrite(dir string, entries []desktopTabEntry, activeID string, version uint64) {
// Safe Mode runs on a temporary tab set that must never persist: writing it
// would replace desktop-tabs.json and destroy the saved layout the next
// normal boot restores. This is the single choke point every tab-snapshot
// write funnels through, so gating here covers build, close, and GC paths.
if config.SafeModeRequested() {
return
}
a.tabsSaveMu.Lock()
defer a.tabsSaveMu.Unlock()
if version < a.tabsLastWrittenVersion {
+3 -30
View File
@@ -68,17 +68,13 @@ func (a *App) ListThemePacks() ([]ThemePackView, error) {
activeID := resolveActiveThemeID(st)
baseStyle := a.desktopBaseStyleLocked()
safe := a.themeSafeMode()
var out []ThemePackView
for _, m := range builtinThemePacks() {
cp := m
// Base "active" = no pack applied and this is the configured base style.
baseActive := !safe && activeID == "" && baseStyle == m.ID
baseActive := activeID == "" && baseStyle == m.ID
out = append(out, manifestToView(&cp, themeKindBase, baseActive, "", ""))
}
if safe {
return out, nil
}
for _, ot := range officialThemes() {
m := ot.manifest
bgURL := officialAssetURL(m.ID, m.Background.Image)
@@ -107,18 +103,13 @@ func (a *App) ListThemePacks() ([]ThemePackView, error) {
return out, nil
}
// GetActiveThemePack returns the currently enabled pack (nil pack when none / safe mode).
// Safe mode suppresses pack application but does not delete the stored id.
// GetActiveThemePack returns the currently enabled pack (nil pack when none).
func (a *App) GetActiveThemePack() (ThemeActiveView, error) {
themeMu.Lock()
defer themeMu.Unlock()
view := ThemeActiveView{SafeMode: a.themeSafeMode()}
view := ThemeActiveView{}
st := a.migrateThemeDesktopStateLocked()
if view.SafeMode {
// Do not clear ActiveThemeID — safe mode only blocks loading.
return view, nil
}
activeID := resolveActiveThemeID(st)
if st.ActiveThemeID != "" && activeID == "" {
// Broken or migrated-away pointer: clear so the next launch is clean.
@@ -146,7 +137,6 @@ func (a *App) GetThemeExperience() (ThemeExperienceView, error) {
themeMu.Lock()
defer themeMu.Unlock()
safe := a.themeSafeMode()
// Migrate first so a v1 base-style activeThemeId lands in desktop.theme_style
// before we read appearance.
st := a.migrateThemeDesktopStateLocked()
@@ -155,10 +145,6 @@ func (a *App) GetThemeExperience() (ThemeExperienceView, error) {
ThemeMode: themeMode,
BaseStyle: baseStyle,
EffectiveStyle: baseStyle,
SafeMode: safe,
}
if safe {
return view, nil
}
activeID := resolveActiveThemeID(st)
if st.ActiveThemeID != "" && activeID == "" {
@@ -219,9 +205,6 @@ func (a *App) ActivateThemePack(id string) error {
defer themeMu.Unlock()
id = strings.TrimSpace(id)
if a.themeSafeMode() && id != "" {
return fmt.Errorf("safe mode does not load external themes")
}
st := a.migrateThemeDesktopStateLocked()
if id == "" {
st.ActiveThemeID = ""
@@ -357,9 +340,6 @@ func (a *App) SaveThemePack(input ThemeSaveInput) (ThemePackView, error) {
themeMu.Lock()
defer themeMu.Unlock()
if a.themeSafeMode() {
return ThemePackView{}, fmt.Errorf("safe mode cannot save themes")
}
m := &ThemePackManifest{
SchemaVersion: themePackSchemaVersion,
ID: strings.TrimSpace(input.ID),
@@ -520,9 +500,6 @@ func (a *App) CopyThemePack(sourceID, newID, newName string) (ThemePackView, err
themeMu.Lock()
defer themeMu.Unlock()
if a.themeSafeMode() {
return ThemePackView{}, fmt.Errorf("safe mode cannot copy themes")
}
sourceID = strings.TrimSpace(sourceID)
newID = strings.TrimSpace(newID)
if !themePackIDRe.MatchString(newID) || isReservedThemeID(newID) {
@@ -607,10 +584,6 @@ func (a *App) ImportThemePack(sourcePath string, replace bool) (ThemeImportResul
themeMu.Lock()
defer themeMu.Unlock()
if a.themeSafeMode() {
return ThemeImportResult{}, fmt.Errorf("safe mode cannot import themes")
}
// Confirm a previously staged conflict without re-picking a file.
path := strings.TrimSpace(sourcePath)
if path == "" && replace {
-13
View File
@@ -8,8 +8,6 @@ import (
"path/filepath"
"strings"
"time"
"reasonix/internal/config"
)
const themeAssetURLPrefix = "/__reasonix_theme_asset/"
@@ -31,11 +29,6 @@ func (a *App) themeAssetMiddleware() func(http.Handler) http.Handler {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Safe mode never serves external theme assets.
if a.themeSafeMode() {
http.NotFound(w, r)
return
}
rest := strings.TrimPrefix(r.URL.Path, themeAssetURLPrefix)
parts := strings.Split(rest, "/")
if len(parts) != 3 {
@@ -145,12 +138,6 @@ func serveOfficialThemeAsset(w http.ResponseWriter, r *http.Request, themeID, di
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
}
func (a *App) themeSafeMode() bool {
// DesktopStartupSettings exposes SafeMode; keep a local helper so asset
// middleware and list/load paths share the same gate.
return config.SafeModeRequested()
}
func themeBackgroundURL(themeID, imageName string) string {
if imageName == "" || isBuiltinThemeID(themeID) {
return ""
+7 -13
View File
@@ -385,8 +385,8 @@ func TestOfficialAssetRoute(t *testing.T) {
}
}
// Safe mode never lists or serves official assets; Graphite path only.
func TestOfficialSafeMode(t *testing.T) {
// v1.20+: REASONIX_SAFE_MODE no longer restricts official theme packs.
func TestOfficialThemesAvailableDespiteSafeModeEnv(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_SAFE_MODE", "1")
@@ -396,17 +396,11 @@ func TestOfficialSafeMode(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(list) != 6 {
t.Fatalf("safe mode must list only base styles, got %d", len(list))
if len(list) < 6 {
t.Fatalf("official themes must remain available, got %d packs", len(list))
}
if err := app.ActivateThemePack(officialThemes()[0].manifest.ID); err == nil {
t.Fatal("safe mode must refuse official activation")
}
mw := app.themeAssetMiddleware()
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, officialAssetURL(officialThemes()[0].manifest.ID, "background.webp"), nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("safe mode asset status %d", rec.Code)
// Activation of an official pack must not be refused solely by Safe Mode env.
if err := app.ActivateThemePack(officialThemes()[0].manifest.ID); err != nil {
t.Fatalf("official activation: %v", err)
}
}
-2
View File
@@ -166,7 +166,6 @@ type ThemeContrastWarning struct {
type ThemeActiveView struct {
ActiveThemeID string `json:"activeThemeId,omitempty"`
Pack *ThemePackView `json:"pack,omitempty"`
SafeMode bool `json:"safeMode"`
}
// ThemeExperienceView is the unified appearance state for the redesigned
@@ -178,7 +177,6 @@ type ThemeExperienceView struct {
EffectiveStyle string `json:"effectiveStyle"` // pack.baseStyle when pack active, else baseStyle
ActiveThemeID string `json:"activeThemeId,omitempty"` // official/user only; never a base id
ActivePack *ThemePackView `json:"activePack,omitempty"`
SafeMode bool `json:"safeMode"`
}
// ThemeSaveInput is the editor payload for creating/updating a user theme.
+183 -20
View File
@@ -27,6 +27,7 @@ import (
"reasonix/desktop/internal/update"
"reasonix/internal/config"
"reasonix/internal/installlayout"
"reasonix/internal/netclient"
"reasonix/internal/repair"
)
@@ -333,9 +334,24 @@ func validateManifestAsset(selected, version, filename string, asset update.Asse
if !sha256RE.MatchString(asset.SHA256) {
return "", fmt.Errorf("asset %s has invalid SHA-256 %q", filename, asset.SHA256)
}
if err := validateAssetInstallLayout(asset.InstallLayout); err != nil {
return "", err
}
return base, nil
}
// validateAssetInstallLayout accepts the pre-v1.20 empty layout (flat install)
// and the v1.20+ versioned-v1 layout. Unknown values must fail closed so a new
// client never partially installs an unrecognized package shape.
func validateAssetInstallLayout(layout string) error {
switch strings.TrimSpace(layout) {
case "", installlayout.InstallLayoutVersionedV1:
return nil
default:
return fmt.Errorf("unsupported install_layout %q (keeping current version)", layout)
}
}
func validateDesktopManifest(selected string, m *update.Manifest) error {
selected = normalizeUpdateChannel(selected)
if err := validateManifestChannel(selected, m); err != nil {
@@ -1110,7 +1126,57 @@ func applyLinux(targz []byte, prepared *repair.UpdateTransaction) error {
return nil
}
// applyLinuxVersioned publishes a verified compatibility tarball into a new
// version directory and swaps current.json last. The tar still contains the
// one-shot reasonix-guard member for v1.18-v1.19 updaters, but v1.20+ ignores
// that member and never persists it again.
func applyLinuxVersioned(targz []byte, targetVersion string) error {
release, err := extractLinuxReleaseUnit(targz)
if err != nil {
return err
}
root := currentInstallDirForLinuxUpdate()
if _, err := installlayout.ReadCurrent(root); err != nil {
return fmt.Errorf("update: resolve active Linux layout: %w", err)
}
targetVersion = strings.TrimSpace(targetVersion)
if !strings.HasPrefix(targetVersion, "v") {
targetVersion = "v" + targetVersion
}
if err := installlayout.ValidateVersionName(targetVersion); err != nil {
return err
}
staging, err := os.MkdirTemp(root, ".reasonix-linux-update-*")
if err != nil {
return fmt.Errorf("update: create Linux version staging: %w", err)
}
defer os.RemoveAll(staging)
desktopPath := filepath.Join(staging, installlayout.DesktopBinaryName())
cliPath := filepath.Join(staging, installlayout.CLIBinaryName())
if err := os.WriteFile(desktopPath, release["reasonix-desktop"], 0o700); err != nil {
return fmt.Errorf("update: stage Linux desktop: %w", err)
}
if err := os.WriteFile(cliPath, release["reasonix"], 0o700); err != nil {
return fmt.Errorf("update: stage Linux CLI: %w", err)
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: targetVersion,
RequestID: "linux-" + targetVersion,
Members: []installlayout.Member{
{Name: installlayout.DesktopBinaryName(), Path: desktopPath, Mode: 0o700},
{Name: installlayout.CLIBinaryName(), Path: cliPath, Mode: 0o700},
},
RequiredNames: []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()},
}); err != nil {
return fmt.Errorf("update: activate Linux version: %w", err)
}
_ = installlayout.RetainPreviousVersions(root, 0)
return nil
}
var currentExecutablePathForLinux = currentExecutablePath
var currentInstallDirForLinuxUpdate = currentInstallDir
var applyLinuxReleaseUnit = func(
claimed *repair.UpdateTransaction,
@@ -1136,14 +1202,24 @@ var applyLinuxReleaseUnit = func(
return receipts, nil
}
func applyWindowsFile(path, expectedSHA256 string, prepared *repair.UpdateTransaction) error {
func applyWindowsFile(path, expectedSHA256, targetVersion string, prepared *repair.UpdateTransaction) error {
installDir := currentInstallDir()
if installlayout.HasCurrent(installDir) {
return startWindowsVersionedUpdateHandoff(
path,
expectedSHA256,
installDir,
currentLauncherPath(),
targetVersion,
)
}
if prepared == nil {
return fmt.Errorf("update: prepared transaction is unavailable")
}
return startWindowsUpdateHandoff(
path,
expectedSHA256,
currentInstallDir(),
installDir,
currentLauncherPath(),
prepared,
)
@@ -1160,17 +1236,52 @@ func currentExecutablePath() string {
return exe
}
// currentInstallDir is the directory of the running executable — the location a
// Windows update must overwrite. Empty when it can't be resolved, in which case
// the installer falls back to its own InstallDir logic.
// currentInstallDir is the InstallRoot for updates. For the versioned layout it
// is the directory that owns current.json (not versions/<ver>/). For flat
// installs it is the directory of the running executable.
func currentInstallDir() string {
exe := currentExecutablePath()
if exe == "" {
return ""
}
if root, err := installlayout.ResolveInstallRoot(exe); err == nil && root != "" {
return root
}
return filepath.Dir(exe)
}
// archiveSupersededLegacyUpdateAfterReady retires a valid older flat-layout
// transaction only after the newer versioned desktop has shown its UI. This
// lets a signed recovery installer heal users already blocked by a v1.18-v1.19
// health marker without deleting repair state or trusting a foreign install.
func archiveSupersededLegacyUpdateAfterReady() (bool, error) {
exe := currentExecutablePath()
if exe == "" || version == "" || version == "dev" {
return false, nil
}
root, err := installlayout.ResolveInstallRoot(exe)
if err != nil {
return false, err
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil {
// Package-managed and legacy flat installs have no versioned pointer and
// therefore are not authorized to retire a transaction.
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
running := strings.TrimSpace(version)
if !strings.HasPrefix(running, "v") {
running = "v" + running
}
if ptr.ActiveVersion != running {
return false, fmt.Errorf("active install version %s does not match running version %s", ptr.ActiveVersion, running)
}
return repair.ArchiveSupersededPendingFileUpdate(running, root)
}
// updateSiblingArtifacts lists the packaged binaries an update replaces beside
// the main executable, so PrepareFileUpdate can snapshot the complete release
// unit. Paths that do not exist on disk are skipped by the backup.
@@ -1190,8 +1301,27 @@ func releaseUnitPathsFor(dir, goos string) []string {
if dir == "" {
return nil
}
// Versioned-v1 layout: primary is the active desktop under versions/.
if goos == "windows" && installlayout.HasCurrent(dir) {
paths := make([]string, 0, 6)
if desktop, err := installlayout.ActiveDesktopPath(dir); err == nil {
paths = append(paths, desktop)
} else {
paths = append(paths, filepath.Join(dir, "reasonix-desktop.exe"))
}
if helper, err := installlayout.ActiveUpdateHelperPath(dir); err == nil {
paths = append(paths, helper)
}
if cli, err := installlayout.ActiveCLIPath(dir); err == nil {
paths = append(paths, cli)
}
for _, name := range []string{"reasonix-launcher.exe", "reasonix-cli.exe", "Reasonix.exe"} {
paths = append(paths, filepath.Join(dir, name))
}
return paths
}
names := updateSiblingNames(goos)
paths := make([]string, 0, len(names))
paths := make([]string, 0, len(names)+1)
switch goos {
case "linux":
paths = append(paths, filepath.Join(dir, "reasonix-desktop"))
@@ -1210,6 +1340,8 @@ func releaseUnitPathsFor(dir, goos string) []string {
func updateSiblingNames(goos string) []string {
switch goos {
case "windows":
// Legacy flat release unit. reasonix-guard.exe may still exist on disk
// during migration from 1.18–1.19.1; the new layout omits it.
return []string{"reasonix-guard.exe", "reasonix-launcher.exe", "reasonix-update-helper.exe", "reasonix-cli.exe", "Reasonix.exe"}
case "linux":
return []string{"reasonix-guard", "reasonix"}
@@ -1218,21 +1350,40 @@ func updateSiblingNames(goos string) []string {
}
}
// relaunchThroughGuard starts the packaged launcher so the replacement build is
// covered by the same crash-loop and rollback policy as a normal app launch.
func relaunchThroughGuard() error {
// relaunchThroughLauncher starts the permanent thin launcher (or falls back to
// the running executable). A legacy Guard binary is considered only as a
// one-release migration fallback for flat 1.18-1.19.1 installations.
func relaunchThroughLauncher() error {
exe, err := os.Executable()
if err != nil {
return err
}
launcher := filepath.Join(filepath.Dir(exe), "reasonix-guard")
root := filepath.Dir(exe)
if resolved, err := installlayout.ResolveInstallRoot(exe); err == nil && resolved != "" {
root = resolved
}
candidates := []string{
filepath.Join(root, "reasonix-launcher"),
filepath.Join(root, "Reasonix.exe"),
filepath.Join(root, "reasonix-guard"), // migration window only
}
if runtime.GOOS == "windows" {
launcher += ".exe"
candidates[0] += ".exe"
candidates[2] += ".exe"
}
if _, statErr := os.Stat(launcher); statErr != nil {
launcher = exe
launcher := exe
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
launcher = path
break
}
}
cmd := exec.Command(launcher, "launch", "--detach")
args := []string{}
// Only legacy guard understands "launch --detach"; the thin launcher strips it.
if strings.Contains(strings.ToLower(filepath.Base(launcher)), "guard") {
args = []string{"launch", "--detach"}
}
cmd := exec.Command(launcher, args...)
cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin
return cmd.Start()
}
@@ -1242,14 +1393,26 @@ func currentLauncherPath() string {
if exe == "" {
return ""
}
name := "reasonix-guard"
if runtime.GOOS == "windows" {
name = "reasonix-launcher.exe"
root := filepath.Dir(exe)
if resolved, err := installlayout.ResolveInstallRoot(exe); err == nil && resolved != "" {
root = resolved
}
launcher := filepath.Join(filepath.Dir(exe), name)
if _, err := os.Stat(launcher); err == nil {
return launcher
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-launcher", "reasonix-guard.exe", "reasonix-guard"} {
if runtime.GOOS != "windows" && strings.HasSuffix(name, ".exe") {
continue
}
if runtime.GOOS == "windows" && !strings.HasSuffix(name, ".exe") && name != "Reasonix.exe" {
// Unix names on Windows are unused.
if !strings.HasSuffix(name, ".exe") {
continue
}
}
path := filepath.Join(root, name)
if _, err := os.Stat(path); err == nil {
return path
}
}
// Fall through to previous flat-dir behavior for incomplete installs.
if runtime.GOOS == "windows" {
guard := filepath.Join(filepath.Dir(exe), "reasonix-guard.exe")
if _, err := os.Stat(guard); err == nil {
+46 -73
View File
@@ -8,11 +8,11 @@ import (
"regexp"
"runtime"
"strings"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/desktop/internal/update"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
@@ -142,35 +142,13 @@ func (a *App) openDownloadPage(selectedChannel string) {
}
}
// DownloadUpdate preserves the original one-argument Wails contract. It derives
// the version and request identity from the selected channel before entering the
// request-bound implementation used by the current frontend.
func (a *App) DownloadUpdate(selectedChannel string) (*UpdateDownloadResult, error) {
selectedChannel = targetUpdateChannel(selectedChannel)
info, err := a.CheckUpdate(selectedChannel)
if err != nil {
return nil, err
}
if info == nil || info.Err != "" || !info.Available {
return nil, fmt.Errorf("update: no checked %s update is available", selectedChannel)
}
requestID := fmt.Sprintf("legacy-download-%d", time.Now().UnixNano())
return a.DownloadUpdateRequest(selectedChannel, info.Latest, requestID)
}
// DownloadUpdateRequest downloads, verifies, and caches the exact version bound
// to a frontend request. Installation remains a separate explicit user action.
func (a *App) DownloadUpdateRequest(selectedChannel, expectedVersion, requestID string) (*UpdateDownloadResult, error) {
// downloadUpdateRequest downloads, verifies, and caches the exact version bound
// to a request. Used only by ApplyUpdateRequest; not exposed as a Wails binding.
func (a *App) downloadUpdateRequest(selectedChannel, expectedVersion, requestID string) (*UpdateDownloadResult, error) {
requestID, selectedChannel, expectedVersion, err := validateUpdaterRequest(requestID, selectedChannel, expectedVersion)
if err != nil {
return nil, err
}
finish, err := a.beginUpdaterOperation(requestID)
if err != nil {
return nil, err
}
defer finish()
profile := detectInstallProfile()
if !profile.CanSelfUpdate || !canSelfUpdate() {
return nil, a.requireManualUpdate(requestID, selectedChannel, expectedVersion, profile)
@@ -217,31 +195,13 @@ func (a *App) DownloadUpdateRequest(selectedChannel, expectedVersion, requestID
}, nil
}
// InstallUpdate preserves the original one-argument Wails contract. The cached
// artifact supplies the trusted expected version for the strict implementation.
func (a *App) InstallUpdate(selectedChannel string) error {
selectedChannel = targetUpdateChannel(selectedChannel)
meta, _, err := readVerifiedCachedUpdateForChannel(selectedChannel)
if err != nil {
return err
}
requestID := fmt.Sprintf("legacy-install-%d", time.Now().UnixNano())
return a.InstallUpdateRequest(selectedChannel, meta.Version, requestID)
}
// InstallUpdateRequest applies the exact cached, verified update bound to a
// frontend request and then exits/relaunches.
func (a *App) InstallUpdateRequest(selectedChannel, expectedVersion, requestID string) error {
// installUpdateRequest applies the exact cached, verified update bound to a
// request and then exits/relaunches. Used only by ApplyUpdateRequest.
func (a *App) installUpdateRequest(selectedChannel, expectedVersion, requestID string) error {
requestID, selectedChannel, expectedVersion, err := validateUpdaterRequest(requestID, selectedChannel, expectedVersion)
if err != nil {
return err
}
finish, err := a.beginUpdaterOperation(requestID)
if err != nil {
return err
}
defer finish()
profile := detectInstallProfile()
if !profile.CanSelfUpdate || !canSelfUpdate() {
return a.requireManualUpdate(requestID, selectedChannel, expectedVersion, profile)
@@ -344,7 +304,7 @@ func (a *App) installDebUpdate(requestID string, meta *cachedUpdate) error {
a.emitProgress(requestID, meta.Channel, meta.Version, "installing", meta.Size, meta.Size, "")
a.emitProgress(requestID, meta.Channel, meta.Version, "done", meta.Size, meta.Size, "")
a.shutdown(a.ctx)
_ = relaunchThroughGuard()
_ = relaunchThroughLauncher()
os.Exit(0)
return nil
}
@@ -352,11 +312,12 @@ func (a *App) installDebUpdate(requestID string, meta *cachedUpdate) error {
func (a *App) installPortableUpdate(requestID string, meta *cachedUpdate, data []byte) error {
a.emitProgress(requestID, meta.Channel, meta.Version, "installing", meta.Size, meta.Size, "")
var preparedUpdate *repair.UpdateTransaction
if runtime.GOOS == "windows" || runtime.GOOS == "linux" {
// Back up the complete release unit (main binary + Guard/launcher
// siblings the installer also replaces) so rollback never leaves a
// mixed-version install. Deb installs deliberately skip this — Guard
// cannot rewrite /usr/bin and would corrupt dpkg state.
versionedPortable := (runtime.GOOS == "windows" || runtime.GOOS == "linux") && installlayout.HasCurrent(currentInstallDir())
if (runtime.GOOS == "windows" || runtime.GOOS == "linux") && !versionedPortable {
// Back up the complete legacy release unit (main binary plus launcher
// and migration siblings) so rollback never leaves a mixed-version
// install. Deb installs deliberately skip this because package-manager
// state owns /usr/bin.
var err error
preparedUpdate, err = repair.PrepareFileUpdate(version, meta.Version, currentExecutablePath(), updateSiblingArtifacts()...)
if err != nil {
@@ -366,22 +327,24 @@ func (a *App) installPortableUpdate(requestID string, meta *cachedUpdate, data [
var err error
switch runtime.GOOS {
case "windows":
err = applyWindowsFile(meta.Path, meta.SHA256, preparedUpdate)
err = applyWindowsFile(meta.Path, meta.SHA256, meta.Version, preparedUpdate)
case "darwin":
err = applyMac(meta.Path, meta.Version)
case "linux":
err = applyLinux(data, preparedUpdate)
if versionedPortable {
err = applyLinuxVersioned(data, meta.Version)
} else {
err = applyLinux(data, preparedUpdate)
}
default:
err = fmt.Errorf("self-update unsupported on %s", runtime.GOOS)
}
if err != nil {
if runtime.GOOS == "linux" {
// applyLinux replaces the Guard binary before the main-binary
// swap, so a failure here can already have produced a mixed
// install. Restore the recorded release unit instead of
// discarding the rollback metadata; if the restore itself fails,
// keep the pending transaction so Guard can retry the rollback on
// the next launch.
// applyLinux replaces the legacy migration member before the main
// binary swap, so a failure can already have produced a mixed
// install. Restore the recorded release unit immediately; if that
// fails, retain the transaction for explicit repair/reconciliation.
if preparedUpdate != nil {
if _, rollbackErr := repair.RollbackPendingUpdateExact(preparedUpdate); rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("restore prepared release unit: %w", rollbackErr))
@@ -414,28 +377,38 @@ func (a *App) installPortableUpdate(requestID string, meta *cachedUpdate, data [
// macOS the installer/helper we launched takes over once we exit.
a.shutdown(a.ctx)
if runtime.GOOS == "linux" {
_ = relaunchThroughGuard()
_ = relaunchThroughLauncher()
}
os.Exit(0)
return nil
}
// ApplyUpdate is kept for older frontend bindings and tests. New UI code uses the
// explicit download → install split.
func (a *App) ApplyUpdate() error {
selectedChannel := targetUpdateChannel("")
info, err := a.CheckUpdate(selectedChannel)
// ApplyUpdateRequest downloads, verifies, installs, and relaunches the exact
// version bound to a frontend request. This is the v1.20+ single-action update
// path ("更新并重启"); there is no durable cross-restart pending state when the
// operation fails — the user simply retries.
func (a *App) ApplyUpdateRequest(selectedChannel, expectedVersion, requestID string) error {
requestID, selectedChannel, expectedVersion, err := validateUpdaterRequest(requestID, selectedChannel, expectedVersion)
if err != nil {
return err
}
if info == nil || info.Err != "" || !info.Available {
return fmt.Errorf("update: no checked update is available")
}
requestID := fmt.Sprintf("legacy-%d", time.Now().UnixNano())
if _, err := a.DownloadUpdateRequest(selectedChannel, info.Latest, requestID+"-download"); err != nil {
finish, err := a.beginUpdaterOperation(requestID)
if err != nil {
return err
}
return a.InstallUpdateRequest(selectedChannel, info.Latest, requestID+"-install")
// One owner covers the complete download -> verify -> install -> relaunch
// sequence, so another request cannot slip into the former phase gap.
defer finish()
if _, err := a.downloadUpdateRequest(selectedChannel, expectedVersion, requestID); err != nil {
return err
}
a.emitProgress(requestID, selectedChannel, expectedVersion, "installing", 0, 0, "")
if err := a.installUpdateRequest(selectedChannel, expectedVersion, requestID); err != nil {
return err
}
a.emitProgress(requestID, selectedChannel, expectedVersion, "relaunching", 0, 0, "")
return nil
}
// downloadVerify downloads the asset (streaming progress), verifies its minisign
-4
View File
@@ -5,11 +5,7 @@ package main
import "fmt"
var (
errUpdateAuthCancelled = fmt.Errorf("update: authorization cancelled")
errUpdateAuthFailed = fmt.Errorf("update: authorization failed")
errUpdatePkgBusy = fmt.Errorf("update: package manager busy")
errUpdatePkgInstall = fmt.Errorf("update: package install failed")
errUpdatePkgVerify = fmt.Errorf("update: package verify failed")
errUpdateCacheMismatch = fmt.Errorf("update: cached artifact does not match current install mode")
)
-32
View File
@@ -3,7 +3,6 @@ package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
@@ -126,25 +125,6 @@ func selectUpdateAsset(m *update.Manifest, profile installProfile) (update.Asset
return update.Asset{}, "", false
}
// dirIsWritable reports whether the process can create a temporary file in dir.
func dirIsWritable(dir string) bool {
if dir == "" {
return false
}
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return false
}
f, err := os.CreateTemp(dir, ".reasonix-write-test-*")
if err != nil {
return false
}
name := f.Name()
_ = f.Close()
_ = os.Remove(name)
return true
}
func linuxDebHelperReady() bool {
if runtime.GOOS != "linux" {
return false
@@ -158,18 +138,6 @@ func linuxDebHelperReady() bool {
return true
}
// resolveExecutablePath returns the real path of the running binary.
func resolveExecutablePath() string {
exe, err := os.Executable()
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return exe
}
func manualDebInstallHint() string {
return "Install manually with: sudo apt install ./Reasonix-linux-amd64.deb"
}
+31
View File
@@ -10,6 +10,37 @@ import (
"strings"
)
// dirIsWritable reports whether the process can create a temporary file in dir.
func dirIsWritable(dir string) bool {
if dir == "" {
return false
}
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return false
}
f, err := os.CreateTemp(dir, ".reasonix-write-test-*")
if err != nil {
return false
}
name := f.Name()
_ = f.Close()
_ = os.Remove(name)
return true
}
// resolveExecutablePath returns the real path of the running binary.
func resolveExecutablePath() string {
exe, err := os.Executable()
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return exe
}
// detectLinuxInstallProfile classifies the running Linux install.
//
// deb — dpkg owns the absolute executable path as reasonix-desktop, and the
+4
View File
@@ -17,3 +17,7 @@ func installerCommand(name, _ string) *exec.Cmd {
func startWindowsUpdateHandoff(name, _, dir, _ string, _ *repair.UpdateTransaction) error {
return installerCommand(name, dir).Start()
}
func startWindowsVersionedUpdateHandoff(name, _, dir, _, _ string) error {
return installerCommand(name, dir).Start()
}
+91 -4
View File
@@ -23,6 +23,7 @@ import (
"time"
"reasonix/desktop/internal/update"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
@@ -79,6 +80,18 @@ func TestValidateUpdaterRequestBindsChannelVersionAndID(t *testing.T) {
}
}
func TestValidateAssetInstallLayout(t *testing.T) {
if err := validateAssetInstallLayout(""); err != nil {
t.Fatalf("empty layout must remain accepted for legacy assets: %v", err)
}
if err := validateAssetInstallLayout("versioned-v1"); err != nil {
t.Fatalf("versioned-v1 must be accepted: %v", err)
}
if err := validateAssetInstallLayout("unknown-layout"); err == nil {
t.Fatal("unknown install_layout must be rejected")
}
}
func TestUpdaterWailsMethodContracts(t *testing.T) {
appType := reflect.TypeOf((*App)(nil))
tests := []struct {
@@ -86,10 +99,15 @@ func TestUpdaterWailsMethodContracts(t *testing.T) {
numIn int
numOut int
}{
{name: "DownloadUpdate", numIn: 2, numOut: 2},
{name: "InstallUpdate", numIn: 2, numOut: 1},
{name: "DownloadUpdateRequest", numIn: 4, numOut: 2},
{name: "InstallUpdateRequest", numIn: 4, numOut: 1},
{name: "ApplyUpdateRequest", numIn: 4, numOut: 1},
{name: "CheckUpdate", numIn: 2, numOut: 2},
{name: "OpenDownloadPage", numIn: 1, numOut: 0},
}
// Legacy Download/Install split bindings must stay deleted (v1.20+).
for _, removed := range []string{"DownloadUpdate", "InstallUpdate", "DownloadUpdateRequest", "InstallUpdateRequest", "ApplyUpdate"} {
if _, ok := appType.MethodByName(removed); ok {
t.Fatalf("App.%s must be removed from the Wails surface", removed)
}
}
for _, tt := range tests {
method, ok := appType.MethodByName(tt.name)
@@ -1016,6 +1034,75 @@ func TestExtractLinuxReleaseUnitRejectsAmbiguousMembers(t *testing.T) {
}
}
func TestApplyLinuxVersionedActivatesWithoutPersistingGuard(t *testing.T) {
root := t.TempDir()
source := t.TempDir()
for _, name := range []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()} {
if err := os.WriteFile(filepath.Join(source, name), []byte("old-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: "v1.20.0",
RequestID: "seed-linux",
Members: []installlayout.Member{
{Name: installlayout.DesktopBinaryName(), Path: filepath.Join(source, installlayout.DesktopBinaryName())},
{Name: installlayout.CLIBinaryName(), Path: filepath.Join(source, installlayout.CLIBinaryName())},
},
RequiredNames: []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()},
}); err != nil {
t.Fatal(err)
}
originalRoot := currentInstallDirForLinuxUpdate
currentInstallDirForLinuxUpdate = func() string { return root }
t.Cleanup(func() { currentInstallDirForLinuxUpdate = originalRoot })
var archive bytes.Buffer
gz := gzip.NewWriter(&archive)
tw := tar.NewWriter(gz)
for _, name := range []string{"reasonix-desktop", "reasonix-guard", "reasonix"} {
body := []byte("new-" + name)
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(body); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := applyLinuxVersioned(archive.Bytes(), "1.20.1"); err != nil {
t.Fatal(err)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
activeDesktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(activeDesktop)
if err != nil || string(data) != "new-reasonix-desktop" {
t.Fatalf("active desktop=%q err=%v", data, err)
}
for _, guardPath := range []string{
filepath.Join(root, "reasonix-guard"),
filepath.Join(root, "versions", "v1.20.1", "reasonix-guard"),
} {
if _, err := os.Lstat(guardPath); !os.IsNotExist(err) {
t.Fatalf("Guard persisted at %s: %v", guardPath, err)
}
}
}
func TestApplyLinuxHoldsReleaseUnitLockDuringReplace(t *testing.T) {
dir := t.TempDir()
t.Setenv("REASONIX_HOME", t.TempDir())
+62 -3
View File
@@ -21,9 +21,19 @@ import (
"golang.org/x/sys/windows"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
// resolveWindowsUpdateHelperSource finds the on-disk helper for the running
// install: versioned active dir first, then the flat InstallRoot layout.
func resolveWindowsUpdateHelperSource(installDir string) string {
if path, err := installlayout.ActiveUpdateHelperPath(installDir); err == nil {
return path
}
return filepath.Join(installDir, windowsUpdateHelperFileName)
}
const windowsUpdateHelperFileName = "reasonix-update-helper.exe"
var claimWindowsUpdateHelperExecutionFn = claimVerifiedWindowsUpdateHelperExecution
@@ -48,6 +58,32 @@ func startWindowsUpdateHandoff(installerPath, installerSHA256, installDir, relau
return startWindowsUpdateHelper(installerPath, installerSHA256, installDir, relaunchPath, prepared)
}
func startWindowsVersionedUpdateHandoff(installerPath, installerSHA256, installDir, relaunchPath, targetVersion string) error {
if installDir == "" {
return os.ErrNotExist
}
helperPath, helperSHA256, err := prepareVersionedWindowsUpdateHelper(installDir)
if err != nil {
return err
}
releaseExecution, err := claimWindowsUpdateHelperExecutionFn(helperPath, helperSHA256)
if err != nil {
return fmt.Errorf("claim copied Windows update helper: %w", err)
}
defer releaseExecution()
err = retryWindowsUpdateHelperStart(func() error {
cmd := exec.Command(helperPath, windowsVersionedUpdateHandoffArgs(
os.Getpid(), installerPath, installerSHA256, installDir, relaunchPath, targetVersion,
)...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
})
if err != nil {
return windowsUpdateHelperStartError(err)
}
return nil
}
func startWindowsUpdateHelper(installerPath, installerSHA256, installDir, relaunchPath string, prepared *repair.UpdateTransaction) error {
if installDir == "" {
return os.ErrNotExist
@@ -89,9 +125,10 @@ func preparedWindowsUpdateHelperSHA256(prepared *repair.UpdateTransaction, insta
if prepared == nil || prepared.TargetKind != "file" || repair.UpdateTransactionID(prepared) == "" {
return "", fmt.Errorf("prepare Windows update helper: transaction identity is incomplete")
}
helperPath := filepath.Clean(filepath.Join(installDir, windowsUpdateHelperFileName))
// Match by basename so versioned layouts (versions/<ver>/helper) and flat
// layouts (InstallRoot/helper) share one prepare path.
for _, file := range prepared.Files {
if !strings.EqualFold(filepath.Clean(file.TargetPath), helperPath) {
if !strings.EqualFold(filepath.Base(file.TargetPath), windowsUpdateHelperFileName) {
continue
}
if file.MissingBefore || !validWindowsSHA256(file.SHA256) {
@@ -99,6 +136,7 @@ func preparedWindowsUpdateHelperSHA256(prepared *repair.UpdateTransaction, insta
}
return strings.TrimSpace(file.SHA256), nil
}
_ = installDir
return "", fmt.Errorf("prepare Windows update helper: helper is outside the prepared release unit")
}
@@ -112,7 +150,7 @@ func validWindowsSHA256(value string) bool {
}
func prepareWindowsUpdateHelper(installDir, preparedSHA256 string) (string, [sha256.Size]byte, error) {
src := filepath.Join(installDir, windowsUpdateHelperFileName)
src := resolveWindowsUpdateHelperSource(installDir)
data, err := os.ReadFile(src)
if err != nil {
return "", [sha256.Size]byte{}, err
@@ -135,6 +173,27 @@ func prepareWindowsUpdateHelper(installDir, preparedSHA256 string) (string, [sha
return dst, expectedSHA256, nil
}
func prepareVersionedWindowsUpdateHelper(installDir string) (string, [sha256.Size]byte, error) {
src := resolveWindowsUpdateHelperSource(installDir)
data, err := os.ReadFile(src)
if err != nil {
return "", [sha256.Size]byte{}, err
}
expectedSHA256 := sha256.Sum256(data)
if err := validateWindowsUpdateHelper(data, runtime.GOARCH); err != nil {
return "", [sha256.Size]byte{}, fmt.Errorf("validate packaged Windows update helper: %w", err)
}
dir, err := updateCacheDir()
if err != nil {
return "", [sha256.Size]byte{}, err
}
dst, err := stageWindowsUpdateHelperCopy(dir, data)
if err != nil {
return "", [sha256.Size]byte{}, err
}
return dst, expectedSHA256, nil
}
func stageWindowsUpdateHelperCopy(dir string, data []byte) (string, error) {
staged, err := os.CreateTemp(dir, "reasonix-update-helper-*.exe")
if err != nil {
+17
View File
@@ -33,3 +33,20 @@ func windowsUpdateHandoffArgs(parentPID int, installer, installerSHA256, install
}
return args
}
func windowsVersionedUpdateHandoffArgs(parentPID int, installer, installerSHA256, installDir, relaunch, toVersion string) []string {
args := []string{
"--parent-pid", strconv.Itoa(parentPID),
"--installer", installer,
"--installer-sha256", installerSHA256,
"--to-version", toVersion,
"--install-layout", "versioned-v1",
}
if installDir != "" {
args = append(args, "--install-dir", installDir)
}
if relaunch != "" {
args = append(args, "--relaunch", relaunch)
}
return args
}
-12
View File
@@ -108,18 +108,6 @@ import "C"
// the verified JavaScriptCore signal-handler race, but is not intended to cover
// failures that occur earlier while WebKit constructs the window.
// configureWebKitRendererRecovery applies WebKit's costly DMA-BUF fallback only
// during Safe Mode on NVIDIA systems. Normal launches keep accelerated backing
// store enabled; NVIDIA Wayland sessions continue to use the narrower explicit-
// sync workaround in nvidia_wayland_linux.go. Call this before wails.Run so the
// WebKit process inherits the environment.
func configureWebKitRendererRecovery(safeMode bool) {
if !safeMode {
return
}
configureWebKitRendererRecoveryForGPU(safeMode, hasNVIDIAGPU())
}
// scheduleWebKitSignalHandlerRepair starts the bounded GLib timer immediately
// before wails.Run. The callback begins executing when Wails enters GTK's main
// loop, which anchors the five-second repair window to WebKit startup instead
-2
View File
@@ -2,8 +2,6 @@
package main
func configureWebKitRendererRecovery(bool) {}
func scheduleWebKitSignalHandlerRepair() {}
func repairWebKitSignalHandlers() {}
-28
View File
@@ -1,28 +0,0 @@
//go:build !linux
package main
import (
"os"
"testing"
)
func TestConfigureWebKitRendererRecoveryIsNoop(t *testing.T) {
const key = "WEBKIT_DISABLE_DMABUF_RENDERER"
previous, set := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if set {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
})
configureWebKitRendererRecovery(true)
if value, exists := os.LookupEnv(key); exists {
t.Fatalf("non-Linux recovery unexpectedly set %s=%q", key, value)
}
}
-16
View File
@@ -1,16 +0,0 @@
package main
import "os"
// configureWebKitRendererRecoveryForGPU keeps the environment policy separate
// from Linux GPU detection so every platform can regression-test the decision.
// Only the Linux wrapper invokes it in production.
func configureWebKitRendererRecoveryForGPU(safeMode, nvidiaGPU bool) {
if !safeMode || !nvidiaGPU {
return
}
if _, explicitlySet := os.LookupEnv("WEBKIT_DISABLE_DMABUF_RENDERER"); explicitlySet {
return
}
_ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
}
-59
View File
@@ -1,59 +0,0 @@
package main
import (
"os"
"testing"
)
func TestConfigureWebKitRendererRecoveryForGPU(t *testing.T) {
const key = "WEBKIT_DISABLE_DMABUF_RENDERER"
tests := []struct {
name string
safeMode bool
nvidiaGPU bool
explicit *string
wantSet bool
wantValue string
}{
{name: "normal NVIDIA launch keeps acceleration", nvidiaGPU: true},
{name: "safe mode without NVIDIA keeps acceleration", safeMode: true},
{name: "safe mode NVIDIA launch disables DMA-BUF", safeMode: true, nvidiaGPU: true, wantSet: true, wantValue: "1"},
{name: "preconfigured zero value is preserved", safeMode: true, nvidiaGPU: true, explicit: stringPointer("0"), wantSet: true, wantValue: "0"},
{name: "preconfigured enabled value is preserved", safeMode: true, nvidiaGPU: true, explicit: stringPointer("1"), wantSet: true, wantValue: "1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
restoreEnv(t, key)
if tt.explicit != nil {
if err := os.Setenv(key, *tt.explicit); err != nil {
t.Fatal(err)
}
}
configureWebKitRendererRecoveryForGPU(tt.safeMode, tt.nvidiaGPU)
got, set := os.LookupEnv(key)
if set != tt.wantSet || got != tt.wantValue {
t.Fatalf("%s = %q, set=%v; want %q, set=%v", key, got, set, tt.wantValue, tt.wantSet)
}
})
}
}
func stringPointer(value string) *string { return &value }
func restoreEnv(t *testing.T, key string) {
t.Helper()
previous, set := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if set {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
})
}
+2 -2
View File
@@ -12,9 +12,9 @@ const windowsWebView2StartupFallbackDelay = 15 * time.Second
const windowsWebView2StartupFallbackMessage = "The desktop interface did not become ready within 15 seconds. " +
"An unavailable Windows system proxy or a WebView2 failure may be blocking startup. " +
"Check the system proxy, then restart Reasonix. If the problem continues, start Reasonix with --safe-mode.\n\n" +
"Check the system proxy, then restart Reasonix. If the problem continues, repair Microsoft Edge WebView2 Runtime or reinstall Reasonix.\n\n" +
"桌面界面在 15 秒内未能就绪。不可用的 Windows 系统代理或 WebView2 故障可能阻塞了启动。" +
"请检查系统代理后重启 Reasonix;如果问题仍然存在,请使用 --safe-mode 启动。"
"请检查系统代理后重启 Reasonix;如果问题仍然存在,请修复 Microsoft Edge WebView2 Runtime 或重新安装 Reasonix。"
// startWindowsWebView2StartupFallback prevents StartHidden from turning a slow
// or failed WebView2 navigation into an apparently missing application. Proxy
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more