Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# KusPus CI — build, test, lint on every PR + push to main.
# Phase 12 — see TECH_SPEC §31.
# KusPus CI — build + test on every PR + push to main.
# Phase 12 — see TECH_SPEC §31, validated against 2025 best-practices
# research (2026-05-17).

name: ci

Expand All @@ -8,11 +9,57 @@ on:
branches: [main]
pull_request:

# Cancel in-flight CI when a newer commit lands on the same branch.
# Safe on CI (not release) because each commit re-runs the suite from
# scratch — there's no partial state to clean up.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
placeholder:
build-test:
runs-on: windows-latest
steps:
- name: Not implemented
run: |
echo "ci.yml not implemented yet (Phase 12)."
exit 1
- name: Checkout
uses: actions/checkout@v5
# Don't fetch submodules — Phase 12 downloads whisper.cpp prebuilt
# binaries via tools/build-whisper-windows.ps1 instead of building
# from third_party/whisper.cpp. Submodule is preserved in the repo
# for source-audit purposes but isn't needed for CI.
with:
submodules: false

- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Build.props') }}
restore-keys: |
nuget-${{ runner.os }}-

- name: Restore
run: dotnet restore

- name: Build (Debug, zero warnings tolerated)
# Directory.Build.props sets TreatWarningsAsErrors=true so any new
# analyzer rule firing on master fails CI immediately.
run: dotnet build --configuration Debug --no-restore

- name: Test
run: dotnet test --configuration Debug --no-build --logger "trx;LogFileName=test-results.trx"

- name: Upload test results
if: always() # publish even on test failure so the report is browsable
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/test-results.trx'
retention-days: 14
128 changes: 123 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,135 @@
# KusPus release — tag-triggered build of the unsigned installer.
# Phase 12 — see TECH_SPEC §31, §32.
# Phase 12 — see TECH_SPEC §31, §32. Validated against 2025 best-practices
# research (2026-05-17).
#
# Flow on tag push (v*.*.*):
# 1. Checkout (no submodules — whisper.cpp pulled prebuilt by step 4)
# 2. Setup .NET 10
# 3. Restore + run full test suite (release fails fast on a regression)
# 4. Run tools/build-whisper-windows.ps1 → installer/payload/whisper/
# 5. dotnet publish with PublishProfile=win-x64 → publish/win-x64/
# (self-contained single-file ~86 MB)
# 6. Install Inno Setup 6.4.3 (windows-latest migrated to Server 2025 in
# Sept 2025 — Inno is no longer pre-installed; see
# actions/runner-images#12464)
# 7. iscc.exe installer/KusPus.iss /DAppVersion=<tag> → installer/Output/
# 8. softprops/action-gh-release publishes as DRAFT with auto-generated
# notes; you flip to published from the GitHub UI after smoke-testing
# the produced setup.exe on a clean machine.

name: release

on:
push:
tags: ['v*']

# A release is one-and-done; do NOT cancel-in-progress on tag pushes. If
# you push two tags in quick succession, run them both.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: write # needed for action-gh-release to create the GitHub Release

jobs:
placeholder:
release:
runs-on: windows-latest
steps:
- name: Not implemented
- name: Checkout
uses: actions/checkout@v5
with:
submodules: false # whisper.cpp pulled prebuilt by step 4
fetch-depth: 0 # release notes generation reads full git log

- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Build.props') }}
restore-keys: |
nuget-${{ runner.os }}-

- name: Restore
run: dotnet restore

- name: Test (full suite)
# Release builds run the full xunit suite first — a failing test
# on a tagged commit is a release-blocker. Use Release config so
# the test environment matches what the published binary uses.
run: dotnet test --configuration Release --no-restore

- name: Build whisper.exe payload (pinned upstream release)
# Downloads v1.8.4 (or whatever tools/build-whisper-windows.ps1
# has -Tag default) from ggerganov/whisper.cpp's GitHub release,
# extracts whisper.exe + DLLs + SHA256SUMS to installer/payload/whisper/.
shell: pwsh
run: ./tools/build-whisper-windows.ps1

- name: Publish KusPus.App (self-contained single-file)
# PublishProfile=win-x64 sets SelfContained, PublishSingleFile,
# IncludeNativeLibrariesForSelfExtract, EnableCompressionInSingleFile,
# PublishReadyToRun, PublishTrimmed=false, DebugType=embedded.
run: dotnet publish src/KusPus.App -p:PublishProfile=win-x64 -o publish/win-x64

- name: Build installer (install Inno + compile)
# As of the Sept 2025 windows-latest → Windows Server 2025 migration,
# Inno Setup is no longer pre-installed on GitHub-hosted runners
# (actions/runner-images#12464). This action both installs Inno 6
# AND runs ISCC.exe on the script — one step for two jobs.
# AppVersion via /DAppVersion= stamps the tag into both
# OutputBaseFilename (→ KusPus-Setup-v1.0.0.exe) and the .exe's
# version-info resource.
# /Qp = quiet output, errors only.
# TODO Phase 12+: pin to commit SHA + add Dependabot config so
# third-party actions auto-update. v1.2.5 was the latest as of
# 2026-05-17 research; current at run time may be newer.
uses: Minionguyjpro/Inno-Setup-Action@v1.2.5
with:
path: installer/KusPus.iss
options: /DAppVersion=${{ github.ref_name }} /Qp

- name: Verify installer artifact
# Belt-and-suspenders: confirm the .exe exists at the expected path
# before action-gh-release tries to upload it. fail_on_unmatched_files
# on the upload action would also catch this, but a named step gives
# a clearer log on failure.
run: |
echo "release.yml not implemented yet (Phase 12)."
exit 1
$artifact = Get-ChildItem -Path installer/Output -Filter "KusPus-Setup-*.exe" | Select-Object -First 1
if (-not $artifact) { throw "No KusPus-Setup-*.exe in installer/Output/" }
$sizeMB = [math]::Round($artifact.Length / 1MB, 1)
$sha = (Get-FileHash -Algorithm SHA256 -LiteralPath $artifact.FullName).Hash
Write-Host "Artifact: $($artifact.Name) ($sizeMB MB)"
Write-Host "SHA-256: $sha"
shell: pwsh

- name: Publish GitHub Release (draft)
# draft: true is intentional — the friends-only audience means the
# author smoke-tests the installer on a clean Windows VM before
# flipping the release from "draft" to "published" via the GitHub UI.
# Flipping draft = no extra workflow run, no rebuild.
# generate_release_notes auto-builds the notes from PRs + commits
# since the previous tag.
uses: softprops/action-gh-release@v2
with:
files: installer/Output/KusPus-Setup-*.exe
generate_release_notes: true
draft: true
fail_on_unmatched_files: true
name: KusPus ${{ github.ref_name }}
body: |
Local Privacy First. Windows dictation, fully on-device.

## Install
1. Download `KusPus-Setup-${{ github.ref_name }}.exe`.
2. **Right-click → Properties → check "Unblock" → Apply** before running. This downgrades SmartScreen friction to a single "Run anyway" click.
3. Run the installer. No admin prompt — installs to `%LOCALAPPDATA%\Programs\KusPus`.
4. If Smart App Control is enabled (Win11), the installer will be silently blocked with no override UI. Disable SAC under Windows Security → App & browser control → Smart App Control → Off, then re-run.

## Notes
See generated release notes below.
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,21 @@ When a gate forces a deliberate deviation from TECH_SPEC's literal text, log it
- **`src/KusPus.App/MainWindow.xaml` — About tagline + History search bar accent.** Tagline `"Press a hotkey. Speak. Get pasted."` → `"Local · Privacy First"` with middle-dot rhythm matching the legal line. History search bar gets a 2-layer mint accent per UX Pro Max: magnifier glyph `Foreground=Mint` (persistent brand cue, low-key) + bottom border `1px BorderSubtle` → `2px Mint` (element doubles as accent + divider above table header). No bg fill — that would compete with row data.
- **`src/KusPus.App/Styles/Tokens.xaml` — `Icon.Glyph=11` + `Icon.Chevron=9` double tokens** were added earlier in this session; restated here so a future grep for "icon size" finds the deviation log alongside the token definition.

### Phase 12 — packaging + installer + release pipeline (2026-05-17)

- **`tools/build-whisper-windows.ps1` — rewritten as download-prebuilt** (was: build-from-source via MSVC + CMake + `third_party/whisper.cpp` submodule). Downloads `whisper-bin-x64.zip` from `ggerganov/whisper.cpp` release for the pinned tag (`v1.8.4`), extracts, copies `whisper-cli.exe` (renamed → `whisper.exe`) + DLLs + `SHA256SUMS` to `installer/payload/whisper/`. Idempotent via `.tag` marker. Per-tag cache at `.local-temp/whisper-cache/`. Build-from-source flow lives in git history if ever needed for audit. Picked download-prebuilt over build-from-source per Phase 12 research 2026-05-17: no MSVC/CMake on dev machines or CI, ~3 s vs ~5 min iteration, upstream binaries are GGML_NATIVE=OFF + carry MotW reputation from GitHub release flow.
- **`tools/build-whisper-windows.ps1` — prefer `whisper-cli.exe` over `main.exe`.** v1.8.x ships both binaries in the release zip; `main.exe` is a **deprecation stub** that prints "this binary is deprecated" and exits 1. The initial picker grabbed whichever sorted first alphabetically (= `main.exe`), so rc1 shipped the stub. KusPus tried to transcribe with it, the stub always exited 1 with the deprecation message on stdout, "stderr preview" was empty → confusing failure mode. Fixed by explicit preference: `whisper-cli.exe` first, fall back to `main.exe` only if absent.
- **`tools/build-whisper-windows.ps1` — smoke test uses `Start-Process` not `& $exe`.** PowerShell 5.1 (Windows PowerShell — what most devs have) wraps native-command stderr as `ErrorRecord` instances which trip `$ErrorActionPreference='Stop'` even when the binary returns success. PowerShell 7 (`pwsh`, what CI uses) doesn't. Using `Start-Process` with explicit file redirects works identically in both shells.
- **`installer/KusPus.iss` — new (was 14-line stub `#error`).** Inno Setup 6.x script per 2025 best-practices research. Per-user (`PrivilegesRequired=lowest`, `{autopf}\KusPus`), `ArchitecturesAllowed=x64compatible`, `MinVersion=10.0.17763` (Win10 1809 — DWM rounded-corner floor), LZMA2/max + solid + separate-process compression, fixed AppId GUID `{7E263B33-A253-4E7D-B1A1-1B9D29405A02}` for upgrade detection. `RestartApplications=no` (initially `RestartApplicationsIfNeeded=no` — invented directive name, caught by CI on first compile). No `[UninstallDelete]` on user data paths.
- **`src/KusPus.App/Properties/PublishProfiles/win-x64.pubxml` — new.** Self-contained, `PublishSingleFile=true`, `IncludeNativeLibrariesForSelfExtract=true`, `EnableCompressionInSingleFile=true`, `PublishReadyToRun=true`, `PublishTrimmed=false` (WPF + SharpVectors XAML/reflection break under trim), `DebugType=embedded`. Lives in a PublishProfile (not the csproj) so dev builds stay lean — putting `SelfContained=true` in the csproj forces every `dotnet build` to materialise the full 200+ MB self-contained runtime in `bin/Debug/net10.0-windows/win-x64/`. Invoked via `dotnet publish -p:PublishProfile=win-x64 -o publish/win-x64`. Output: ~86 MB single-file `KusPus.exe`.
- **`src/KusPus.App/KusPus.App.csproj` — `EnableSingleFileAnalyzer=true`.** Fires IL3000-family warnings at compile time on single-file-incompatible APIs (`Assembly.Location`, `Assembly.CodeBase`, etc.). Caught one regression immediately (see next entry).
- **`src/KusPus.App/MainWindow.xaml.cs` — `Assembly.Location` → `AppContext.BaseDirectory` for About-tab build date.** The single-file analyzer (above) flagged this — `Assembly.Location` returns empty in published single-file apps so the About tab would have shown "Built · dev build" (two spaces, no timestamp) after release. Now reads mtime of `BaseDirectory\KusPus.exe`.
- **`src/KusPus.App/KusPus.App.csproj` — `EmitWhisperShaConstant` MSBuild target.** Runs `BeforeCompile;CoreCompile`. Reads `installer/payload/whisper/SHA256SUMS`, regex-extracts the `whisper.exe` line's hash, writes `obj/.../WhisperSha.g.cs` containing `internal static class BuildConstants { public const string ExpectedWhisperSha256 = "<sha>"; }`. Adds it to `@(Compile)` from inside the target (bypasses parse-time `Exists()` chicken-and-egg). Empty constant on dev builds (no `SHA256SUMS`) → integrity check no-ops. Release builds carry the real hash so `WhisperRunner.ExpectedWhisperSha256` enforces it.
- **`src/KusPus.App/AppPaths.cs` — `ExpectedWhisperSha256` reads `BuildConstants.ExpectedWhisperSha256`** (the build-time constant). Env-var `KUSPUS_WHISPER_SHA256` override stays for dev escape hatches. Empty-string fallback for unit-test hosts where the constant isn't compiled in.
- **`.github/workflows/ci.yml` + `.github/workflows/release.yml`** — replaced placeholder workflows. CI: `push: main` + `pull_request` triggers, windows-latest + .NET 10 + NuGet cache + `dotnet build` (TreatWarningsAsErrors via Directory.Build.props) + `dotnet test` + TRX artifact upload. Release: `push: tag v*` triggers, full pipeline (checkout → test → `build-whisper-windows.ps1` → `dotnet publish` with PublishProfile → `Minionguyjpro/Inno-Setup-Action@v1.2.5` (installs Inno 6 + compiles iss in one step, critical since the Sept 2025 windows-latest → Server 2025 migration removed Inno from the runner image — see `actions/runner-images#12464`) → verify artifact + log SHA-256 → `softprops/action-gh-release@v2` publishes as **draft** with auto-generated notes + MotW unblock instructions in the body. Permissions least-privilege per workflow (`contents: read` for CI, `contents: write` for release).
- **`src/KusPus.App/AppPaths.cs` — `ModelsDir` moved from `%LOCALAPPDATA%\KusPus\models` → `{app}\whisper\models`** (under `WhisperDir`). Dogfood finding 2026-05-17: Windows Defender's **Controlled Folder Access (CFA)** blocks unsigned KusPus.exe from listing files in `%LOCALAPPDATA%\KusPus\models\` even when the user's ACL grants FullControl. `Directory.Exists` returns true but `Directory.GetFiles` throws `UnauthorizedAccessException` and `File.Exists` returns false — silent CFA sandboxing. CFA almost never blocks an app from reading its own install directory, so moving the models there sidesteps the issue without per-machine CFA whitelisting. `WhisperRunner.ExpectedWhisperSha256` integrity check and uninstall-preserves-user-data behaviour both still hold (Inno's `[Files]` doesn't track downloaded .bin files; uninstall leaves them in place). Override via `KUSPUS_MODELS_DIR` env var for tests/portable layouts. **Migration:** none — existing testers re-download their model files (tiny.en ~30 s, base.en ~1 min). Stale files at the old path stay orphaned for manual cleanup.
- **rc-tag history during Phase 12 dogfood** (2026-05-17): rc1 shipped `main.exe` deprecation stub (Bug #2 above). rc2 fixed that. rc3 added diagnostic logging that surfaced the CFA issue. rc4 moves ModelsDir. rc3 diagnostic logging removed before rc4 ship — code hygiene per user request.

Append to this list (don't replace) when a new deviation lands.

## When in doubt
Expand Down
2 changes: 1 addition & 1 deletion docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ Bundled subprocess:
| Clipboard contents | PasteEngine | OS clipboard | OS clipboard (replaces prior) | **No** |
| History records | HistoryStore | SQLite | `%LOCALAPPDATA%\KusPus\history.db` | **No** |
| Settings | PrefsStore | `settings.json` | `%APPDATA%\KusPus\settings.json` | **No** |
| Model files | HTTPS download | ModelManager | `%LOCALAPPDATA%\KusPus\models\*.bin` | **OUT** to huggingface.co (allowlisted) |
| Model files | HTTPS download | ModelManager | `{app}\whisper\models\*.bin` (CFA-friendly install-dir location since 2026-05-17; was `%LOCALAPPDATA%\KusPus\models\`) | **OUT** to huggingface.co (allowlisted) |
| Crash minidumps | Sentry SDK | Scrubber → Sentry | sentry.io (opt-in) | **OUT** to sentry.io (allowlisted, opt-in only) |
| Logs | LogSink | Rotating file appender | `%LOCALAPPDATA%\KusPus\logs\*.log` | **No** |
| Failed audio | AudioRecorder on transcribe failure | Filesystem | `%LOCALAPPDATA%\KusPus\failed\` (24 h auto-prune) | **No** |
Expand Down
2 changes: 1 addition & 1 deletion docs/TECH_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,7 @@ List available models. Download from HuggingFace. Verify SHA-256. Activate. Dete
### Download flow

1. `HttpClient` GET to manifest URL. Header `Accept-Encoding: identity` (no compression — whisper models are pre-compressed binaries).
2. Stream into `%LOCALAPPDATA%\KusPus\models\{fileName}.tmp`, computing SHA-256 as we go.
2. Stream into `{app}\whisper\models\{fileName}.tmp`, computing SHA-256 as we go. (Was `%LOCALAPPDATA%\KusPus\models\` — moved into the install dir in Phase 12 dogfood 2026-05-17 because Defender's Controlled Folder Access silently blocks unsigned binaries from listing files under `%LOCALAPPDATA%\<vendor>\` even when the user's ACL allows it. CFA almost never blocks an app reading its own install directory. See `CLAUDE.md` deviation log.)
3. On completion, compare SHA to manifest entry.
4. SHA match: `File.Replace` `.tmp` → final name. SHA mismatch: delete `.tmp`, surface error, do not activate.
5. Progress events at 1 Hz to the UI.
Expand Down
Loading
Loading