diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 64b7bb09..b177e90c 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -1,6 +1,9 @@ name: Build artifacts on: + push: + tags: + - 'v*.*.*' workflow_dispatch: inputs: version: @@ -13,7 +16,11 @@ permissions: contents: read env: - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} + +concurrency: + group: build-artifacts-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} + cancel-in-progress: false jobs: frontend: @@ -57,7 +64,7 @@ jobs: build: needs: frontend - name: Build ${{ matrix.target.platform }} ${{ matrix.arch }} + name: Build ${{ matrix.target.platform }} ${{ matrix.arch.label }} runs-on: ${{ matrix.target.runner }} strategy: fail-fast: false @@ -73,13 +80,15 @@ jobs: cgo: 0 suffix: .exe desktop_ldflags: -w -s -H windowsgui - arch: [x64, arm64] + arch: + - {label: x64, goarch: amd64, ota: amd64} + - {label: arm64, goarch: arm64, ota: arm64} env: CGO_ENABLED: ${{ matrix.target.cgo }} CGO_CFLAGS: ${{ matrix.target.platform == 'macos' && '-mmacosx-version-min=12.0' || '' }} CGO_LDFLAGS: ${{ matrix.target.platform == 'macos' && '-mmacosx-version-min=12.0' || '' }} - GOARCH: ${{ matrix.arch == 'x64' && 'amd64' || 'arm64' }} + GOARCH: ${{ matrix.arch.goarch }} MACOSX_DEPLOYMENT_TARGET: ${{ matrix.target.platform == 'macos' && '12.0' || '' }} steps: @@ -104,14 +113,6 @@ jobs: go build -tags wails,production -trimpath -buildvcs=false -ldflags="${{ matrix.target.desktop_ldflags }} $version_ldflag" -o "bin/oneagent-desktop${{ matrix.target.suffix }}" ./cmd/oneagent-desktop go build -trimpath -buildvcs=false -ldflags="-w -s $version_ldflag" -o "bin/oneagent${{ matrix.target.suffix }}" ./cmd/oneagent - - name: Verify production Go dependency inventory - shell: bash - run: | - python3 scripts/generate_third_party_licenses.py \ - --verify-platform "${{ matrix.target.platform }}-${{ matrix.arch }}" \ - --verify-go-binary "bin/oneagent-desktop${{ matrix.target.suffix }}" \ - --verify-go-binary "bin/oneagent${{ matrix.target.suffix }}" - - name: Package macOS app if: matrix.target.platform == 'macos' shell: bash @@ -126,39 +127,77 @@ jobs: codesign --force --deep --sign - "bin/OneAgent.app" rm "bin/oneagent-desktop" - - name: Add license material to artifact - shell: bash - run: | - cp LICENSE NOTICE third_party/THIRD_PARTY_NOTICES.md bin/ - cp -R third_party/licenses bin/licenses - if [[ "${{ matrix.target.platform }}" == "macos" ]]; then - compliance="bin/OneAgent.app/Contents/Resources/compliance" - mkdir -p "$compliance" - cp LICENSE NOTICE third_party/THIRD_PARTY_NOTICES.md "$compliance/" - cp -R third_party/licenses "$compliance/licenses" - fi + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: OneAgent-${{ matrix.target.platform }}-${{ matrix.arch.label }} + path: bin/* + if-no-files-found: error + compression-level: 0 - - name: Verify artifact license material + - name: Package macOS OTA archive + if: matrix.target.platform == 'macos' shell: bash run: | - test -f bin/LICENSE - test -f bin/NOTICE - test -f bin/THIRD_PARTY_NOTICES.md - test -f bin/licenses/go/github.com_go-ole_go-ole@v1.3.0/LICENSE - test -f bin/licenses/npm/react-router@7.18.1/LICENSE.md - test -f bin/licenses/npm/scheduler@0.27.0/LICENSE - test -f bin/licenses/npm/cookie@1.0.1/LICENSE - test -f bin/licenses/npm/set-cookie-parser@2.6.0/LICENSE - if [[ "${{ matrix.target.platform }}" == "macos" ]]; then - test -f bin/OneAgent.app/Contents/Resources/compliance/LICENSE - test -f bin/OneAgent.app/Contents/Resources/compliance/NOTICE - test -f bin/OneAgent.app/Contents/Resources/compliance/THIRD_PARTY_NOTICES.md - fi + (cd bin && zip -qry "OneAgent-darwin-${{ matrix.arch.ota }}.zip" OneAgent.app) + roots="$(unzip -Z1 "bin/OneAgent-darwin-${{ matrix.arch.ota }}.zip" | awk -F/ 'NF {print $1}' | sort -u)" + test "$roots" = OneAgent.app - - name: Upload artifact + - name: Package Windows OTA archive + if: matrix.target.platform == 'windows' + shell: pwsh + run: | + $archive = "bin/OneAgent-windows-${{ matrix.arch.ota }}.zip" + Compress-Archive -Path "bin/oneagent-desktop.exe" -DestinationPath $archive + $roots = @(tar -tf $archive | ForEach-Object { ($_ -split '/')[0] } | Where-Object { $_ } | Sort-Object -Unique) + if ($roots.Count -ne 1 -or $roots[0] -ne "oneagent-desktop.exe") { + throw "unexpected OTA archive roots: $($roots -join ', ')" + } + + - name: Upload OTA archive uses: actions/upload-artifact@v7 with: - name: OneAgent-${{ matrix.target.platform }}-${{ matrix.arch }} - path: bin/* + name: ota-${{ matrix.target.platform }}-${{ matrix.arch.label }} + path: bin/OneAgent-${{ matrix.target.platform == 'macos' && 'darwin' || 'windows' }}-${{ matrix.arch.ota }}.zip if-no-files-found: error - compression-level: 0 + + release: + needs: build + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + name: Publish release + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - name: Download OTA archives + uses: actions/download-artifact@v8 + with: + pattern: ota-* + path: release-assets + merge-multiple: true + + - name: Validate and publish release + shell: bash + run: | + set -euo pipefail + expected=( + OneAgent-darwin-amd64.zip + OneAgent-darwin-arm64.zip + OneAgent-windows-amd64.zip + OneAgent-windows-arm64.zip + ) + mapfile -t archives < <(find release-assets -maxdepth 1 -type f -name 'OneAgent-*.zip' -printf '%f\n' | sort) + test "${#archives[@]}" -eq 4 + diff <(printf '%s\n' "${expected[@]}" | sort) <(printf '%s\n' "${archives[@]}") + (cd release-assets && sha256sum OneAgent-*.zip > SHA256SUMS) + test "$(wc -l < release-assets/SHA256SUMS)" -eq 4 + + if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then + gh release upload "$RELEASE_VERSION" release-assets/* --clobber + gh release edit "$RELEASE_VERSION" --draft=false --prerelease=false + else + gh release create "$RELEASE_VERSION" release-assets/* --verify-tag --title "$RELEASE_VERSION" --generate-notes + fi diff --git a/README.md b/README.md index c59a60d0..6c88bdb2 100644 --- a/README.md +++ b/README.md @@ -162,9 +162,10 @@ documentation link and language check. ## Releasing -Release packages are built by `.github/workflows/build-artifacts.yml`, triggered manually -via `workflow_dispatch`. It builds x64 and arm64 Wails desktop binaries and pure Go CLIs -for macOS and Windows, packaging the macOS artifacts as `.app`. +Pushing a stable `vX.Y.Z` tag triggers `.github/workflows/build-artifacts.yml` and publishes +macOS and Windows OTA archives for amd64 and arm64, plus `SHA256SUMS`, to the matching +GitHub Release. Each macOS archive contains `OneAgent.app`; each Windows archive contains +`oneagent-desktop.exe`. The workflow can also be run manually with a required version. Wails is still in Alpha, so there is no Stable release, and no platform signing, notarization, or store distribution. The signing gate for Stable is deferred to a later diff --git a/cmd/oneagent-desktop/main_wails.go b/cmd/oneagent-desktop/main_wails.go index 616d0771..b788ed28 100644 --- a/cmd/oneagent-desktop/main_wails.go +++ b/cmd/oneagent-desktop/main_wails.go @@ -12,9 +12,33 @@ import ( "github.com/MaimoryLab/OneAgent/internal/binding" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/MaimoryLab/OneAgent/internal/process" + "github.com/MaimoryLab/OneAgent/internal/version" "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/updater" + "github.com/wailsapp/wails/v3/pkg/updater/providers/github" ) +func configureUpdater(appInstance *application.App) binding.UpdateBackend { + current := version.UpdaterVersion() + if current == "" { + return nil + } + provider, err := github.New(github.Config{Repository: "MaimoryLab/OneAgent", ChecksumAsset: "SHA256SUMS"}) + if err != nil { + slog.Error("OneAgent updater provider is unavailable", "error", err) + return nil + } + if err := appInstance.Updater.Init(updater.Config{ + CurrentVersion: current, + Providers: []updater.Provider{provider}, + Window: updater.WindowNone, + }); err != nil { + slog.Error("OneAgent updater is unavailable", "error", err) + return nil + } + return appInstance.Updater +} + func main() { var appInstance *application.App core := newDesktopUseCases() @@ -70,6 +94,16 @@ func main() { }, Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true}, }) + updateBackend := configureUpdater(appInstance) + appInstance.Event.On(updater.EventDownloadProgress, func(event *application.CustomEvent) { + if event == nil { + return + } + if output, ok := binding.UpdateProgressOutput(event.Data); ok { + appInstance.Event.Emit("oneagent:install-output", output) + } + }) + appInstance.RegisterService(application.NewServiceWithOptions(binding.NewUpdateService(updateBackend), application.ServiceOptions{MarshalError: oneerrors.Marshal})) if !application.System.IsServer() { // A floor, not a second breakpoint: the sidebar deliberately collapses to // a 72px icon rail under 900px, and the layout is verified down to 560px. diff --git a/docs/superpowers/plans/2026-08-06-ota-updater.md b/docs/superpowers/plans/2026-08-06-ota-updater.md new file mode 100644 index 00000000..586248e8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-ota-updater.md @@ -0,0 +1,1278 @@ +# OTA Updater Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a release-only GitHub Releases OTA flow that asks before downloading, exposes the download in the existing task center, supports cancellation, and restarts into the staged update only when the user requests it. + +**Architecture:** Wails' `pkg/updater` owns release comparison, GitHub asset selection, checksum verification, archive extraction, staging, swapping, and relaunch. A narrow `internal/binding.UpdateService` adapts that concrete updater to three cancellable Wails methods. A frontend-only `AppUpdater` checks once at startup, uses `@wailsio/runtime`'s native `Dialogs.Question` for consent, and drives the existing task-center state and progress event pipeline. + +**Tech Stack:** Go 1.26, Wails v3.0.0-beta.3 `pkg/updater`, GitHub Releases API, React 19, TypeScript, Vitest, pnpm, GitHub Actions. + +--- + +## File Map + +### Go + +- Modify `internal/version/version.go`: add the release-version gate and remove the linker tag's leading `v` for Wails. +- Create `internal/version/version_test.go`: table-test development, tagged, and whitespace inputs. +- Create `internal/binding/update.go`: define the updater backend interface, Wails service methods, stable error conversion, and the OTA progress-to-install-output adapter. +- Create `internal/binding/update_test.go`: fake the three updater calls and cover disabled/current/new-release, cancellation, delegation, and progress payloads. +- Modify `internal/binding/services_test.go`: include `UpdateService` in the method allowlist. +- Modify `cmd/oneagent-desktop/main_wails.go`: initialise GitHub updater settings for non-development versions, register the update service, and bridge download progress to `oneagent:install-output`. +- Modify `go.mod` and `go.sum`: accept only the checksum/module changes required by Wails updater/binding generation (`go mod tidy`). + +### Frontend + +- Modify `frontend/src/backend/wails.ts`: import generated `UpdateService` and expose `checkUpdate`, `downloadUpdate`, and `restartUpdate`; export the stable progress target. +- Modify `frontend/src/backend/wails.test.ts`: mock the generated update service and test forwarding plus cancellation. +- Modify `frontend/src/state/TaskCenterContext.tsx`: add one optional terminal action and the two minimal setters needed to update that action/message after a request settles. +- Modify `frontend/src/components/TaskCenter.tsx`: render a terminal action button without nesting buttons and keep the existing cancel/dismiss controls. +- Modify `frontend/src/components/TaskCenter.test.tsx`: cover terminal action rendering and invocation. +- Modify `frontend/src/styles/app.css`: reserve a compact action row in a task card. +- Modify `frontend/src/i18n.tsx`: add the update-dialog, task-result, restart, and failure strings in the existing Chinese-first dictionary. +- Create `frontend/src/components/AppUpdater.tsx`: StrictMode-safe startup coordinator; native confirmation; task registration, cancellation, progress attribution, completion, and restart retry. +- Create `frontend/src/components/AppUpdater.test.tsx`: test the consent branches, one-check guard, cancellable download, ready action, and restart failure. +- Modify `frontend/src/App.tsx`: mount `AppUpdater` inside `TaskCenterProvider`. +- Regenerate `frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.ts` and the generated binding index through the existing task; never hand-edit generated files. + +### Release and documentation + +- Modify `.github/workflows/build-artifacts.yml`: trigger stable `vX.Y.Z` tags, retain the four-platform build matrix, create the four exact OTA zips, generate `SHA256SUMS`, and create/update the GitHub Release. +- Modify `README.md`: replace the stale manual-release description with the tag-triggered OTA asset description. + +--- + +### Task 1: Version Gate + +**Files:** +- Modify: `internal/version/version.go` +- Create: `internal/version/version_test.go` + +- [ ] **Step 1: Write the failing table test** + +~~~go +package version + +import "testing" + +func TestUpdaterVersion(t *testing.T) { + original := Version + defer func() { Version = original }() + + tests := []struct { + name string + value string + want string + }{ + {name: "release with v", value: "v1.2.3", want: "1.2.3"}, + {name: "release without v", value: "1.2.3", want: "1.2.3"}, + {name: "development", value: "v0.0.0-dev", want: ""}, + {name: "development without v", value: "1.2.3-dev", want: ""}, + {name: "blank", value: " ", want: ""}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + Version = test.value + if got := UpdaterVersion(); got != test.want { + t.Fatalf("UpdaterVersion() = %q, want %q", got, test.want) + } + }) + } +} +~~~ + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: `go test ./internal/version -run TestUpdaterVersion -count=1` + +Expected: FAIL because `UpdaterVersion` is not defined. + +- [ ] **Step 3: Implement the smallest gate** + +~~~go +package version + +import "strings" + +// Version is replaced with the release version through Go linker flags. +var Version = "v0.0.0-dev" + +// UpdaterVersion returns the Wails semver input. Development builds opt out of +// OTA so local runs never contact the release feed. +func UpdaterVersion() string { + value := strings.TrimSpace(strings.TrimPrefix(Version, "v")) + if value == "" || strings.HasSuffix(value, "-dev") { + return "" + } + return value +} +~~~ + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: `go test ./internal/version -run TestUpdaterVersion -count=1` + +Expected: PASS. + +- [ ] **Step 5: Commit the isolated version change** + +~~~bash +git add internal/version/version.go internal/version/version_test.go +git commit -m "feat: gate OTA on release versions" +~~~ + +### Task 2: Wails Update Service and Progress Adapter + +**Files:** +- Create: `internal/binding/update.go` +- Create: `internal/binding/update_test.go` +- Modify: `internal/binding/services_test.go` + +- [ ] **Step 1: Write the failing fake-backed service tests** + +Create `internal/binding/update_test.go` with the following fake and tests: + +~~~go +package binding + +import ( + "context" + "errors" + "reflect" + "slices" + "strings" + "testing" + + "github.com/wailsapp/wails/v3/pkg/updater" +) + +type updateBackendFake struct { + checkCalls int + downloadCalls int + restartCalls int + release *updater.Release + checkErr error + downloadErr error + restartErr error + lastContext context.Context +} + +func (f *updateBackendFake) Check(ctx context.Context) (*updater.Release, error) { + f.checkCalls++ + f.lastContext = ctx + return f.release, f.checkErr +} + +func (f *updateBackendFake) DownloadAndInstall(ctx context.Context) error { + f.downloadCalls++ + f.lastContext = ctx + return f.downloadErr +} + +func (f *updateBackendFake) Restart(ctx context.Context) error { + f.restartCalls++ + f.lastContext = ctx + return f.restartErr +} + +func TestUpdateServiceCheckDisabledAndCurrent(t *testing.T) { + service := NewUpdateService(nil) + if got, err := service.Check(context.Background()); err != nil || got != "" { + t.Fatalf("disabled Check() = %q, %v", got, err) + } + + fake := &updateBackendFake{} + service = NewUpdateService(fake) + if got, err := service.Check(context.Background()); err != nil || got != "" || fake.checkCalls != 1 { + t.Fatalf("current Check() = %q, %v, calls=%d", got, err, fake.checkCalls) + } +} + +func TestUpdateServiceDelegatesReleaseDownloadAndRestart(t *testing.T) { + fake := &updateBackendFake{release: &updater.Release{Version: "1.4.0"}} + service := NewUpdateService(fake) + ctx := context.WithValue(context.Background(), struct{}{}, "caller") + + if got, err := service.Check(ctx); err != nil || got != "1.4.0" { + t.Fatalf("new release Check() = %q, %v", got, err) + } + if err := service.DownloadAndInstall(ctx); err != nil { + t.Fatal(err) + } + if err := service.Restart(ctx); err != nil { + t.Fatal(err) + } + if fake.checkCalls != 1 || fake.downloadCalls != 1 || fake.restartCalls != 1 || fake.lastContext != ctx { + t.Fatalf("backend calls = %#v", fake) + } +} + +func TestUpdateServiceRejectsCancelledRequestsBeforeDelegation(t *testing.T) { + fake := &updateBackendFake{} + service := NewUpdateService(fake) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := service.Check(ctx); err == nil { + t.Fatal("cancelled Check() succeeded") + } + if err := service.DownloadAndInstall(ctx); err == nil { + t.Fatal("cancelled DownloadAndInstall() succeeded") + } + if err := service.Restart(ctx); err == nil { + t.Fatal("cancelled Restart() succeeded") + } + if fake.checkCalls != 0 || fake.downloadCalls != 0 || fake.restartCalls != 0 { + t.Fatalf("cancelled request reached backend: %#v", fake) + } +} + +func TestUpdateServiceConvertsFailuresAndProgress(t *testing.T) { + fake := &updateBackendFake{checkErr: errors.New("network detail")} + service := NewUpdateService(fake) + + if _, err := service.Check(context.Background()); err == nil || !strings.Contains(err.Error(), "Unable to check for updates") { + t.Fatalf("check error = %v", err) + } + output, ok := UpdateProgressOutput(updater.Progress{Written: 12, Total: 30}) + if !ok || output.Kind != "progress" || output.Target != UpdateProgressTarget || output.Received != 12 || output.Total != 30 { + t.Fatalf("progress output = %#v, ok=%v", output, ok) + } + if _, ok := UpdateProgressOutput(&updater.Progress{Written: 1, Total: 0}); !ok { + t.Fatal("pointer progress payload was rejected") + } + if _, ok := UpdateProgressOutput(struct{}{}); ok { + t.Fatal("unrelated payload was accepted") + } +} + +func TestUpdateServiceMethodAllowlist(t *testing.T) { + typeOf := reflect.TypeOf(&UpdateService{}) + got := make([]string, 0, typeOf.NumMethod()) + for method := range typeOf.Methods() { + got = append(got, method.Name) + } + want := []string{"Check", "DownloadAndInstall", "Restart"} + slices.Sort(got) + slices.Sort(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("methods = %v, want %v", got, want) + } +} +~~~ + +- [ ] **Step 2: Run the focused tests and verify the expected red state** + +Run: `go test ./internal/binding -run 'TestUpdateService|TestUpdateServiceMethodAllowlist' -count=1` + +Expected: FAIL with undefined updater-service symbols. + +- [ ] **Step 3: Implement the narrow service and event conversion** + +~~~go +package binding + +import ( + "context" + "errors" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/process" + "github.com/wailsapp/wails/v3/pkg/updater" +) + +const UpdateProgressTarget = "oneagent-update" + +type UpdateBackend interface { + Check(context.Context) (*updater.Release, error) + DownloadAndInstall(context.Context) error + Restart(context.Context) error +} + +type UpdateService struct { + backend UpdateBackend +} + +func NewUpdateService(backend UpdateBackend) *UpdateService { + return &UpdateService{backend: backend} +} + +func (s *UpdateService) Check(ctx context.Context) (string, error) { + if err := contextError(ctx); err != nil { + return "", err + } + if s == nil || s.backend == nil { + return "", nil + } + release, err := s.backend.Check(ctx) + if err != nil { + return "", updateError("Unable to check for updates", err) + } + if release == nil { + return "", nil + } + return release.Version, nil +} + +func (s *UpdateService) DownloadAndInstall(ctx context.Context) error { + if err := contextError(ctx); err != nil { + return err + } + if s == nil || s.backend == nil { + return notReady("Update service is not configured") + } + if err := s.backend.DownloadAndInstall(ctx); err != nil { + return updateError("Unable to download the OneAgent update", err) + } + return nil +} + +func (s *UpdateService) Restart(ctx context.Context) error { + if err := contextError(ctx); err != nil { + return err + } + if s == nil || s.backend == nil { + return notReady("Update service is not configured") + } + if err := s.backend.Restart(ctx); err != nil { + return updateError("Unable to restart OneAgent for update", err) + } + return nil +} + +func UpdateProgressOutput(payload any) (process.Output, bool) { + var progress updater.Progress + switch value := payload.(type) { + case updater.Progress: + progress = value + case *updater.Progress: + if value == nil { + return process.Output{}, false + } + progress = *value + default: + return process.Output{}, false + } + return process.Output{ + Kind: "progress", Target: UpdateProgressTarget, + Received: progress.Written, Total: progress.Total, + }, true +} + +func updateError(message string, err error) error { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return oneerrors.New(oneerrors.Timeout, message+" was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + return oneerrors.New(oneerrors.InternalError, message, oneerrors.WithStatus(500), oneerrors.WithRetryable(true), oneerrors.WithCause(err)) +} +~~~ + +- [ ] **Step 4: Add the service to the existing allowlist test** + +Extend the `tests` slice in `internal/binding/services_test.go` with: + +~~~go +{&UpdateService{}, []string{"Check", "DownloadAndInstall", "Restart"}}, +~~~ + +- [ ] **Step 5: Run Go tests and verify green** + +Run: `go test ./internal/binding -run 'Test(UpdateService|ServiceMethodAllowlist)' -count=1` + +Expected: PASS, including the existing service tests. + +- [ ] **Step 6: Commit the backend service** + +~~~bash +git add internal/binding/update.go internal/binding/update_test.go internal/binding/services_test.go +git commit -m "feat: expose the Wails updater service" +~~~ + +### Task 3: Wails Initialisation and Progress Wiring + +**Files:** +- Modify: `cmd/oneagent-desktop/main_wails.go` +- Modify: `go.mod` +- Modify: `go.sum` + +- [ ] **Step 1: Add the release-only initialisation path** + +Import these packages in `main_wails.go`: + +~~~go + "github.com/MaimoryLab/OneAgent/internal/version" + "github.com/wailsapp/wails/v3/pkg/updater" + "github.com/wailsapp/wails/v3/pkg/updater/providers/github" +~~~ + +Add this helper below `main`: + +~~~go +func configureUpdater(appInstance *application.App) binding.UpdateBackend { + current := version.UpdaterVersion() + if current == "" { + return nil + } + provider, err := github.New(github.Config{ + Repository: "MaimoryLab/OneAgent", + ChecksumAsset: "SHA256SUMS", + }) + if err != nil { + slog.Error("OneAgent updater provider is unavailable", "error", err) + return nil + } + if err := appInstance.Updater.Init(updater.Config{ + CurrentVersion: current, + Providers: []updater.Provider{provider}, + Window: updater.WindowNone, + }); err != nil { + slog.Error("OneAgent updater is unavailable", "error", err) + return nil + } + return appInstance.Updater +} +~~~ + +`WindowNone` is intentional: `CheckAndInstall` is not used because beta.3 starts downloading immediately. The frontend supplies the confirmation dialog. + +- [ ] **Step 2: Register the service and progress listener before `Run`** + +Immediately after the `application.New(...)` assignment and before the window creation block, add: + +~~~go + updateBackend := configureUpdater(appInstance) + appInstance.Event.On(updater.EventDownloadProgress, func(event *application.CustomEvent) { + if event == nil { + return + } + output, ok := binding.UpdateProgressOutput(event.Data) + if !ok { + return + } + appInstance.Event.Emit("oneagent:install-output", output) + }) + appInstance.RegisterService(application.NewServiceWithOptions( + binding.NewUpdateService(updateBackend), + application.ServiceOptions{MarshalError: oneerrors.Marshal}, + )) +~~~ + +The existing `InstallOutput` callback stays unchanged; both ordinary downloads and OTA downloads now feed the same frontend event name. `RegisterService` is before `Run`, so the generated binding analyser still discovers the concrete service through `NewServiceWithOptions`. + +- [ ] **Step 3: Tidy the module and inspect the dependency diff** + +Run: `go mod tidy` + +Expected: Wails' missing `golang.org/x/mod` checksum (and only the necessary `go.mod`/`go.sum` entries) is added. Do not add a second updater dependency. + +- [ ] **Step 4: Generate bindings and verify the new service exists** + +Run: `task generate:bindings` + +Expected: generated `frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.ts` contains `Check`, `DownloadAndInstall`, and `Restart`; the generated binding index exports `UpdateService`. No generated file is edited by hand. + +- [ ] **Step 5: Run the Wails-tag compile check** + +Run: `go test -tags wails ./cmd/oneagent-desktop ./internal/binding -run '^$'` + +Expected: compile succeeds without running native windows/macOS UI tests. + +- [ ] **Step 6: Commit the wiring and generated bindings** + +~~~bash +git add cmd/oneagent-desktop/main_wails.go go.mod go.sum frontend/bindings +git commit -m "feat: wire GitHub Releases into the desktop updater" +~~~ ++ + +### Task 4: Frontend Wails Adapter + +**Files:** +- Modify: frontend/src/backend/wails.ts +- Modify: frontend/src/backend/wails.test.ts +- Generated: frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.ts + +- [ ] **Step 1: Write failing adapter tests** + +Add the update bridge functions to the hoisted mock: + +~~~ts + updateCheck: vi.fn(), + updateDownload: vi.fn(), + updateRestart: vi.fn(), +~~~ + +Mock the generated module: + +~~~ts +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.js", () => ({ + Check: bridge.updateCheck, + DownloadAndInstall: bridge.updateDownload, + Restart: bridge.updateRestart, +})); +~~~ + +Add this forwarding test: + +~~~ts +it("forwards OTA calls", async () => { + bridge.updateCheck.mockResolvedValue("1.4.0"); + bridge.updateDownload.mockResolvedValue(undefined); + bridge.updateRestart.mockResolvedValue(undefined); + + await expect(wailsApi.checkUpdate()).resolves.toBe("1.4.0"); + await expect(wailsApi.downloadUpdate()).resolves.toBeUndefined(); + await expect(wailsApi.restartUpdate()).resolves.toBeUndefined(); + expect(bridge.updateCheck).toHaveBeenCalledWith(); + expect(bridge.updateDownload).toHaveBeenCalledWith(); + expect(bridge.updateRestart).toHaveBeenCalledWith(); +}); +~~~ + +Add the cancellation assertion using the existing runtime helper: + +~~~ts +it("exposes the generated OTA download cancellation", async () => { + const oncancelled = vi.fn(); + bridge.updateDownload.mockReturnValue(new CancellablePromise(() => {}, oncancelled)); + const request = wailsApi.downloadUpdate(); + expect(typeof request.cancel).toBe("function"); + await request.cancel?.(); + expect(oncancelled).toHaveBeenCalledOnce(); +}); +~~~ + +- [ ] **Step 2: Run the focused tests and verify red** + +Run: cd frontend && pnpm test -- src/backend/wails.test.ts + +Expected: FAIL because the three OTA adapter methods are not defined. + +- [ ] **Step 3: Add the adapter methods** + +In frontend/src/backend/wails.ts, import UpdateService beside the other generated services and add: + +~~~ts +export const OTA_PROGRESS_TARGET = "oneagent-update"; + +// inside wailsApi: + checkUpdate: (): Promise => call(() => UpdateService.Check()) as Promise, + downloadUpdate: (): CancellableRequest => + call(() => UpdateService.DownloadAndInstall()) as CancellableRequest, + restartUpdate: (): Promise => call(() => UpdateService.Restart()).then(() => undefined), +~~~ + +Keep these calls inside the existing call normalizer so structured Wails errors and generated cancellation errors retain the current frontend contract. + +- [ ] **Step 4: Run the focused tests and typecheck** + +Run: cd frontend && pnpm test -- src/backend/wails.test.ts && pnpm run typecheck + +Expected: PASS and no TypeScript errors. + +- [ ] **Step 5: Commit the adapter** + +~~~bash +git add frontend/src/backend/wails.ts frontend/src/backend/wails.test.ts frontend/bindings +git commit -m "feat: expose OTA calls to the frontend" +~~~ ++ + +### Task 5: Terminal Task Actions + +**Files:** +- Modify: frontend/src/state/TaskCenterContext.tsx +- Modify: frontend/src/components/TaskCenter.tsx +- Modify: frontend/src/components/TaskCenter.test.tsx +- Modify: frontend/src/styles/app.css + +- [ ] **Step 1: Write the failing task-action test** + +Add this harness to TaskCenter.test.tsx: + +~~~tsx +function TaskActionHarness({ action }: { action: () => void }) { + const { startTask, finishTask, setTaskAction } = useTaskCenter(); + const id = taskKey("update", "oneagent-update"); + return ( + <> + + + + + ); +} +~~~ + +Add this complete helper beside the existing renderTaskCenter helper: + +~~~tsx +import type { ReactNode } from "react"; + +function renderTaskCenterWith(children: ReactNode, initialEntry = "/overview") { + return render( + + + + {children} + + } /> + + + , + ); +} +~~~ + +Add this assertion: + +~~~tsx +it("renders and invokes an optional terminal action", async () => { + const action = vi.fn(); + const user = userEvent.setup(); + renderTaskCenterWith(); + await user.click(screen.getByRole("button", { name: "启动更新" })); + await user.click(screen.getByRole("button", { name: "完成更新" })); + await user.click(screen.getByRole("button", { name: "添加操作" })); + await user.click(screen.getByRole("button", { name: "重启并更新" })); + expect(action).toHaveBeenCalledOnce(); +}); +~~~ + +- [ ] **Step 2: Run the focused test and verify red** + +Run: cd frontend && pnpm test -- src/components/TaskCenter.test.tsx + +Expected: FAIL because TaskAction, setTaskAction, and the action button do not exist. + +- [ ] **Step 3: Extend the context with the minimum terminal-action state** + +Replace the existing task type block with these complete definitions: + +~~~tsx +export interface TaskAction { + label: string; + run: () => void | PromiseLike; +} + +export interface TaskInput { + id?: string; + kind: TaskKind; + target: string; + title: string; + route: string; + progressTarget?: string; + group?: string; + action?: TaskAction; +} + +export interface TaskRecord extends TaskInput { + id: string; + progressTarget: string; + state: TaskState; + progress?: TaskProgress; + message?: string; + startedAt: number; +} +~~~ + +Add these methods to TaskCenterValue and the default context: + +~~~tsx +setTaskAction: (id: string, action?: TaskAction) => void; +setTaskMessage: (id: string, message: string) => void; +~~~ + +Implement them beside finishTask: + +~~~tsx +const setTaskAction = useCallback((id: string, action?: TaskAction) => { + updateTasks((current) => current.map((task) => ( + task.id === id || task.target === id ? { ...task, action } : task + ))); +}, [updateTasks]); + +const setTaskMessage = useCallback((id: string, message: string) => { + updateTasks((current) => current.map((task) => ( + task.id === id || task.target === id ? { ...task, message } : task + ))); +}, [updateTasks]); +~~~ + +Include both callbacks in the provider value and dependency list. The existing finishTask spread preserves action, so a success or failure transition does not lose the restart retry. + +- [ ] **Step 4: Render the action as a sibling button** + +Import RefreshCw from lucide-react. Add has-action to the card class when the task has a terminal action, then render this after the main card button and before the dismiss button: + +~~~tsx +{task.state !== "running" && task.action ? ( + +) : null} +~~~ + +This keeps the DOM valid: the action is never nested inside the existing main button. + +- [ ] **Step 5: Add compact responsive styling** + +Append these rules beside the existing task-card rules: + +~~~css +.task-card.has-action { display: grid; grid-template-columns: minmax(0, 1fr); } +.task-card-action { + min-height: 27px; + margin: 0 30px 7px 10px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + color: var(--text-primary); + background: var(--window-bg); + font: inherit; + font-size: 11px; + cursor: pointer; +} +.task-card-action:hover { background: var(--surface-pressed); } +~~~ + +- [ ] **Step 6: Run the focused tests and build** + +Run: cd frontend && pnpm test -- src/components/TaskCenter.test.tsx && pnpm run typecheck + +Expected: PASS; existing progress, cancellation, and dismissal tests stay green. + +- [ ] **Step 7: Commit the task-center extension** + +~~~bash +git add frontend/src/state/TaskCenterContext.tsx frontend/src/components/TaskCenter.tsx frontend/src/components/TaskCenter.test.tsx frontend/src/styles/app.css +git commit -m "feat: add terminal actions to task cards" +~~~ ++ + +### Task 6: Startup Coordinator and Native Confirmation + +**Files:** +- Create: frontend/src/components/AppUpdater.tsx +- Create: frontend/src/components/AppUpdater.test.tsx +- Modify: frontend/src/i18n.tsx +- Modify: frontend/src/App.tsx + +- [ ] **Step 1: Write failing coordinator tests and runtime mocks** + +Create AppUpdater.test.tsx with these mocks and a deferred promise helper: + +~~~tsx +import { StrictMode } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const bridge = vi.hoisted(() => ({ + check: vi.fn(), + download: vi.fn(), + restart: vi.fn(), + question: vi.fn(), + onInstallOutput: vi.fn(() => vi.fn()), +})); + +beforeEach(() => { + vi.clearAllMocks(); + bridge.onInstallOutput.mockReturnValue(vi.fn()); +}); + +vi.mock("@wailsio/runtime", () => ({ + Dialogs: { Question: bridge.question }, +})); +vi.mock("../backend/api", () => ({ + api: { + checkUpdate: bridge.check, + downloadUpdate: bridge.download, + restartUpdate: bridge.restart, + onInstallOutput: bridge.onInstallOutput, + }, + describeError: (error: unknown, fallback: string) => ({ + message: error instanceof Error ? error.message : fallback, + }), + isCancellationError: (error: unknown) => ( + error && typeof error === "object" && (error as { name?: unknown }).name === "CancelError" + ), + taskCanceller: (request: { cancel?: () => void }) => request.cancel ? () => request.cancel?.() : undefined, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; }); + return { promise, resolve, reject }; +} +~~~ + +Add the provider wrapper used by every test: + +~~~tsx +import type { PropsWithChildren } from "react"; +import { I18nProvider } from "../i18n"; +import { TaskCenter } from "./TaskCenter"; +import { TaskCenterProvider } from "../state/TaskCenterContext"; +import { AppUpdater } from "./AppUpdater"; + +function TestProviders({ children }: PropsWithChildren) { + return ( + + + + {children} + + + ); +} +~~~ + +Render the component with TestProviders. Add cases that assert: StrictMode calls +Check once; Not now calls no download; Update creates a card and attaches/calls +cancel; a resolved request renders Restart and update; and a rejected restart +leaves that action available with the failure message. + +The approved-download assertion should look for the versioned task title +Update OneAgent 1.4.0, then click the card's Cancel task control and assert the +fake request's cancel function was called exactly once. + +Use these concrete terminal-action assertions: + +~~~tsx +it("finishes with a restart action", async () => { + bridge.check.mockResolvedValue("1.4.0"); + bridge.question.mockResolvedValue("更新"); + bridge.download.mockResolvedValue(undefined); + bridge.restart.mockResolvedValue(undefined); + const user = userEvent.setup(); + render(, { wrapper: TestProviders }); + await user.click(await screen.findByRole("button", { name: "重启并更新" })); + expect(bridge.restart).toHaveBeenCalledOnce(); +}); + +it("keeps restart available and reports a restart failure", async () => { + bridge.check.mockResolvedValue("1.4.0"); + bridge.question.mockResolvedValue("更新"); + bridge.download.mockResolvedValue(undefined); + bridge.restart.mockRejectedValue(new Error("restart failed")); + const user = userEvent.setup(); + render(, { wrapper: TestProviders }); + const action = await screen.findByRole("button", { name: "重启并更新" }); + await user.click(action); + expect(await screen.findByText("restart failed")).toBeTruthy(); + expect(screen.getByRole("button", { name: "重启并更新" })).toBeTruthy(); +}); +~~~ + +- [ ] **Step 2: Run the focused test and verify red** + +Run: cd frontend && pnpm test -- src/components/AppUpdater.test.tsx + +Expected: FAIL because AppUpdater and the new backend methods do not exist. + +- [ ] **Step 3: Add the update strings** + +Add these keys to the english dictionary in frontend/src/i18n.tsx: + +~~~ts + "OneAgent 更新": "OneAgent update", + "发现 OneAgent 新版本 {version},现在下载吗?": "OneAgent {version} is available. Download it now?", + "暂不": "Not now", + "更新 OneAgent": "Update OneAgent", + "更新 OneAgent {version}": "Update OneAgent {version}", + "更新已下载": "Update downloaded", + "重启并更新": "Restart and update", + "更新失败": "Update failed", + "无法重启并更新": "Could not restart and update", +~~~ + +- [ ] **Step 4: Implement the one-shot coordinator** + +Create frontend/src/components/AppUpdater.tsx: + +~~~tsx +import { Dialogs } from "@wailsio/runtime"; +import { useCallback, useEffect, useRef } from "react"; + +import { api, describeError, isCancellationError, taskCanceller } from "../backend/api"; +import { OTA_PROGRESS_TARGET } from "../backend/wails"; +import { useI18n } from "../i18n"; +import { taskKey, useTaskCenter } from "../state/TaskCenterContext"; + +export const OTA_TASK_ID = taskKey("update", OTA_PROGRESS_TARGET); + +export function AppUpdater() { + const { t } = useI18n(); + const { + startTask, finishTask, setTaskCanceller, setTaskAction, setTaskMessage, isTaskRunning, + } = useTaskCenter(); + + const restart = useCallback(async () => { + try { + await api.restartUpdate(); + } catch (error) { + setTaskMessage(OTA_TASK_ID, describeError(error, t("无法重启并更新")).message); + } + }, [setTaskMessage, t]); + + const download = useCallback(async (version: string) => { + if (isTaskRunning(OTA_TASK_ID) || !startTask({ + id: OTA_TASK_ID, + kind: "update", + target: OTA_PROGRESS_TARGET, + progressTarget: OTA_PROGRESS_TARGET, + title: t("更新 OneAgent {version}", { version }), + route: "/overview", + })) return; + + try { + const request = api.downloadUpdate(); + setTaskCanceller(OTA_TASK_ID, taskCanceller(request)); + await request; + finishTask(OTA_TASK_ID, { kind: "success", message: t("更新已下载") }); + setTaskAction(OTA_TASK_ID, { label: t("重启并更新"), run: restart }); + } catch (error) { + const cancelled = isCancellationError(error); + finishTask(OTA_TASK_ID, { + kind: cancelled ? "cancelled" : "failure", + message: cancelled ? t("已取消") : describeError(error, t("更新失败")).message, + }); + } + }, [finishTask, isTaskRunning, restart, setTaskAction, setTaskCanceller, startTask, t]); + + const check = useCallback(async () => { + if (isTaskRunning(OTA_TASK_ID)) return; + let version = ""; + try { + version = await api.checkUpdate(); + } catch { + return; + } + if (!version || isTaskRunning(OTA_TASK_ID)) return; + + try { + const update = t("更新"); + const choice = await Dialogs.Question({ + Title: t("OneAgent 更新"), + Message: t("发现 OneAgent 新版本 {version},现在下载吗?", { version }), + Buttons: [ + { Label: update, IsDefault: true }, + { Label: t("暂不"), IsCancel: true }, + ], + }); + if (choice === update) await download(version); + } catch { + // A background prompt failure must not interrupt startup. + } + }, [download, isTaskRunning, t]); + + const started = useRef(false); + useEffect(() => { + if (started.current) return; + started.current = true; + void check(); + }, [check]); + + return null; +} +~~~ + +The version parameter supplies visible dialog and task text; Wails retains the checked pending release, so the frontend never picks an asset or reimplements version comparison. Not now is not persisted. + +- [ ] **Step 5: Mount it inside the existing provider** + +Change the provider tree in frontend/src/App.tsx to: + +~~~tsx + + + + + + +~~~ + +Add the import from ./components/AppUpdater. + +- [ ] **Step 6: Run coordinator and frontend tests** + +Run: cd frontend && pnpm test -- src/components/AppUpdater.test.tsx src/components/TaskCenter.test.tsx && pnpm run typecheck + +Expected: PASS; ignored updates create no card, approved downloads are cancellable, successful downloads expose the restart action, and restart errors leave that action available. + +- [ ] **Step 7: Commit the frontend OTA flow** + +~~~bash +git add frontend/src/components/AppUpdater.tsx frontend/src/components/AppUpdater.test.tsx frontend/src/i18n.tsx frontend/src/App.tsx +git commit -m "feat: add consented OTA task flow" +~~~ ++ + +### Task 7: Tag Release Packaging and Checksums + +**Files:** +- Modify: .github/workflows/build-artifacts.yml +- Modify: README.md + +- [ ] **Step 1: Change the workflow trigger and release permissions** + +Replace the manual-only trigger/env block with: + +~~~yaml +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + version: + description: Release version (for example v0.3.0) + required: true + type: string + default: v0.0.0 + +permissions: + contents: read + +env: + RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} +~~~ + +The existing validation command remains the exact gate: + +~~~bash +if [[ ! "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "version must match vX.Y.Z" >&2 + exit 1 +fi +~~~ + +- [ ] **Step 2: Keep the existing build labels and add OTA asset labels** + +The license inventory and existing artifact checks use x64 as the amd64 label, so +do not rename that matrix value. Replace the string matrix with an object that +preserves the existing label while adding the Wails-facing archive token: + +~~~yaml + arch: + - label: x64 + goarch: amd64 + ota: amd64 + - label: arm64 + goarch: arm64 + ota: arm64 + + env: + CGO_ENABLED: ${{ matrix.target.cgo }} + CGO_CFLAGS: ${{ matrix.target.platform == 'macos' && '-mmacosx-version-min=12.0' || '' }} + CGO_LDFLAGS: ${{ matrix.target.platform == 'macos' && '-mmacosx-version-min=12.0' || '' }} + GOARCH: ${{ matrix.arch.goarch }} + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.target.platform == 'macos' && '12.0' || '' }} +~~~ + +Change the build job name to use the matrix arch label. Use the label field for +the existing third-party verification and non-OTA artifact names; use the ota +field only for OTA zip names. Keep the existing linker flag exactly: + +~~~bash +version_ldflag="-X github.com/MaimoryLab/OneAgent/internal/version.Version=$RELEASE_VERSION" +~~~ + +The concrete substitutions are: + +~~~yaml + name: ${{ matrix.target.platform }} ${{ matrix.arch.label }} + --verify-platform "${{ matrix.target.platform }}-${{ matrix.arch.label }}" + name: OneAgent-${{ matrix.target.platform }}-${{ matrix.arch.label }} +~~~ + +- [ ] **Step 3: Zip exactly one top-level desktop payload per matrix entry** + +After the existing macOS bundle/license verification, add: + +~~~yaml + - name: Package macOS OTA archive + if: matrix.target.platform == 'macos' + shell: bash + run: | + (cd bin && zip -qry "OneAgent-darwin-${{ matrix.arch.ota }}.zip" OneAgent.app) + roots=$(unzip -Z1 "bin/OneAgent-darwin-${{ matrix.arch.ota }}.zip" | awk -F/ 'NF {print $1}' | sort -u) + test "$roots" = "OneAgent.app" + + - name: Package Windows OTA archive + if: matrix.target.platform == 'windows' + shell: pwsh + run: | + $archive = "bin/OneAgent-windows-${{ matrix.arch.ota }}.zip" + Compress-Archive -Path bin/oneagent-desktop.exe -DestinationPath $archive -CompressionLevel Optimal + $roots = @(tar -tf $archive | ForEach-Object { ($_ -split '/')[0] } | Sort-Object -Unique) + if ($roots.Count -ne 1 -or $roots[0] -ne 'oneagent-desktop.exe') { throw 'OTA archive has unexpected top-level entries' } + + - name: Upload OTA archive + uses: actions/upload-artifact@v7 + with: + name: ota-${{ matrix.target.platform }}-${{ matrix.arch.label }} + path: bin/OneAgent-${{ matrix.target.platform }}-${{ matrix.arch.ota }}.zip + if-no-files-found: error +~~~ + +The macOS archive is made from OneAgent.app only; the Windows archive is made +from oneagent-desktop.exe only. Compliance files remain inside the macOS app +bundle as they do in the existing package. + +- [ ] **Step 4: Add a release job that generates SHA256SUMS and creates or updates the release** + +Append this job: + +~~~yaml + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/download-artifact@v8 + with: + pattern: ota-* + path: release-assets + merge-multiple: true + + - name: Validate OTA set and create checksum manifest + shell: bash + run: | + set -euo pipefail + cd release-assets + test "$(find . -maxdepth 1 -type f -name 'OneAgent-*.zip' | wc -l)" -eq 4 + printf '%s\n' OneAgent-darwin-amd64.zip OneAgent-darwin-arm64.zip OneAgent-windows-amd64.zip OneAgent-windows-arm64.zip | while read -r name; do + test -f "$name" + done + sha256sum OneAgent-*.zip > SHA256SUMS + test "$(wc -l < SHA256SUMS)" -eq 4 + + - name: Create or update GitHub Release + shell: bash + run: | + set -euo pipefail + if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then + gh release upload "$RELEASE_VERSION" release-assets/* --clobber + else + gh release create "$RELEASE_VERSION" release-assets/* --verify-tag --title "$RELEASE_VERSION" --generate-notes + fi +~~~ + +The job has write permission only where publication occurs. SHA256SUMS uses the exact archive base names that the Wails GitHub provider requests through ChecksumAsset: SHA256SUMS. + +- [ ] **Step 5: Update the release documentation** + +Replace the manual-release paragraph in README.md with: + +~~~md +Release packages are built and published by .github/workflows/build-artifacts.yml +when a stable vX.Y.Z tag is pushed. It publishes macOS and Windows amd64/arm64 +OTA archives plus SHA256SUMS; the macOS archives contain OneAgent.app and the +Windows archives contain oneagent-desktop.exe. +~~~ + +- [ ] **Step 6: Validate workflow text and commit** + +Run: + +~~~bash +git diff --check +ruby -e 'require "yaml"; YAML.load_file(".github/workflows/build-artifacts.yml"); puts "workflow yaml parsed"' +python3 scripts/check-docs.py +~~~ + +Expected: no whitespace errors, YAML parses, and documentation checks pass. + +~~~bash +git add .github/workflows/build-artifacts.yml README.md +git commit -m "ci: publish OTA archives on version tags" +~~~ + +### Task 8: Full Verification and Handoff + +**Files:** +- No new files; inspect all changes and generated output. + +- [ ] **Step 1: Regenerate bindings from the current source** + +Run: task generate:bindings + +Expected: the pinned Wails CLI completes and produces no unexpected edits beyond the updater service bindings. + +- [ ] **Step 2: Run all Go checks** + +Run: go test ./... && go vet ./... + +Expected: PASS. If the host lacks native Wails libraries, use the repository's existing non-Wails Go suite and separately run: + +~~~bash +go test -tags wails ./cmd/oneagent-desktop ./internal/binding -run '^$' +~~~ + +- [ ] **Step 3: Run all frontend checks** + +Run: cd frontend && pnpm test && pnpm run build + +Expected: all Vitest tests pass and typecheck plus Vite production build complete. + +- [ ] **Step 4: Validate archive rules with disposable fixtures** + +Run the same root-entry checks against temporary fixtures without touching tracked files: + +~~~bash +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +mkdir -p "$tmp_dir/OneAgent.app/Contents/MacOS" +touch "$tmp_dir/OneAgent.app/Contents/MacOS/oneagent-desktop" +(cd "$tmp_dir" && zip -qry OneAgent-darwin-amd64.zip OneAgent.app) +test "$(unzip -Z1 "$tmp_dir/OneAgent-darwin-amd64.zip" | awk -F/ 'NF {print $1}' | sort -u)" = "OneAgent.app" +printf 'binary' > "$tmp_dir/oneagent-desktop.exe" +(cd "$tmp_dir" && zip -qry OneAgent-windows-amd64.zip oneagent-desktop.exe) +test "$(unzip -Z1 "$tmp_dir/OneAgent-windows-amd64.zip" | awk -F/ 'NF {print $1}' | sort -u)" = "oneagent-desktop.exe" +echo "archive checks passed" +~~~ + +Expected: archive checks passed. + +- [ ] **Step 5: Review the diff against the specification** + +Run: + +~~~bash +git diff --stat +git diff --check +git status --short +~~~ + +Confirm every spec section has an implementation: release-only startup check, native consent, current-launch ignore, task progress/cancellation/failure, restart retry, four exact archives, checksum manifest, and tag publication. Do not add forced-update, prerelease, skip-version persistence, signing keys, or custom updater-window code; they are explicit non-goals. + +- [ ] **Step 6: Commit the verified final state** + +~~~bash +git add docs/superpowers/plans/2026-08-06-ota-updater.md internal/version internal/binding cmd/oneagent-desktop frontend .github/workflows/build-artifacts.yml README.md go.mod go.sum +git commit -m "feat: complete GitHub Releases OTA updates" +~~~ + +## Self-Review Checklist + +- Spec coverage: Tasks 1-3 cover version gating, GitHub provider configuration, checksum selection, Wails delegation, and progress translation. Tasks 4-6 cover generated bindings, native confirmation, one-shot startup, task-center cancellation/action/failure behavior. Task 7 covers tag release packaging and publication. Task 8 covers requested verification. +- Placeholder scan: every task step has a concrete snippet and command with an expected result; no deferred implementation is hidden behind a vague instruction. +- Type consistency: OTA_PROGRESS_TARGET is the shared frontend target, UpdateProgressTarget is its Go counterpart, UpdateBackend is the exact interface accepted by NewUpdateService, and the generated methods are Check, DownloadAndInstall, and Restart throughout. diff --git a/docs/superpowers/specs/2026-08-06-ota-updater-design.md b/docs/superpowers/specs/2026-08-06-ota-updater-design.md new file mode 100644 index 00000000..8f0a5a78 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-ota-updater-design.md @@ -0,0 +1,110 @@ +# OTA Updater Design + +## Goal + +Add opt-in desktop OTA updates backed by GitHub Releases. OneAgent checks once +per launch, asks before downloading, reports the download through the existing +task center, supports real cancellation, and lets the user restart when ready. + +## User Flow + +1. A release build silently checks the latest GitHub Release after the frontend + starts. Development builds do not check. +2. No update or a failed background check produces no task or notification. +3. When a newer version exists, a native Wails question dialog shows the target + version with `Update` and `Not Now` actions. +4. `Not Now` dismisses the update for the current launch only. The next launch + checks again. +5. `Update` starts a task-center entry and downloads the selected artifact. +6. The task card shows byte progress and can cancel the underlying Wails call. +7. After verification and staging, the completed task exposes `Restart & + Update`. The Wails updater swaps the executable or app bundle and relaunches + OneAgent only after that action. + +The built-in updater window is not used. In the pinned Wails beta.3, +`CheckAndInstall` opens that window but immediately calls +`DownloadAndInstall`; its buttons do not gate the download. A native question +dialog provides the requested confirmation without copying or forking Wails' +updater template. + +## Architecture + +### Backend + +- Configure `app.Updater` with the GitHub provider for + `MaimoryLab/OneAgent`, `SHA256SUMS`, the linker-injected current version, and + `updater.WindowNone`. +- Strip the leading `v` from `internal/version.Version` before passing it to + Wails. Do not configure the updater for the default `-dev` version. +- Add a small Wails service exposing `Check`, `DownloadAndInstall`, and + `Restart`. `Check` returns only the available version string; an empty string + means current or disabled. +- Translate Wails `EventDownloadProgress` payloads into the existing + `oneagent:install-output` progress event with one stable OTA target. + +Wails remains responsible for release comparison, download, checksum +verification, safe archive extraction, staging, executable/app-bundle swap, +rollback, and relaunch. + +### Frontend + +- Mount one update coordinator inside the existing task-center provider. +- Call `Check` once, then use `Dialogs.Question` only when a version is found. +- On approval, register an `update` task, attach the generated binding's + canceller, and call `DownloadAndInstall`. +- Extend task records with one optional terminal action. The OTA task uses it + for `Restart & Update`; existing tasks remain unchanged. +- Reuse the existing progress event listener, byte progress UI, cancellation + state, failure state, dismissal, and task locking. + +## Failure Handling + +- Background check failures stay silent because there is no user-started task. +- Download cancellation ends the task as cancelled and cancels the binding + context, which stops the HTTP request. +- Download, checksum, extraction, and staging failures end the task as failed. +- Restart failures keep the app running and show the failure on the task card; + the restart action remains available for retry. +- A second startup check or download is not started while the OTA task is + already active. + +## Release Workflow + +Reuse `.github/workflows/build-artifacts.yml` and trigger it for stable tags +matching `vX.Y.Z`. Keep the existing Windows/macOS and amd64/arm64 build +matrix, inject the tag through the existing linker flag, and publish these +GitHub Release assets: + +- `OneAgent-darwin-amd64.zip` +- `OneAgent-darwin-arm64.zip` +- `OneAgent-windows-amd64.zip` +- `OneAgent-windows-arm64.zip` +- `SHA256SUMS` + +Each macOS archive contains exactly one top-level `OneAgent.app`. Each Windows +archive contains exactly one top-level `oneagent-desktop.exe`. The platform and +architecture tokens intentionally match the Wails GitHub provider's default +asset matcher. The release job generates `SHA256SUMS` after collecting all four +archives and creates or updates the release for the pushed tag. + +The checksum detects corruption and is the integrity mechanism supported by +the GitHub provider. Signing-key infrastructure is out of scope; add it only if +the release source moves to a provider or manifest that carries Wails signature +metadata. + +## Verification + +- Go tests cover disabled/current/new-version checks and the update service's + delegation to Wails. +- Frontend tests cover `Update` versus `Not Now`, task creation, binding + cancellation, completion, and the restart action. +- Existing task-center tests continue to cover shared progress rendering and + task lifecycle behavior; add only the optional terminal-action cases. +- Run all Go tests, frontend tests, frontend build/typecheck, binding generation, + and available workflow/archive validation before completion. + +## Non-Goals + +- Forced updates, periodic checks, prerelease channels, persisted skip-version + preferences, a custom updater window, and release-note rendering. +- Reimplementing download, verification, extraction, swap, or rollback logic. diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts index de02370f..4c199d28 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts @@ -7,13 +7,15 @@ import * as ProfileService from "./profileservice.js"; import * as ProviderService from "./providerservice.js"; import * as RuntimeService from "./runtimeservice.js"; import * as StatusService from "./statusservice.js"; +import * as UpdateService from "./updateservice.js"; export { AgentService, DesktopAgentService, ProfileService, ProviderService, RuntimeService, - StatusService + StatusService, + UpdateService }; export type { diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.ts new file mode 100644 index 00000000..81de709d --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.ts @@ -0,0 +1,18 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +export function Check(): $CancellablePromise { + return $Call.ByID(4243899107); +} + +export function DownloadAndInstall(): $CancellablePromise { + return $Call.ByID(697947605); +} + +export function Restart(): $CancellablePromise { + return $Call.ByID(841440456); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54da33f0..c76763c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { AppWindow } from "./components/AppWindow"; +import { AppUpdater } from "./components/AppUpdater"; import { ActivationPage } from "./pages/ActivationPage"; import { AgentProfilePage } from "./pages/AgentProfilePage"; import { AgentSelectionPage } from "./pages/AgentSelectionPage"; @@ -91,6 +92,7 @@ export default function App() { + diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts index e24cd621..295fbbdb 100644 --- a/frontend/src/backend/wails.test.ts +++ b/frontend/src/backend/wails.test.ts @@ -20,6 +20,9 @@ const bridge = vi.hoisted(() => ({ desktopConfigure: vi.fn(), profiles: vi.fn(), saveProfile: vi.fn(), + updateCheck: vi.fn(), + updateDownload: vi.fn(), + updateRestart: vi.fn(), eventsOn: vi.fn(), })); @@ -51,6 +54,11 @@ vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profiles ListProfiles: bridge.profiles, SaveProfile: bridge.saveProfile, })); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.js", () => ({ + Check: bridge.updateCheck, + DownloadAndInstall: bridge.updateDownload, + Restart: bridge.updateRestart, +})); import { CancellablePromise } from "@wailsio/runtime"; import { INSTALL_OUTPUT_EVENT, normalizeWailsError, onInstallOutput, wailsApi } from "./wails"; @@ -150,6 +158,23 @@ describe("Wails backend adapter", () => { await expect(rejection).resolves.toMatchObject({ name: "CancelError" }); }); + it("forwards OTA calls and preserves download cancellation", async () => { + const oncancelled = vi.fn(); + bridge.updateCheck.mockResolvedValue("1.2.3"); + bridge.updateDownload.mockReturnValue(new CancellablePromise(() => {}, oncancelled)); + bridge.updateRestart.mockResolvedValue(undefined); + + await expect(wailsApi.checkUpdate()).resolves.toBe("1.2.3"); + const request = wailsApi.downloadUpdate(); + expect(typeof request.cancel).toBe("function"); + await request.cancel?.(); + await expect(wailsApi.restartUpdate()).resolves.toBeUndefined(); + expect(oncancelled).toHaveBeenCalledOnce(); + expect(bridge.updateCheck).toHaveBeenCalledWith(); + expect(bridge.updateDownload).toHaveBeenCalledWith(); + expect(bridge.updateRestart).toHaveBeenCalledWith(); + }); + it("subscribes to and filters installation output events", () => { const unsubscribe = vi.fn(); const listener = vi.fn(); diff --git a/frontend/src/backend/wails.ts b/frontend/src/backend/wails.ts index dca25c5d..91d550ab 100644 --- a/frontend/src/backend/wails.ts +++ b/frontend/src/backend/wails.ts @@ -6,6 +6,7 @@ import * as ProfileService from "../../bindings/github.com/MaimoryLab/OneAgent/i import * as ProviderService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js"; import * as RuntimeService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/runtimeservice.js"; import * as StatusService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js"; +import * as UpdateService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.js"; import type { ActivateAgentResponse, DesktopAgentActionResult, @@ -33,6 +34,7 @@ import { currentLocale, translate } from "../i18n"; import { isCancellationError, OneAgentApiError } from "./errors"; export const INSTALL_OUTPUT_EVENT = "oneagent:install-output"; +export const OTA_PROGRESS_TARGET = "oneagent-update"; export function onInstallOutput(listener: (output: InstallOutput) => void): () => void { return Events.On(INSTALL_OUTPUT_EVENT, (event) => { @@ -98,6 +100,9 @@ function call(operation: () => PromiseLike): CancellableRequest { export const wailsApi = { onInstallOutput, status: (): Promise => call(() => StatusService.GetStatus()) as Promise, + checkUpdate: (): Promise => call(() => UpdateService.Check()) as Promise, + downloadUpdate: (): CancellableRequest => call(() => UpdateService.DownloadAndInstall()) as CancellableRequest, + restartUpdate: (): Promise => call(() => UpdateService.Restart()).then(() => undefined), desktopAgentStatus: (agentId: string): Promise => call(() => DesktopAgentService.GetStatus({ agent_id: agentId })) as Promise, installDesktopAgent: (agentId: string): CancellableRequest => diff --git a/frontend/src/components/AppUpdater.test.tsx b/frontend/src/components/AppUpdater.test.tsx new file mode 100644 index 00000000..adff76d3 --- /dev/null +++ b/frontend/src/components/AppUpdater.test.tsx @@ -0,0 +1,212 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StrictMode, useEffect } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { OTA_PROGRESS_TARGET } from "../backend/wails"; +import { I18nProvider, LOCALE_STORAGE_KEY } from "../i18n"; +import { taskKey, TaskCenterProvider, useTaskCenter } from "../state/TaskCenterContext"; +import { AppUpdater } from "./AppUpdater"; +import { TaskCenter } from "./TaskCenter"; + +const mocks = vi.hoisted(() => ({ + question: vi.fn(), + checkUpdate: vi.fn(), + downloadUpdate: vi.fn(), + restartUpdate: vi.fn(), +})); + +vi.mock("@wailsio/runtime", () => ({ Dialogs: { Question: mocks.question } })); +vi.mock("../backend/api", async () => { + const errors = await import("../backend/errors"); + return { + api: { + onInstallOutput: () => () => {}, + checkUpdate: mocks.checkUpdate, + downloadUpdate: mocks.downloadUpdate, + restartUpdate: mocks.restartUpdate, + }, + describeError: errors.describeError, + isCancellationError: errors.isCancellationError, + }; +}); + +function deferredDownload() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const request = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }) as Promise & { cancel: ReturnType }; + request.cancel = vi.fn(); + return { request, resolve, reject }; +} + +function ExistingUpdate() { + const { isTaskRunning, startTask } = useTaskCenter(); + const id = taskKey("update", OTA_PROGRESS_TARGET); + useEffect(() => { + startTask({ + id, + kind: "update", + target: OTA_PROGRESS_TARGET, + title: "Existing update", + route: "/overview", + }); + }, [id, startTask]); + return isTaskRunning(id) ? : null; +} + +function mount({ strict = false, existing = false } = {}) { + const content = ( + + + {existing ? : null} + {existing ? null : } + + + + ); + return render(strict ? {content} : content); +} + +describe("AppUpdater", () => { + beforeEach(() => { + localStorage.setItem(LOCALE_STORAGE_KEY, "en"); + mocks.question.mockReset().mockResolvedValue("Not now"); + mocks.checkUpdate.mockReset().mockResolvedValue(""); + mocks.downloadUpdate.mockReset(); + mocks.restartUpdate.mockReset(); + }); + + it("checks once under StrictMode", async () => { + mount({ strict: true }); + await waitFor(() => expect(mocks.checkUpdate).toHaveBeenCalledTimes(1)); + }); + + it.each([ + ["a failed check", () => Promise.reject(new Error("offline"))], + ["the current version", () => Promise.resolve("")], + ])("silently ignores %s", async (_name, result) => { + mocks.checkUpdate.mockImplementation(result); + mount(); + await waitFor(() => expect(mocks.checkUpdate).toHaveBeenCalledTimes(1)); + expect(mocks.question).not.toHaveBeenCalled(); + expect(mocks.downloadUpdate).not.toHaveBeenCalled(); + expect(screen.queryByText("Update OneAgent")).toBeNull(); + }); + + it("does nothing when the OTA task is already running", async () => { + mocks.checkUpdate.mockResolvedValue("v2.0.0"); + mount({ existing: true }); + expect(await screen.findByText("Existing update")).toBeTruthy(); + expect(mocks.checkUpdate).not.toHaveBeenCalled(); + expect(mocks.question).not.toHaveBeenCalled(); + expect(mocks.downloadUpdate).not.toHaveBeenCalled(); + }); + + it("skips the download when Not now is chosen", async () => { + mocks.checkUpdate.mockResolvedValue("v2.0.0"); + mount(); + await waitFor(() => expect(mocks.question).toHaveBeenCalledWith({ + Title: "OneAgent update", + Message: "OneAgent v2.0.0 is available. Download it now?", + Buttons: [ + { Label: "Update", IsDefault: true }, + { Label: "Not now", IsCancel: true }, + ], + })); + expect(mocks.downloadUpdate).not.toHaveBeenCalled(); + expect(screen.queryByText("Update OneAgent v2.0.0")).toBeNull(); + }); + + it("starts a versioned cancellable task after approval", async () => { + const user = userEvent.setup(); + const download = deferredDownload(); + mocks.checkUpdate.mockResolvedValue("v2.0.0"); + mocks.question.mockResolvedValue("Update"); + mocks.downloadUpdate.mockReturnValue(download.request); + mount(); + + expect(await screen.findByText("Update OneAgent v2.0.0")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Cancel task" })); + expect(download.request.cancel).toHaveBeenCalledTimes(1); + expect(screen.getByText("Cancelled")).toBeTruthy(); + download.reject(Object.assign(new Error("cancelled"), { name: "CancelError" })); + }); + + it("offers restart after a successful download", async () => { + const user = userEvent.setup(); + mocks.checkUpdate.mockResolvedValue("v2.0.0"); + mocks.question.mockResolvedValue("Update"); + mocks.downloadUpdate.mockResolvedValue(undefined); + mocks.restartUpdate.mockResolvedValue(undefined); + mount(); + + await waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "Task center" })); + expect(await screen.findByText(/Completed.*Update downloaded/)).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Restart and update" })); + expect(mocks.restartUpdate).toHaveBeenCalledTimes(1); + }); + + it("shows download failures and preserves cancellation", async () => { + const user = userEvent.setup(); + const failed = deferredDownload(); + mocks.checkUpdate.mockResolvedValue("v2.0.0"); + mocks.question.mockResolvedValue("Update"); + mocks.downloadUpdate.mockReturnValue(failed.request); + const view = mount(); + expect(await screen.findByText("Update OneAgent v2.0.0")).toBeTruthy(); + failed.reject(new Error("download failed")); + expect(await screen.findByText(/Failed.*download failed/)).toBeTruthy(); + + view.unmount(); + const cancelled = deferredDownload(); + mocks.downloadUpdate.mockReturnValue(cancelled.request); + mount(); + expect(await screen.findByText("Update OneAgent v2.0.0")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Cancel task" })); + cancelled.reject(Object.assign(new Error("cancelled"), { name: "CancelledRejectionError" })); + expect(await screen.findByText("Cancelled")).toBeTruthy(); + }); + + it("keeps the restart action after restart fails", async () => { + const user = userEvent.setup(); + mocks.checkUpdate.mockResolvedValue("v2.0.0"); + mocks.question.mockResolvedValue("Update"); + mocks.downloadUpdate.mockResolvedValue(undefined); + mocks.restartUpdate.mockRejectedValue(new Error("restart failed")); + mount(); + + await waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "Task center" })); + const restart = await screen.findByRole("button", { name: "Restart and update" }); + await user.click(restart); + expect(await screen.findByText(/restart failed/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Restart and update" })).toBeTruthy(); + }); + + it("serializes restart attempts and allows retry after failure", async () => { + const user = userEvent.setup(); + let rejectRestart!: (error: unknown) => void; + const restart = new Promise((_resolve, reject) => { rejectRestart = reject; }); + mocks.checkUpdate.mockResolvedValue("v2.0.0"); + mocks.question.mockResolvedValue("Update"); + mocks.downloadUpdate.mockResolvedValue(undefined); + mocks.restartUpdate.mockReturnValueOnce(restart).mockResolvedValueOnce(undefined); + mount(); + + await waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "Task center" })); + const restartButton = await screen.findByRole("button", { name: "Restart and update" }); + await user.click(restartButton); + await user.click(restartButton); + expect(mocks.restartUpdate).toHaveBeenCalledTimes(1); + + rejectRestart(new Error("restart failed")); + expect(await screen.findByText(/restart failed/)).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Restart and update" })); + expect(mocks.restartUpdate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/components/AppUpdater.tsx b/frontend/src/components/AppUpdater.tsx new file mode 100644 index 00000000..5b272c2b --- /dev/null +++ b/frontend/src/components/AppUpdater.tsx @@ -0,0 +1,88 @@ +import { Dialogs } from "@wailsio/runtime"; +import { useEffect, useRef } from "react"; + +import { api, describeError, isCancellationError } from "../backend/api"; +import { OTA_PROGRESS_TARGET } from "../backend/wails"; +import { useI18n } from "../i18n"; +import { taskCanceller, taskKey, useTaskCenter } from "../state/TaskCenterContext"; + +const OTA_TASK_ID = taskKey("update", OTA_PROGRESS_TARGET); + +export function AppUpdater() { + const { t } = useI18n(); + const taskCenter = useTaskCenter(); + const latest = useRef(taskCenter); + const checked = useRef(false); + const active = useRef(false); + const restarting = useRef(false); + latest.current = taskCenter; + + useEffect(() => { + active.current = true; + if (checked.current) return () => { active.current = false; }; + checked.current = true; + if (latest.current.isTaskRunning(OTA_TASK_ID)) return () => { active.current = false; }; + + void (async () => { + let version: string; + try { + version = await api.checkUpdate(); + } catch { + return; + } + if (!active.current || !version || latest.current.isTaskRunning(OTA_TASK_ID)) return; + + const updateLabel = t("更新"); + let choice: string; + try { + choice = await Dialogs.Question({ + Title: t("OneAgent 更新"), + Message: t("发现 OneAgent 新版本 {version},现在下载吗?", { version }), + Buttons: [ + { Label: updateLabel, IsDefault: true }, + { Label: t("暂不"), IsCancel: true }, + ], + }); + } catch { + return; + } + if (!active.current || choice !== updateLabel || !latest.current.startTask({ + id: OTA_TASK_ID, + kind: "update", + target: OTA_PROGRESS_TARGET, + progressTarget: OTA_PROGRESS_TARGET, + title: t("更新 OneAgent {version}", { version }), + route: "/overview", + })) return; + + try { + const request = api.downloadUpdate(); + latest.current.setTaskCanceller(OTA_TASK_ID, taskCanceller(request)); + await request; + latest.current.finishTask(OTA_TASK_ID, { kind: "success", message: t("更新已下载") }); + latest.current.setTaskAction(OTA_TASK_ID, { + label: t("重启并更新"), + run: async () => { + if (restarting.current) return; + restarting.current = true; + try { + await api.restartUpdate(); + } catch (error) { + latest.current.setTaskMessage(OTA_TASK_ID, describeError(error, t("无法重启并更新")).message); + } finally { + restarting.current = false; + } + }, + }); + } catch (error) { + latest.current.finishTask(OTA_TASK_ID, isCancellationError(error) + ? { kind: "cancelled", message: t("已取消") } + : { kind: "failure", message: describeError(error, t("更新失败")).message }); + } + })(); + + return () => { active.current = false; }; + }, [t]); + + return null; +} diff --git a/frontend/src/components/TaskCenter.test.tsx b/frontend/src/components/TaskCenter.test.tsx index c29f73dd..53fbfcb6 100644 --- a/frontend/src/components/TaskCenter.test.tsx +++ b/frontend/src/components/TaskCenter.test.tsx @@ -12,6 +12,7 @@ import { TaskCenter } from "./TaskCenter"; let emit: ((output: InstallOutput) => void) | null = null; const unsubscribe = vi.fn(); const cancelRequest = vi.fn(); +const terminalAction = vi.fn(); vi.mock("../backend/api", async () => { const errors = await import("../backend/errors"); @@ -41,13 +42,14 @@ const installTask: TaskInput = { }; function TaskHarness() { - const { startTask, finishTask, setTaskCanceller } = useTaskCenter(); + const { startTask, finishTask, setTaskCanceller, setTaskAction } = useTaskCenter(); return (
+ + {task.state !== "running" && task.action ? ( + + ) : null} {task.state === "running" ? (