mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
commit
12c4dbb624
224 files changed
+10055
-5888
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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" `
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ·
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package main
|
||||
|
||||
type recoveryChoice int
|
||||
|
||||
const (
|
||||
recoveryQuit recoveryChoice = iota
|
||||
recoverySafeMode
|
||||
recoveryRepair
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build !darwin && !windows && !linux
|
||||
|
||||
package main
|
||||
|
||||
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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": "授权安装并重启",
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func telemetryInstallProfile() string {
|
||||
return metricBucket(detectInstallProfile().Mode)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,8 +2,6 @@
|
||||
|
||||
package main
|
||||
|
||||
func configureWebKitRendererRecovery(bool) {}
|
||||
|
||||
func scheduleWebKitSignalHandlerRepair() {}
|
||||
|
||||
func repairWebKitSignalHandlers() {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user