Compare commits
51 Commits
2479bb6ea1
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1f148122 | ||
|
|
2b3fe02d8c | ||
|
|
d3b85f2234 | ||
|
|
fc9029de97 | ||
|
|
1c764dae3f | ||
|
|
cfec3297a4 | ||
|
|
6e1d64b489 | ||
|
|
f599f8d0af | ||
|
|
9b928c6217 | ||
| c9e38aef88 | |||
| 66843d242b | |||
| 6afe5959ca | |||
| b623651a5d | |||
|
|
6b1b920149 | ||
|
|
9a407bde83 | ||
|
|
8c051d8f62 | ||
|
|
8577c55685 | ||
|
|
b7a8d78d4a | ||
|
|
b5455a1965 | ||
|
|
5d42438a72 | ||
|
|
2898bec314 | ||
|
|
ac38ea8c34 | ||
|
|
8d2f7e9907 | ||
|
|
da1fe2109a | ||
|
|
5e432a4a27 | ||
|
|
01c29bb6f6 | ||
|
|
12e532718c | ||
|
|
fe913ae5ef | ||
|
|
4fab0481c4 | ||
|
|
0989176127 | ||
|
|
548251841f | ||
|
|
ea32a74baa | ||
|
|
c1e330164e | ||
|
|
5b4af29420 | ||
|
|
d87de152e0 | ||
|
|
b4dc9509cb | ||
|
|
97fb215ce6 | ||
|
|
83d7058b32 | ||
|
|
5603fd458d | ||
|
|
d0c0e2ce1f | ||
|
|
2fc6924dcb | ||
|
|
921e626208 | ||
|
|
c23ed94817 | ||
|
|
2d34afb2e5 | ||
|
|
c0bd46542a | ||
| 7119c8474e | |||
| aea09098e6 | |||
|
|
0498fbae47 | ||
|
|
43a10cff95 | ||
|
|
bd7d5940a2 | ||
|
|
78831b2263 |
174
.gitea/workflows/release.yml
Normal file
174
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,174 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOTNET_ROOT: /home/mika/.dotnet
|
||||
GITEA_API: https://git.kuns.dev/api/v1
|
||||
REPO: releases/ClaudeDo
|
||||
steps:
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ github.ref_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Building version: $VERSION (tag: $TAG)"
|
||||
|
||||
- name: Prepare workspace
|
||||
id: ws
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WORK="$(mktemp -d -t claudedo-release-XXXXXX)"
|
||||
echo "dir=$WORK" >> "$GITHUB_OUTPUT"
|
||||
echo "Workspace: $WORK"
|
||||
|
||||
- name: Checkout tag
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 --branch "$TAG" \
|
||||
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
|
||||
"$WORK/src"
|
||||
git -C "$WORK/src" log -1 --oneline
|
||||
|
||||
- name: Publish ClaudeDo.App (win-x64, self-contained)
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$DOTNET_ROOT:$PATH"
|
||||
cd "$WORK/src"
|
||||
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
|
||||
-c Release -r win-x64 --self-contained true \
|
||||
/p:Version=$VERSION -o out/app
|
||||
|
||||
- name: Publish ClaudeDo.Worker (win-x64, self-contained)
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$DOTNET_ROOT:$PATH"
|
||||
cd "$WORK/src"
|
||||
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
|
||||
-c Release -r win-x64 --self-contained true \
|
||||
/p:Version=$VERSION -o out/worker
|
||||
|
||||
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$DOTNET_ROOT:$PATH"
|
||||
cd "$WORK/src"
|
||||
# Framework-dependent — WPF runtime pack isn't distributed on Linux SDK;
|
||||
# the previous self-contained bundle crashed at startup (apphost AV).
|
||||
# Target machines need .NET 8 Desktop Runtime (x64).
|
||||
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
|
||||
-c Release -r win-x64 --self-contained false \
|
||||
/p:Version=$VERSION /p:PublishSingleFile=true \
|
||||
-o out/installer
|
||||
|
||||
- name: Package assets
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$WORK/src"
|
||||
mkdir -p assets
|
||||
|
||||
# 1) App + Worker bundle (top-level dirs /app and /worker)
|
||||
rm -rf bundle
|
||||
mkdir -p bundle
|
||||
cp -r out/app bundle/app
|
||||
cp -r out/worker bundle/worker
|
||||
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
|
||||
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
|
||||
|
||||
# 2) Installer single-file exe (renamed)
|
||||
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
|
||||
if [ -z "$INSTALLER_EXE" ]; then
|
||||
echo "::error::No .exe produced by installer publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
|
||||
|
||||
# 3) Checksums (sha256, relative filenames)
|
||||
( cd assets && sha256sum \
|
||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
||||
> checksums.txt )
|
||||
|
||||
echo "--- assets ---"
|
||||
ls -la assets
|
||||
|
||||
- name: Create Gitea Release
|
||||
id: release
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BODY=$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "$TAG" \
|
||||
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
|
||||
RESP=$(curl -sS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
"${GITEA_API}/repos/${REPO}/releases")
|
||||
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
|
||||
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||
echo "::error::Release creation failed" >&2
|
||||
echo "$RESP" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Created release id=$RELEASE_ID for tag=$TAG"
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$WORK/src/assets"
|
||||
for f in \
|
||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
||||
"checksums.txt"
|
||||
do
|
||||
echo "Uploading: $f"
|
||||
curl -sS --fail-with-body -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-F "attachment=@${f}" \
|
||||
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \
|
||||
> /dev/null
|
||||
done
|
||||
echo "All assets uploaded."
|
||||
|
||||
- name: Cleanup workspace
|
||||
if: always()
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
run: |
|
||||
rm -rf "$WORK" || true
|
||||
@@ -4,9 +4,11 @@
|
||||
<Project Path="src/ClaudeDo.Data/ClaudeDo.Data.csproj" />
|
||||
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
|
||||
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
|
||||
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
||||
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
||||
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
2382
docs/superpowers/plans/2026-04-15-installer-download-mode.md
Normal file
2382
docs/superpowers/plans/2026-04-15-installer-download-mode.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
# Installer: Download-Mode + Gitea Releases
|
||||
|
||||
Date: 2026-04-15
|
||||
Status: Design — awaiting implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Turn `ClaudeDo.Installer` into a self-contained tool that any user can run on
|
||||
any Windows machine to install, update, reconfigure, repair, or uninstall
|
||||
ClaudeDo. The installer pulls prebuilt binaries from a Gitea release on
|
||||
`git.kuns.dev` instead of building from source.
|
||||
|
||||
End-user experience:
|
||||
|
||||
1. Download `ClaudeDo.Installer-<version>.exe` from the releases page.
|
||||
2. Run it.
|
||||
3. Done — no .NET SDK, no source checkout, no manual steps.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Code signing the installer or the app binaries (future concern).
|
||||
- Cross-platform installs (Windows-only, same as today).
|
||||
- In-app update notifications (the installer handles updates when run; the app
|
||||
does not self-update).
|
||||
- Arbitrary-version selection UI. Installer always targets "latest" release.
|
||||
- A package-manager listing (winget/Chocolatey/Scoop). Future, separate spec.
|
||||
|
||||
## Current State (2026-04-15)
|
||||
|
||||
The existing installer (`src/ClaudeDo.Installer/`) is a WPF wizard that only
|
||||
works from inside a source checkout on a machine with the .NET SDK installed:
|
||||
|
||||
- `PublishAppStep` runs `dotnet publish src/ClaudeDo.App/...`
|
||||
- `PublishWorkerStep` runs `dotnet publish src/ClaudeDo.Worker/...`
|
||||
- `DeployBinariesStep` copies `bin/Release/.../publish` into the install dir
|
||||
- Subsequent steps (`WriteConfigStep`, `InitDatabaseStep`,
|
||||
`CreateShortcutsStep`, `RegisterServiceStep`) are fine to keep.
|
||||
|
||||
The installer also contains a partial "Settings" window
|
||||
(`Views/SettingsWindow.xaml`, `Views/SettingsViewModel.cs`) — that wiring is
|
||||
reused for the Config view shown on subsequent launches (see Mode detection
|
||||
below).
|
||||
|
||||
## High-Level Design
|
||||
|
||||
Two pieces, each small:
|
||||
|
||||
**1) A Gitea Actions workflow** that, on every `v*` tag push, builds the App,
|
||||
Worker, and Installer; packages them; and creates a Gitea Release on the
|
||||
public repo at `git.kuns.dev/releases/ClaudeDo`.
|
||||
|
||||
The `releases/` org on the Gitea instance is world-readable without auth;
|
||||
private work (including the source repo, if you want) lives under `kuns/*`
|
||||
which is never public. The installer only needs to hit `releases/ClaudeDo`.
|
||||
|
||||
**2) An installer rewrite** that replaces the three publish/deploy steps with
|
||||
a single `DownloadAndExtractStep`, detects existing installs via a marker
|
||||
file, and on subsequent launches checks the Gitea API for updates before
|
||||
deciding whether to show the Update flow or jump straight to the Config view.
|
||||
|
||||
## Release Artifacts
|
||||
|
||||
Each `v*` tag produces a Gitea Release with three assets:
|
||||
|
||||
```
|
||||
ClaudeDo-<version>-win-x64.zip # contains /app and /worker subdirs
|
||||
ClaudeDo.Installer-<version>.exe # self-contained installer (no .NET needed)
|
||||
checksums.txt # SHA256 of the above
|
||||
```
|
||||
|
||||
Decisions:
|
||||
|
||||
- **One combined app+worker zip** (not two separate). Reasons: one download,
|
||||
one extract, guaranteed version-locked pair.
|
||||
- **Self-contained installer exe** — user does not need .NET installed.
|
||||
- **App + Worker: self-contained** (`--self-contained true`, `-r win-x64`).
|
||||
Zero runtime dependency on the target machine, at the cost of a larger
|
||||
download (~100 MB combined zip). Decision: acceptable trade-off — the
|
||||
installer is one-click, not per-user-problem-to-debug.
|
||||
- **Checksums file** — plain text, one line per asset (`<sha256> <filename>`),
|
||||
verified by installer before extract.
|
||||
|
||||
The "latest installer exe" URL is stable:
|
||||
|
||||
```
|
||||
https://git.kuns.dev/releases/ClaudeDo/releases/latest/download/ClaudeDo.Installer-<version>.exe
|
||||
```
|
||||
|
||||
(Gitea also exposes `/releases/download/<tag>/<filename>` for specific
|
||||
versions.)
|
||||
|
||||
## Gitea Actions Workflow
|
||||
|
||||
File: `.gitea/workflows/release.yml`
|
||||
|
||||
- **Trigger:** `push` on tags matching `v*`
|
||||
- **Runner:** Linux container with .NET 8 SDK (`dotnet publish -r win-x64`
|
||||
works cross-platform). The installer itself requires Windows to run, but
|
||||
`dotnet publish` can target `win-x64` from Linux.
|
||||
- **Steps:**
|
||||
1. Checkout
|
||||
2. Setup .NET 8 SDK
|
||||
3. Derive version from tag (`${{ gitea.ref_name }}` without the `v` prefix)
|
||||
4. `dotnet publish src/ClaudeDo.App -c Release -r win-x64 --self-contained true /p:Version=$VERSION -o out/app`
|
||||
5. `dotnet publish src/ClaudeDo.Worker -c Release -r win-x64 --self-contained true /p:Version=$VERSION -o out/worker`
|
||||
6. `dotnet publish src/ClaudeDo.Installer -c Release -r win-x64 --self-contained true /p:Version=$VERSION /p:PublishSingleFile=true -o out/installer`
|
||||
7. Zip `out/app` + `out/worker` as `ClaudeDo-<version>-win-x64.zip` with
|
||||
`app/` and `worker/` as top-level dirs
|
||||
8. Copy `out/installer/ClaudeDo.Installer.exe` to
|
||||
`ClaudeDo.Installer-<version>.exe`
|
||||
9. Generate `checksums.txt` (`sha256sum` both files)
|
||||
10. Create release via Gitea API using the built-in `${{ gitea.token }}`
|
||||
(this token has repo write scope automatically on Actions runs). Release
|
||||
name = tag name. Release notes = `git log` summary between previous tag
|
||||
and this one (nice-to-have).
|
||||
|
||||
The workflow needs **no custom secrets** — `gitea.token` is sufficient for
|
||||
creating releases on its own repo.
|
||||
|
||||
## Installer Changes
|
||||
|
||||
### New: `install.json` marker file
|
||||
|
||||
Written at the end of every successful install or update to
|
||||
`{InstallDir}/install.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"installDir": "C:\\Program Files\\ClaudeDo",
|
||||
"workerDir": "C:\\Program Files\\ClaudeDo\\worker",
|
||||
"installedAt": "2026-04-15T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
The installer reads this on startup (from the default install dir, or a
|
||||
path supplied via CLI arg) to decide which mode to run in.
|
||||
|
||||
### Launch flow (`InstallModeDetector`)
|
||||
|
||||
On every launch, the installer checks for `install.json` first:
|
||||
|
||||
```
|
||||
install.json absent?
|
||||
-> Install mode: Welcome -> Paths -> UiSettings -> Service -> Install
|
||||
(writes install.json at the end)
|
||||
|
||||
install.json present?
|
||||
-> Query https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest
|
||||
(short timeout; if offline, treat as "no update available")
|
||||
|
||||
latest.tag_name > installed.version
|
||||
-> Update mode: Welcome ("Update v0.1.0 -> v0.2.0, Update / Later")
|
||||
If user accepts -> Install steps (download + swap service)
|
||||
If user declines -> fall through to Config view
|
||||
latest.tag_name <= installed.version (or API unreachable)
|
||||
-> Config view: directly open Paths/UiSettings/Service tabs,
|
||||
prefilled from existing ~/.todo-app/*.json.
|
||||
Action buttons: Save · Repair · Uninstall.
|
||||
```
|
||||
|
||||
Key properties:
|
||||
|
||||
- **First run = wizard**, as today — no behavior change for new users.
|
||||
- **Every subsequent run = update check first**, then either offer update or
|
||||
drop straight into Config. No "Manage page" with a menu of actions — the
|
||||
Config view *is* the default, and Repair/Uninstall are buttons on it.
|
||||
- **Offline / API error = not fatal**: if the release endpoint can't be
|
||||
reached, the installer silently skips the update check and opens Config.
|
||||
The user is never blocked from managing an existing install by a network
|
||||
issue.
|
||||
- **Downgrade** (installed version > latest) is treated the same as "no
|
||||
update available" — we don't ever offer a downgrade.
|
||||
|
||||
The installer's own version (shown for reference in Config) comes from its
|
||||
assembly (`AssemblyInformationalVersion`), set by the workflow's
|
||||
`/p:Version=$VERSION`. The *installed* version comes from `install.json`.
|
||||
|
||||
### New step: `DownloadAndExtractStep`
|
||||
|
||||
Replaces `PublishAppStep`, `PublishWorkerStep`, `DeployBinariesStep`.
|
||||
|
||||
```
|
||||
1. GET https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest
|
||||
Parse tag_name and asset URLs for:
|
||||
- ClaudeDo-<ver>-win-x64.zip
|
||||
- checksums.txt
|
||||
2. Download both to %TEMP%\ClaudeDo-install-<guid>\
|
||||
3. Parse checksums.txt, verify SHA256 of the zip. Fail hard if mismatch.
|
||||
4. (Update mode only) Stop Worker service via sc.exe stop ClaudeDoWorker.
|
||||
Wait up to 30s for it to actually stop. If it won't stop, fail.
|
||||
5. (Update mode only) Delete contents of {InstallDir}/app and
|
||||
{InstallDir}/worker, but leave the directories and install.json in place.
|
||||
6. Extract zip: /app -> {InstallDir}/app, /worker -> {InstallDir}/worker.
|
||||
7. (Update mode only) Start service again via sc.exe start ClaudeDoWorker.
|
||||
8. Progress is reported via IProgress<string> — the UI already shows it.
|
||||
```
|
||||
|
||||
Config files (`~/.todo-app/*.json`) and DB (`~/.todo-app/todo.db`) live
|
||||
outside `InstallDir` and are never touched by this step — updates are
|
||||
naturally non-destructive.
|
||||
|
||||
### Update mode — which steps run
|
||||
|
||||
- **Yes:** `DownloadAndExtractStep`
|
||||
- **No:** `WriteConfigStep` (user already has config — we don't overwrite)
|
||||
- **No:** `InitDatabaseStep` (DB exists)
|
||||
- **No:** `CreateShortcutsStep` (already there; Repair can re-run this)
|
||||
- **Conditional:** `RegisterServiceStep` only if service is not currently
|
||||
registered (covers someone who unregistered it manually)
|
||||
|
||||
### Config view — actions
|
||||
|
||||
- **Save** (primary): writes the Paths / UiSettings / Service fields to
|
||||
`~/.todo-app/*.json`. If worker config changed, prompts "Restart service?"
|
||||
and calls `sc stop` / `sc start` if accepted. No download.
|
||||
- **Repair:** re-download + extract (same as Update flow), re-create
|
||||
shortcuts, re-register service. Leaves config/DB alone. Confirmation
|
||||
dialog before starting.
|
||||
- **Uninstall:** confirmation dialog ("This removes ClaudeDo *and* all of
|
||||
your tasks, config, and database. Type UNINSTALL to confirm."). On
|
||||
confirm:
|
||||
1. Stop + unregister service (`sc stop`, `sc delete ClaudeDoWorker`)
|
||||
2. Remove Start Menu / Desktop shortcuts
|
||||
3. Delete `{InstallDir}` (including `install.json`)
|
||||
4. Delete `~/.todo-app` in full (config + DB + logs)
|
||||
5. Exit
|
||||
|
||||
Everything is removed. No "keep my data" option — that was explicitly
|
||||
declined in the design discussion.
|
||||
|
||||
### Files to add
|
||||
|
||||
```
|
||||
src/ClaudeDo.Installer/Core/InstallModeDetector.cs
|
||||
src/ClaudeDo.Installer/Core/ReleaseClient.cs // Gitea API + downloads
|
||||
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
|
||||
src/ClaudeDo.Installer/Core/InstallManifest.cs // read/write install.json
|
||||
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
|
||||
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
|
||||
src/ClaudeDo.Installer/Steps/StopServiceStep.cs // used in Update+Uninstall
|
||||
src/ClaudeDo.Installer/Steps/StartServiceStep.cs // used in Update+Repair
|
||||
.gitea/workflows/release.yml
|
||||
```
|
||||
|
||||
### Files to remove
|
||||
|
||||
```
|
||||
src/ClaudeDo.Installer/Steps/PublishAppStep.cs
|
||||
src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
|
||||
src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
|
||||
```
|
||||
|
||||
### Files to update
|
||||
|
||||
- `Core/InstallerService.cs` — mode-aware step list
|
||||
- `Core/InstallContext.cs` — add `Version`, `Mode`, `IsFirstInstall` fields
|
||||
- `Pages/WelcomePage/*` — content + buttons depend on mode
|
||||
- `Views/WizardViewModel.cs` — route pages based on mode
|
||||
- `Core/PageResolver.cs` — register new/renamed pages
|
||||
- `ClaudeDo.Installer.csproj` — add `PublishSingleFile`, `SelfContained`
|
||||
properties (only active when published)
|
||||
|
||||
## Failure Modes & Recovery
|
||||
|
||||
| Failure | Behavior |
|
||||
|---------------------------------------|-------------------------------------------------------|
|
||||
| No network / Gitea unreachable | Step fails with clear message + retry button |
|
||||
| API returns no releases yet | "No release available — publish a tag first" |
|
||||
| Checksum mismatch | Step fails, temp files deleted, user prompted retry |
|
||||
| Zip extraction fails mid-way (update) | InstallDir is left partially empty — user re-runs |
|
||||
| Service won't stop | Fail before extract; nothing destructive has happened |
|
||||
| User cancels mid-download | Temp dir cleaned up; install state unchanged |
|
||||
|
||||
For safety, the `DownloadAndExtractStep` always downloads + verifies
|
||||
**before** it deletes the old binaries. An aborted download cannot leave
|
||||
an install in a half-deleted state.
|
||||
|
||||
## Security
|
||||
|
||||
- All downloads over HTTPS from a pinned host (`git.kuns.dev`).
|
||||
- SHA256 verification before extract (protects against partial downloads and
|
||||
tampered caches on the wire; not a substitute for code signing).
|
||||
- No tokens shipped in the installer — repo is public.
|
||||
- Worker service runs under the same account as today (no change).
|
||||
|
||||
## Decisions to Revisit
|
||||
|
||||
1. **Release notes content.** Auto-generated `git log` summary vs manual
|
||||
notes in the tag message vs empty. Start empty; revisit when there are
|
||||
enough releases to care.
|
||||
|
||||
2. **Signed installer.** Out of scope for v1. Users will see a SmartScreen
|
||||
warning the first time. Note this in the README.
|
||||
|
||||
3. **Installer distribution page.** A simple `README.md` badge or a pinned
|
||||
"Latest release" link on the Gitea repo home is enough for v1.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- On a fresh Windows VM with **no source checkout, no .NET runtime, and no
|
||||
.NET SDK**:
|
||||
1. Download `ClaudeDo.Installer-<ver>.exe`.
|
||||
2. Run it.
|
||||
3. Complete the wizard.
|
||||
4. ClaudeDo App launches, Worker service is running, a task can be created
|
||||
and picked up.
|
||||
- Running the same installer a second time, with no new release published,
|
||||
opens directly in the Config view after a quick update check.
|
||||
- Publishing a new tag, then running the installer on the existing install,
|
||||
offers the update; accepting performs it without touching `~/.todo-app/todo.db`
|
||||
or the config JSONs.
|
||||
- Uninstall leaves no trace: `{InstallDir}` gone, `~/.todo-app` gone, service
|
||||
unregistered, shortcuts removed.
|
||||
- The entire release pipeline runs on `git.kuns.dev` with no manual steps
|
||||
beyond `git tag vX.Y.Z && git push --tags`.
|
||||
6
global.json
Normal file
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.418",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,16 @@ CREATE TABLE IF NOT EXISTS task_runs (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subtasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
order_num INTEGER NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subtasks_task_id ON subtasks(task_id);
|
||||
|
||||
-- Seed: minimal tag set (ignored if already present)
|
||||
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
|
||||
INSERT OR IGNORE INTO tags (name) VALUES ('manual');
|
||||
|
||||
BIN
src/ClaudeDo.App/Assets/ClaudeTask.ico
Normal file
BIN
src/ClaudeDo.App/Assets/ClaudeTask.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 B |
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\ClaudeTask.ico</ApplicationIcon>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -22,8 +22,19 @@ sealed class Program
|
||||
var factory = services.GetRequiredService<SqliteConnectionFactory>();
|
||||
SchemaInitializer.Apply(factory);
|
||||
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
try
|
||||
{
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Dispose the container so WorkerClient.DisposeAsync runs —
|
||||
// cancels the retry loop and closes the SignalR connection cleanly
|
||||
// instead of abandoning it.
|
||||
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
|
||||
catch { /* best effort on shutdown */ }
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
@@ -49,6 +60,7 @@ sealed class Program
|
||||
// Repositories
|
||||
sc.AddSingleton<ListRepository>();
|
||||
sc.AddSingleton<TaskRepository>();
|
||||
sc.AddSingleton<SubtaskRepository>();
|
||||
sc.AddSingleton<TagRepository>();
|
||||
sc.AddSingleton<WorktreeRepository>();
|
||||
|
||||
@@ -66,7 +78,8 @@ sealed class Program
|
||||
sp.GetRequiredService<ListRepository>(),
|
||||
sp.GetRequiredService<GitService>(),
|
||||
sp.GetRequiredService<WorkerClient>(),
|
||||
sp.GetRequiredService<TagRepository>()));
|
||||
sp.GetRequiredService<TagRepository>(),
|
||||
sp.GetRequiredService<SubtaskRepository>()));
|
||||
sc.AddSingleton<TaskListViewModel>(sp =>
|
||||
{
|
||||
var taskRepo = sp.GetRequiredService<TaskRepository>();
|
||||
|
||||
@@ -104,20 +104,34 @@ public sealed class GitService
|
||||
using var proc = new Process { StartInfo = psi };
|
||||
proc.Start();
|
||||
|
||||
// On cancellation: kill the git process tree. Killing closes the
|
||||
// redirected pipes, which unblocks the ReadToEndAsync calls below
|
||||
// and lets WaitForExitAsync return so the process is reaped.
|
||||
// Without this, cancelling mid-git leaves zombie processes.
|
||||
await using var ctr = ct.Register(() =>
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); }
|
||||
catch { /* already exited */ }
|
||||
});
|
||||
|
||||
if (stdinData is not null)
|
||||
{
|
||||
await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct);
|
||||
proc.StandardInput.Close();
|
||||
}
|
||||
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
|
||||
// Drain output without ct — pipes close when the process exits
|
||||
// (whether naturally or via Kill above), so these always complete.
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync();
|
||||
|
||||
await proc.WaitForExitAsync(ct);
|
||||
await proc.WaitForExitAsync(CancellationToken.None);
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
|
||||
}
|
||||
}
|
||||
|
||||
11
src/ClaudeDo.Data/Models/SubtaskEntity.cs
Normal file
11
src/ClaudeDo.Data/Models/SubtaskEntity.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class SubtaskEntity
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string TaskId { get; init; }
|
||||
public required string Title { get; set; }
|
||||
public bool Completed { get; set; }
|
||||
public int OrderNum { get; set; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
}
|
||||
81
src/ClaudeDo.Data/Repositories/SubtaskRepository.cs
Normal file
81
src/ClaudeDo.Data/Repositories/SubtaskRepository.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class SubtaskRepository
|
||||
{
|
||||
private readonly SqliteConnectionFactory _factory;
|
||||
|
||||
public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory;
|
||||
|
||||
public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, task_id, title, completed, order_num, created_at FROM subtasks WHERE task_id = @task_id ORDER BY order_num";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var result = new List<SubtaskEntity>();
|
||||
while (await reader.ReadAsync(ct))
|
||||
result.Add(ReadSubtask(reader));
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at)
|
||||
VALUES (@id, @task_id, @title, @completed, @order_num, @created_at)
|
||||
""";
|
||||
BindSubtask(cmd, entity);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE subtasks SET title = @title, completed = @completed, order_num = @order_num
|
||||
WHERE id = @id
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@id", entity.Id);
|
||||
cmd.Parameters.AddWithValue("@title", entity.Title);
|
||||
cmd.Parameters.AddWithValue("@completed", entity.Completed ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@order_num", entity.OrderNum);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM subtasks WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@id", e.Id);
|
||||
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
|
||||
cmd.Parameters.AddWithValue("@title", e.Title);
|
||||
cmd.Parameters.AddWithValue("@completed", e.Completed ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@order_num", e.OrderNum);
|
||||
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
|
||||
}
|
||||
|
||||
private static SubtaskEntity ReadSubtask(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetString(0),
|
||||
TaskId = r.GetString(1),
|
||||
Title = r.GetString(2),
|
||||
Completed = r.GetInt64(3) != 0,
|
||||
OrderNum = r.GetInt32(4),
|
||||
CreatedAt = DateTime.Parse(r.GetString(5)),
|
||||
};
|
||||
}
|
||||
@@ -174,26 +174,36 @@ public sealed class TaskRepository
|
||||
|
||||
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
||||
{
|
||||
// Atomically claim the next queued agent task: the UPDATE flips its
|
||||
// status to 'running' in the same statement that returns its row,
|
||||
// eliminating the TOCTOU gap where two queue-loop iterations could
|
||||
// both select the same queued task before either marked it running.
|
||||
// The caller is responsible for populating started_at shortly after.
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT t.id, t.list_id, t.title, t.description, t.status, t.scheduled_for,
|
||||
t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type,
|
||||
t.model, t.system_prompt, t.agent_path
|
||||
FROM tasks t
|
||||
WHERE t.status = 'queued'
|
||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM task_tags tt
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tt.task_id = t.id AND tg.name = 'agent'
|
||||
UNION
|
||||
SELECT 1 FROM list_tags lt
|
||||
JOIN tags tg ON tg.id = lt.tag_id
|
||||
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
||||
)
|
||||
ORDER BY t.created_at ASC
|
||||
LIMIT 1
|
||||
UPDATE tasks
|
||||
SET status = 'running'
|
||||
WHERE id = (
|
||||
SELECT t.id
|
||||
FROM tasks t
|
||||
WHERE t.status = 'queued'
|
||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM task_tags tt
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tt.task_id = t.id AND tg.name = 'agent'
|
||||
UNION
|
||||
SELECT 1 FROM list_tags lt
|
||||
JOIN tags tg ON tg.id = lt.tag_id
|
||||
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
||||
)
|
||||
ORDER BY t.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, list_id, title, description, status, scheduled_for,
|
||||
result, log_path, created_at, started_at, finished_at, commit_type,
|
||||
model, system_prompt, agent_path
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@now", now.ToString("o"));
|
||||
|
||||
|
||||
15
src/ClaudeDo.Installer/App.xaml
Normal file
15
src/ClaudeDo.Installer/App.xaml
Normal file
@@ -0,0 +1,15 @@
|
||||
<Application x:Class="ClaudeDo.Installer.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:core="clr-namespace:ClaudeDo.Installer.Core">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Theme/DarkTheme.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<core:NullToCollapsedConverter x:Key="NullToCollapsedConverter"/>
|
||||
<core:StepActiveConverter x:Key="StepActiveConverter"/>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVisConverter"/>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
132
src/ClaudeDo.Installer/App.xaml.cs
Normal file
132
src/ClaudeDo.Installer/App.xaml.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Pages.InstallPage;
|
||||
using ClaudeDo.Installer.Pages.PathsPage;
|
||||
using ClaudeDo.Installer.Pages.ServicePage;
|
||||
using ClaudeDo.Installer.Pages.UiSettingsPage;
|
||||
using ClaudeDo.Installer.Pages.WelcomePage;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
using ClaudeDo.Installer.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ClaudeDo.Installer;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private ServiceProvider? _services;
|
||||
|
||||
protected override async void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
_services = BuildServices();
|
||||
|
||||
var context = _services.GetRequiredService<InstallContext>();
|
||||
context.InstallerVersion = GetInstallerVersion();
|
||||
|
||||
// Default install dir for detection — on upgrade we stay where we were.
|
||||
var detector = _services.GetRequiredService<InstallModeDetector>();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Read manifest up front so we can fall back to Config if the API times out
|
||||
// on an existing install. If the API is slow, we do NOT want to drop an
|
||||
// already-installed user into FreshInstall — that would risk overwriting them.
|
||||
var existingManifest = InstallManifestStore.TryRead(context.InstallDirectory);
|
||||
|
||||
DetectedState state;
|
||||
try
|
||||
{
|
||||
state = await detector.DetectAsync(context.InstallDirectory, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
state = existingManifest is not null
|
||||
? new DetectedState(InstallerMode.Config, existingManifest, null, null)
|
||||
: new DetectedState(InstallerMode.FreshInstall, null, null, null);
|
||||
}
|
||||
|
||||
context.Mode = state.Mode;
|
||||
context.InstalledVersion = state.Existing?.Version;
|
||||
context.LatestVersion = state.LatestVersion;
|
||||
if (state.Existing is not null)
|
||||
context.InstallDirectory = state.Existing.InstallDir;
|
||||
|
||||
Window mainWindow = state.Mode switch
|
||||
{
|
||||
InstallerMode.FreshInstall or InstallerMode.Update => new WizardWindow
|
||||
{
|
||||
DataContext = _services.GetRequiredService<WizardViewModel>()
|
||||
},
|
||||
InstallerMode.Config => new SettingsWindow
|
||||
{
|
||||
DataContext = _services.GetRequiredService<SettingsViewModel>()
|
||||
},
|
||||
_ => throw new InvalidOperationException($"Unknown installer mode: {state.Mode}")
|
||||
};
|
||||
|
||||
DarkTitleBar.Apply(mainWindow);
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
_services?.Dispose();
|
||||
base.OnExit(e);
|
||||
}
|
||||
|
||||
private static string GetInstallerVersion()
|
||||
{
|
||||
var infoAttr = Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
return infoAttr?.InformationalVersion ?? "0.0.0";
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildServices()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
|
||||
// Core
|
||||
sc.AddSingleton<InstallContext>();
|
||||
sc.AddSingleton<PageResolver>();
|
||||
// HTTP + release client
|
||||
sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(15) });
|
||||
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
|
||||
sc.AddSingleton<InstallModeDetector>();
|
||||
|
||||
// Pages
|
||||
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage, PathsPageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage, ServicePageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
|
||||
|
||||
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
|
||||
// Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline
|
||||
// can pull them out individually via GetRequiredService<T>().
|
||||
sc.AddSingleton<DownloadAndExtractStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
|
||||
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
||||
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
|
||||
sc.AddSingleton<WriteInstallManifestStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
||||
|
||||
// Stop/Start — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
|
||||
// Pulled by Update flow + Repair/Uninstall.
|
||||
sc.AddSingleton<StopServiceStep>();
|
||||
sc.AddSingleton<StartServiceStep>();
|
||||
|
||||
// Runners
|
||||
sc.AddSingleton<UninstallRunner>();
|
||||
|
||||
// ViewModels
|
||||
sc.AddSingleton<WizardViewModel>();
|
||||
sc.AddSingleton<SettingsViewModel>();
|
||||
|
||||
return sc.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
50
src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
Normal file
50
src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
Normal file
@@ -0,0 +1,50 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- Allow Linux Gitea runners to publish this WPF project for win-x64; no-op on Windows. -->
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<ApplicationIcon>ClaudeTaskSetup.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Embed icon so it is available via pack URI in WPF windows. -->
|
||||
<Resource Include="ClaudeTaskSetup.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Debug: asInvoker so Rider/VS can debug without elevation -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<ApplicationManifest>app.debug.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Release: requireAdministrator for service registration + shortcuts -->
|
||||
<PropertyGroup Condition="'$(Configuration)' != 'Debug'">
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
|
||||
<!-- Framework-dependent: the WPF runtime pack isn't distributed for cross-compile
|
||||
on Linux CI, which made self-contained bundles crash on startup with AV in the
|
||||
apphost. Target machines already have the .NET 8 Desktop Runtime. -->
|
||||
<SelfContained>false</SelfContained>
|
||||
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
|
||||
<EnableCompressionInSingleFile>false</EnableCompressionInSingleFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
BIN
src/ClaudeDo.Installer/ClaudeTaskSetup.ico
Normal file
BIN
src/ClaudeDo.Installer/ClaudeTaskSetup.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 B |
37
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
Normal file
37
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public static class ChecksumVerifier
|
||||
{
|
||||
public static string ComputeSha256(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static bool Verify(string filePath, string expectedSha256)
|
||||
{
|
||||
var actual = ComputeSha256(filePath);
|
||||
return string.Equals(actual, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, string> ParseChecksumsFile(string content)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var rawLine in content.Split('\n'))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0) continue;
|
||||
var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2) continue;
|
||||
var hashPart = parts[0].Trim();
|
||||
if (hashPart.Length != 64) continue;
|
||||
map[parts[1].Trim()] = hashPart;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
109
src/ClaudeDo.Installer/Core/ConfigModels.cs
Normal file
109
src/ClaudeDo.Installer/Core/ConfigModels.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ClaudeDo.Data;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
|
||||
/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
|
||||
/// </summary>
|
||||
public sealed class InstallerWorkerConfig
|
||||
{
|
||||
[JsonPropertyName("db_path")]
|
||||
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
||||
|
||||
[JsonPropertyName("sandbox_root")]
|
||||
public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
|
||||
|
||||
[JsonPropertyName("log_root")]
|
||||
public string LogRoot { get; set; } = "~/.todo-app/logs";
|
||||
|
||||
[JsonPropertyName("worktree_root_strategy")]
|
||||
public string WorktreeRootStrategy { get; set; } = "sibling";
|
||||
|
||||
[JsonPropertyName("central_worktree_root")]
|
||||
public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
|
||||
|
||||
[JsonPropertyName("queue_backstop_interval_ms")]
|
||||
public int QueueBackstopIntervalMs { get; set; } = 30_000;
|
||||
|
||||
[JsonPropertyName("signalr_port")]
|
||||
public int SignalRPort { get; set; } = 47_821;
|
||||
|
||||
[JsonPropertyName("claude_bin")]
|
||||
public string ClaudeBin { get; set; } = "claude";
|
||||
|
||||
private static readonly JsonSerializerOptions ReadOpts = new()
|
||||
{
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions WriteOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
public static InstallerWorkerConfig Load()
|
||||
{
|
||||
var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||
if (!File.Exists(path)) return new();
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<InstallerWorkerConfig>(json, ReadOpts) ?? new();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var dir = Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "worker.config.json");
|
||||
var json = JsonSerializer.Serialize(this, WriteOpts);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors ClaudeDo.Ui.AppSettings JSON shape.
|
||||
/// Keep in sync with src/ClaudeDo.Ui/AppSettings.cs.
|
||||
/// </summary>
|
||||
public sealed class InstallerAppSettings
|
||||
{
|
||||
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
||||
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
|
||||
|
||||
private static readonly JsonSerializerOptions ReadOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions WriteOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
public static InstallerAppSettings Load()
|
||||
{
|
||||
var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json");
|
||||
if (!File.Exists(path)) return new();
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<InstallerAppSettings>(json, ReadOpts) ?? new();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var dir = Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "ui.config.json");
|
||||
var json = JsonSerializer.Serialize(this, WriteOpts);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
}
|
||||
29
src/ClaudeDo.Installer/Core/DarkTitleBar.cs
Normal file
29
src/ClaudeDo.Installer/Core/DarkTitleBar.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public static class DarkTitleBar
|
||||
{
|
||||
[DllImport("dwmapi.dll", PreserveSig = true)]
|
||||
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
|
||||
|
||||
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
|
||||
|
||||
public static void Apply(Window window)
|
||||
{
|
||||
if (window.IsLoaded)
|
||||
SetDarkMode(window);
|
||||
else
|
||||
window.SourceInitialized += (_, _) => SetDarkMode(window);
|
||||
}
|
||||
|
||||
private static void SetDarkMode(Window window)
|
||||
{
|
||||
var hwnd = new WindowInteropHelper(window).Handle;
|
||||
if (hwnd == IntPtr.Zero) return;
|
||||
int value = 1;
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
|
||||
}
|
||||
}
|
||||
20
src/ClaudeDo.Installer/Core/IInstallStep.cs
Normal file
20
src/ClaudeDo.Installer/Core/IInstallStep.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public interface IInstallStep
|
||||
{
|
||||
string Name { get; }
|
||||
Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class StepResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public static StepResult Ok() => new() { Success = true };
|
||||
public static StepResult Fail(string error) => new() { Success = false, ErrorMessage = error };
|
||||
}
|
||||
|
||||
public enum StepStatus { Pending, Running, Done, Failed, Skipped }
|
||||
|
||||
public sealed record StepProgress(string StepName, StepStatus Status, string? Message = null);
|
||||
16
src/ClaudeDo.Installer/Core/IInstallerPage.cs
Normal file
16
src/ClaudeDo.Installer/Core/IInstallerPage.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public interface IInstallerPage
|
||||
{
|
||||
string Title { get; }
|
||||
string Icon { get; }
|
||||
int Order { get; }
|
||||
bool ShowInWizard { get; }
|
||||
bool ShowInSettings { get; }
|
||||
UserControl View { get; }
|
||||
Task LoadAsync();
|
||||
Task ApplyAsync();
|
||||
bool Validate();
|
||||
}
|
||||
15
src/ClaudeDo.Installer/Core/IReleaseClient.cs
Normal file
15
src/ClaudeDo.Installer/Core/IReleaseClient.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
|
||||
|
||||
public sealed record GiteaRelease(
|
||||
string TagName,
|
||||
string Name,
|
||||
IReadOnlyList<ReleaseAsset> Assets);
|
||||
|
||||
public interface IReleaseClient
|
||||
{
|
||||
Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct);
|
||||
|
||||
Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct);
|
||||
}
|
||||
35
src/ClaudeDo.Installer/Core/InstallContext.cs
Normal file
35
src/ClaudeDo.Installer/Core/InstallContext.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed class InstallContext
|
||||
{
|
||||
// WelcomePage / install destination
|
||||
public string InstallDirectory { get; set; } = @"C:\Program Files\ClaudeDo";
|
||||
|
||||
// Mode + versions (set by App startup after InstallModeDetector runs)
|
||||
public InstallerMode Mode { get; set; } = InstallerMode.FreshInstall;
|
||||
public string? InstallerVersion { get; set; } // from this installer's assembly
|
||||
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
|
||||
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
|
||||
|
||||
// PathsPage
|
||||
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
||||
public string LogRoot { get; set; } = "~/.todo-app/logs";
|
||||
public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
|
||||
public string WorktreeRootStrategy { get; set; } = "sibling";
|
||||
public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
|
||||
|
||||
// ServicePage
|
||||
public int SignalRPort { get; set; } = 47_821;
|
||||
public int QueueBackstopIntervalMs { get; set; } = 30_000;
|
||||
public string ClaudeBin { get; set; } = "claude";
|
||||
public string ServiceAccount { get; set; } = "LocalSystem";
|
||||
public bool AutoStart { get; set; } = true;
|
||||
public int RestartDelayMs { get; set; } = 5000;
|
||||
|
||||
// UiSettingsPage
|
||||
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
|
||||
public string UiDbPath { get; set; } = "~/.todo-app/todo.db";
|
||||
|
||||
// InstallPage
|
||||
public bool CreateDesktopShortcut { get; set; } = true;
|
||||
}
|
||||
48
src/ClaudeDo.Installer/Core/InstallManifest.cs
Normal file
48
src/ClaudeDo.Installer/Core/InstallManifest.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed record InstallManifest(
|
||||
string Version,
|
||||
string InstallDir,
|
||||
string WorkerDir,
|
||||
DateTimeOffset InstalledAt);
|
||||
|
||||
public static class InstallManifestStore
|
||||
{
|
||||
public const string FileName = "install.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public static string ManifestPath(string installDir) => Path.Combine(installDir, FileName);
|
||||
|
||||
public static InstallManifest? TryRead(string installDir)
|
||||
{
|
||||
var path = ManifestPath(installDir);
|
||||
if (!File.Exists(path)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<InstallManifest>(json, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Write(string installDir, InstallManifest manifest)
|
||||
{
|
||||
Directory.CreateDirectory(installDir);
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
File.WriteAllText(ManifestPath(installDir), json);
|
||||
}
|
||||
}
|
||||
48
src/ClaudeDo.Installer/Core/InstallModeDetector.cs
Normal file
48
src/ClaudeDo.Installer/Core/InstallModeDetector.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed record DetectedState(
|
||||
InstallerMode Mode,
|
||||
InstallManifest? Existing,
|
||||
GiteaRelease? LatestRelease,
|
||||
string? LatestVersion);
|
||||
|
||||
public sealed class InstallModeDetector
|
||||
{
|
||||
private readonly IReleaseClient _releases;
|
||||
|
||||
public InstallModeDetector(IReleaseClient releases)
|
||||
{
|
||||
_releases = releases;
|
||||
}
|
||||
|
||||
public async Task<DetectedState> DetectAsync(string installDir, CancellationToken ct)
|
||||
{
|
||||
var manifest = InstallManifestStore.TryRead(installDir);
|
||||
if (manifest is null)
|
||||
return new DetectedState(InstallerMode.FreshInstall, null, null, null);
|
||||
|
||||
var release = await _releases.GetLatestReleaseAsync(ct);
|
||||
if (release is null)
|
||||
return new DetectedState(InstallerMode.Config, manifest, null, null);
|
||||
|
||||
var latestVersion = release.TagName.TrimStart('v', 'V');
|
||||
if (IsNewer(latestVersion, manifest.Version))
|
||||
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
|
||||
|
||||
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
|
||||
/// AND latest > current. Semver pre-release tags like "0.2.0-beta" fail to parse and are
|
||||
/// treated as "not newer" — the user drops into Config mode with no update offered.
|
||||
/// This is deliberate: offering an update we can't compare is worse than silently skipping it.
|
||||
/// If the project starts shipping pre-release tags, revisit this.
|
||||
/// </summary>
|
||||
private static bool IsNewer(string latest, string current)
|
||||
{
|
||||
if (!Version.TryParse(latest, out var lv)) return false;
|
||||
if (!Version.TryParse(current, out var cv)) return false;
|
||||
return lv > cv;
|
||||
}
|
||||
}
|
||||
8
src/ClaudeDo.Installer/Core/InstallerMode.cs
Normal file
8
src/ClaudeDo.Installer/Core/InstallerMode.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public enum InstallerMode
|
||||
{
|
||||
FreshInstall, // No install.json present -> run full wizard
|
||||
Update, // install.json present, newer release available
|
||||
Config, // install.json present, no update (or API unreachable)
|
||||
}
|
||||
49
src/ClaudeDo.Installer/Core/InstallerService.cs
Normal file
49
src/ClaudeDo.Installer/Core/InstallerService.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed class InstallerService
|
||||
{
|
||||
private readonly IEnumerable<IInstallStep> _steps;
|
||||
|
||||
public InstallerService(IEnumerable<IInstallStep> steps) => _steps = steps;
|
||||
|
||||
public async Task<IReadOnlyList<(IInstallStep Step, StepResult Result)>> ExecuteAsync(
|
||||
InstallContext ctx,
|
||||
IProgress<StepProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var results = new List<(IInstallStep, StepResult)>();
|
||||
|
||||
foreach (var step in _steps)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
progress.Report(new StepProgress(step.Name, StepStatus.Running));
|
||||
|
||||
var lineProgress = new Progress<string>(msg =>
|
||||
progress.Report(new StepProgress(step.Name, StepStatus.Running, msg)));
|
||||
|
||||
try
|
||||
{
|
||||
var result = await step.ExecuteAsync(ctx, lineProgress, ct);
|
||||
var status = result.Success ? StepStatus.Done : StepStatus.Failed;
|
||||
progress.Report(new StepProgress(step.Name, status, result.ErrorMessage));
|
||||
results.Add((step, result));
|
||||
|
||||
if (!result.Success) break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
progress.Report(new StepProgress(step.Name, StepStatus.Failed, "Cancelled"));
|
||||
results.Add((step, StepResult.Fail("Cancelled")));
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
progress.Report(new StepProgress(step.Name, StepStatus.Failed, ex.Message));
|
||||
results.Add((step, StepResult.Fail(ex.Message)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
14
src/ClaudeDo.Installer/Core/NullToCollapsedConverter.cs
Normal file
14
src/ClaudeDo.Installer/Core/NullToCollapsedConverter.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed class NullToCollapsedConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is null or "" ? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
17
src/ClaudeDo.Installer/Core/PageResolver.cs
Normal file
17
src/ClaudeDo.Installer/Core/PageResolver.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed class PageResolver
|
||||
{
|
||||
private readonly IReadOnlyList<IInstallerPage> _allPages;
|
||||
|
||||
public PageResolver(IEnumerable<IInstallerPage> pages)
|
||||
{
|
||||
_allPages = pages.OrderBy(p => p.Order).ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<IInstallerPage> WizardPages =>
|
||||
_allPages.Where(p => p.ShowInWizard).ToList();
|
||||
|
||||
public IReadOnlyList<IInstallerPage> SettingsPages =>
|
||||
_allPages.Where(p => p.ShowInSettings).ToList();
|
||||
}
|
||||
60
src/ClaudeDo.Installer/Core/ProcessRunner.cs
Normal file
60
src/ClaudeDo.Installer/Core/ProcessRunner.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public static class ProcessRunner
|
||||
{
|
||||
public static async Task<(int ExitCode, string Output)> RunAsync(
|
||||
string fileName,
|
||||
string arguments,
|
||||
string? workingDirectory,
|
||||
IProgress<string>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var output = new StringBuilder();
|
||||
var outputLock = new object();
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
|
||||
if (!process.Start())
|
||||
return (-1, "Failed to start process");
|
||||
|
||||
var stdoutTask = ReadStreamAsync(process.StandardOutput, output, outputLock, progress);
|
||||
var stderrTask = ReadStreamAsync(process.StandardError, output, outputLock, progress);
|
||||
|
||||
using var reg = ct.Register(() =>
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
});
|
||||
|
||||
await Task.WhenAll(stdoutTask, stderrTask);
|
||||
await process.WaitForExitAsync(ct);
|
||||
|
||||
return (process.ExitCode, output.ToString());
|
||||
}
|
||||
|
||||
private static async Task ReadStreamAsync(
|
||||
StreamReader reader,
|
||||
StringBuilder output,
|
||||
object outputLock,
|
||||
IProgress<string>? progress)
|
||||
{
|
||||
while (await reader.ReadLineAsync() is { } line)
|
||||
{
|
||||
lock (outputLock) { output.AppendLine(line); }
|
||||
progress?.Report(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/ClaudeDo.Installer/Core/ReleaseClient.cs
Normal file
85
src/ClaudeDo.Installer/Core/ReleaseClient.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed class ReleaseClient : IReleaseClient
|
||||
{
|
||||
public const string DefaultApiBase = "https://git.kuns.dev/api/v1/repos/releases/ClaudeDo";
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _apiBase;
|
||||
|
||||
public ReleaseClient(HttpClient http, string apiBase = DefaultApiBase)
|
||||
{
|
||||
_http = http;
|
||||
_apiBase = apiBase.TrimEnd('/');
|
||||
}
|
||||
|
||||
public async Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _http.GetAsync($"{_apiBase}/releases/latest", ct);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
return ParseRelease(json);
|
||||
}
|
||||
catch (HttpRequestException) { return null; }
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return null; }
|
||||
}
|
||||
|
||||
public async Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
||||
{
|
||||
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var input = await response.Content.ReadAsStreamAsync(ct);
|
||||
await using var output = File.Create(destPath);
|
||||
|
||||
var buffer = new byte[81920];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = await input.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
total += read;
|
||||
progress.Report(total);
|
||||
}
|
||||
}
|
||||
|
||||
private static GiteaRelease? ParseRelease(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("tag_name", out var tagEl)) return null;
|
||||
|
||||
var tag = tagEl.GetString() ?? "";
|
||||
var name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
|
||||
|
||||
var assets = new List<ReleaseAsset>();
|
||||
if (root.TryGetProperty("assets", out var arr) && arr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in arr.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("name", out var nameField)) continue;
|
||||
if (!item.TryGetProperty("browser_download_url", out var urlField)) continue;
|
||||
|
||||
var aName = nameField.GetString() ?? "";
|
||||
var aUrl = urlField.GetString() ?? "";
|
||||
var aSize = item.TryGetProperty("size", out var s) ? s.GetInt64() : 0L;
|
||||
assets.Add(new ReleaseAsset(aName, aUrl, aSize));
|
||||
}
|
||||
}
|
||||
|
||||
return new GiteaRelease(tag, name, assets);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/ClaudeDo.Installer/Core/StepIndicatorConverter.cs
Normal file
37
src/ClaudeDo.Installer/Core/StepIndicatorConverter.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-value converter: compares the page's index with the current page index
|
||||
/// to determine step indicator styling.
|
||||
/// </summary>
|
||||
public sealed class StepActiveConverter : IMultiValueConverter
|
||||
{
|
||||
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (values.Length < 2 ||
|
||||
values[0] is not IInstallerPage page ||
|
||||
values[1] is not IInstallerPage currentPage)
|
||||
return DependencyProperty.UnsetValue;
|
||||
|
||||
var isActive = ReferenceEquals(page, currentPage);
|
||||
|
||||
var key = parameter?.ToString() switch
|
||||
{
|
||||
"Background" => isActive ? "AccentBrush" : "WindowBgBrush",
|
||||
"Foreground" => isActive ? "TextPrimaryBrush" : "TextMutedBrush",
|
||||
"BorderBrush" => isActive ? "AccentBrush" : "BorderSubtleBrush",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (key is null) return DependencyProperty.UnsetValue;
|
||||
return Application.Current.Resources[key] as SolidColorBrush ?? DependencyProperty.UnsetValue;
|
||||
}
|
||||
|
||||
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
186
src/ClaudeDo.Installer/Core/UninstallRunner.cs
Normal file
186
src/ClaudeDo.Installer/Core/UninstallRunner.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed class UninstallRunner
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private readonly StopServiceStep _stopService;
|
||||
|
||||
public UninstallRunner(InstallContext context, StopServiceStep stopService)
|
||||
{
|
||||
_context = context;
|
||||
_stopService = stopService;
|
||||
}
|
||||
|
||||
public async Task<StepResult> RunAsync(IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
// 1) Validate install dir up front — refuse obviously unsafe paths.
|
||||
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
|
||||
if (!IsSafeInstallDir(_context.InstallDirectory, out var safeError))
|
||||
return StepResult.Fail($"Refusing to uninstall: {safeError}");
|
||||
|
||||
// 2) Stop service. If stop fails we MUST abort — deleting a service whose
|
||||
// process is still running leaves orphan locked binaries under the install dir
|
||||
// which Directory.Delete will silently skip.
|
||||
progress.Report("Stopping worker service...");
|
||||
var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
|
||||
if (!stopResult.Success)
|
||||
return StepResult.Fail(
|
||||
$"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " +
|
||||
"Kill the worker manually and re-run uninstall.");
|
||||
|
||||
// 3) Unregister service.
|
||||
progress.Report("Unregistering service...");
|
||||
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
|
||||
|
||||
// 3b) Remove Apps & Features registry entry (best-effort).
|
||||
progress.Report("Removing Add/Remove Programs entry...");
|
||||
try
|
||||
{
|
||||
Registry.LocalMachine.DeleteSubKeyTree(WriteUninstallRegistryStep.UninstallKeyPath, throwOnMissingSubKey: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
progress.Report($"Warning: could not delete uninstall registry key: {ex.Message}");
|
||||
}
|
||||
|
||||
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
|
||||
progress.Report("Removing shortcuts...");
|
||||
TryDeleteFile(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
|
||||
"ClaudeDo.lnk"));
|
||||
TryDeleteFile(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
|
||||
"Programs", "ClaudeDo.lnk"));
|
||||
|
||||
// 5) Delete install directory. Track success so we can report partial state.
|
||||
var failures = new List<string>();
|
||||
if (Directory.Exists(_context.InstallDirectory))
|
||||
{
|
||||
progress.Report($"Deleting {_context.InstallDirectory}...");
|
||||
if (!TryDeleteDir(_context.InstallDirectory, out var err))
|
||||
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
|
||||
}
|
||||
|
||||
// 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal.
|
||||
var appData = Paths.AppDataRoot();
|
||||
if (Directory.Exists(appData))
|
||||
{
|
||||
progress.Report($"Deleting {appData}...");
|
||||
if (!TryDeleteDir(appData, out var err))
|
||||
failures.Add($"app data ({appData}): {err}");
|
||||
}
|
||||
|
||||
// 7) If we were launched from inside the install dir (Apps & Features case),
|
||||
// our own exe is still locked — schedule a cmd.exe trampoline to finish
|
||||
// the deletion after this process exits. Best-effort: if this fails the
|
||||
// user is left with an empty <uninstaller> folder which is harmless.
|
||||
var runningExe = Environment.ProcessPath;
|
||||
if (runningExe is not null
|
||||
&& IsInsideDirectory(runningExe, _context.InstallDirectory)
|
||||
&& Directory.Exists(_context.InstallDirectory))
|
||||
{
|
||||
progress.Report("Scheduling final cleanup after exit...");
|
||||
TryScheduleTrampolineDelete(_context.InstallDirectory);
|
||||
// The trampoline will finish the job — clear the residual failure entry for the install dir.
|
||||
failures.RemoveAll(f => f.StartsWith("install dir"));
|
||||
}
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
return StepResult.Fail(
|
||||
"Uninstall partially succeeded — the following could not be removed:\n " +
|
||||
string.Join("\n ", failures));
|
||||
}
|
||||
|
||||
progress.Report("Uninstall complete.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
private static bool IsInsideDirectory(string filePath, string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(filePath);
|
||||
var dir = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return full.StartsWith(dir, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static void TryScheduleTrampolineDelete(string installDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pid = Environment.ProcessId;
|
||||
// Wait for this process to exit, then recursively remove the install dir.
|
||||
// /B timeout avoids a visible window; ping as a portable sleep; rmdir /S /Q is silent.
|
||||
var cmd = $"/C start \"\" /MIN cmd /C \"ping 127.0.0.1 -n 3 >nul & rmdir /S /Q \"\"{installDir}\"\"\"";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = cmd,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
});
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guards against catastrophic recursive-delete paths. The install dir must be
|
||||
/// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").
|
||||
/// </summary>
|
||||
private static bool IsSafeInstallDir(string path, out string reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
reason = "install directory is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
string full;
|
||||
try { full = Path.GetFullPath(path); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
reason = $"install directory is not a valid path: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
var name = Path.GetFileName(full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
reason = $"install directory resolves to a drive root ({full})";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort — single shortcut */ }
|
||||
}
|
||||
|
||||
private static bool TryDeleteDir(string path, out string error)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
error = "";
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml
Normal file
101
src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml
Normal file
@@ -0,0 +1,101 @@
|
||||
<UserControl x:Class="ClaudeDo.Installer.Pages.InstallPage.InstallPageView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.InstallPage"
|
||||
d:DataContext="{d:DesignInstance local:InstallPageViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,16">
|
||||
<TextBlock Text="Installation" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Click Install to build and deploy ClaudeDo."
|
||||
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Step List -->
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Steps}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type local:StepViewModel}">
|
||||
<Border Margin="0,0,0,6" Padding="10,8"
|
||||
Background="{StaticResource IslandBgBrush}"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}"
|
||||
BorderThickness="1" CornerRadius="4">
|
||||
<StackPanel>
|
||||
<!-- Step header -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<Ellipse Grid.Column="0" Width="10" Height="10" Margin="0,0,10,0"
|
||||
VerticalAlignment="Center">
|
||||
<Ellipse.Style>
|
||||
<Style TargetType="Ellipse">
|
||||
<Setter Property="Fill" Value="{StaticResource StatusGrayBrush}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Status}" Value="Running">
|
||||
<Setter Property="Fill" Value="{StaticResource StatusOrangeBrush}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Status}" Value="Done">
|
||||
<Setter Property="Fill" Value="{StaticResource StatusGreenBrush}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Status}" Value="Failed">
|
||||
<Setter Property="Fill" Value="{StaticResource StatusRedBrush}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Ellipse.Style>
|
||||
</Ellipse>
|
||||
|
||||
<TextBlock Grid.Column="1" Text="{Binding Name}" FontSize="13"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Messages (expandable) -->
|
||||
<ItemsControl ItemsSource="{Binding Messages}" Margin="20,4,0,0"
|
||||
Visibility="{Binding IsExpanded, Converter={StaticResource BoolToVisConverter}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" FontSize="11" FontFamily="Consolas"
|
||||
Foreground="{StaticResource TextDimBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<ProgressBar Grid.Row="2" Value="{Binding OverallProgress}" Maximum="100"
|
||||
Margin="0,12,0,0"
|
||||
Visibility="{Binding IsInstalling, Converter={StaticResource BoolToVisConverter}}"/>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="Cancel" Command="{Binding CancelInstallCommand}"
|
||||
Visibility="{Binding IsInstalling, Converter={StaticResource BoolToVisConverter}}"
|
||||
Margin="0,0,8,0"/>
|
||||
|
||||
<Button Content="Launch ClaudeDo" Command="{Binding LaunchAppCommand}"
|
||||
Style="{StaticResource AccentButton}"
|
||||
Visibility="{Binding IsComplete, Converter={StaticResource BoolToVisConverter}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.InstallPage;
|
||||
|
||||
public partial class InstallPageView : UserControl
|
||||
{
|
||||
public InstallPageView() => InitializeComponent();
|
||||
}
|
||||
163
src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs
Normal file
163
src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.InstallPage;
|
||||
|
||||
public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private InstallPageView? _view;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
public string Title => "Install";
|
||||
public string Icon => "\uE896";
|
||||
public int Order => 99;
|
||||
public bool ShowInWizard => true;
|
||||
public bool ShowInSettings => false;
|
||||
public UserControl View => _view ??= new InstallPageView { DataContext = this };
|
||||
|
||||
public ObservableCollection<StepViewModel> Steps { get; } = [];
|
||||
|
||||
[ObservableProperty] private bool _isInstalling;
|
||||
[ObservableProperty] private bool _isComplete;
|
||||
[ObservableProperty] private bool _hasErrors;
|
||||
[ObservableProperty] private double _overallProgress;
|
||||
|
||||
public InstallPageViewModel(InstallContext context, IServiceProvider serviceProvider)
|
||||
{
|
||||
_context = context;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public Task LoadAsync()
|
||||
{
|
||||
Steps.Clear();
|
||||
if (_context.Mode == InstallerMode.Update)
|
||||
{
|
||||
Steps.Add(new StepViewModel("Stop Worker Service"));
|
||||
Steps.Add(new StepViewModel("Download and Extract"));
|
||||
Steps.Add(new StepViewModel("Start Worker Service"));
|
||||
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Steps.Add(new StepViewModel("Download and Extract"));
|
||||
Steps.Add(new StepViewModel("Write Configuration"));
|
||||
Steps.Add(new StepViewModel("Initialize Database"));
|
||||
Steps.Add(new StepViewModel("Register Windows Service"));
|
||||
Steps.Add(new StepViewModel("Create Shortcuts"));
|
||||
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
|
||||
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ApplyAsync() => RunInstallAsync();
|
||||
|
||||
public bool Validate() => true;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RunInstallAsync()
|
||||
{
|
||||
if (IsInstalling) return;
|
||||
|
||||
IsInstalling = true;
|
||||
IsComplete = false;
|
||||
HasErrors = false;
|
||||
OverallProgress = 0;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
var progress = new Progress<StepProgress>(p =>
|
||||
{
|
||||
var step = Steps.FirstOrDefault(s => s.Name == p.StepName);
|
||||
if (step is null) return;
|
||||
|
||||
step.Status = p.Status;
|
||||
if (p.Message is not null)
|
||||
{
|
||||
// Messages starting with "\r" overwrite the previous line (live progress).
|
||||
if (p.Message.StartsWith('\r'))
|
||||
{
|
||||
var line = p.Message[1..];
|
||||
if (step.Messages.Count > 0 && step.Messages[^1].StartsWith(" "))
|
||||
step.Messages[^1] = line;
|
||||
else
|
||||
step.Messages.Add(line);
|
||||
}
|
||||
else
|
||||
{
|
||||
step.Messages.Add(p.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (p.Status is StepStatus.Running && !step.IsExpanded)
|
||||
step.IsExpanded = true;
|
||||
|
||||
if (p.Status is StepStatus.Done or StepStatus.Failed)
|
||||
{
|
||||
var completed = Steps.Count(s => s.Status is StepStatus.Done or StepStatus.Failed);
|
||||
OverallProgress = (double)completed / Steps.Count * 100;
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
IEnumerable<IInstallStep> steps;
|
||||
if (_context.Mode == InstallerMode.Update)
|
||||
{
|
||||
steps = new IInstallStep[]
|
||||
{
|
||||
_serviceProvider.GetRequiredService<StopServiceStep>(),
|
||||
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
|
||||
_serviceProvider.GetRequiredService<StartServiceStep>(),
|
||||
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
steps = _serviceProvider.GetServices<IInstallStep>();
|
||||
}
|
||||
|
||||
var runner = new InstallerService(steps);
|
||||
var results = await runner.ExecuteAsync(_context, progress, _cts.Token);
|
||||
HasErrors = results.Any(r => !r.Result.Success);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
HasErrors = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsInstalling = false;
|
||||
IsComplete = true;
|
||||
_cts.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelInstall()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void LaunchApp()
|
||||
{
|
||||
var appExe = System.IO.Path.Combine(_context.InstallDirectory, "app", "ClaudeDo.App.exe");
|
||||
if (System.IO.File.Exists(appExe))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(appExe) { UseShellExecute = true });
|
||||
Application.Current.Shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/ClaudeDo.Installer/Pages/InstallPage/StepViewModel.cs
Normal file
17
src/ClaudeDo.Installer/Pages/InstallPage/StepViewModel.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.InstallPage;
|
||||
|
||||
public partial class StepViewModel : ObservableObject
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
[ObservableProperty] private StepStatus _status = StepStatus.Pending;
|
||||
[ObservableProperty] private bool _isExpanded;
|
||||
|
||||
public ObservableCollection<string> Messages { get; } = [];
|
||||
|
||||
public StepViewModel(string name) => Name = name;
|
||||
}
|
||||
41
src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml
Normal file
41
src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml
Normal file
@@ -0,0 +1,41 @@
|
||||
<UserControl x:Class="ClaudeDo.Installer.Pages.PathsPage.PathsPageView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.PathsPage"
|
||||
d:DataContext="{d:DesignInstance local:PathsPageViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel MaxWidth="520">
|
||||
<TextBlock Text="Data Paths" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Configure where ClaudeDo stores its data."
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<Label Content="Database Path"/>
|
||||
<TextBox Text="{Binding DbPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
|
||||
|
||||
<Label Content="Log Directory"/>
|
||||
<TextBox Text="{Binding LogRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
|
||||
|
||||
<Label Content="Sandbox Root"/>
|
||||
<TextBox Text="{Binding SandboxRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
|
||||
|
||||
<Label Content="Worktree Strategy"/>
|
||||
<ComboBox SelectedItem="{Binding WorktreeRootStrategy}" Margin="0,0,0,12">
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">sibling</sys:String>
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">central</sys:String>
|
||||
</ComboBox>
|
||||
|
||||
<StackPanel Visibility="{Binding IsCentralVisible, Converter={StaticResource BoolToVisConverter}}">
|
||||
<Label Content="Central Worktree Root"/>
|
||||
<TextBox Text="{Binding CentralWorktreeRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
|
||||
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.PathsPage;
|
||||
|
||||
public partial class PathsPageView : UserControl
|
||||
{
|
||||
public PathsPageView() => InitializeComponent();
|
||||
}
|
||||
74
src/ClaudeDo.Installer/Pages/PathsPage/PathsPageViewModel.cs
Normal file
74
src/ClaudeDo.Installer/Pages/PathsPage/PathsPageViewModel.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using System.Windows.Controls;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.PathsPage;
|
||||
|
||||
public partial class PathsPageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private PathsPageView? _view;
|
||||
|
||||
public string Title => "Paths";
|
||||
public string Icon => "\uE8B7";
|
||||
public int Order => 1;
|
||||
public bool ShowInWizard => true;
|
||||
public bool ShowInSettings => true;
|
||||
public UserControl View => _view ??= new PathsPageView { DataContext = this };
|
||||
|
||||
[ObservableProperty] private string _dbPath = "~/.todo-app/todo.db";
|
||||
[ObservableProperty] private string _logRoot = "~/.todo-app/logs";
|
||||
[ObservableProperty] private string _sandboxRoot = "~/.todo-app/sandbox";
|
||||
[ObservableProperty] private string _worktreeRootStrategy = "sibling";
|
||||
[ObservableProperty] private string _centralWorktreeRoot = "~/.todo-app/worktrees";
|
||||
[ObservableProperty] private string? _validationError;
|
||||
|
||||
public bool IsCentralVisible => WorktreeRootStrategy == "central";
|
||||
|
||||
public PathsPageViewModel(InstallContext context) => _context = context;
|
||||
|
||||
partial void OnWorktreeRootStrategyChanged(string value) =>
|
||||
OnPropertyChanged(nameof(IsCentralVisible));
|
||||
|
||||
public Task LoadAsync()
|
||||
{
|
||||
var cfg = InstallerWorkerConfig.Load();
|
||||
DbPath = cfg.DbPath;
|
||||
LogRoot = cfg.LogRoot;
|
||||
SandboxRoot = cfg.SandboxRoot;
|
||||
WorktreeRootStrategy = cfg.WorktreeRootStrategy;
|
||||
CentralWorktreeRoot = cfg.CentralWorktreeRoot;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ApplyAsync()
|
||||
{
|
||||
_context.DbPath = DbPath;
|
||||
_context.UiDbPath = DbPath;
|
||||
_context.LogRoot = LogRoot;
|
||||
_context.SandboxRoot = SandboxRoot;
|
||||
_context.WorktreeRootStrategy = WorktreeRootStrategy;
|
||||
_context.CentralWorktreeRoot = CentralWorktreeRoot;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DbPath) ||
|
||||
string.IsNullOrWhiteSpace(LogRoot) ||
|
||||
string.IsNullOrWhiteSpace(SandboxRoot))
|
||||
{
|
||||
ValidationError = "All path fields are required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (WorktreeRootStrategy == "central" && string.IsNullOrWhiteSpace(CentralWorktreeRoot))
|
||||
{
|
||||
ValidationError = "Central worktree root is required when using central strategy.";
|
||||
return false;
|
||||
}
|
||||
|
||||
ValidationError = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<UserControl x:Class="ClaudeDo.Installer.Pages.ServicePage.ServicePageView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.ServicePage"
|
||||
d:DataContext="{d:DesignInstance local:ServicePageViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel MaxWidth="520">
|
||||
<TextBlock Text="Worker Service" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Configure the ClaudeDo Worker background service."
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<Label Content="SignalR Port"/>
|
||||
<TextBox Text="{Binding SignalRPort, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
|
||||
|
||||
<Label Content="Queue Backstop Interval (ms)"/>
|
||||
<TextBox Text="{Binding QueueBackstopIntervalMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
|
||||
|
||||
<Label Content="Claude CLI Path"/>
|
||||
<Grid Margin="0,0,0,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0" Text="{Binding ClaudeBin, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<Button Grid.Column="1" Content="Browse..." Command="{Binding BrowseClaudeCommand}"
|
||||
Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
|
||||
<Separator Margin="0,4,0,12"/>
|
||||
|
||||
<Label Content="Service Account"/>
|
||||
<StackPanel Margin="0,0,0,12">
|
||||
<RadioButton Content="Local System (recommended)"
|
||||
IsChecked="{Binding IsLocalSystem}" Margin="0,0,0,4"/>
|
||||
<RadioButton Content="Current User"
|
||||
IsChecked="{Binding IsCurrentUser}"/>
|
||||
<TextBlock Text="Running as current user requires 'Log on as a service' privilege."
|
||||
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="20,2,0,0"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<CheckBox Content="Start service automatically" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
|
||||
|
||||
<Label Content="Restart Delay (ms)"/>
|
||||
<TextBox Text="{Binding RestartDelayMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
|
||||
|
||||
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
|
||||
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.ServicePage;
|
||||
|
||||
public partial class ServicePageView : UserControl
|
||||
{
|
||||
public ServicePageView() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Windows.Controls;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.ServicePage;
|
||||
|
||||
public partial class ServicePageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private ServicePageView? _view;
|
||||
|
||||
public string Title => "Service";
|
||||
public string Icon => "\uE912";
|
||||
public int Order => 2;
|
||||
public bool ShowInWizard => true;
|
||||
public bool ShowInSettings => true;
|
||||
public UserControl View => _view ??= new ServicePageView { DataContext = this };
|
||||
|
||||
[ObservableProperty] private int _signalRPort = 47_821;
|
||||
[ObservableProperty] private int _queueBackstopIntervalMs = 30_000;
|
||||
[ObservableProperty] private string _claudeBin = "claude";
|
||||
[ObservableProperty] private bool _isLocalSystem = true;
|
||||
[ObservableProperty] private bool _isCurrentUser;
|
||||
[ObservableProperty] private bool _autoStart = true;
|
||||
[ObservableProperty] private int _restartDelayMs = 5000;
|
||||
[ObservableProperty] private string? _validationError;
|
||||
|
||||
public ServicePageViewModel(InstallContext context) => _context = context;
|
||||
|
||||
public Task LoadAsync()
|
||||
{
|
||||
var cfg = InstallerWorkerConfig.Load();
|
||||
SignalRPort = cfg.SignalRPort;
|
||||
QueueBackstopIntervalMs = cfg.QueueBackstopIntervalMs;
|
||||
ClaudeBin = cfg.ClaudeBin;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ApplyAsync()
|
||||
{
|
||||
_context.SignalRPort = SignalRPort;
|
||||
_context.QueueBackstopIntervalMs = QueueBackstopIntervalMs;
|
||||
_context.ClaudeBin = ClaudeBin;
|
||||
_context.ServiceAccount = IsCurrentUser ? "CurrentUser" : "LocalSystem";
|
||||
_context.AutoStart = AutoStart;
|
||||
_context.RestartDelayMs = RestartDelayMs;
|
||||
_context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool Validate()
|
||||
{
|
||||
if (SignalRPort < 1024 || SignalRPort > 65535)
|
||||
{
|
||||
ValidationError = "Port must be between 1024 and 65535.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (QueueBackstopIntervalMs <= 0)
|
||||
{
|
||||
ValidationError = "Queue backstop interval must be greater than 0.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClaudeBin))
|
||||
{
|
||||
ValidationError = "Claude CLI path is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
ValidationError = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void BrowseClaude()
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "Select Claude CLI executable",
|
||||
Filter = "Executables (*.exe)|*.exe|All files (*.*)|*.*",
|
||||
};
|
||||
if (dialog.ShowDialog() == true)
|
||||
ClaudeBin = dialog.FileName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<UserControl x:Class="ClaudeDo.Installer.Pages.UiSettingsPage.UiSettingsPageView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.UiSettingsPage"
|
||||
d:DataContext="{d:DesignInstance local:UiSettingsPageViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel MaxWidth="520">
|
||||
<TextBlock Text="UI Settings" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Configure the ClaudeDo desktop UI connection settings."
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<CheckBox Content="Sync with service settings" IsChecked="{Binding IsSynced}" Margin="0,0,0,16"/>
|
||||
|
||||
<Label Content="SignalR URL"/>
|
||||
<TextBox Text="{Binding SignalRUrl, UpdateSourceTrigger=PropertyChanged}"
|
||||
IsReadOnly="{Binding IsSynced}" Margin="0,0,0,12"/>
|
||||
|
||||
<Label Content="Database Path"/>
|
||||
<TextBox Text="{Binding UiDbPath, UpdateSourceTrigger=PropertyChanged}"
|
||||
IsReadOnly="{Binding IsSynced}" Margin="0,0,0,12"/>
|
||||
|
||||
<TextBlock Text="When synced, these values are derived from the Service and Paths pages."
|
||||
Foreground="{StaticResource TextDimBrush}" FontSize="11" TextWrapping="Wrap"
|
||||
Visibility="{Binding IsSynced, Converter={StaticResource BoolToVisConverter}}"
|
||||
Margin="0,0,0,12"/>
|
||||
|
||||
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
|
||||
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.UiSettingsPage;
|
||||
|
||||
public partial class UiSettingsPageView : UserControl
|
||||
{
|
||||
public UiSettingsPageView() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Windows.Controls;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.UiSettingsPage;
|
||||
|
||||
public partial class UiSettingsPageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private UiSettingsPageView? _view;
|
||||
|
||||
public string Title => "UI Settings";
|
||||
public string Icon => "\uE771";
|
||||
public int Order => 3;
|
||||
public bool ShowInWizard => true;
|
||||
public bool ShowInSettings => true;
|
||||
public UserControl View => _view ??= new UiSettingsPageView { DataContext = this };
|
||||
|
||||
[ObservableProperty] private string _signalRUrl = "http://127.0.0.1:47821/hub";
|
||||
[ObservableProperty] private string _uiDbPath = "~/.todo-app/todo.db";
|
||||
[ObservableProperty] private bool _isSynced = true;
|
||||
[ObservableProperty] private string? _validationError;
|
||||
|
||||
public UiSettingsPageViewModel(InstallContext context) => _context = context;
|
||||
|
||||
partial void OnIsSyncedChanged(bool value)
|
||||
{
|
||||
if (value) SyncFromContext();
|
||||
}
|
||||
|
||||
private void SyncFromContext()
|
||||
{
|
||||
SignalRUrl = $"http://127.0.0.1:{_context.SignalRPort}/hub";
|
||||
UiDbPath = _context.DbPath;
|
||||
}
|
||||
|
||||
public Task LoadAsync()
|
||||
{
|
||||
if (IsSynced)
|
||||
{
|
||||
SyncFromContext();
|
||||
}
|
||||
else
|
||||
{
|
||||
var cfg = InstallerAppSettings.Load();
|
||||
SignalRUrl = cfg.SignalRUrl;
|
||||
UiDbPath = cfg.DbPath;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ApplyAsync()
|
||||
{
|
||||
if (IsSynced) SyncFromContext();
|
||||
_context.SignalRUrl = SignalRUrl;
|
||||
_context.UiDbPath = UiDbPath;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SignalRUrl))
|
||||
{
|
||||
ValidationError = "SignalR URL is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(UiDbPath))
|
||||
{
|
||||
ValidationError = "Database path is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(SignalRUrl, UriKind.Absolute, out _))
|
||||
{
|
||||
ValidationError = "SignalR URL must be a valid URL.";
|
||||
return false;
|
||||
}
|
||||
|
||||
ValidationError = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<UserControl x:Class="ClaudeDo.Installer.Pages.WelcomePage.WelcomePageView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.WelcomePage"
|
||||
d:DataContext="{d:DesignInstance local:WelcomePageViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel MaxWidth="520">
|
||||
|
||||
<TextBlock Text="{Binding Heading}" FontSize="20" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||
<TextBlock Text="{Binding Subheading}" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,24"/>
|
||||
|
||||
<Label Content="Install Directory"/>
|
||||
<Grid Margin="0,0,0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding InstallDirectory, UpdateSourceTrigger=PropertyChanged}"
|
||||
IsEnabled="{Binding InstallDirEditable}"/>
|
||||
<Button Grid.Column="1"
|
||||
Content="Browse..."
|
||||
Margin="8,0,0,0"
|
||||
Command="{Binding BrowseInstallCommand}"
|
||||
IsEnabled="{Binding InstallDirEditable}"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
|
||||
Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.WelcomePage;
|
||||
|
||||
public partial class WelcomePageView : UserControl
|
||||
{
|
||||
public WelcomePageView() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.IO;
|
||||
using System.Windows.Controls;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.WelcomePage;
|
||||
|
||||
public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private WelcomePageView? _view;
|
||||
|
||||
public string Title => "Welcome";
|
||||
public string Icon => "\uE80F";
|
||||
public int Order => 0;
|
||||
public bool ShowInWizard => true;
|
||||
public bool ShowInSettings => false;
|
||||
public UserControl View => _view ??= new WelcomePageView { DataContext = this };
|
||||
|
||||
[ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
|
||||
[ObservableProperty] private string? _installError;
|
||||
[ObservableProperty] private string _heading = "Install ClaudeDo";
|
||||
[ObservableProperty] private string _subheading = "Set the installation directory and continue.";
|
||||
[ObservableProperty] private bool _installDirEditable = true;
|
||||
|
||||
public WelcomePageViewModel(InstallContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task LoadAsync()
|
||||
{
|
||||
InstallDirectory = string.IsNullOrEmpty(_context.InstallDirectory)
|
||||
? @"C:\Program Files\ClaudeDo"
|
||||
: _context.InstallDirectory;
|
||||
|
||||
switch (_context.Mode)
|
||||
{
|
||||
case InstallerMode.FreshInstall:
|
||||
Heading = "Install ClaudeDo";
|
||||
Subheading = "Choose where to install ClaudeDo, then click Next.";
|
||||
InstallDirEditable = true;
|
||||
break;
|
||||
|
||||
case InstallerMode.Update:
|
||||
Heading = $"Update ClaudeDo {_context.InstalledVersion ?? "?"} -> {_context.LatestVersion ?? "?"}";
|
||||
Subheading = "Your tasks, config, and database will be preserved. Click Next to continue.";
|
||||
InstallDirEditable = false; // stay where we were installed
|
||||
break;
|
||||
|
||||
default:
|
||||
// Config and any future modes should never reach the wizard; guard loudly if they do.
|
||||
throw new InvalidOperationException(
|
||||
$"WelcomePage is not valid for installer mode {_context.Mode}");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ApplyAsync()
|
||||
{
|
||||
_context.InstallDirectory = InstallDirectory;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(InstallDirectory))
|
||||
{
|
||||
InstallError = "Install directory is required";
|
||||
return false;
|
||||
}
|
||||
InstallError = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void BrowseInstall()
|
||||
{
|
||||
if (!InstallDirEditable) return;
|
||||
var dialog = new OpenFolderDialog { Title = "Select installation directory" };
|
||||
if (dialog.ShowDialog() == true)
|
||||
InstallDirectory = dialog.FolderName;
|
||||
}
|
||||
}
|
||||
91
src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs
Normal file
91
src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.ComTypes;
|
||||
using System.Text;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class CreateShortcutsStep : IInstallStep
|
||||
{
|
||||
public string Name => "Create Shortcuts";
|
||||
|
||||
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appExe = Path.Combine(ctx.InstallDirectory, "app", "ClaudeDo.App.exe");
|
||||
var workingDir = Path.Combine(ctx.InstallDirectory, "app");
|
||||
|
||||
// Start Menu shortcut
|
||||
var startMenuDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
|
||||
"Programs");
|
||||
Directory.CreateDirectory(startMenuDir);
|
||||
var startMenuPath = Path.Combine(startMenuDir, "ClaudeDo.lnk");
|
||||
CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
|
||||
progress.Report($"Created Start Menu shortcut: {startMenuPath}");
|
||||
|
||||
// Desktop shortcut (optional)
|
||||
if (ctx.CreateDesktopShortcut)
|
||||
{
|
||||
var desktopPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
|
||||
"ClaudeDo.lnk");
|
||||
CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
|
||||
progress.Report($"Created Desktop shortcut: {desktopPath}");
|
||||
}
|
||||
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(StepResult.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
|
||||
{
|
||||
var link = (IShellLink)new ShellLink();
|
||||
link.SetPath(targetPath);
|
||||
link.SetWorkingDirectory(workingDir);
|
||||
link.SetDescription(description);
|
||||
link.SetIconLocation(targetPath, 0);
|
||||
|
||||
var file = (IPersistFile)link;
|
||||
file.Save(shortcutPath, false);
|
||||
}
|
||||
|
||||
#region COM Interop for IShellLink
|
||||
|
||||
[ComImport]
|
||||
[Guid("00021401-0000-0000-C000-000000000046")]
|
||||
private class ShellLink { }
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("000214F9-0000-0000-C000-000000000046")]
|
||||
private interface IShellLink
|
||||
{
|
||||
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
|
||||
void GetIDList(out IntPtr ppidl);
|
||||
void SetIDList(IntPtr pidl);
|
||||
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
|
||||
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
||||
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
|
||||
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
|
||||
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
|
||||
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
|
||||
void GetHotkey(out short pwHotkey);
|
||||
void SetHotkey(short wHotkey);
|
||||
void GetShowCmd(out int piShowCmd);
|
||||
void SetShowCmd(int iShowCmd);
|
||||
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
|
||||
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
|
||||
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
|
||||
void Resolve(IntPtr hwnd, int fFlags);
|
||||
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
100
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
Normal file
100
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class DownloadAndExtractStep : IInstallStep
|
||||
{
|
||||
private readonly IReleaseClient _releases;
|
||||
|
||||
public DownloadAndExtractStep(IReleaseClient releases)
|
||||
{
|
||||
_releases = releases;
|
||||
}
|
||||
|
||||
public string Name => "Download and Extract";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ctx.InstallDirectory))
|
||||
return StepResult.Fail("Install directory is not set.");
|
||||
|
||||
progress.Report("Fetching latest release metadata...");
|
||||
var release = await _releases.GetLatestReleaseAsync(ct);
|
||||
if (release is null)
|
||||
return StepResult.Fail("Could not reach the release server. Check your network connection and try again.");
|
||||
|
||||
var zipAsset = release.Assets.FirstOrDefault(a =>
|
||||
a.Name.StartsWith("ClaudeDo-", StringComparison.OrdinalIgnoreCase) &&
|
||||
a.Name.EndsWith("-win-x64.zip", StringComparison.OrdinalIgnoreCase));
|
||||
var checksumAsset = release.Assets.FirstOrDefault(a =>
|
||||
a.Name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (zipAsset is null)
|
||||
return StepResult.Fail("Release zip asset not found in release metadata.");
|
||||
if (checksumAsset is null)
|
||||
return StepResult.Fail("checksums.txt not found in release metadata.");
|
||||
|
||||
var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(scratchDir);
|
||||
|
||||
try
|
||||
{
|
||||
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
|
||||
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
|
||||
|
||||
var totalMb = zipAsset.Size / (1024 * 1024);
|
||||
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
|
||||
long lastReportedMb = -1;
|
||||
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
|
||||
new Progress<long>(b =>
|
||||
{
|
||||
var mb = b / (1024 * 1024);
|
||||
if (mb == lastReportedMb) return;
|
||||
lastReportedMb = mb;
|
||||
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
|
||||
progress.Report($"\r {mb} / {totalMb} MB downloaded");
|
||||
}),
|
||||
ct);
|
||||
|
||||
progress.Report("Downloading checksums...");
|
||||
await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
|
||||
new Progress<long>(_ => { }), ct);
|
||||
|
||||
progress.Report("Verifying checksum...");
|
||||
var map = ChecksumVerifier.ParseChecksumsFile(await File.ReadAllTextAsync(checksumPath, ct));
|
||||
if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
|
||||
return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");
|
||||
if (!ChecksumVerifier.Verify(zipPath, expectedHash))
|
||||
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
|
||||
|
||||
// Only after verification do we touch the install directory.
|
||||
progress.Report("Clearing previous app/worker binaries...");
|
||||
var appDest = Path.Combine(ctx.InstallDirectory, "app");
|
||||
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
|
||||
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
|
||||
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
|
||||
|
||||
progress.Report("Extracting...");
|
||||
Directory.CreateDirectory(ctx.InstallDirectory);
|
||||
try
|
||||
{
|
||||
ZipFile.ExtractToDirectory(zipPath, ctx.InstallDirectory, overwriteFiles: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StepResult.Fail(
|
||||
$"Extraction failed after old binaries were removed: {ex.Message}. " +
|
||||
"Your install directory may be incomplete. Re-run the installer to retry.");
|
||||
}
|
||||
|
||||
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
|
||||
return StepResult.Ok();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs
Normal file
28
src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class InitDatabaseStep : IInstallStep
|
||||
{
|
||||
public string Name => "Initialize Database";
|
||||
|
||||
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var expandedPath = Paths.Expand(ctx.DbPath);
|
||||
progress.Report($"Initializing database at {expandedPath}");
|
||||
|
||||
var factory = new SqliteConnectionFactory(expandedPath);
|
||||
SchemaInitializer.Apply(factory);
|
||||
|
||||
progress.Report("Schema applied successfully");
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(StepResult.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
Normal file
90
src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class RegisterServiceStep : IInstallStep
|
||||
{
|
||||
private const string ServiceName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Register Windows Service";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
|
||||
if (!File.Exists(workerExe))
|
||||
return StepResult.Fail($"Worker executable not found: {workerExe}");
|
||||
|
||||
// Stop existing service (ignore errors — may not exist)
|
||||
progress.Report("Stopping existing service (if any)...");
|
||||
await RunSc($"stop {ServiceName}", ctx, progress, ct, ignoreErrors: true);
|
||||
|
||||
// Delete existing service (ignore errors)
|
||||
progress.Report("Removing existing service registration (if any)...");
|
||||
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
|
||||
|
||||
// Wait for the service to actually disappear from SCM. `sc delete` returns
|
||||
// immediately but the service stays "marked for deletion" until every open
|
||||
// handle (services.msc, Task Manager, a prior sc query process) is closed.
|
||||
// Poll up to 30s — then fail with actionable guidance if it's still there.
|
||||
progress.Report("Waiting for prior service registration to clear...");
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var (queryExit, _) = await RunSc($"query {ServiceName}", ctx, progress, ct, ignoreErrors: true);
|
||||
if (queryExit != 0) break; // service no longer registered — good
|
||||
if (i == 29)
|
||||
return StepResult.Fail(
|
||||
$"Service '{ServiceName}' is marked for deletion but hasn't cleared after 30s. " +
|
||||
"Close any open Services console (services.msc), Task Manager Services tab, or " +
|
||||
"Event Viewer showing the service, then retry. A reboot will also clear it.");
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
|
||||
// Create service
|
||||
var startType = ctx.AutoStart ? "auto" : "demand";
|
||||
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
|
||||
|
||||
if (ctx.ServiceAccount == "CurrentUser")
|
||||
{
|
||||
var username = Environment.UserName;
|
||||
createArgs += $" obj= \".\\{username}\"";
|
||||
}
|
||||
|
||||
progress.Report("Creating service...");
|
||||
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
|
||||
if (exitCode == 1072)
|
||||
return StepResult.Fail(
|
||||
$"Service '{ServiceName}' is still marked for deletion. " +
|
||||
"Close services.msc / Task Manager / Event Viewer and retry, or reboot.");
|
||||
if (exitCode != 0)
|
||||
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
|
||||
|
||||
// Configure restart policy
|
||||
var delay = ctx.RestartDelayMs;
|
||||
var failureArgs = $"failure {ServiceName} reset= 86400 actions= restart/{delay}/restart/{delay}/restart/{delay}";
|
||||
progress.Report("Configuring restart policy...");
|
||||
var (failExit, failOutput) = await RunSc(failureArgs, ctx, progress, ct);
|
||||
if (failExit != 0)
|
||||
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
|
||||
|
||||
// Start service if auto-start
|
||||
if (ctx.AutoStart)
|
||||
{
|
||||
progress.Report("Starting service...");
|
||||
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
|
||||
if (startExit != 0)
|
||||
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
|
||||
}
|
||||
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string Output)> RunSc(
|
||||
string arguments, InstallContext ctx, IProgress<string> progress,
|
||||
CancellationToken ct, bool ignoreErrors = false)
|
||||
{
|
||||
var result = await ProcessRunner.RunAsync("sc.exe", arguments, null, progress, ct);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
27
src/ClaudeDo.Installer/Steps/StartServiceStep.cs
Normal file
27
src/ClaudeDo.Installer/Steps/StartServiceStep.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StartServiceStep : IInstallStep
|
||||
{
|
||||
private const string ServiceName = StopServiceStep.ServiceName;
|
||||
|
||||
public string Name => "Start Worker Service";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report($"Starting {ServiceName}...");
|
||||
|
||||
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
|
||||
if (exit == 0) return StepResult.Ok();
|
||||
|
||||
// Exit 1056 = ERROR_SERVICE_ALREADY_RUNNING — that's fine too.
|
||||
if (exit == 1056)
|
||||
{
|
||||
progress.Report("Service was already running.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
|
||||
}
|
||||
}
|
||||
54
src/ClaudeDo.Installer/Steps/StopServiceStep.cs
Normal file
54
src/ClaudeDo.Installer/Steps/StopServiceStep.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StopServiceStep : IInstallStep
|
||||
{
|
||||
public const string ServiceName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Stop Worker Service";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report($"Stopping {ServiceName} (if running)...");
|
||||
|
||||
// sc.exe query -> returns non-zero if the service does not exist; that's fine.
|
||||
var (queryExit, queryOutput) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
|
||||
if (queryExit != 0)
|
||||
{
|
||||
progress.Report("Service is not registered — nothing to stop.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
if (queryOutput.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
progress.Report("Service is already stopped.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
|
||||
// 1062 = ERROR_SERVICE_NOT_ACTIVE — registered but not running, treat as already stopped.
|
||||
if (stopExit == 1062)
|
||||
{
|
||||
progress.Report("Service was registered but not running.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
if (stopExit != 0)
|
||||
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
|
||||
|
||||
// Poll until stopped or timeout (up to 30s).
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await Task.Delay(1000, ct);
|
||||
var (e, o) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
|
||||
if (e != 0 || o.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
progress.Report("Service stopped.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
return StepResult.Fail("Service did not stop within 30 seconds.");
|
||||
}
|
||||
}
|
||||
42
src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
Normal file
42
src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class WriteConfigStep : IInstallStep
|
||||
{
|
||||
public string Name => "Write Configuration";
|
||||
|
||||
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var workerCfg = new InstallerWorkerConfig
|
||||
{
|
||||
DbPath = ctx.DbPath,
|
||||
SandboxRoot = ctx.SandboxRoot,
|
||||
LogRoot = ctx.LogRoot,
|
||||
WorktreeRootStrategy = ctx.WorktreeRootStrategy,
|
||||
CentralWorktreeRoot = ctx.CentralWorktreeRoot,
|
||||
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
|
||||
SignalRPort = ctx.SignalRPort,
|
||||
ClaudeBin = ctx.ClaudeBin,
|
||||
};
|
||||
workerCfg.Save();
|
||||
progress.Report("Written worker.config.json");
|
||||
|
||||
var uiCfg = new InstallerAppSettings
|
||||
{
|
||||
DbPath = ctx.UiDbPath,
|
||||
SignalRUrl = ctx.SignalRUrl,
|
||||
};
|
||||
uiCfg.Save();
|
||||
progress.Report("Written ui.config.json");
|
||||
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(StepResult.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
Normal file
32
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class WriteInstallManifestStep : IInstallStep
|
||||
{
|
||||
public string Name => "Write Install Manifest";
|
||||
|
||||
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ctx.InstalledVersion))
|
||||
return Task.FromResult(StepResult.Fail("Installed version is not set — DownloadAndExtractStep must run first."));
|
||||
|
||||
try
|
||||
{
|
||||
var manifest = new InstallManifest(
|
||||
Version: ctx.InstalledVersion,
|
||||
InstallDir: ctx.InstallDirectory,
|
||||
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
|
||||
InstalledAt: DateTimeOffset.UtcNow);
|
||||
|
||||
InstallManifestStore.Write(ctx.InstallDirectory, manifest);
|
||||
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(StepResult.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs
Normal file
81
src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Registers ClaudeDo under <c>HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo</c>
|
||||
/// so it shows up in Windows "Apps & Features" / "Programs and Features".
|
||||
/// Also copies the running installer into the install directory so there is an exe
|
||||
/// for UninstallString to reference after the temp-extracted single-file bundle is gone.
|
||||
/// </summary>
|
||||
public sealed class WriteUninstallRegistryStep : IInstallStep
|
||||
{
|
||||
internal const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo";
|
||||
|
||||
public string Name => "Register in Add/Remove Programs";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
var uninstallDir = Path.Combine(ctx.InstallDirectory, "uninstaller");
|
||||
Directory.CreateDirectory(uninstallDir);
|
||||
var targetExe = Path.Combine(uninstallDir, "ClaudeDo.Installer.exe");
|
||||
|
||||
// Copy the running installer so Apps & Features has a stable exe to launch —
|
||||
// the single-file temp extract is gone once this process exits.
|
||||
var sourceExe = Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Cannot resolve running installer path.");
|
||||
try
|
||||
{
|
||||
progress.Report("Copying uninstaller binary...");
|
||||
File.Copy(sourceExe, targetExe, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
|
||||
}
|
||||
|
||||
progress.Report("Writing Add/Remove Programs entry...");
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath, writable: true);
|
||||
if (key is null)
|
||||
return StepResult.Fail("Could not open uninstall registry key (permission denied?).");
|
||||
|
||||
key.SetValue("DisplayName", "ClaudeDo", RegistryValueKind.String);
|
||||
key.SetValue("DisplayVersion", ctx.InstallerVersion ?? "0.0.0", RegistryValueKind.String);
|
||||
key.SetValue("Publisher", "Mika Kuns", RegistryValueKind.String);
|
||||
key.SetValue("InstallLocation", ctx.InstallDirectory, RegistryValueKind.String);
|
||||
key.SetValue("UninstallString", $"\"{targetExe}\"", RegistryValueKind.String);
|
||||
key.SetValue("DisplayIcon", targetExe, RegistryValueKind.String);
|
||||
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
|
||||
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
||||
|
||||
// Best-effort install size (KB) — scan install dir.
|
||||
try
|
||||
{
|
||||
var sizeKb = (int)(DirectorySizeBytes(ctx.InstallDirectory) / 1024);
|
||||
key.SetValue("EstimatedSize", sizeKb, RegistryValueKind.DWord);
|
||||
}
|
||||
catch { /* best-effort only */ }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StepResult.Fail($"Failed to write uninstall registry: {ex.Message}");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
private static long DirectorySizeBytes(string path)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
try { total += new FileInfo(file).Length; } catch { /* ignore */ }
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
373
src/ClaudeDo.Installer/Theme/DarkTheme.xaml
Normal file
373
src/ClaudeDo.Installer/Theme/DarkTheme.xaml
Normal file
@@ -0,0 +1,373 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Color palette — mirrored from ClaudeDo.App App.axaml
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Accent: Forest Teal -->
|
||||
<Color x:Key="AccentColor">#3d9474</Color>
|
||||
<Color x:Key="AccentLightColor">#6bb89e</Color>
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#3d9474"/>
|
||||
<SolidColorBrush x:Key="AccentLightBrush" Color="#6bb89e"/>
|
||||
<SolidColorBrush x:Key="AccentSubtleBrush" Color="#1A3D9474"/>
|
||||
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#263D9474"/>
|
||||
|
||||
<!-- Text -->
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#f1f5f9"/>
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#c8d0dc"/>
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#8892a2"/>
|
||||
<SolidColorBrush x:Key="TextDimBrush" Color="#6b7688"/>
|
||||
|
||||
<!-- Borders & Backgrounds -->
|
||||
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#3a3f46"/>
|
||||
<SolidColorBrush x:Key="WindowBgBrush" Color="#1c1e21"/>
|
||||
<SolidColorBrush x:Key="IslandBgBrush" Color="#272a2e"/>
|
||||
<SolidColorBrush x:Key="SidebarBgBrush" Color="#272a2e"/>
|
||||
<SolidColorBrush x:Key="ContentBgBrush" Color="#272a2e"/>
|
||||
|
||||
<!-- Status -->
|
||||
<SolidColorBrush x:Key="StatusGrayBrush" Color="#475569"/>
|
||||
<SolidColorBrush x:Key="StatusOrangeBrush" Color="#e67e22"/>
|
||||
<SolidColorBrush x:Key="StatusGreenBrush" Color="#3d9474"/>
|
||||
<SolidColorBrush x:Key="StatusRedBrush" Color="#ef4444"/>
|
||||
|
||||
<!-- Selection highlights -->
|
||||
<SolidColorBrush x:Key="SelectionBrush" Color="#333d9474"/>
|
||||
<SolidColorBrush x:Key="SelectionHoverBrush" Color="#1A3D9474"/>
|
||||
<SolidColorBrush x:Key="SelectionActiveHoverBrush" Color="#403D9474"/>
|
||||
|
||||
<!-- Validation -->
|
||||
<SolidColorBrush x:Key="ErrorBrush" Color="#ef4444"/>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Global control styles
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Window -->
|
||||
<Style TargetType="Window">
|
||||
<Setter Property="Background" Value="{StaticResource WindowBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="FontFamily" Value="Segoe UI"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
|
||||
<!-- UserControl — transparent so window background shows through -->
|
||||
<Style TargetType="UserControl">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ContentControl — transparent container -->
|
||||
<Style TargetType="ContentControl">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- TextBlock -->
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Label -->
|
||||
<Style TargetType="Label">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Padding" Value="0,0,0,2"/>
|
||||
</Style>
|
||||
|
||||
<!-- TextBox -->
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="8,6"/>
|
||||
<Setter Property="CaretBrush" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="SelectionBrush" Value="{StaticResource AccentSubtleBrush}"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsFocused" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsReadOnly" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- PasswordBox -->
|
||||
<Style TargetType="PasswordBox">
|
||||
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="8,6"/>
|
||||
<Setter Property="CaretBrush" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsFocused" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- Button (default) -->
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="16,6"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="4"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentSubtleBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.4"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Accent Button style -->
|
||||
<Style x:Key="AccentButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="16,6"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="4"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentLightBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.4"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ComboBox toggle button (dropdown arrow chrome) -->
|
||||
<ControlTemplate x:Key="ComboBoxToggleButtonTemplate" TargetType="ToggleButton">
|
||||
<Border x:Name="Bd"
|
||||
Background="{StaticResource IslandBgBrush}"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="20"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Path Grid.Column="1"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Fill="{StaticResource TextSecondaryBrush}"
|
||||
Data="M 0 0 L 4 4 L 8 0 Z"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
|
||||
<!-- ComboBox -->
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="8,6"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton x:Name="ToggleButton"
|
||||
Template="{StaticResource ComboBoxToggleButtonTemplate}"
|
||||
Focusable="False"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
ClickMode="Press"/>
|
||||
<ContentPresenter x:Name="ContentSite"
|
||||
IsHitTestVisible="False"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Left"
|
||||
TextElement.Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<Popup x:Name="Popup"
|
||||
Placement="Bottom"
|
||||
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||
AllowsTransparency="True" Focusable="False"
|
||||
PopupAnimation="Slide">
|
||||
<Border x:Name="DropDownBorder"
|
||||
Background="{StaticResource IslandBgBrush}"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
MinWidth="{TemplateBinding ActualWidth}"
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}">
|
||||
<ScrollViewer SnapsToDevicePixels="True">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ComboBoxItem — dark dropdown rows -->
|
||||
<Style TargetType="ComboBoxItem">
|
||||
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Padding" Value="8,6"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- CheckBox -->
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Padding" Value="4,0,0,0"/>
|
||||
</Style>
|
||||
|
||||
<!-- RadioButton -->
|
||||
<Style TargetType="RadioButton">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Padding" Value="4,0,0,0"/>
|
||||
</Style>
|
||||
|
||||
<!-- ListBox -->
|
||||
<Style TargetType="ListBox">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
</Style>
|
||||
|
||||
<!-- ListBoxItem -->
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Padding" Value="10,8"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="Bd"
|
||||
Background="Transparent"
|
||||
CornerRadius="4"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
|
||||
</Trigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsSelected" Value="True"/>
|
||||
<Condition Property="IsMouseOver" Value="True"/>
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionActiveHoverBrush}"/>
|
||||
</MultiTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ProgressBar -->
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Height" Value="6"/>
|
||||
</Style>
|
||||
|
||||
<!-- ScrollViewer -->
|
||||
<Style TargetType="ScrollViewer">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Border — default transparent -->
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
</Style>
|
||||
|
||||
<!-- ItemsControl -->
|
||||
<Style TargetType="ItemsControl">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Separator -->
|
||||
<Style TargetType="Separator">
|
||||
<Setter Property="Background" Value="{StaticResource BorderSubtleBrush}"/>
|
||||
<Setter Property="Height" Value="1"/>
|
||||
<Setter Property="Margin" Value="0,8"/>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
161
src/ClaudeDo.Installer/Views/SettingsViewModel.cs
Normal file
161
src/ClaudeDo.Installer/Views/SettingsViewModel.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Windows;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Installer.Views;
|
||||
|
||||
public partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private readonly IReleaseClient _releases;
|
||||
private readonly StopServiceStep _stopService;
|
||||
private readonly StartServiceStep _startService;
|
||||
private readonly DownloadAndExtractStep _downloadStep;
|
||||
private readonly UninstallRunner _uninstallRunner;
|
||||
|
||||
public IReadOnlyList<IInstallerPage> Pages { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private IInstallerPage? _selectedPage;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _statusMessage;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isStatusError;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _versionLabel = "";
|
||||
|
||||
public SettingsViewModel(
|
||||
PageResolver resolver,
|
||||
InstallContext context,
|
||||
IReleaseClient releases,
|
||||
StopServiceStep stopService,
|
||||
StartServiceStep startService,
|
||||
DownloadAndExtractStep downloadStep,
|
||||
UninstallRunner uninstallRunner)
|
||||
{
|
||||
Pages = resolver.SettingsPages;
|
||||
_context = context;
|
||||
_releases = releases;
|
||||
_stopService = stopService;
|
||||
_startService = startService;
|
||||
_downloadStep = downloadStep;
|
||||
_uninstallRunner = uninstallRunner;
|
||||
_selectedPage = Pages.FirstOrDefault();
|
||||
|
||||
VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
|
||||
|
||||
_ = LoadAllAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAllAsync()
|
||||
{
|
||||
foreach (var page in Pages)
|
||||
await page.LoadAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Save()
|
||||
{
|
||||
foreach (var page in Pages)
|
||||
{
|
||||
if (!page.Validate())
|
||||
{
|
||||
SelectedPage = page;
|
||||
StatusMessage = $"Validation failed on {page.Title}. Please fix the errors.";
|
||||
IsStatusError = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var page in Pages)
|
||||
await page.ApplyAsync();
|
||||
|
||||
var workerCfg = new InstallerWorkerConfig
|
||||
{
|
||||
DbPath = _context.DbPath,
|
||||
SandboxRoot = _context.SandboxRoot,
|
||||
LogRoot = _context.LogRoot,
|
||||
WorktreeRootStrategy = _context.WorktreeRootStrategy,
|
||||
CentralWorktreeRoot = _context.CentralWorktreeRoot,
|
||||
QueueBackstopIntervalMs = _context.QueueBackstopIntervalMs,
|
||||
SignalRPort = _context.SignalRPort,
|
||||
ClaudeBin = _context.ClaudeBin,
|
||||
};
|
||||
workerCfg.Save();
|
||||
|
||||
var uiCfg = new InstallerAppSettings
|
||||
{
|
||||
DbPath = _context.UiDbPath,
|
||||
SignalRUrl = _context.SignalRUrl,
|
||||
};
|
||||
uiCfg.Save();
|
||||
|
||||
StatusMessage = "Settings saved.";
|
||||
IsStatusError = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Repair()
|
||||
{
|
||||
var confirm = MessageBox.Show(
|
||||
"Re-download and reinstall the ClaudeDo binaries? Your config and database are NOT affected.",
|
||||
"Repair ClaudeDo",
|
||||
MessageBoxButton.OKCancel,
|
||||
MessageBoxImage.Question);
|
||||
|
||||
if (confirm != MessageBoxResult.OK) return;
|
||||
|
||||
StatusMessage = "Repairing...";
|
||||
IsStatusError = false;
|
||||
|
||||
var progress = new Progress<string>(msg => StatusMessage = msg);
|
||||
var steps = new IInstallStep[] { _stopService, _downloadStep, _startService };
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
var r = await step.ExecuteAsync(_context, progress, CancellationToken.None);
|
||||
if (!r.Success)
|
||||
{
|
||||
StatusMessage = $"{step.Name} failed: {r.ErrorMessage}";
|
||||
IsStatusError = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
StatusMessage = "Repair complete.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Uninstall()
|
||||
{
|
||||
var confirm = MessageBox.Show(
|
||||
"This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
|
||||
"Uninstall ClaudeDo",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning);
|
||||
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
|
||||
var progress = new Progress<string>(msg => StatusMessage = msg);
|
||||
var r = await _uninstallRunner.RunAsync(progress, CancellationToken.None);
|
||||
|
||||
if (!r.Success)
|
||||
{
|
||||
StatusMessage = $"Uninstall failed: {r.ErrorMessage}";
|
||||
IsStatusError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
MessageBox.Show("ClaudeDo has been removed.", "Uninstall complete",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
Application.Current.Shutdown();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => Application.Current.Shutdown();
|
||||
}
|
||||
103
src/ClaudeDo.Installer/Views/SettingsWindow.xaml
Normal file
103
src/ClaudeDo.Installer/Views/SettingsWindow.xaml
Normal file
@@ -0,0 +1,103 @@
|
||||
<Window x:Class="ClaudeDo.Installer.Views.SettingsWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
|
||||
Title="ClaudeDo Settings"
|
||||
Icon="/ClaudeTaskSetup.ico"
|
||||
Width="720" Height="520"
|
||||
MinWidth="620" MinHeight="460"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource WindowBgBrush}"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
FontFamily="Segoe UI"
|
||||
FontSize="13"
|
||||
d:DataContext="{d:DesignInstance views:SettingsViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Main content area -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<Border Grid.Column="0" Background="{StaticResource SidebarBgBrush}"
|
||||
Padding="8,12">
|
||||
<ListBox ItemsSource="{Binding Pages}"
|
||||
SelectedItem="{Binding SelectedPage}"
|
||||
HorizontalContentAlignment="Stretch">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Icon}" FontSize="14" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Title}" FontSize="13"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<!-- Separator -->
|
||||
<Border Grid.Column="1" Width="1" Background="{StaticResource BorderSubtleBrush}"/>
|
||||
|
||||
<!-- Page content -->
|
||||
<Border Grid.Column="2" Padding="24,20">
|
||||
<ContentControl Content="{Binding SelectedPage.View}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<Border Grid.Row="1" Background="{StaticResource IslandBgBrush}"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,1,0,0"
|
||||
Padding="20,12">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Status message / version label -->
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding VersionLabel}" FontSize="11" Opacity="0.7"/>
|
||||
<TextBlock Text="{Binding StatusMessage}" FontSize="12">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsStatusError}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
|
||||
Command="{Binding UninstallCommand}"/>
|
||||
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
|
||||
Command="{Binding RepairCommand}"/>
|
||||
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
|
||||
Command="{Binding SaveCommand}"
|
||||
Style="{StaticResource AccentButton}"/>
|
||||
<Button Grid.Column="4" Content="Close"
|
||||
Command="{Binding CloseCommand}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
11
src/ClaudeDo.Installer/Views/SettingsWindow.xaml.cs
Normal file
11
src/ClaudeDo.Installer/Views/SettingsWindow.xaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace ClaudeDo.Installer.Views;
|
||||
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
public SettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
85
src/ClaudeDo.Installer/Views/WizardViewModel.cs
Normal file
85
src/ClaudeDo.Installer/Views/WizardViewModel.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Pages.InstallPage;
|
||||
using ClaudeDo.Installer.Pages.WelcomePage;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Installer.Views;
|
||||
|
||||
public partial class WizardViewModel : ObservableObject
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
|
||||
public IReadOnlyList<IInstallerPage> Pages { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(CanGoBack))]
|
||||
[NotifyPropertyChangedFor(nameof(IsLastPage))]
|
||||
[NotifyPropertyChangedFor(nameof(NextButtonText))]
|
||||
[NotifyPropertyChangedFor(nameof(CurrentPage))]
|
||||
private int _currentPageIndex;
|
||||
|
||||
public IInstallerPage CurrentPage => Pages[CurrentPageIndex];
|
||||
public bool CanGoBack => CurrentPageIndex > 0;
|
||||
public bool IsLastPage => CurrentPageIndex == Pages.Count - 1;
|
||||
public string NextButtonText => IsLastPage ? "Install" : "Next \u2192";
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _validationError;
|
||||
|
||||
public WizardViewModel(PageResolver resolver, InstallContext context)
|
||||
{
|
||||
_context = context;
|
||||
|
||||
var all = resolver.WizardPages;
|
||||
Pages = context.Mode == InstallerMode.Update
|
||||
? all.Where(p => p is WelcomePageViewModel
|
||||
|| p is InstallPageViewModel).ToList()
|
||||
: all;
|
||||
|
||||
if (Pages.Count > 0)
|
||||
_ = InitAsync();
|
||||
}
|
||||
|
||||
private async Task InitAsync()
|
||||
{
|
||||
try { await Pages[0].LoadAsync(); }
|
||||
catch { /* first page loads with defaults on error */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task GoBack()
|
||||
{
|
||||
if (!CanGoBack) return;
|
||||
CurrentPageIndex--;
|
||||
await CurrentPage.LoadAsync();
|
||||
ValidationError = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task GoNext()
|
||||
{
|
||||
if (!CurrentPage.Validate())
|
||||
{
|
||||
ValidationError = "Please fix the highlighted errors before continuing.";
|
||||
return;
|
||||
}
|
||||
|
||||
ValidationError = null;
|
||||
await CurrentPage.ApplyAsync();
|
||||
|
||||
if (CurrentPageIndex < Pages.Count - 1)
|
||||
{
|
||||
CurrentPageIndex++;
|
||||
await CurrentPage.LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close()
|
||||
{
|
||||
Application.Current.Shutdown();
|
||||
}
|
||||
}
|
||||
100
src/ClaudeDo.Installer/Views/WizardWindow.xaml
Normal file
100
src/ClaudeDo.Installer/Views/WizardWindow.xaml
Normal file
@@ -0,0 +1,100 @@
|
||||
<Window x:Class="ClaudeDo.Installer.Views.WizardWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
|
||||
Title="ClaudeDo Installer"
|
||||
Icon="/ClaudeTaskSetup.ico"
|
||||
Width="720" Height="520"
|
||||
MinWidth="620" MinHeight="460"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource WindowBgBrush}"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
FontFamily="Segoe UI"
|
||||
FontSize="13"
|
||||
d:DataContext="{d:DesignInstance views:WizardViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Step Indicator -->
|
||||
<Border Grid.Row="0" Background="{StaticResource IslandBgBrush}"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,0,0,1"
|
||||
Padding="20,14">
|
||||
<ItemsControl ItemsSource="{Binding Pages}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border x:Name="StepBorder" CornerRadius="4" Padding="10,5" Margin="0,0,6,0"
|
||||
BorderThickness="1">
|
||||
<Border.Background>
|
||||
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="Background">
|
||||
<Binding/>
|
||||
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
|
||||
</MultiBinding>
|
||||
</Border.Background>
|
||||
<Border.BorderBrush>
|
||||
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="BorderBrush">
|
||||
<Binding/>
|
||||
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
|
||||
</MultiBinding>
|
||||
</Border.BorderBrush>
|
||||
<TextBlock Text="{Binding Title}" FontSize="12">
|
||||
<TextBlock.Foreground>
|
||||
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="Foreground">
|
||||
<Binding/>
|
||||
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
|
||||
</MultiBinding>
|
||||
</TextBlock.Foreground>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<!-- Page Content -->
|
||||
<Border Grid.Row="1" Padding="24,20">
|
||||
<ContentControl Content="{Binding CurrentPage.View}"/>
|
||||
</Border>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<Border Grid.Row="2" Background="{StaticResource IslandBgBrush}"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,1,0,0"
|
||||
Padding="20,12">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Validation error -->
|
||||
<TextBlock Grid.Column="0" Text="{Binding ValidationError}"
|
||||
Foreground="{StaticResource ErrorBrush}"
|
||||
VerticalAlignment="Center" FontSize="12"
|
||||
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
|
||||
|
||||
<Button Grid.Column="1" Content="Back"
|
||||
Command="{Binding GoBackCommand}"
|
||||
IsEnabled="{Binding CanGoBack}"
|
||||
Margin="0,0,8,0" MinWidth="80"/>
|
||||
|
||||
<Button Grid.Column="2" Content="{Binding NextButtonText}"
|
||||
Command="{Binding GoNextCommand}"
|
||||
Style="{StaticResource AccentButton}"
|
||||
MinWidth="100"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
11
src/ClaudeDo.Installer/Views/WizardWindow.xaml.cs
Normal file
11
src/ClaudeDo.Installer/Views/WizardWindow.xaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace ClaudeDo.Installer.Views;
|
||||
|
||||
public partial class WizardWindow : Window
|
||||
{
|
||||
public WizardWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
16
src/ClaudeDo.Installer/app.debug.manifest
Normal file
16
src/ClaudeDo.Installer/app.debug.manifest
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="ClaudeDo.Installer"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
16
src/ClaudeDo.Installer/app.manifest
Normal file
16
src/ClaudeDo.Installer/app.manifest
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="ClaudeDo.Installer"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
23
src/ClaudeDo.Ui/ViewModels/SubtaskItemViewModel.cs
Normal file
23
src/ClaudeDo.Ui/ViewModels/SubtaskItemViewModel.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class SubtaskItemViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _title = string.Empty;
|
||||
[ObservableProperty] private bool _completed;
|
||||
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string? OriginalTitle { get; set; }
|
||||
public bool OriginalCompleted { get; set; }
|
||||
|
||||
public static SubtaskItemViewModel From(SubtaskEntity e) => new()
|
||||
{
|
||||
Id = e.Id,
|
||||
Title = e.Title,
|
||||
Completed = e.Completed,
|
||||
OriginalTitle = e.Title,
|
||||
OriginalCompleted = e.Completed,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
private readonly GitService _git;
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly TagRepository _tagRepo;
|
||||
private readonly SubtaskRepository _subtaskRepo;
|
||||
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string? _description;
|
||||
@@ -28,9 +29,14 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _statusText = "";
|
||||
[ObservableProperty] private string _statusChoice = "Manual";
|
||||
[ObservableProperty] private string _commitType = "chore";
|
||||
[ObservableProperty] private string _modelChoice = "(list default)";
|
||||
[ObservableProperty] private string? _systemPromptOverride;
|
||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||
public List<AgentInfo> AvailableAgents { get; } = [];
|
||||
|
||||
public static string[] StatusChoices { get; } = ["Manual", "Queued", "Running", "Done", "Failed"];
|
||||
public static string[] CommitTypes { get; } = ["feat", "fix", "refactor", "docs", "test", "chore", "ci", "perf", "style", "build"];
|
||||
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
|
||||
|
||||
// Worktree
|
||||
[ObservableProperty] private bool _hasWorktree;
|
||||
@@ -44,15 +50,21 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
private StreamLineFormatter _formatter = new();
|
||||
public ObservableCollection<TagEntity> Tags { get; } = new();
|
||||
[ObservableProperty] private string _newTagInput = "";
|
||||
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
||||
|
||||
private string? _taskId;
|
||||
private string? _listId;
|
||||
private bool _isLoading;
|
||||
// Cancels an in-flight LoadAsync when a new TaskUpdated event arrives
|
||||
// before the previous load finished — prevents torn state on _taskId,
|
||||
// Subtasks, Tags, etc.
|
||||
private CancellationTokenSource? _loadCts;
|
||||
|
||||
public event Action<string>? TaskChanged;
|
||||
|
||||
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
|
||||
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo)
|
||||
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo,
|
||||
SubtaskRepository subtaskRepo)
|
||||
{
|
||||
_taskRepo = taskRepo;
|
||||
_worktreeRepo = worktreeRepo;
|
||||
@@ -60,6 +72,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
_git = git;
|
||||
_worker = worker;
|
||||
_tagRepo = tagRepo;
|
||||
_subtaskRepo = subtaskRepo;
|
||||
|
||||
worker.TaskMessageEvent += OnTaskMessage;
|
||||
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
||||
@@ -70,43 +83,98 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
|
||||
public async Task LoadAsync(string taskId)
|
||||
{
|
||||
// Cancel any in-flight load so rapid TaskUpdated events don't race
|
||||
// on _taskId / Subtasks / Tags. The newest caller wins.
|
||||
var oldCts = _loadCts;
|
||||
var cts = new CancellationTokenSource();
|
||||
_loadCts = cts;
|
||||
oldCts?.Cancel();
|
||||
oldCts?.Dispose();
|
||||
var ct = cts.Token;
|
||||
|
||||
_taskId = taskId;
|
||||
LiveText = "";
|
||||
_formatter = new StreamLineFormatter();
|
||||
|
||||
var task = await _taskRepo.GetByIdAsync(taskId);
|
||||
if (task is null) return;
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
_listId = task.ListId;
|
||||
Title = task.Title;
|
||||
Description = task.Description;
|
||||
Result = task.Result;
|
||||
LogPath = task.LogPath;
|
||||
if (task.LogPath is not null
|
||||
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
|
||||
&& File.Exists(task.LogPath))
|
||||
var task = await _taskRepo.GetByIdAsync(taskId, ct);
|
||||
if (task is null) return;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (AvailableAgents.Count == 0)
|
||||
{
|
||||
_formatter = new StreamLineFormatter();
|
||||
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath));
|
||||
var agents = await _worker.GetAgentsAsync();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
AvailableAgents.AddRange(agents);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
||||
StatusChoice = task.Status.ToString();
|
||||
CommitType = task.CommitType;
|
||||
|
||||
Tags.Clear();
|
||||
var tags = await _taskRepo.GetTagsAsync(taskId);
|
||||
foreach (var tag in tags)
|
||||
Tags.Add(tag);
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
_listId = task.ListId;
|
||||
Title = task.Title;
|
||||
Description = task.Description;
|
||||
Result = task.Result;
|
||||
LogPath = task.LogPath;
|
||||
if (task.LogPath is not null
|
||||
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
|
||||
&& File.Exists(task.LogPath))
|
||||
{
|
||||
_formatter = new StreamLineFormatter();
|
||||
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
|
||||
}
|
||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
||||
StatusChoice = task.Status.ToString();
|
||||
CommitType = task.CommitType;
|
||||
ModelChoice = task.Model is not null
|
||||
? ListEditorViewModel.ModelIdToDisplay(task.Model)
|
||||
: "(list default)";
|
||||
SystemPromptOverride = task.SystemPrompt;
|
||||
if (task.AgentPath is not null)
|
||||
{
|
||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
|
||||
if (match is null)
|
||||
{
|
||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
|
||||
AvailableAgents.Add(match);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = match;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedAgent = null;
|
||||
}
|
||||
|
||||
Tags.Clear();
|
||||
var tags = await _taskRepo.GetTagsAsync(taskId, ct);
|
||||
foreach (var tag in tags)
|
||||
Tags.Add(tag);
|
||||
|
||||
// Tear down old subtask subscriptions before replacing them.
|
||||
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||
Subtasks.Clear();
|
||||
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct);
|
||||
foreach (var s in subtasks)
|
||||
{
|
||||
var vm = SubtaskItemViewModel.From(s);
|
||||
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
||||
Subtasks.Add(vm);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
await LoadWorktreeAsync(taskId);
|
||||
}
|
||||
finally
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_isLoading = false;
|
||||
// Superseded by a newer LoadAsync — nothing to do.
|
||||
}
|
||||
|
||||
await LoadWorktreeAsync(taskId);
|
||||
}
|
||||
|
||||
public async Task SaveAsync()
|
||||
@@ -119,6 +187,11 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
entity.Title = Title;
|
||||
entity.Description = Description;
|
||||
entity.CommitType = CommitType;
|
||||
entity.Model = ModelChoice != "(list default)"
|
||||
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
|
||||
: null;
|
||||
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
||||
entity.AgentPath = SelectedAgent?.Path;
|
||||
|
||||
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
|
||||
entity.Status = status;
|
||||
@@ -155,8 +228,76 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
TaskChanged?.Invoke(_taskId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddSubtask()
|
||||
{
|
||||
if (_taskId is null) return;
|
||||
var entity = new SubtaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TaskId = _taskId,
|
||||
Title = "",
|
||||
Completed = false,
|
||||
OrderNum = Subtasks.Count,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _subtaskRepo.AddAsync(entity);
|
||||
var vm = SubtaskItemViewModel.From(entity);
|
||||
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
||||
Subtasks.Add(vm);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveSubtask(SubtaskItemViewModel item)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(item.Id))
|
||||
await _subtaskRepo.DeleteAsync(item.Id);
|
||||
item.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||
Subtasks.Remove(item);
|
||||
}
|
||||
|
||||
private async void OnSubtaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
|
||||
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
|
||||
try
|
||||
{
|
||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity
|
||||
{
|
||||
Id = vm.Id,
|
||||
TaskId = _taskId ?? "",
|
||||
Title = vm.Title,
|
||||
Completed = vm.Completed,
|
||||
OrderNum = Subtasks.IndexOf(vm),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// async void must never throw — surface via Debug.
|
||||
Debug.WriteLine($"[TaskDetailViewModel] Subtask update failed for {vm.Id}: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
public void SetAgentFromPath(string path)
|
||||
{
|
||||
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
|
||||
AvailableAgents.Add(existing);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = existing;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
// Cancel any load in flight so it doesn't resurrect state after Clear.
|
||||
_loadCts?.Cancel();
|
||||
_loadCts?.Dispose();
|
||||
_loadCts = null;
|
||||
|
||||
_taskId = null;
|
||||
_listId = null;
|
||||
Title = "";
|
||||
@@ -169,8 +310,13 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
_formatter = new StreamLineFormatter();
|
||||
Tags.Clear();
|
||||
NewTagInput = "";
|
||||
foreach (var s in Subtasks) s.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||
Subtasks.Clear();
|
||||
StatusChoice = "Manual";
|
||||
CommitType = "chore";
|
||||
ModelChoice = "(list default)";
|
||||
SystemPromptOverride = null;
|
||||
SelectedAgent = null;
|
||||
}
|
||||
|
||||
private async Task LoadWorktreeAsync(string taskId)
|
||||
@@ -299,12 +445,28 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
private async void OnWorktreeUpdated(string taskId)
|
||||
{
|
||||
if (taskId != _taskId) return;
|
||||
await LoadWorktreeAsync(taskId);
|
||||
try
|
||||
{
|
||||
await LoadWorktreeAsync(taskId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// async void must never throw.
|
||||
Debug.WriteLine($"[TaskDetailViewModel] OnWorktreeUpdated failed for {taskId}: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnTaskUpdated(string taskId)
|
||||
{
|
||||
if (taskId != _taskId) return;
|
||||
await LoadAsync(taskId);
|
||||
try
|
||||
{
|
||||
await LoadAsync(taskId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// async void must never throw.
|
||||
Debug.WriteLine($"[TaskDetailViewModel] OnTaskUpdated failed for {taskId}: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -8,6 +11,8 @@ namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class TaskEditorViewModel : ViewModelBase
|
||||
{
|
||||
private readonly SubtaskRepository _subtaskRepo;
|
||||
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string? _description;
|
||||
[ObservableProperty] private string _commitType = "chore";
|
||||
@@ -18,6 +23,7 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
[ObservableProperty] private string? _systemPromptOverride;
|
||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||
public List<AgentInfo> AvailableAgents { get; set; } = [];
|
||||
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
||||
|
||||
private string? _editId;
|
||||
private string _listId = "";
|
||||
@@ -34,11 +40,28 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
public static string[] StatusChoices { get; } =
|
||||
["manual", "queued"];
|
||||
|
||||
public TaskEditorViewModel(SubtaskRepository subtaskRepo)
|
||||
{
|
||||
_subtaskRepo = subtaskRepo;
|
||||
}
|
||||
|
||||
public async Task LoadAgentsAsync(WorkerClient worker)
|
||||
{
|
||||
AvailableAgents = await worker.GetAgentsAsync();
|
||||
}
|
||||
|
||||
public void SetAgentFromPath(string path)
|
||||
{
|
||||
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
|
||||
AvailableAgents.Add(existing);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = existing;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> SelectedTagNames =>
|
||||
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct()
|
||||
@@ -51,8 +74,54 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
_createdAt = DateTime.UtcNow;
|
||||
CommitType = defaultCommitType;
|
||||
WindowTitle = "New Task";
|
||||
Subtasks.Clear();
|
||||
}
|
||||
|
||||
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
|
||||
{
|
||||
_editId = entity.Id;
|
||||
_listId = entity.ListId;
|
||||
_createdAt = entity.CreatedAt;
|
||||
Title = entity.Title;
|
||||
Description = entity.Description;
|
||||
CommitType = entity.CommitType;
|
||||
StatusChoice = entity.Status switch
|
||||
{
|
||||
TaskStatus.Manual => "manual",
|
||||
TaskStatus.Queued => "queued",
|
||||
_ => entity.Status.ToString().ToLowerInvariant(),
|
||||
};
|
||||
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
|
||||
ModelChoice = entity.Model is not null
|
||||
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
||||
: "(list default)";
|
||||
SystemPromptOverride = entity.SystemPrompt;
|
||||
|
||||
if (entity.AgentPath is not null)
|
||||
{
|
||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
|
||||
if (match is null)
|
||||
{
|
||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
|
||||
AvailableAgents.Add(match);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = match;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedAgent = null;
|
||||
}
|
||||
|
||||
WindowTitle = $"Edit Task: {entity.Title}";
|
||||
|
||||
Subtasks.Clear();
|
||||
var list = await _subtaskRepo.GetByTaskIdAsync(entity.Id, ct);
|
||||
foreach (var s in list)
|
||||
Subtasks.Add(SubtaskItemViewModel.From(s));
|
||||
}
|
||||
|
||||
// Keep old sync overload for callers that haven't loaded agents yet
|
||||
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
|
||||
{
|
||||
_editId = entity.Id;
|
||||
@@ -72,14 +141,34 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
||||
: "(list default)";
|
||||
SystemPromptOverride = entity.SystemPrompt;
|
||||
SelectedAgent = entity.AgentPath is not null
|
||||
? AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath)
|
||||
: null;
|
||||
|
||||
if (entity.AgentPath is not null)
|
||||
{
|
||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
|
||||
if (match is null)
|
||||
{
|
||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
|
||||
AvailableAgents.Add(match);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = match;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedAgent = null;
|
||||
}
|
||||
|
||||
WindowTitle = $"Edit Task: {entity.Title}";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
private void AddSubtask() => Subtasks.Add(new SubtaskItemViewModel());
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSubtask(SubtaskItemViewModel item) => Subtasks.Remove(item);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Save()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Title)) return;
|
||||
var status = StatusChoice switch
|
||||
@@ -87,9 +176,10 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
"queued" => TaskStatus.Queued,
|
||||
_ => TaskStatus.Manual,
|
||||
};
|
||||
var taskId = _editId ?? Guid.NewGuid().ToString();
|
||||
var entity = new TaskEntity
|
||||
{
|
||||
Id = _editId ?? Guid.NewGuid().ToString(),
|
||||
Id = taskId,
|
||||
ListId = _listId,
|
||||
Title = Title.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
|
||||
@@ -102,6 +192,42 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
: null;
|
||||
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
||||
entity.AgentPath = SelectedAgent?.Path;
|
||||
|
||||
// Persist subtask changes
|
||||
if (_editId is not null)
|
||||
{
|
||||
var existing = await _subtaskRepo.GetByTaskIdAsync(taskId);
|
||||
var existingIds = existing.Select(s => s.Id).ToHashSet();
|
||||
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
|
||||
|
||||
// Deleted
|
||||
foreach (var id in existingIds.Except(currentIds))
|
||||
await _subtaskRepo.DeleteAsync(id);
|
||||
|
||||
// Updated
|
||||
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
|
||||
{
|
||||
if (vm.Id == "") continue;
|
||||
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
|
||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
|
||||
else
|
||||
{
|
||||
// update order_num if position changed
|
||||
var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
|
||||
if (orig is not null && orig.OrderNum != idx)
|
||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Added (id == "" means new)
|
||||
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
|
||||
var newId = Guid.NewGuid().ToString();
|
||||
await _subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
_tcs.TrySetResult(entity);
|
||||
RequestClose?.Invoke();
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ public partial class TaskListViewModel : ViewModelBase
|
||||
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
|
||||
var editor = _editorFactory();
|
||||
await editor.LoadAgentsAsync(_worker);
|
||||
editor.InitForEdit(entity, taskTags);
|
||||
await editor.InitForEditAsync(entity, taskTags);
|
||||
|
||||
var window = new TaskEditorView { DataContext = editor };
|
||||
editor.RequestClose += () => window.Close();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
x:Class="ClaudeDo.Ui.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="ClaudeDo"
|
||||
Icon="avares://ClaudeDo.App/Assets/ClaudeTask.ico"
|
||||
MinWidth="800" MinHeight="500"
|
||||
KeyDown="OnGlobalKeyDown">
|
||||
|
||||
@@ -18,7 +19,7 @@
|
||||
|
||||
<!-- Lists island -->
|
||||
<Border Grid.Column="0" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
|
||||
MinWidth="180" MaxWidth="320" Margin="0,0,4,8" ClipToBounds="True">
|
||||
MinWidth="180" Margin="0,0,4,8" ClipToBounds="True">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top"
|
||||
Text="Lists" FontWeight="SemiBold" FontSize="13"
|
||||
@@ -95,7 +96,7 @@
|
||||
|
||||
<!-- Detail island -->
|
||||
<Border Grid.Column="2" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
|
||||
MinWidth="280" MaxWidth="500" Margin="4,0,0,8" ClipToBounds="True">
|
||||
MinWidth="280" Margin="4,0,0,8" ClipToBounds="True">
|
||||
<v:TaskDetailView DataContext="{Binding TaskDetail}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -86,6 +86,71 @@
|
||||
PlaceholderText="Add a description..."
|
||||
LostFocus="OnFieldLostFocus"/>
|
||||
|
||||
<!-- Sub-Tasks -->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,8,0,4"/>
|
||||
<TextBlock Text="Sub-Tasks" FontWeight="Bold" FontSize="13"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<ItemsControl ItemsSource="{Binding Subtasks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,2,0,2">
|
||||
<CheckBox IsChecked="{Binding Completed}" VerticalAlignment="Center"/>
|
||||
<TextBox Text="{Binding Title}" PlaceholderText="Subtask title..." Width="220"
|
||||
VerticalAlignment="Center"
|
||||
LostFocus="OnSubtaskTitleLostFocus"/>
|
||||
<Button Content="✕" Padding="6,2"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource TextMutedBrush}"
|
||||
Command="{Binding $parent[UserControl].((vm:TaskDetailViewModel)DataContext).RemoveSubtaskCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Content="+ Add Sub-Task" Command="{Binding AddSubtaskCommand}"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource AccentLightBrush}" HorizontalAlignment="Left"/>
|
||||
|
||||
<!-- Agent Config (overrides) -->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,8,0,4"/>
|
||||
<TextBlock Text="Agent Config (overrides)" FontWeight="Bold" FontSize="13"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
|
||||
<Grid ColumnDefinitions="*,12,*" Margin="0,4,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Model" FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<ComboBox ItemsSource="{Binding ModelChoices}"
|
||||
SelectedItem="{Binding ModelChoice}"
|
||||
MinWidth="100"
|
||||
LostFocus="OnFieldLostFocus"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Agent File" FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<ComboBox ItemsSource="{Binding AvailableAgents}"
|
||||
SelectedItem="{Binding SelectedAgent}"
|
||||
MinWidth="100"
|
||||
LostFocus="OnFieldLostFocus">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="m:AgentInfo">
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Content="…" Click="OnBrowseAgent" ToolTip.Tip="Browse for agent .md file"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="System Prompt" FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,2"/>
|
||||
<TextBox Text="{Binding SystemPromptOverride}"
|
||||
PlaceholderText="(inherits from list)"
|
||||
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"
|
||||
LostFocus="OnFieldLostFocus"/>
|
||||
|
||||
<!-- === READ-ONLY ZONE === -->
|
||||
|
||||
<TextBlock Text="Result" FontWeight="SemiBold" FontSize="12"
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
@@ -19,6 +20,31 @@ public partial class TaskDetailView : UserControl
|
||||
await vm.SaveAsync();
|
||||
}
|
||||
|
||||
private void OnSubtaskTitleLostFocus(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// Title change is handled by SubtaskItemViewModel.PropertyChanged → OnSubtaskPropertyChanged in the VM
|
||||
}
|
||||
|
||||
private async void OnBrowseAgent(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is null) return;
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Select Agent File",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[] { new FilePickerFileType("Agent Files") { Patterns = ["*.md"] } },
|
||||
});
|
||||
if (files.Count == 0) return;
|
||||
var path = files[0].TryGetLocalPath();
|
||||
if (path is null) return;
|
||||
if (DataContext is TaskDetailViewModel vm)
|
||||
{
|
||||
vm.SetAgentFromPath(path);
|
||||
await vm.SaveAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTagInputKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter && DataContext is TaskDetailViewModel vm)
|
||||
|
||||
@@ -35,6 +35,30 @@
|
||||
<TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/>
|
||||
|
||||
<!-- Sub-Tasks -->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
|
||||
<TextBlock Text="Sub-Tasks" FontWeight="Bold" FontSize="13"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<ItemsControl ItemsSource="{Binding Subtasks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,2,0,2">
|
||||
<CheckBox IsChecked="{Binding Completed}" VerticalAlignment="Center"/>
|
||||
<TextBox Text="{Binding Title}" PlaceholderText="Subtask title..." Width="320"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Content="✕" Padding="6,2"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource TextMutedBrush}"
|
||||
Command="{Binding $parent[Window].((vm:TaskEditorViewModel)DataContext).RemoveSubtaskCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Content="+ Add Sub-Task" Command="{Binding AddSubtaskCommand}"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource AccentLightBrush}" HorizontalAlignment="Left"/>
|
||||
|
||||
<!-- Divider -->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
|
||||
|
||||
@@ -55,15 +79,18 @@
|
||||
|
||||
<TextBlock Text="Agent File" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<ComboBox ItemsSource="{Binding AvailableAgents}"
|
||||
SelectedItem="{Binding SelectedAgent}"
|
||||
MinWidth="150">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:AgentInfo">
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ComboBox ItemsSource="{Binding AvailableAgents}"
|
||||
SelectedItem="{Binding SelectedAgent}"
|
||||
MinWidth="150">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:AgentInfo">
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Content="…" Click="OnBrowseAgent" ToolTip.Tip="Browse for agent .md file"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
|
||||
@@ -8,4 +11,19 @@ public partial class TaskEditorView : Window
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void OnBrowseAgent(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Select Agent File",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[] { new FilePickerFileType("Agent Files") { Patterns = ["*.md"] } },
|
||||
});
|
||||
if (files.Count == 0) return;
|
||||
var path = files[0].TryGetLocalPath();
|
||||
if (path is null) return;
|
||||
if (DataContext is TaskEditorViewModel vm)
|
||||
vm.SetAgentFromPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -10,6 +10,10 @@ var cfg = WorkerConfig.Load();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// When launched by the Windows SCM, speak the Service Control Protocol so SCM
|
||||
// doesn't think we crashed (~30s timeout). No-op when running interactively.
|
||||
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
|
||||
|
||||
// Initialize DB schema before the host starts accepting connections.
|
||||
var dbFactory = new SqliteConnectionFactory(cfg.DbPath);
|
||||
SchemaInitializer.Apply(dbFactory);
|
||||
@@ -19,6 +23,7 @@ builder.Services.AddSingleton(dbFactory);
|
||||
builder.Services.AddSingleton<TagRepository>();
|
||||
builder.Services.AddSingleton<ListRepository>();
|
||||
builder.Services.AddSingleton<TaskRepository>();
|
||||
builder.Services.AddSingleton<SubtaskRepository>();
|
||||
builder.Services.AddSingleton<WorktreeRepository>();
|
||||
builder.Services.AddSingleton<TaskRunRepository>();
|
||||
builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||
|
||||
@@ -45,6 +45,9 @@ public sealed class ClaudeProcess : IClaudeProcess
|
||||
var analyzer = new StreamAnalyzer();
|
||||
var lastStderr = new StringBuilder();
|
||||
|
||||
// On cancellation: kill the tree. Killing closes the redirected pipes,
|
||||
// which unblocks the ReadLineAsync loops below (which run without ct
|
||||
// so they reliably drain instead of hanging on cancellation).
|
||||
await using var ctr = ct.Register(() =>
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); }
|
||||
@@ -53,26 +56,30 @@ public sealed class ClaudeProcess : IClaudeProcess
|
||||
|
||||
var stdoutTask = Task.Run(async () =>
|
||||
{
|
||||
while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
|
||||
while (await process.StandardOutput.ReadLineAsync() is { } line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
await onStdoutLine(line);
|
||||
analyzer.ProcessLine(line);
|
||||
}
|
||||
}, ct);
|
||||
});
|
||||
|
||||
var stderrTask = Task.Run(async () =>
|
||||
{
|
||||
while (await process.StandardError.ReadLineAsync(ct) is { } line)
|
||||
while (await process.StandardError.ReadLineAsync() is { } line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
lastStderr.AppendLine(line);
|
||||
await onStdoutLine($"[stderr] {line}");
|
||||
}
|
||||
}, ct);
|
||||
});
|
||||
|
||||
await Task.WhenAll(stdoutTask, stderrTask);
|
||||
await process.WaitForExitAsync(ct);
|
||||
await process.WaitForExitAsync(CancellationToken.None);
|
||||
|
||||
// If we were asked to cancel, surface that to the caller now that
|
||||
// the process is fully reaped.
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var exitCode = process.ExitCode;
|
||||
var streamResult = analyzer.GetResult();
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class TaskRunner
|
||||
private readonly TaskRunRepository _runRepo;
|
||||
private readonly ListRepository _listRepo;
|
||||
private readonly WorktreeRepository _wtRepo;
|
||||
private readonly SubtaskRepository _subtaskRepo;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly WorktreeManager _wtManager;
|
||||
private readonly ClaudeArgsBuilder _argsBuilder;
|
||||
@@ -24,6 +25,7 @@ public sealed class TaskRunner
|
||||
TaskRunRepository runRepo,
|
||||
ListRepository listRepo,
|
||||
WorktreeRepository wtRepo,
|
||||
SubtaskRepository subtaskRepo,
|
||||
HubBroadcaster broadcaster,
|
||||
WorktreeManager wtManager,
|
||||
ClaudeArgsBuilder argsBuilder,
|
||||
@@ -35,6 +37,7 @@ public sealed class TaskRunner
|
||||
_runRepo = runRepo;
|
||||
_listRepo = listRepo;
|
||||
_wtRepo = wtRepo;
|
||||
_subtaskRepo = subtaskRepo;
|
||||
_broadcaster = broadcaster;
|
||||
_wtManager = wtManager;
|
||||
_argsBuilder = argsBuilder;
|
||||
@@ -91,9 +94,16 @@ public sealed class TaskRunner
|
||||
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||
|
||||
// Build prompt.
|
||||
var prompt = string.IsNullOrWhiteSpace(task.Description)
|
||||
? task.Title
|
||||
: $"{task.Title}\n\n{task.Description.Trim()}";
|
||||
var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct);
|
||||
var sb = new System.Text.StringBuilder(task.Title);
|
||||
if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim());
|
||||
if (subtasks.Count > 0)
|
||||
{
|
||||
sb.Append("\n\n## Sub-Tasks\n");
|
||||
foreach (var s in subtasks)
|
||||
sb.Append(s.Completed ? "- [x] " : "- [ ] ").Append(s.Title).Append('\n');
|
||||
}
|
||||
var prompt = sb.ToString();
|
||||
|
||||
// Run 1.
|
||||
var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
|
||||
@@ -222,33 +232,56 @@ public sealed class TaskRunner
|
||||
|
||||
await using var logWriter = new LogWriter(logPath);
|
||||
|
||||
var result = await _claude.RunAsync(
|
||||
arguments,
|
||||
prompt,
|
||||
runDir,
|
||||
async line =>
|
||||
try
|
||||
{
|
||||
var result = await _claude.RunAsync(
|
||||
arguments,
|
||||
prompt,
|
||||
runDir,
|
||||
async line =>
|
||||
{
|
||||
await logWriter.WriteLineAsync(line, ct);
|
||||
await _broadcaster.TaskMessage(taskId, line);
|
||||
},
|
||||
ct);
|
||||
|
||||
// Update the run record with results. Use CancellationToken.None:
|
||||
// this is a terminal write that must always complete, even if the
|
||||
// caller's token is already cancelled.
|
||||
run.SessionId = result.SessionId;
|
||||
run.ResultMarkdown = result.ResultMarkdown;
|
||||
run.StructuredOutputJson = result.StructuredOutputJson;
|
||||
run.ErrorMarkdown = result.ErrorMarkdown;
|
||||
run.ExitCode = result.ExitCode;
|
||||
run.TurnCount = result.TurnCount;
|
||||
run.TokensIn = result.TokensIn;
|
||||
run.TokensOut = result.TokensOut;
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
await _runRepo.UpdateAsync(run, CancellationToken.None);
|
||||
|
||||
// Update denormalized fields on the task.
|
||||
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ensure the run row is completed so ContinueAsync / inspection
|
||||
// isn't left staring at a null session_id / finished_at.
|
||||
run.ErrorMarkdown = "Cancelled.";
|
||||
run.ExitCode = -1;
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
await logWriter.WriteLineAsync(line, ct);
|
||||
await _broadcaster.TaskMessage(taskId, line);
|
||||
},
|
||||
ct);
|
||||
|
||||
// Update the run record with results.
|
||||
run.SessionId = result.SessionId;
|
||||
run.ResultMarkdown = result.ResultMarkdown;
|
||||
run.StructuredOutputJson = result.StructuredOutputJson;
|
||||
run.ErrorMarkdown = result.ErrorMarkdown;
|
||||
run.ExitCode = result.ExitCode;
|
||||
run.TurnCount = result.TurnCount;
|
||||
run.TokensIn = result.TokensIn;
|
||||
run.TokensOut = result.TokensOut;
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
await _runRepo.UpdateAsync(run, ct);
|
||||
|
||||
// Update denormalized fields on the task.
|
||||
await _taskRepo.SetLogPathAsync(taskId, logPath, ct);
|
||||
|
||||
return result;
|
||||
await _runRepo.UpdateAsync(run, CancellationToken.None);
|
||||
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
||||
}
|
||||
catch (Exception updateEx)
|
||||
{
|
||||
_logger.LogError(updateEx, "Failed to finalize cancelled run {RunId} for task {TaskId}", runId, taskId);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
|
||||
@@ -260,8 +293,11 @@ public sealed class TaskRunner
|
||||
await _broadcaster.WorktreeUpdated(task.Id);
|
||||
}
|
||||
|
||||
// Terminal DB write uses CancellationToken.None so the task status
|
||||
// is never left as 'running' because of a cancel that arrived
|
||||
// after the Claude run already succeeded.
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct);
|
||||
await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
||||
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
||||
@@ -269,8 +305,10 @@ public sealed class TaskRunner
|
||||
|
||||
private async Task HandleFailure(string taskId, string slot, RunResult result)
|
||||
{
|
||||
// Intentionally does not accept a CancellationToken: this is the
|
||||
// terminal write for a failed task and must always be persisted.
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown);
|
||||
await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
||||
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
||||
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
|
||||
}
|
||||
@@ -280,7 +318,8 @@ public sealed class TaskRunner
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
await _taskRepo.MarkFailedAsync(taskId, now, error);
|
||||
// Terminal write — never cancel.
|
||||
await _taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
|
||||
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ public sealed class WorktreeManager
|
||||
throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}");
|
||||
|
||||
var baseCommit = await _git.RevParseHeadAsync(workingDir, ct);
|
||||
var shortId = task.Id.Length >= 8 ? task.Id[..8] : task.Id;
|
||||
var branchName = $"claudedo/{shortId}";
|
||||
// Use the full task id (dashes stripped) in the branch name so
|
||||
// two GUIDs sharing an 8-char prefix cannot collide on the same branch.
|
||||
var idForBranch = task.Id.Replace("-", "");
|
||||
var branchName = $"claudedo/{idForBranch}";
|
||||
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
||||
|
||||
var worktreePath = _cfg.WorktreeRootStrategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
@@ -71,6 +71,7 @@ public sealed class QueueService : BackgroundService
|
||||
_ = RunInSlotAsync(task, "override", cts.Token).ContinueWith(_ =>
|
||||
{
|
||||
lock (_lock) { _overrideSlot = null; }
|
||||
cts.Dispose();
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +95,7 @@ public sealed class QueueService : BackgroundService
|
||||
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ =>
|
||||
{
|
||||
lock (_lock) { _overrideSlot = null; }
|
||||
cts.Dispose();
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
@@ -155,6 +157,7 @@ public sealed class QueueService : BackgroundService
|
||||
_ = RunInSlotAsync(task, "queue", cts.Token).ContinueWith(_ =>
|
||||
{
|
||||
lock (_lock) { _queueSlot = null; }
|
||||
cts.Dispose();
|
||||
WakeQueue(); // Check for next task immediately.
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
106
tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
Normal file
106
tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class ChecksumVerifierTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ChecksumVerifierTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoChecksum-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSha256_KnownVector_EmptyFile()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "empty.bin");
|
||||
File.WriteAllBytes(path, Array.Empty<byte>());
|
||||
|
||||
var hash = ChecksumVerifier.ComputeSha256(path);
|
||||
|
||||
Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSha256_KnownVector_Hello()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "hello.bin");
|
||||
File.WriteAllText(path, "hello");
|
||||
|
||||
var hash = ChecksumVerifier.ComputeSha256(path);
|
||||
|
||||
Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsTrue_WhenHashMatches()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "x.bin");
|
||||
File.WriteAllText(path, "hello");
|
||||
|
||||
Assert.True(ChecksumVerifier.Verify(path,
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_IsCaseInsensitive()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "x.bin");
|
||||
File.WriteAllText(path, "hello");
|
||||
|
||||
Assert.True(ChecksumVerifier.Verify(path,
|
||||
"2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsFalse_OnMismatch()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "x.bin");
|
||||
File.WriteAllText(path, "hello");
|
||||
|
||||
Assert.False(ChecksumVerifier.Verify(path, new string('0', 64)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseChecksumsFile_ReadsTwoLines()
|
||||
{
|
||||
var content = """
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ClaudeDo-0.2.0-win-x64.zip
|
||||
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 ClaudeDo.Installer-0.2.0.exe
|
||||
""";
|
||||
|
||||
var map = ChecksumVerifier.ParseChecksumsFile(content);
|
||||
|
||||
Assert.Equal(2, map.Count);
|
||||
Assert.Equal(
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
map["ClaudeDo-0.2.0-win-x64.zip"]);
|
||||
Assert.Equal(
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
map["ClaudeDo.Installer-0.2.0.exe"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseChecksumsFile_SkipsBlankAndMalformedLines()
|
||||
{
|
||||
var content = """
|
||||
|
||||
not a line
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file.zip
|
||||
|
||||
""";
|
||||
|
||||
var map = ChecksumVerifier.ParseChecksumsFile(content);
|
||||
|
||||
Assert.Single(map);
|
||||
Assert.True(map.ContainsKey("file.zip"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<!-- Allow Linux Gitea runners to build this Windows-targeted project; no-op on Windows. -->
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ClaudeDo.Installer\ClaudeDo.Installer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
148
tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
Normal file
148
tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class DownloadAndExtractStepTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string _installDir;
|
||||
|
||||
public DownloadAndExtractStepTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDownloadStep-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_installDir = Path.Combine(_tempDir, "install");
|
||||
Directory.CreateDirectory(_installDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private sealed class FileCopyReleaseClient : IReleaseClient
|
||||
{
|
||||
private readonly Dictionary<string, string> _urlToSourceFile;
|
||||
public GiteaRelease? Release { get; set; }
|
||||
|
||||
public FileCopyReleaseClient(Dictionary<string, string> urlToSourceFile)
|
||||
=> _urlToSourceFile = urlToSourceFile;
|
||||
|
||||
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
|
||||
|
||||
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
||||
{
|
||||
File.Copy(_urlToSourceFile[url], destPath, overwrite: true);
|
||||
progress.Report(new FileInfo(destPath).Length);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Extracts_Zip_Into_InstallDir_App_And_Worker()
|
||||
{
|
||||
var zipPath = Path.Combine(_tempDir, "release.zip");
|
||||
using (var fs = File.Create(zipPath))
|
||||
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
|
||||
{
|
||||
var a = zip.CreateEntry("app/a.txt");
|
||||
using (var w = new StreamWriter(a.Open())) w.Write("hello-app");
|
||||
var b = zip.CreateEntry("worker/b.txt");
|
||||
using (var w = new StreamWriter(b.Open())) w.Write("hello-worker");
|
||||
}
|
||||
|
||||
var zipHash = ChecksumVerifier.ComputeSha256(zipPath);
|
||||
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
|
||||
File.WriteAllText(checksumsPath, $"{zipHash} ClaudeDo-0.1.0-win-x64.zip\n");
|
||||
|
||||
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
|
||||
{
|
||||
new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", new FileInfo(zipPath).Length),
|
||||
new ReleaseAsset("checksums.txt", "fake://checksums", new FileInfo(checksumsPath).Length),
|
||||
});
|
||||
|
||||
var client = new FileCopyReleaseClient(new()
|
||||
{
|
||||
["fake://zip"] = zipPath,
|
||||
["fake://checksums"] = checksumsPath,
|
||||
}) { Release = release };
|
||||
|
||||
var step = new DownloadAndExtractStep(client);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
|
||||
Assert.Equal("hello-worker", File.ReadAllText(Path.Combine(_installDir, "worker", "b.txt")));
|
||||
Assert.Equal("0.1.0", ctx.InstalledVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_On_ChecksumMismatch_Without_Overwriting_InstallDir()
|
||||
{
|
||||
var zipPath = Path.Combine(_tempDir, "release.zip");
|
||||
using (var fs = File.Create(zipPath))
|
||||
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
|
||||
{
|
||||
var a = zip.CreateEntry("app/a.txt");
|
||||
using (var w = new StreamWriter(a.Open())) w.Write("x");
|
||||
}
|
||||
|
||||
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
|
||||
File.WriteAllText(checksumsPath, $"{new string('0', 64)} ClaudeDo-0.1.0-win-x64.zip\n");
|
||||
|
||||
File.WriteAllText(Path.Combine(_installDir, "marker.txt"), "untouched");
|
||||
|
||||
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
|
||||
{
|
||||
new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", 0),
|
||||
new ReleaseAsset("checksums.txt", "fake://checksums", 0),
|
||||
});
|
||||
|
||||
var client = new FileCopyReleaseClient(new()
|
||||
{
|
||||
["fake://zip"] = zipPath,
|
||||
["fake://checksums"] = checksumsPath,
|
||||
}) { Release = release };
|
||||
|
||||
var step = new DownloadAndExtractStep(client);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("checksum", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(File.Exists(Path.Combine(_installDir, "marker.txt")));
|
||||
Assert.False(Directory.Exists(Path.Combine(_installDir, "app")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_When_Release_Has_No_Zip_Asset()
|
||||
{
|
||||
var release = new GiteaRelease("v0.1.0", "v0.1.0", Array.Empty<ReleaseAsset>());
|
||||
var client = new FileCopyReleaseClient(new()) { Release = release };
|
||||
var step = new DownloadAndExtractStep(client);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_When_ReleaseClient_Returns_Null()
|
||||
{
|
||||
var client = new FileCopyReleaseClient(new()) { Release = null };
|
||||
var step = new DownloadAndExtractStep(client);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
32
tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs
Normal file
32
tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||
private readonly object _lock = new();
|
||||
private readonly List<HttpRequestMessage> _requests = new();
|
||||
|
||||
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public FakeHttpMessageHandler(HttpStatusCode status, string body)
|
||||
: this(_ => new HttpResponseMessage(status) { Content = new StringContent(body) })
|
||||
{
|
||||
}
|
||||
|
||||
public IReadOnlyList<HttpRequestMessage> Requests
|
||||
{
|
||||
get { lock (_lock) return _requests.ToArray(); }
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock) _requests.Add(request);
|
||||
return Task.FromResult(_handler(request));
|
||||
}
|
||||
}
|
||||
76
tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs
Normal file
76
tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class InstallManifestStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public InstallManifestStoreTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoInstallerTests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_ReturnsNull_WhenFileMissing()
|
||||
{
|
||||
var result = InstallManifestStore.TryRead(_tempDir);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_Then_Read_RoundTripsAllFields()
|
||||
{
|
||||
var manifest = new InstallManifest(
|
||||
Version: "0.2.0",
|
||||
InstallDir: _tempDir,
|
||||
WorkerDir: Path.Combine(_tempDir, "worker"),
|
||||
InstalledAt: new DateTimeOffset(2026, 4, 15, 12, 34, 56, TimeSpan.Zero));
|
||||
|
||||
InstallManifestStore.Write(_tempDir, manifest);
|
||||
|
||||
var round = InstallManifestStore.TryRead(_tempDir);
|
||||
Assert.NotNull(round);
|
||||
Assert.Equal("0.2.0", round!.Version);
|
||||
Assert.Equal(manifest.InstallDir, round.InstallDir);
|
||||
Assert.Equal(manifest.WorkerDir, round.WorkerDir);
|
||||
Assert.Equal(manifest.InstalledAt, round.InstalledAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_CreatesInstallDir_IfMissing()
|
||||
{
|
||||
var nested = Path.Combine(_tempDir, "nested");
|
||||
Assert.False(Directory.Exists(nested));
|
||||
|
||||
InstallManifestStore.Write(nested, new InstallManifest(
|
||||
"0.0.1", nested, Path.Combine(nested, "worker"), DateTimeOffset.UtcNow));
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(nested, "install.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_ReturnsNull_WhenJsonMalformed()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_tempDir, "install.json"), "{ not json");
|
||||
var result = InstallManifestStore.TryRead(_tempDir);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_ReturnsNull_WhenJsonIsValidButShapeIsWrong()
|
||||
{
|
||||
// Valid JSON, but installedAt has a wrong type — causes JsonException, swallowed silently.
|
||||
File.WriteAllText(Path.Combine(_tempDir, "install.json"),
|
||||
"""{"version":"1.0","installDir":"x","workerDir":"y","installedAt":12345}""");
|
||||
var result = InstallManifestStore.TryRead(_tempDir);
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
124
tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
Normal file
124
tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class InstallModeDetectorTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public InstallModeDetectorTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDetector-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private sealed class FakeReleaseClient : IReleaseClient
|
||||
{
|
||||
public GiteaRelease? Release { get; set; }
|
||||
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
|
||||
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_FreshInstall_WhenManifestMissing()
|
||||
{
|
||||
var detector = new InstallModeDetector(new FakeReleaseClient());
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.FreshInstall, state.Mode);
|
||||
Assert.Null(state.Existing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_Config_WhenManifestPresent_And_Api_Unreachable()
|
||||
{
|
||||
InstallManifestStore.Write(_tempDir,
|
||||
new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
|
||||
|
||||
var detector = new InstallModeDetector(new FakeReleaseClient { Release = null });
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.Config, state.Mode);
|
||||
Assert.Equal("0.1.0", state.Existing!.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_Update_WhenLatest_GreaterThan_Installed()
|
||||
{
|
||||
InstallManifestStore.Write(_tempDir,
|
||||
new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
|
||||
|
||||
var fake = new FakeReleaseClient
|
||||
{
|
||||
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
|
||||
};
|
||||
var detector = new InstallModeDetector(fake);
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.Update, state.Mode);
|
||||
Assert.Equal("0.1.0", state.Existing!.Version);
|
||||
Assert.Equal("0.2.0", state.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_Config_WhenLatest_EqualsOrOlderThan_Installed()
|
||||
{
|
||||
InstallManifestStore.Write(_tempDir,
|
||||
new InstallManifest("0.2.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
|
||||
|
||||
var fake = new FakeReleaseClient
|
||||
{
|
||||
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
|
||||
};
|
||||
var detector = new InstallModeDetector(fake);
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.Config, state.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_Config_WhenInstalledIs_Newer_Than_Latest()
|
||||
{
|
||||
InstallManifestStore.Write(_tempDir,
|
||||
new InstallManifest("0.3.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
|
||||
|
||||
var fake = new FakeReleaseClient
|
||||
{
|
||||
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
|
||||
};
|
||||
var detector = new InstallModeDetector(fake);
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.Config, state.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_Config_WhenInstalledVersion_IsUnparseable()
|
||||
{
|
||||
// install.json has been tampered with or written by an older installer with a
|
||||
// version string we can't compare. Must not crash; must land on Config (no update).
|
||||
InstallManifestStore.Write(_tempDir,
|
||||
new InstallManifest("garbage", _tempDir, _tempDir, DateTimeOffset.UtcNow));
|
||||
|
||||
var fake = new FakeReleaseClient
|
||||
{
|
||||
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
|
||||
};
|
||||
var detector = new InstallModeDetector(fake);
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.Config, state.Mode);
|
||||
}
|
||||
}
|
||||
109
tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
Normal file
109
tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class ReleaseClientTests
|
||||
{
|
||||
private const string ApiBase = "https://git.example.test/api/v1/repos/releases/ClaudeDo";
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestReleaseAsync_ParsesTagAndAssets()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"tag_name": "v0.2.0",
|
||||
"name": "v0.2.0",
|
||||
"assets": [
|
||||
{
|
||||
"name": "ClaudeDo-0.2.0-win-x64.zip",
|
||||
"browser_download_url": "https://git.example.test/dl/zip",
|
||||
"size": 12345
|
||||
},
|
||||
{
|
||||
"name": "checksums.txt",
|
||||
"browser_download_url": "https://git.example.test/dl/checksums",
|
||||
"size": 128
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
|
||||
using var http = new HttpClient(handler);
|
||||
var client = new ReleaseClient(http, ApiBase);
|
||||
|
||||
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal("v0.2.0", release!.TagName);
|
||||
Assert.Equal(2, release.Assets.Count);
|
||||
Assert.Equal("ClaudeDo-0.2.0-win-x64.zip", release.Assets[0].Name);
|
||||
Assert.Equal("https://git.example.test/dl/zip", release.Assets[0].BrowserDownloadUrl);
|
||||
Assert.Equal(12345, release.Assets[0].Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestReleaseAsync_Returns_Null_On404()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound, "");
|
||||
using var http = new HttpClient(handler);
|
||||
var client = new ReleaseClient(http, ApiBase);
|
||||
|
||||
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||
|
||||
Assert.Null(release);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestReleaseAsync_Returns_Null_OnNetworkError()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("boom"));
|
||||
using var http = new HttpClient(handler);
|
||||
var client = new ReleaseClient(http, ApiBase);
|
||||
|
||||
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||
|
||||
Assert.Null(release);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestReleaseAsync_Hits_CorrectUrl()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, "{\"tag_name\":\"v0.1.0\",\"assets\":[]}");
|
||||
using var http = new HttpClient(handler);
|
||||
var client = new ReleaseClient(http, ApiBase);
|
||||
|
||||
_ = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(handler.Requests);
|
||||
Assert.Equal($"{ApiBase}/releases/latest", handler.Requests[0].RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAsync_WritesBytesToDisk()
|
||||
{
|
||||
var payload = new byte[] { 1, 2, 3, 4, 5 };
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(payload)
|
||||
});
|
||||
using var http = new HttpClient(handler);
|
||||
var client = new ReleaseClient(http, ApiBase);
|
||||
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), "ClaudeDoDlTest-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
await client.DownloadAsync("https://example/foo", tempPath,
|
||||
new Progress<long>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.True(File.Exists(tempPath));
|
||||
Assert.Equal(payload, File.ReadAllBytes(tempPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempPath)) File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class WriteInstallManifestStepTests : IDisposable
|
||||
{
|
||||
private readonly string _installDir;
|
||||
|
||||
public WriteInstallManifestStepTests()
|
||||
{
|
||||
_installDir = Path.Combine(Path.GetTempPath(), "ClaudeDoWriteManifest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_installDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_installDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writes_Manifest_WithAllFields()
|
||||
{
|
||||
var ctx = new InstallContext
|
||||
{
|
||||
InstallDirectory = _installDir,
|
||||
InstalledVersion = "0.2.0",
|
||||
};
|
||||
|
||||
var step = new WriteInstallManifestStep();
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var manifest = InstallManifestStore.TryRead(_installDir);
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal("0.2.0", manifest!.Version);
|
||||
Assert.Equal(_installDir, manifest.InstallDir);
|
||||
Assert.Equal(Path.Combine(_installDir, "worker"), manifest.WorkerDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_When_InstalledVersion_Missing()
|
||||
{
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir }; // no version set
|
||||
|
||||
var step = new WriteInstallManifestStep();
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public class WorktreeManagerTests : IDisposable
|
||||
|
||||
Assert.NotNull(ctx);
|
||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||
Assert.Equal($"claudedo/{task.Id[..8]}", ctx.BranchName);
|
||||
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
|
||||
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
||||
|
||||
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
||||
|
||||
@@ -51,7 +51,8 @@ public sealed class QueueServiceTests : IDisposable
|
||||
var runRepo = new TaskRunRepository(_db.Factory);
|
||||
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var argsBuilder = new ClaudeArgsBuilder();
|
||||
var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, broadcaster, wtManager, argsBuilder, _cfg,
|
||||
var subtaskRepo = new SubtaskRepository(_db.Factory);
|
||||
var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, subtaskRepo, broadcaster, wtManager, argsBuilder, _cfg,
|
||||
NullLogger<TaskRunner>.Instance);
|
||||
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
|
||||
return (service, fake);
|
||||
|
||||
Reference in New Issue
Block a user