diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f53d4c5..74d50b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67eb85f..49ae643 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,21 @@ # 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= → 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 @@ -7,11 +23,113 @@ 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. diff --git a/CLAUDE.md b/CLAUDE.md index cec92c7..15edf91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,34 @@ When a gate forces a deliberate deviation from TECH_SPEC's literal text, log it - **`src/KusPus.App/FloatingPillWindow.xaml` — `MicChooserButton` hover** swapped from `Opacity=1.4` (silent no-op — WPF clamps at 1) to `Background={DynamicResource SurfaceElevated}` via Setter. Real, theme-aware lift. - **`docs/ROADMAP.md` — added R1.2-10 long-mode chunk-on-VAD streaming entry** (2026-05-17). User dogfood feedback asked for continuous "speak-pause-paste" loop; researched 3 architectures (sliding-window vs chunk-on-VAD vs library-binding) and recommended chunk-on-VAD on a second hotkey ("Option B"). Deferred to v1.2 — ~2 weeks build + 1 week dogfood, too large for the pre-v1 polish window. Entry includes 8-cluster plan + risk register + latency expectations. +### Late-afternoon dogfood pass (2026-05-17, commits 03853e7…0ce194c) + +- **`src/KusPus.App/OnboardingWindow.xaml.cs` — Skip now sets `Onboarding.Completed = true`** (was: false). Onboarding modal opens **once-ever** per install; closing via either Skip or Finish is honoured the same way. Re-runnable via About → "Run again". Replaces the prior "skip-on-skip keeps re-prompting" semantics which were hostile to dogfood users. +- **`src/KusPus.App/App.xaml.cs` — pill `Bind()` / `BindLevels()` deferred** to after the onboarding modal closes (or runs immediately if no onboarding). Pill is now invisible while the modal is up, then appears with FadePillIn after Finish/Skip. New `BindPillAndShow()` helper. Per user spec: "When the onboarding setup is opened the pill UI should not be visible." +- **`src/KusPus.App/OnboardingWindow.xaml.cs` — Step 3 mic init runs async** via `OpenMicStepAsync` mirroring `MainWindow.OpenAudioTabAsync`. Page paints immediately with "Loading microphones…" + "LOADING…" placeholders; MMDevice enum + WasapiCapture init run on `Task.Run`; UI populates when ready. Step 3 used to block the dispatcher for ~250 ms on first entry. +- **`src/KusPus.App/OnboardingWindow.xaml.cs` — apartment-marshalling bug fix.** First async pass returned the `MMDevice` across the `Task.Run`→dispatcher boundary, then accessed `.FriendlyName` on STA — NAudio's `IMMDevice` doesn't support standard COM cross-apartment marshalling, so the property getter threw `InvalidCastException` → `E_NOINTERFACE`. Surfaced (after broadened catch) as "Microphone blocked" even when nothing held the mic. Fix: read `FriendlyName` **inside** the `Task.Run` lambda (on the MTA thread that created the device), return only the string + `WasapiCapture` across the await. `MMDevice` never crosses thread boundaries. `WasapiCapture` is fine cross-thread because it caches `WaveFormat` internally before its ctor returns — that's why `MainWindow.OpenAudioTabAsync` (which only returns the capture) never had this bug. Also broadened the `Task.Run` catch from `COMException`+`MmException` → general `Exception`, and wrapped `OpenMicStepAsync` in an outer try/catch so any unhandled error surfaces as `ShowMicError` instead of silent stuck-Loading. +- **`src/KusPus.App/MainWindow.xaml` — Globe + GitHub social icons wrapped in fixed 24×24 `Canvas`.** All four social icons (LinkedIn, X, GitHub, Globe) now wrap their `Path` in a `Canvas Width=24 Height=24` so the `Viewbox` measures against guaranteed identical bounds. Path bboxes vary subtly (GitHub's `M12 .297` y-offset, X's `0.258` left edge, Bezier control points extending beyond visible curves) which caused uneven rendered sizes when `Viewbox` uniformly stretched each to 14×14. Globe geometry was also expanded earlier from `(2,2) W=20 H=20` → `(1,1) W=22 H=22` so its visible ink fills the box; the canvas wrapper is belt-and-suspenders on top of that. +- **`src/KusPus.App/FloatingPillWindow.xaml{,.cs}` — Both record glyphs share the same state pattern.** `CompactRecordGlyph` (corner, 10×10) and `RecordGlyph` (dock, 8×8) are now both **grey** (`MutedText`) when idle, **red** (`#EF5350`) when actively recording. Single brush shared in `UpdateRecordGlyph` so the two glyphs stay in sync. Earlier the dock glyph was always red (reading as "always recording at rest"); the compact corner glyph was the first to flip per Option α; this commit aligned the dock with the corner. +- **`src/KusPus.App/FloatingPillWindow.xaml.cs` — Nudge timer 10 s → 2 s.** "Click into your text field" hint is now a brief flash. The earlier 10 s landed after the dispatch-race fix (Aug 2026-05-17) but turned out to linger far longer than the hint needed. +- **`src/KusPus.App/FloatingPillWindow.xaml{,.cs}` — Pill bottom corners squared while dock is open.** `PillSurface.CornerRadius` snaps `8` → `(8,8,0,0)` at the start of `OpenDock()` and back to `8` at the start of `CloseDock()`. Pinned mode never calls those methods (gated by `!_isPinned` in `OnPillMouseEnter`/`Leave` + `OnPinClick`), so compact-mode pill keeps its full rounded corners untouched. Snap (not animate) because `CornerRadius` isn't a natively animatable `DependencyProperty` and a brief asymmetry during a 240 ms slide is acceptable. Per user: pill+dock should read as one continuous shape while drawer is visible. +- **`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 = ""; }`. 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 diff --git a/docs/APP_DESIGN.md b/docs/APP_DESIGN.md index 5d2f4cb..5b0de4b 100644 --- a/docs/APP_DESIGN.md +++ b/docs/APP_DESIGN.md @@ -1768,6 +1768,82 @@ There is no API path to a custom 12 px radius without dropping into `AllowsTrans The 12 → 8 deviation is recorded here rather than in `CLAUDE.md` because it's a spec revision, not a code-side deviation. +--- + +## Dogfood-driven design updates (2026-05-17) + +Spec-side companion to the running deviation log in `CLAUDE.md`. The items below are revisions to APP_DESIGN, not deviations awaiting reconciliation. Code is the source of truth; revisit this section when the design language evolves further. + +### Default theme + +The default theme is now **`dark`** (was: `auto`). Light theme is in beta polish; new installs land on the polished dark surface. Preferences → General → Theme picker shows `Auto · Light [BETA] · Dark`. Tooltip on the Light radio explains the beta state. `DefaultSettings.ForFirstRun().Ui.Theme = "dark"`; `DefaultSettingsTests` asserts the new default with rationale comment. + +### Tray right-click menu (replaces §5.2's WinForms ContextMenuStrip) + +Tray right-click no longer uses the default `WinForms.ContextMenuStrip`. A custom WPF `TrayMenuWindow` renders a rounded `Surface` card matching `Tray_light.png` / `Tray_dark.png`: + +- KusPus header with state-aware subtitle `Version 1.0.0 · {Idle | Recording | Transcribing}` +- `Toggle Recording [BETA]` row with a hotkey keycap chip on the right (live-bound to `PrefsStore.Hotkey`) +- `Active model: ` row with chevron — clicking opens the Models tab in Preferences +- `Preferences…` → opens General tab +- `History…` → opens History tab +- `Quit` in `ErrorRed` + +Shows at cursor on `NotifyIcon.MouseClick(Right)`. Closes on `Deactivated` (focus loss) or any item click. `WS_EX_TOOLWINDOW` hides it from Alt-Tab/taskbar. + +### State-aware tray icon + +`TrayManager` swaps the tray icon based on `AppCoordinator.State`: + +| FSM state | Icon | Source | +|---|---|---| +| Idle (or any non-Recording/Transcribing) | `icons/icon-idle.ico` | base mint-bars logo | +| Recording / Transcribing | `icons/icon-recording.ico` | base + red dot top-right with red glow | +| Failed PostPaste | `icons/icon-error.ico` | base + red warning triangle top-right | + +All three .ico files are generated from per-state SVGs via `tools/IconBuilder`. Treating a failed `PostPaste` snapshot as Error makes the warning glyph visible for the duration of the error hold. + +### Pill — see PILL_DESIGN §11 + +The pill's dogfood divergences (pin = compact-mode + position-lock, compact corner record button, tap-mode toggle, nudge popup, corner-radius behaviour, shadow softening, removed inner highlight, mint idle wordmark) live in `docs/PILL_DESIGN.md §11`. APP_DESIGN §2 still describes the canonical 5-state visual model; PILL_DESIGN §11 extends it with the Idle visual + pin semantics + dock drawer. + +### Onboarding step 6 — real dictation + +Step 6's "Try it" used to pick a random sentence from a hardcoded `SimulatedSentences` list. It now runs the real `IAudioRecorder` + `IWhisperRunner` pipeline: 5 s countdown → transcribe with active model → render actual transcript (or error if mic/model missing). Surfaces broken-mic / missing-model failures during setup instead of after. `OnboardingWindow` constructor now takes `IAudioRecorder, IWhisperRunner, IModelManager` in addition to `IPrefsStore + IHotkeyEngine`. + +### Onboarding step 3 — input device picker + +Step 3 ("Check your mic") adds an `OnbInputDeviceCombo` above the live meter. Writes to the same `PrefsStore.Audio.InputDeviceId` field that Preferences → Audio uses, so the selection persists until the user changes it from either surface. `ResolveOnbMicDevice` mirrors `MainWindow.ResolveLevelMeterDevice`. Loads async via `OpenMicStepAsync` so the page paints immediately and the mic init runs on `Task.Run`. + +### Onboarding gate — "show once, ever" + +`OnSkipClick` now sets `Onboarding.Completed = true` (was: `false`). Onboarding modal opens once per install; closing via either Skip or Finish is honoured the same way. Re-runnable via About → "Run again". Per the new rule, the pill's first `Show()` is deferred until the modal closes (via new `BindPillAndShow()` helper in `App.OnStartup`) so the pill is invisible while the modal is up. + +### About tab + +- Tagline: `"Press a hotkey. Speak. Get pasted."` → **`"Local · Privacy First"`** (matches the `·` rhythm of the legal line above it). +- Four social icons (LinkedIn, X, GitHub, Portfolio-globe) all wrapped in fixed 24×24 `Canvas` containers so the `Viewbox` measures against guaranteed identical bounds. Path-bbox quirks (GitHub's `M12 .297` y-offset, X's `0.258` left edge, Bezier control points) no longer affect rendered size. Globe geometry expanded from `(2,2) W=20 H=20` → `(1,1) W=22 H=22` so its visible ink fills the box. + +### History search bar — mint accent + +UX Pro Max two-layer brand accent: + +| Layer | Change | +|---|---| +| Persistent brand cue | Magnifier glyph `Foreground` → `Mint` | +| Structural prominence | Bottom border `1px BorderSubtle` → `2px Mint` (element doubles as accent + divider above table header) | + +No background fill — that would compete with row data below. Matches the Material 3 / iOS HIG search-bar-with-brand-accent pattern. + +### Icon-size design tokens + +`Styles/Tokens.xaml` gained two new double tokens for unified glyph sizing: + +- `Icon.Glyph` = `11` (action icons in pill chrome, tray menu, dock buttons) +- `Icon.Chevron` = `9` (small directional indicators — `▾`, `▸`) + +Bound via `{StaticResource Icon.Glyph}` / `{StaticResource Icon.Chevron}`. Replaces literal `FontSize="10/11/8"` previously scattered across glyphs. + diff --git a/docs/PILL_DESIGN.md b/docs/PILL_DESIGN.md index 54fb6e9..fb1ce31 100644 --- a/docs/PILL_DESIGN.md +++ b/docs/PILL_DESIGN.md @@ -379,3 +379,125 @@ without the tray. - **§6.1 Visibility rule** — pill is still hidden whenever the app is not in one of the four active states (`recording`, `transcribing`, `confirmed`, `error`). Hover-extend is only reachable while the pill is already shown. + +--- + +## 11. Dogfood-driven evolution (2026-05-17) + +This section captures pill-spec divergences that landed during the v1 dogfood +pass. Source-of-truth comments live next to the code in +`src/KusPus.App/FloatingPillWindow.xaml{,.cs}`; this section is the spec-side +companion so future edits don't try to "restore" the original behaviour. + +### 11.1 Geometry — current footprint (replaces §10.1 width math) + +| State | Width | Height | Rationale | +|---|---|---|---| +| Collapsed (resting, not hovered, not pinned) | `200` | `56` | Same as original spec | +| Expanded (hovered, not pinned) | `320` | `78` | Pill 56 + dock 22 (was: hover-extend `200→280` width, no height growth) | +| Pinned compact | `200` | `56` | Same as Collapsed — pin disables hover-expand entirely | + +The hover-extend column from §10.1 is replaced by a dock drawer that slides +**down** from below the pill on hover (22 px peek). The previous 200→280 +rightward extension is gone. + +### 11.2 Pin = "compact-mode + position-lock" (replaces §10.1 pin semantics) + +The Pin button no longer "latches the dock open." New semantics: + +- **Click pin while expanded** → contract pill back to 200×56 + slide dock back + + pin button stays mint-tinted at angle=0, always visible. +- **Hover while pinned** → only swap idle content (SVG-wordmark ↔ visualizer); + **no resize, no dock**. +- **Click pin again** → unpin; if still hovered, expand back to hover view. +- **Drag** → disabled while pinned. `OnPillMouseLeftButtonDown` short-circuits + before `DragMove()`. Cursor flips `SizeAll`↔`Arrow` on pin toggle to + telegraph the lock. +- A `CompactRecordButton` (top-**LEFT** corner, 18×18 + Radius=4 matching + Pin/Wand) appears only while pinned so the user can still trigger recording + without unpinning. + +### 11.3 Idle content (extension to §2) + +Adds a fifth pill visual: **Idle** (in-spec dev override per CLAUDE.md). When the +app is not in Recording / Transcribing / Confirmed / Error, the pill stays +visible showing either: + +- **Not hovered**: SVG icon + "KusPus" wordmark (mint `{Mint}` foreground) +- **Hovered (unpinned)**: visualizer bars + "IDLE · HOLD TO DICTATE" label, with + the hover-extend dock visible +- **Hovered (pinned)**: visualizer bars + label, **no resize, no dock** + +This will revert to the spec's "hidden when not in use" once the tray menu's +Quit item makes the close path discoverable — already in place via +`TrayMenuWindow`, so reversion is unblocked but not yet scheduled. + +### 11.4 Tap-mode record button (Toggle Recording \[BETA\]) + +The dock and the compact corner each carry a record toggle wired to +`AppCoordinator.ToggleFromTray`. Both glyphs use the same brush state pattern: + +| FSM state | Glyph fill | Shape | Rationale | +|---|---|---|---| +| Idle | `MutedText` (grey, theme-aware) | Circle (RadiusX = half side) | "Available — tap to start" | +| Recording | `#EF5350` (red) | Rounded square (RadiusX = ~1.5) | "Press to stop" | + +The labels read **"Toggle Recording \[BETA\]"** (both the dock button's +tooltip and the tray menu item) — the mint `[BETA]` chip signals dogfood +expectation that this is freshly-wired tap-mode behaviour. + +Per-user-spec, the toggle does **not** auto-capture a foreground HWND. The +post-transcribe paste lands wherever focus is at the time. A small +`RecordNudgePopup` ("Click into your text field") appears for **2 s** above +the record button on click as a brief hint. + +### 11.5 Bottom corner-radius behaviour + +`PillSurface.CornerRadius` snaps `8` → `(8, 8, 0, 0)` inside `OpenDock()` and +back to `8` inside `CloseDock()` so the pill + dock read as one continuous +shape while the drawer is visible. Pinned mode never calls those methods +(gated by `!_isPinned`), so compact-mode pill keeps its full rounded corners. +Snap (not animate) because `CornerRadius` isn't a natively animatable +`DependencyProperty`. + +### 11.6 Magic wand placeholder + +The §10 "Refine text" button is rendered at `Opacity=0.35` with +`Cursor=Arrow` and tooltip `"Refine text — coming soon"`. Disabled state is +visually legible per UX audit Option α. The button is **dormant** — no click +handler. + +### 11.7 Shadow softened (replaces drop-shadow in §3.3) + +Pill drop shadow lifted from `ShadowDepth=2 BlurRadius=32 Opacity=0.45` → +`ShadowDepth=0 BlurRadius=14 Opacity=0.25`. Omnidirectional soft halo — +no directional bleed onto the dock when the drawer is open. + +### 11.8 Inner highlight removed + +The 1 px `PillInnerHighlight` Rectangle at the pill's top inner edge was +removed — it only existed on the pill (not the dock), so it created a visible +seam at the pill/dock junction when the drawer opened. + +### 11.9 Personality animations (3-phase Organic Pill redesign) + +Pill carries two long-lived `Storyboards` started in `Loaded`: + +- **Breath** — `BreathScale` `ScaleX/Y` sine pulse `1.0 ↔ 1.006` over 4 s + (`AutoReverse`, `RepeatBehavior.Forever`). Subtle "alive" cue. +- **Hue drift** — middle gradient stop of `AccentBrush` cycles mint → seafoam + → cyan → back over 14 s. Constant `R=0x4D` so perceived lightness stays + constant (manual approximation of OKLCH constant-L/C — WPF has no native + OKLCH interpolation). + +`SetReduceAnimations(true)` (driven by Privacy → "Reduce pill animations" OR +Windows accessibility "Show animations" off) stops both storyboards. +State-transition animations (FadePillIn, dock slide, accent line) keep +running regardless. + +### 11.10 Multi-monitor sticky behaviour + +Session-only `Dictionary` keyed by `MONITORINFOEX.szDevice` +remembers per-monitor drag positions. On `Armed`/`Recording` transitions, the +pill jumps to the focused window's monitor (at remembered or default +position). Dictionary cleared every fresh process start per user spec. diff --git a/docs/PRD.md b/docs/PRD.md index 66d886c..0abc945 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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** | diff --git a/docs/TECH_SPEC.md b/docs/TECH_SPEC.md index 20ca311..9e0533e 100644 --- a/docs/TECH_SPEC.md +++ b/docs/TECH_SPEC.md @@ -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%\\` 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. diff --git a/installer/KusPus.iss b/installer/KusPus.iss index dc79c6d..355d32f 100644 --- a/installer/KusPus.iss +++ b/installer/KusPus.iss @@ -1,14 +1,156 @@ -; KusPus installer — Inno Setup script. -; Phase 12 — see TECH_SPEC §30 for the full contract. +; KusPus installer — Inno Setup 6 script. +; Phase 12 — see TECH_SPEC §30 + docs/APP_DESIGN.md. ; -; Key decisions (when filled in): -; - PrivilegesRequired=lowest (per-user install) -; - No HKLM writes -; - Autostart written by the app on opt-in, not by the installer -; - tiny.en bundled as external onlyifdoesntexist -; - Uninstaller leaves user data unless "Also remove my data" is checked +; Architectural decisions (per Phase 12 best-practices research 2026-05-17): +; - PrivilegesRequired=lowest per-user install, no UAC prompt +; - {autopf}\KusPus resolves to %LOCALAPPDATA%\Programs\KusPus +; under PrivilegesRequired=lowest +; - ArchitecturesAllowed=x64compatible Inno 6.3+ token; covers ARM64 x64 emulation +; - MinVersion=10.0.17763 Windows 10 1809 — DWM Mica/rounded-corners +; APIs become usable here (gracefully fall +; back on Win10 1809-21H2; Mica on Win11) +; - Compression=lzma2/max + solid research showed solid is the bigger lever +; - No HKLM writes autostart is opt-in via the app's own +; Preferences UI (HKCU\...\Run\KusPus) +; - Uninstaller leaves user data %APPDATA%\KusPus and %LOCALAPPDATA%\KusPus +; are NEVER touched on uninstall in v1 ; ; Unsigned in perpetuity (PRD §9.9 / N-11). SmartScreen + Defender + SAC friction -; is documented for testers in docs/INSTALL.md. +; is documented for testers in docs/INSTALL.md. MotW preservation note: testers +; should be told to right-click the downloaded setup.exe → Properties → Unblock +; → Apply BEFORE running, per the Phase 12 SAC/SmartScreen research. +; +; Build pipeline: +; 1. tools\build-whisper-windows.ps1 → installer\payload\whisper\ +; 2. dotnet publish src\KusPus.App ... → publish\win-x64\ +; 3. iscc.exe installer\KusPus.iss /DAppVersion=v1.0.0 → installer\Output\KusPus-Setup-v1.0.0.exe +; +; AppId is FIXED — never regenerate. Upgrade detection across versions depends +; on this GUID staying stable for the lifetime of the product. + +#ifndef AppVersion + #define AppVersion "0.0.0-dev" +#endif + +#define AppName "KusPus" +#define AppPublisher "Devang Kumawat" +#define AppPublisherURL "https://github.com/devangk003/kuspus" +#define AppExeName "KusPus.exe" + +[Setup] +AppId={{7E263B33-A253-4E7D-B1A1-1B9D29405A02} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppPublisherURL} +AppSupportURL={#AppPublisherURL}/issues +AppUpdatesURL={#AppPublisherURL}/releases +VersionInfoCompany={#AppPublisher} +VersionInfoProductName={#AppName} +VersionInfoDescription={#AppName} installer + +; Per-user, no UAC. {autopf} resolves to {userpf} = %LOCALAPPDATA%\Programs. +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed= +DefaultDirName={autopf}\{#AppName} +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +DisableDirPage=auto +DisableReadyPage=no +UsePreviousAppDir=yes +UsePreviousPrivileges=yes + +; Inno 6.3+ token. Covers x64 + ARM64-x64-emulation; future-proof vs deprecated "x64". +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +; Win10 1809 (build 17763) floor — earliest version where DWM rounded-corner + +; immersive-dark-mode attributes the pill and MainWindow set are honoured. +MinVersion=10.0.17763 + +WizardStyle=modern +OutputDir=Output +OutputBaseFilename={#AppName}-Setup-{#AppVersion} +SetupIconFile=..\icons\icon.ico +UninstallDisplayIcon={app}\{#AppExeName} +UninstallDisplayName={#AppName} + +; Solid LZMA2 — research showed solid mode is the bigger lever than compression +; level for our payload mix (.NET runtime DLLs + whisper.exe + DLLs share +; redundancy across files). Separate-process speeds up CI on multi-core runners. +Compression=lzma2/max +SolidCompression=yes +LZMAUseSeparateProcess=yes + +; CloseApplications + RestartApplications=no — kill any running KusPus before +; overwriting files; do not auto-restart (we have no in-process update flow +; yet, and an installer-spawned launch wouldn't pick up the new files +; cleanly in self-contained-single-file mode anyway). +CloseApplications=force +RestartApplications=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +; Desktop shortcut — off by default. Pill + tray are the canonical surfaces; +; a Desktop icon is noise for the typical KusPus user, but power users on +; locked-down corp machines may want it. +Name: "desktopicon"; Description: "Create a &desktop shortcut"; \ + GroupDescription: "Additional shortcuts:"; Flags: unchecked + +[Files] +; Published self-contained single-file output (see src\KusPus.App\KusPus.App.csproj +; PublishSingleFile + SelfContained properties). Recurses to cover any side-by-side +; native libraries .NET 10 extracted at build time despite IncludeNativeLibraries +; ForSelfExtract=true (some WPF rasterizers can't be packed inside the single +; file). flags: ignoreversion = always overwrite, no version compare; the +; published EXE doesn't carry monotonically-increasing FileVersion across builds. +Source: "..\publish\win-x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +; Whisper native subprocess + DLLs + SHA256SUMS. Lives in {app}\whisper\ +; (matches AppPaths.WhisperDir convention). Excludes the .tag marker — it's a +; build-time idempotency hint for tools\build-whisper-windows.ps1, not a +; runtime concern. +Source: "payload\whisper\whisper.exe"; DestDir: "{app}\whisper"; Flags: ignoreversion +Source: "payload\whisper\*.dll"; DestDir: "{app}\whisper"; Flags: ignoreversion +Source: "payload\whisper\SHA256SUMS"; DestDir: "{app}\whisper"; Flags: ignoreversion + +; Bundled tiny.en model (~75 MB). Per PRD §6.4 ships pre-installed so first +; launch works offline. Lives in {app}\whisper\models\ — same path +; AppPaths.ModelsDir resolves to. Additional models are downloaded into the +; same dir at runtime by ModelManager. uninsneveruninstall keeps the file +; on disk through a reinstall/upgrade so a user who already has it doesn't +; pay the download twice — Inno wouldn't delete user-downloaded .bin files +; anyway, but this also covers tiny.en specifically. +Source: "payload\whisper\models\*.bin"; DestDir: "{app}\whisper\models"; Flags: ignoreversion uninsneveruninstall + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}" +Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon + +[Run] +; Optional post-install launch. NoIcon avoids registering KusPus as the verb's +; handler — the EXE is launched once on Finish, that's it. +Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; \ + Flags: nowait postinstall skipifsilent + +[UninstallRun] +; Best-effort: kill the running app before uninstalling so file delete doesn't +; trip on locked DLLs. Errors ignored — if KusPus isn't running, taskkill +; returns non-zero but it's not interesting. +Filename: "{cmd}"; Parameters: "/C taskkill /F /IM {#AppExeName}"; Flags: runhidden; RunOnceId: "KillKusPus" -#error KusPus.iss not implemented yet (Phase 12). +; INTENTIONALLY no [UninstallDelete] entries on user data paths: +; %APPDATA%\KusPus\settings.json +; %LOCALAPPDATA%\KusPus\history.db +; %LOCALAPPDATA%\KusPus\logs\ +; %LOCALAPPDATA%\KusPus\models\ +; %LOCALAPPDATA%\KusPus\failed\ +; All survive uninstall. Friends-only audience often reinstalls (e.g. testing +; a new build); preserving settings + history + downloaded models avoids the +; "lost my dictation history" surprise. A future v1.1+ may add an opt-in +; uninstall task "Also remove my data" — deliberately not in v1.0 to prevent +; accidental-tick data loss. diff --git a/src/KusPus.App/App.xaml.cs b/src/KusPus.App/App.xaml.cs index 732ccb9..21401f6 100644 --- a/src/KusPus.App/App.xaml.cs +++ b/src/KusPus.App/App.xaml.cs @@ -95,8 +95,10 @@ protected override void OnStartup(StartupEventArgs e) _pill = new FloatingPillWindow(); _pill.SetLogger(_services.GetRequiredService().CreateLogger()); _pill.SetCloseAction(Shutdown); - _pill.Bind(_coordinator.State); - _pill.BindLevels(_services.GetRequiredService().Levels); + // Bind() / BindLevels() are deferred to BindPillAndShow() below — the + // first snapshot of coordinator.State triggers the pill's initial + // Show() via FadePillIn, and we want that to land AFTER the onboarding + // modal (if any) closes so the pill isn't visible underneath the modal. // MainWindow is created at startup but stays hidden until the user opens it // via the tray "Preferences…" item. Hides on close — only the tray's Quit @@ -153,10 +155,14 @@ protected override void OnStartup(StartupEventArgs e) // First-launch onboarding. Queued with Background priority so OnStartup // returns first and the message loop is fully running before the modal's - // nested dispatcher frame begins. Skip-on-skip semantics: Onboarding.Completed - // stays false until the user actually Finishes, so closing the modal early - // brings it back on the next launch (re-runnable via About → "Run again"). - if (!_services.GetRequiredService().Current.Onboarding.Completed) + // nested dispatcher frame begins. Skip and Finish both mark Completed=true + // now (per user dogfood feedback) so the modal opens once-ever; re-runnable + // via About → "Run again". If we're about to show onboarding, the pill's + // first Show() is deferred to after the modal closes so the pill isn't + // visible underneath. If onboarding is already complete, bind + show + // the pill immediately. + bool needsOnboarding = !_services.GetRequiredService().Current.Onboarding.Completed; + if (needsOnboarding) { Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => { @@ -168,8 +174,30 @@ protected override void OnStartup(StartupEventArgs e) _services.GetRequiredService(), _services.GetService>()); window.ShowDialog(); + // ShowDialog() blocks until the modal closes (Finish or Skip); + // BindPillAndShow runs once the modal is gone so the pill's + // FadePillIn animation isn't masked by the modal. + BindPillAndShow(); })); } + else + { + BindPillAndShow(); + } + } + + private void BindPillAndShow() + { + // Subscribing to the BehaviorSubject immediately emits its current + // snapshot (Idle), which triggers FadePillIn → ShowAtForegroundMonitor + // → the OS-level Show() inside FloatingPillWindow. So calling Bind() + // here is what makes the pill appear. + if (_pill is null || _coordinator is null || _services is null) + { + return; + } + _pill.Bind(_coordinator.State); + _pill.BindLevels(_services.GetRequiredService().Levels); } private static void ConfigureServices(IServiceCollection services) diff --git a/src/KusPus.App/AppPaths.cs b/src/KusPus.App/AppPaths.cs index 1364839..86bebda 100644 --- a/src/KusPus.App/AppPaths.cs +++ b/src/KusPus.App/AppPaths.cs @@ -18,8 +18,6 @@ internal static class AppPaths Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "KusPus"); - public static readonly string ModelsDir = Path.Combine(LocalDataDir, "models"); - public static readonly string LogsDir = Path.Combine(LocalDataDir, "logs"); public static readonly string FailedDir = Path.Combine(LocalDataDir, "failed"); @@ -27,7 +25,7 @@ internal static class AppPaths public static readonly string HistoryDbPath = Path.Combine(LocalDataDir, "history.db"); /// - /// Where whisper.exe + DLLs live. Per TECH_SPEC §7.2 this is alongside the app exe; + /// Where whisper.exe + DLLs live. Alongside the app exe per TECH_SPEC §7.2; /// developers running from dotnet run can override by setting /// KUSPUS_WHISPER_DIR in the environment. /// @@ -36,11 +34,43 @@ internal static class AppPaths ?? Path.Combine(AppContext.BaseDirectory, "whisper"); /// - /// Optional expected SHA-256 of whisper.exe. Empty/null skips the integrity - /// check (dev mode). Phase 12 release builds will set this from SHA256SUMS. + /// Where downloaded model .bin files live. Inside the whisper subdir of + /// the install directory rather than under %LOCALAPPDATA%\KusPus\ + /// per dogfood finding 2026-05-17: Controlled Folder Access (Defender's + /// ransomware protection) blocks unsigned apps from listing files in + /// user-data paths but trusts them in their own install directory. + /// Override with KUSPUS_MODELS_DIR for tests / portable layouts. + /// + public static string ModelsDir => + Environment.GetEnvironmentVariable("KUSPUS_MODELS_DIR") + ?? Path.Combine(WhisperDir, "models"); + + /// + /// Expected SHA-256 of whisper.exe. Empty string ⇒ integrity check + /// is a no-op (dev mode). Resolution order: + /// + /// KUSPUS_WHISPER_SHA256 env-var override (debug-only escape hatch). + /// BuildConstants.ExpectedWhisperSha256 embedded at build time + /// by the EmitWhisperShaConstant MSBuild target in + /// KusPus.App.csproj — reads installer/payload/whisper/SHA256SUMS + /// and emits the SHA into a generated WhisperSha.g.cs. + /// Empty string fallback (dev / unit-test hosts). + /// + /// Release builds run tools/build-whisper-windows.ps1 first, which + /// populates SHA256SUMS, so the build constant carries the real hash. /// - public static string ExpectedWhisperSha256 => - Environment.GetEnvironmentVariable("KUSPUS_WHISPER_SHA256") ?? string.Empty; + public static string ExpectedWhisperSha256 + { + get + { + var envOverride = Environment.GetEnvironmentVariable("KUSPUS_WHISPER_SHA256"); + if (!string.IsNullOrEmpty(envOverride)) + { + return envOverride; + } + return BuildConstants.ExpectedWhisperSha256; + } + } /// /// Where intermediate .wav files live during recording. Defaults to the diff --git a/src/KusPus.App/FloatingPillWindow.xaml b/src/KusPus.App/FloatingPillWindow.xaml index 636b677..5239419 100644 --- a/src/KusPus.App/FloatingPillWindow.xaml +++ b/src/KusPus.App/FloatingPillWindow.xaml @@ -245,10 +245,15 @@ + + Width="10" Height="10" + RadiusX="5" RadiusY="5" + Fill="{DynamicResource MutedText}"/> @@ -391,11 +396,13 @@ + filled rounded-square (RadiusX=1.5) as FSM toggles. + Fill follows the same grey-idle / red-recording pattern + as CompactRecordGlyph — UpdateRecordGlyph swaps it. --> + Fill="{DynamicResource MutedText}"/> + + + + true + + @@ -52,4 +68,46 @@ + + + + <_KusPusShaFile>$(MSBuildThisFileDirectory)..\..\installer\payload\whisper\SHA256SUMS + <_KusPusShaCsFile>$(IntermediateOutputPath)WhisperSha.g.cs + <_KusPusShaText Condition="Exists('$(_KusPusShaFile)')">$([System.IO.File]::ReadAllText('$(_KusPusShaFile)')) + <_KusPusWhisperSha>$([System.Text.RegularExpressions.Regex]::Match('$(_KusPusShaText)', '([0-9a-f]{64})\s+whisper\.exe').Groups[1].Value) + + + + + + + + + + + diff --git a/src/KusPus.App/MainWindow.xaml b/src/KusPus.App/MainWindow.xaml index e0538ed..c8d193b 100644 --- a/src/KusPus.App/MainWindow.xaml +++ b/src/KusPus.App/MainWindow.xaml @@ -541,10 +541,17 @@ - + + BorderBrush="{DynamicResource Mint}" + BorderThickness="0,0,0,2"> @@ -554,6 +561,7 @@ - @@ -899,13 +907,27 @@ HorizontalAlignment="Right" Margin="0,6,0,0"> + + @@ -914,8 +936,10 @@ ToolTip="X (Twitter)" Click="OnOpenXClick"> - + + + @@ -924,20 +948,31 @@ ToolTip="GitHub" Click="OnOpenAuthorGitHubClick"> - + + + + Compound geometry: ellipse + meridian + equator + paths in a Canvas. Geometry expanded to fill the + full 24×24 viewBox so the rendered visual size + matches LinkedIn/X/GitHub at 14×14 Viewbox + (previously the ellipse sat at 2,2 W=20 H=20 + leaving 2 px padding → globe rendered at ~83% + of the other icons' visual size). New geometry: + ellipse at 1,1 W=22 H=22 with stroke=2 fills + 0–24 edge-to-edge; meridian + equator scaled + to match. --> diff --git a/src/KusPus.App/MainWindow.xaml.cs b/src/KusPus.App/MainWindow.xaml.cs index 6429867..9c2c4aa 100644 --- a/src/KusPus.App/MainWindow.xaml.cs +++ b/src/KusPus.App/MainWindow.xaml.cs @@ -190,9 +190,17 @@ private void OnLoaded(object sender, RoutedEventArgs e) var info = asm.GetCustomAttribute(); var version = info?.InformationalVersion ?? asm.GetName().Version?.ToString() ?? "—"; AboutVersion.Text = $"Version {version}"; - var built = System.IO.File.GetLastWriteTime(asm.Location) - .ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); - AboutBuildLine.Text = $"Built {built} · dev build"; + // Use the launcher EXE's path via AppContext.BaseDirectory — Assembly.Location + // returns empty in single-file published apps (IL3000). The launcher .exe + // sits at BaseDirectory\KusPus.exe; its mtime is a good proxy for "when this + // build was produced". Falls back to "dev build" if the file doesn't exist + // (e.g. running from a unit test host). + var launcherPath = System.IO.Path.Combine(System.AppContext.BaseDirectory, "KusPus.exe"); + var builtLine = System.IO.File.Exists(launcherPath) + ? System.IO.File.GetLastWriteTime(launcherPath) + .ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture) + : "—"; + AboutBuildLine.Text = $"Built {builtLine} · dev build"; AboutLogsPath.Text = AppPaths.LogsDir; _loaded = true; diff --git a/src/KusPus.App/OnboardingWindow.xaml.cs b/src/KusPus.App/OnboardingWindow.xaml.cs index 1e4e32f..867d392 100644 --- a/src/KusPus.App/OnboardingWindow.xaml.cs +++ b/src/KusPus.App/OnboardingWindow.xaml.cs @@ -154,13 +154,12 @@ private void ShowStep(int index) UpdateProgressDots(); // Mic check only runs while step 3 is showing — same on/off pattern as - // the MainWindow Audio tab. Populate the device combo FIRST so the - // closed-state Text + selection match the persisted device id before - // the capture session opens. + // the MainWindow Audio tab. The two heavy steps (MMDevice enum + + // WasapiCapture init) run on Task.Run so the step renders immediately + // and the mic loads in gracefully — mirrors MainWindow.OpenAudioTabAsync. if (index == 2) { - PopulateOnbInputDeviceCombo(); - StartMicCheck(); + _ = OpenMicStepAsync(); } else { @@ -191,7 +190,12 @@ private void OnBackClick(object sender, RoutedEventArgs e) private void OnSkipClick(object sender, RoutedEventArgs e) { - FinishOnboarding(completed: false); + // Skip = Completed=true per user dogfood feedback (2026-05-17). + // Previously Skip kept Completed=false so the modal re-appeared every + // launch until the user clicked Finish — that turned out to be hostile + // (the user just wants to dismiss). Show-once-ever is the new rule; + // re-opening is available via About → "Run again". + FinishOnboarding(completed: true); } private async void FinishOnboarding(bool completed) @@ -585,48 +589,85 @@ private Border BuildKeycap(string label) // ── Step 3 · Microphone check ────────────────────────────────────────── - private void StartMicCheck() + // Sync façade — kept so the device-change path (OnOnbInputDeviceChanged) + // and the existing call sites read the same. Fire-and-forget into the + // async variant; WasapiCapture init runs on Task.Run. + private void StartMicCheck() => _ = StartMicCheckAsync(); + + private async System.Threading.Tasks.Task StartMicCheckAsync() { - if (_micCapture is not null) - { - return; - } - try + // Defensive reset rather than early-return: rapid step toggles can + // leave a stale capture around. StopMicCheck is idempotent. + StopMicCheck(); + var savedId = _prefs.Current.Audio.InputDeviceId; + // MMDevice resolve + WasapiCapture ctor + driver shared-mode negotiation + // can take 150-500 ms on some hardware; do it off-thread so the + // onboarding step renders immediately. + var setup = await System.Threading.Tasks.Task.Run(() => { - _mmEnumerator ??= new NAudio.CoreAudioApi.MMDeviceEnumerator(); - // Use the user's saved device (mirrors MainWindow.ResolveLevelMeterDevice) - // so the onboarding meter reflects whichever mic Preferences would. - var device = ResolveOnbMicDevice(_mmEnumerator, _prefs.Current.Audio.InputDeviceId); - MicDeviceLabel.Text = (device.FriendlyName ?? "MICROPHONE").ToUpperInvariant(); - - var capture = new NAudio.CoreAudioApi.WasapiCapture(device); - _micIsFloat = capture.WaveFormat.Encoding == - NAudio.Wave.WaveFormatEncoding.IeeeFloat; - _micBytesPerSample = capture.WaveFormat.BitsPerSample / 8; - capture.DataAvailable += OnMicDataAvailable; - capture.StartRecording(); - _micCapture = capture; + try + { + _mmEnumerator ??= new NAudio.CoreAudioApi.MMDeviceEnumerator(); + var dev = ResolveOnbMicDevice(_mmEnumerator, savedId); + // CRITICAL: read FriendlyName HERE, on the Task.Run (MTA) thread + // that created the MMDevice. NAudio's MMDevice doesn't support + // cross-apartment marshalling — accessing any property from + // the dispatcher (STA) after the await throws + // InvalidCastException → E_NOINTERFACE on IMMDevice. WasapiCapture + // is fine because it caches WaveFormat internally before its + // ctor returns. Verified by the 2026-05-17 log capture. + var friendly = (dev.FriendlyName ?? "MICROPHONE").ToUpperInvariant(); + var cap = new NAudio.CoreAudioApi.WasapiCapture(dev); + return (FriendlyName: friendly, + Capture: (NAudio.CoreAudioApi.WasapiCapture?)cap, + Exception: (Exception?)null); + } + // NAudio's WasapiCapture ctor can throw a wider set than just + // COMException + MmException — InvalidOperationException on a + // busy device, ArgumentException on a malformed format, etc. + // Catch any exception so the user sees ShowMicError instead of a + // silent stuck-loading state. + catch (Exception ex) + { + return (FriendlyName: string.Empty, Capture: null, Exception: (Exception?)ex); + } + }).ConfigureAwait(true); - MicSuccessRow.Visibility = Visibility.Visible; - MicErrorRow.Visibility = Visibility.Collapsed; - MicOpenSettings.Visibility = Visibility.Collapsed; - } - catch (COMException ex) + if (setup.Capture is null) { #pragma warning disable CA1848, CA1873 - _logger.LogWarning(ex, "Mic check device-enumeration failed."); + _logger.LogWarning(setup.Exception, "Onboarding mic check init failed."); #pragma warning restore CA1848, CA1873 ShowMicError(); return; } + + // Back on UI thread — wire callbacks + start. StartRecording itself is + // cheap now that the driver negotiation already happened. + MicDeviceLabel.Text = setup.FriendlyName; + var capture = setup.Capture; + _micIsFloat = capture.WaveFormat.Encoding == + NAudio.Wave.WaveFormatEncoding.IeeeFloat; + _micBytesPerSample = capture.WaveFormat.BitsPerSample / 8; + capture.DataAvailable += OnMicDataAvailable; + try + { + capture.StartRecording(); + } catch (NAudio.MmException ex) { #pragma warning disable CA1848, CA1873 - _logger.LogWarning(ex, "Mic check capture-init failed."); + _logger.LogWarning(ex, "Onboarding mic StartRecording failed after async init."); #pragma warning restore CA1848, CA1873 + capture.Dispose(); ShowMicError(); return; } + _micCapture = capture; + + MicSuccessRow.Visibility = Visibility.Visible; + MicErrorRow.Visibility = Visibility.Collapsed; + MicOpenSettings.Visibility = Visibility.Collapsed; if (_micMeterTimer is null) { @@ -1001,7 +1042,48 @@ private sealed class OnbInputDeviceItem private bool _suppressOnbDeviceChange; - private void PopulateOnbInputDeviceCombo() + // Orchestrates step 3's heavy init off the dispatcher: the page renders + // immediately, then enumeration + capture init run on Task.Run, then + // the UI populates. Mirrors MainWindow.OpenAudioTabAsync. + // Wrapped in a top-level try/catch so any unhandled exception lands as a + // visible ShowMicError instead of silently leaving the meter stuck on + // "LOADING…" (fire-and-forget tasks otherwise route unhandled exceptions + // to UnobservedTaskException, which only logs them). + private async System.Threading.Tasks.Task OpenMicStepAsync() + { + // Placeholder text while enumeration is in flight. + MicDeviceLabel.Text = "LOADING…"; + _suppressOnbDeviceChange = true; + try + { + OnbInputDeviceCombo.ItemsSource = new[] { "Loading microphones…" }; + OnbInputDeviceCombo.SelectedIndex = 0; + } + finally + { + _suppressOnbDeviceChange = false; + } + + try + { + var items = await System.Threading.Tasks.Task.Run(EnumerateOnbInputDeviceItems).ConfigureAwait(true); + if (_currentStep != 2) + { + return; // user navigated away mid-enum + } + ApplyOnbInputDeviceItems(items); + await StartMicCheckAsync().ConfigureAwait(true); + } + catch (Exception ex) + { +#pragma warning disable CA1848, CA1873 + _logger.LogWarning(ex, "Onboarding OpenMicStepAsync failed unexpectedly."); +#pragma warning restore CA1848, CA1873 + ShowMicError(); + } + } + + private System.Collections.Generic.List EnumerateOnbInputDeviceItems() { var items = new System.Collections.Generic.List { @@ -1029,7 +1111,11 @@ private void PopulateOnbInputDeviceCombo() _logger.LogWarning(ex, "Onboarding mic enumeration failed."); #pragma warning restore CA1848, CA1873 } + return items; + } + private void ApplyOnbInputDeviceItems(System.Collections.Generic.List items) + { _suppressOnbDeviceChange = true; try { diff --git a/src/KusPus.App/Properties/PublishProfiles/win-x64.pubxml b/src/KusPus.App/Properties/PublishProfiles/win-x64.pubxml new file mode 100644 index 0000000..05fe47b --- /dev/null +++ b/src/KusPus.App/Properties/PublishProfiles/win-x64.pubxml @@ -0,0 +1,53 @@ + + + + + Release + Any CPU + bin\Release\net10.0-windows\publish\ + FileSystem + net10.0-windows + win-x64 + + true + true + true + true + true + false + embedded + + diff --git a/tools/build-whisper-windows.ps1 b/tools/build-whisper-windows.ps1 index c07736e..ca0306b 100644 --- a/tools/build-whisper-windows.ps1 +++ b/tools/build-whisper-windows.ps1 @@ -1,149 +1,233 @@ <# .SYNOPSIS -Build whisper.cpp for KusPus (Windows, x64, MSVC, CPU only). +Populate installer/payload/whisper/ with whisper.exe + DLLs from a pinned +whisper.cpp GitHub release. .DESCRIPTION -Produces installer/payload/whisper/{whisper.exe, *.dll} from the pinned -third_party/whisper.cpp submodule. See TECH_SPEC §29. - -Requirements: -- Visual Studio 2022 (Community is fine) or VS Build Tools 14.40+ with the - "Desktop development with C++" workload. -- CMake 3.28+ on PATH. -- third_party/whisper.cpp checked out (git submodule update --init --recursive). - -GGML_NATIVE=OFF is mandatory: builds for portable x86-64-v2, not the build -machine's specific CPU. Skipping this would ship a binary that crashes on -older CPUs. +Phase 12 — see TECH_SPEC §29. Downloads `whisper-bin-x64.zip` from the +ggerganov/whisper.cpp release for $Tag, extracts to a tag-scoped cache, +copies whisper-cli.exe (renamed to whisper.exe) + all runtime DLLs into +installer/payload/whisper/, generates SHA256SUMS, and smoke-tests the +binary. + +Build-from-source via the third_party/whisper.cpp submodule was the +original plan (and lives in git history at the prior version of this +file). The dogfood team picked download-prebuilt for v1.0 because: + - No local MSVC + CMake toolchain required on dev machines or CI + - Faster turnaround (a few seconds vs several minutes) + - Upstream binaries are GGML_NATIVE=OFF (portable x86-64-v2) by default + +Idempotent: re-running with the same -Tag is a no-op unless -Force is +passed (we write a .tag marker into the payload dir to detect prior runs). + +.PARAMETER Tag +whisper.cpp release tag (e.g. "v1.8.4"). Defaults to the version pinned +for KusPus v1.0. Override to test newer releases. + +.PARAMETER Force +Re-download + re-extract even if the payload is already at $Tag. #> [CmdletBinding()] param( - [string]$Configuration = 'Release', - [switch]$Clean + [string]$Tag = 'v1.8.4', + [switch]$Force ) $ErrorActionPreference = 'Stop' -$repoRoot = Split-Path $PSScriptRoot -Parent -$whisperSrc = Join-Path $repoRoot 'third_party\whisper.cpp' -$whisperBuild = Join-Path $whisperSrc 'build' -$payload = Join-Path $repoRoot 'installer\payload\whisper' +$repoRoot = Split-Path $PSScriptRoot -Parent +$payload = Join-Path $repoRoot 'installer\payload\whisper' +$cacheDir = Join-Path $repoRoot '.local-temp\whisper-cache' function Write-Step($message) { Write-Host "==> $message" -ForegroundColor Cyan } -# ── 1. Sanity checks ───────────────────────────────────────────────────────── -if (-not (Test-Path $whisperSrc -PathType Container)) { - throw "Submodule missing at $whisperSrc. Run: git submodule update --init --recursive" -} -if (-not (Test-Path (Join-Path $whisperSrc 'CMakeLists.txt'))) { - throw "$whisperSrc exists but has no CMakeLists.txt — submodule isn't checked out." +# ── 1. Idempotency — skip if already populated with the requested tag ───── +$tagMarker = Join-Path $payload '.tag' +if (-not $Force -and (Test-Path $tagMarker)) { + $existing = (Get-Content -LiteralPath $tagMarker -Raw).Trim() + if ($existing -eq $Tag) { + Write-Host "Payload already at $Tag (use -Force to re-download)." -ForegroundColor Green + Write-Host " Path: $payload" + exit 0 + } } -# ── 2. Locate MSVC via vswhere ─────────────────────────────────────────────── -Write-Step "Locating MSVC..." -$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -if (-not (Test-Path $vswhere)) { - throw "vswhere.exe not found. Install Visual Studio 2022 (Community or Build Tools) with the Desktop C++ workload." +# ── 2. Compute artifact URL ─────────────────────────────────────────────── +# Naming convention: https://github.com/ggerganov/whisper.cpp/releases/download//whisper-bin-x64.zip +# The release page also publishes BLAS / cuBLAS / Vulkan variants — we want +# the plain CPU build because PRD §1 promises CPU-only for v1.0. +$artifact = 'whisper-bin-x64.zip' +$url = "https://github.com/ggerganov/whisper.cpp/releases/download/$Tag/$artifact" + +# ── 3. Download (cached by tag) ─────────────────────────────────────────── +New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null +$zipPath = Join-Path $cacheDir "$Tag-$artifact" +if (-not (Test-Path $zipPath) -or $Force) { + Write-Step "Downloading $url" + # Invoke-WebRequest follows redirects + works in PS 5.1. + # ProgressPreference=SilentlyContinue makes the download ~10× faster on + # Windows PowerShell (the progress bar is a known IWR perf hog). + $previousProgressPreference = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + try { + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing + } + finally { + $ProgressPreference = $previousProgressPreference + } + Write-Host " Wrote $zipPath ($([math]::Round((Get-Item $zipPath).Length / 1MB, 1)) MB)" } -$vsInstall = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath -if (-not $vsInstall) { - throw "No Visual Studio install with the MSVC x64 C++ toolset was found." +else { + Write-Host " Cached: $zipPath" -ForegroundColor Green } -Write-Host " VS install: $vsInstall" -$vsDevShellModule = Join-Path $vsInstall 'Common7\Tools\Microsoft.VisualStudio.DevShell.dll' -if (-not (Test-Path $vsDevShellModule)) { - throw "Microsoft.VisualStudio.DevShell.dll not found at $vsDevShellModule." +# ── 4. Extract to a tag-scoped subdirectory ─────────────────────────────── +$extractDir = Join-Path $cacheDir "$Tag-extracted" +if (Test-Path $extractDir) { + Remove-Item -Recurse -Force $extractDir } +Write-Step "Extracting" +Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force -# ── 3. Verify CMake is reachable ───────────────────────────────────────────── -Write-Step "Verifying CMake..." -# NOTE: do NOT redirect stderr (`2>&1`) on native exes in PS 5.1 — it wraps each line -# in an ErrorRecord and flips $? to false even on exit 0, breaking ErrorActionPreference. -$cmakeVersion = (& cmake --version | Select-Object -First 1) -if ($LASTEXITCODE -ne 0) { - throw "CMake not found on PATH. Install CMake 3.28+ from https://cmake.org/download/." +# ── 5. Reset payload directory ──────────────────────────────────────────── +if (Test-Path $payload) { + Remove-Item -Recurse -Force $payload } -Write-Host " $cmakeVersion" - -# ── 4. Enter VS Developer Environment ──────────────────────────────────────── -Write-Step "Entering VS Developer Environment (x64)..." -Import-Module $vsDevShellModule -Enter-VsDevShell -VsInstallPath $vsInstall -DevCmdArguments '-arch=x64 -host_arch=x64' -SkipAutomaticLocation | Out-Null +New-Item -ItemType Directory -Force -Path $payload | Out-Null -# ── 5. Optional clean ──────────────────────────────────────────────────────── -if ($Clean -and (Test-Path $whisperBuild)) { - Write-Step "Cleaning previous build dir..." - Remove-Item -Recurse -Force $whisperBuild +# ── 6. Locate the CLI binary ────────────────────────────────────────────── +# v1.7+ ships `whisper-cli.exe` as the real CLI. v1.8.x ALSO keeps +# `main.exe` around but it's a deprecation stub that prints +# "The binary 'whisper.exe' is deprecated" and exits 1. So always prefer +# `whisper-cli.exe` over `main.exe`. Pre-v1.7 releases only had `main.exe` +# — fall back to it only when whisper-cli.exe is genuinely missing. +$allCli = Get-ChildItem -Path $extractDir -Recurse -File | + Where-Object { $_.Name -in @('whisper-cli.exe', 'main.exe') } +$cli = $allCli | Where-Object { $_.Name -eq 'whisper-cli.exe' } | Select-Object -First 1 +if (-not $cli) { + $cli = $allCli | Where-Object { $_.Name -eq 'main.exe' } | Select-Object -First 1 +} +if (-not $cli) { + throw "Couldn't find whisper-cli.exe or main.exe in $extractDir. Did the artifact layout change for $Tag?" } +Write-Host " Found CLI: $($cli.Name) at $($cli.FullName)" -# ── 6. Configure ───────────────────────────────────────────────────────────── -Write-Step "Configuring whisper.cpp via cmake..." -Push-Location $whisperSrc -try { - & cmake -B build ` - -DGGML_NATIVE=OFF ` - -DWHISPER_BUILD_TESTS=OFF ` - -DWHISPER_BUILD_EXAMPLES=ON ` - -DCMAKE_BUILD_TYPE=$Configuration - if ($LASTEXITCODE -ne 0) { throw "cmake configure failed with exit code $LASTEXITCODE." } - - # ── 7. Build ────────────────────────────────────────────────────────────── - Write-Step "Building whisper-cli ($Configuration)..." - & cmake --build build --config $Configuration --target whisper-cli - if ($LASTEXITCODE -ne 0) { throw "cmake build failed with exit code $LASTEXITCODE." } +# ── 7. Copy CLI as whisper.exe + all runtime DLLs ───────────────────────── +# The C# WhisperRunner expects the binary at /whisper.exe regardless +# of upstream naming; rename happens here, not at install time. +Copy-Item -LiteralPath $cli.FullName -Destination (Join-Path $payload 'whisper.exe') + +$dlls = Get-ChildItem -Path $extractDir -Recurse -File -Filter '*.dll' +foreach ($dll in $dlls) { + Copy-Item -LiteralPath $dll.FullName -Destination $payload -Force } -finally { - Pop-Location +Write-Host " Copied $($dlls.Count) DLL(s) + whisper.exe" + +# ── 8. Tag marker for idempotency ───────────────────────────────────────── +Set-Content -LiteralPath $tagMarker -Value $Tag -Encoding utf8 + +# ── 9. Bundle tiny.en model ─────────────────────────────────────────────── +# Per PRD §6.4: tiny.en ships pre-installed so first launch works offline. +# Source of truth for URL + expected SHA = src/KusPus.Whisper/Resources/models.json +# (the same manifest the runtime ModelManager reads). Cached by SHA so a +# successful download is reused across script invocations. +Write-Step "Bundling tiny.en model" +$manifestPath = Join-Path $repoRoot 'src\KusPus.Whisper\Resources\models.json' +$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json +$tiny = $manifest.models | Where-Object { $_.id -eq 'ggml-tiny.en' } | Select-Object -First 1 +if (-not $tiny) { + throw "models.json is missing the ggml-tiny.en entry - cannot bundle the model." } -# ── 8. Collect outputs ─────────────────────────────────────────────────────── -Write-Step "Copying outputs to $payload..." -if (Test-Path $payload) { - Remove-Item -Recurse -Force $payload +$modelsDir = Join-Path $payload 'models' +New-Item -ItemType Directory -Force -Path $modelsDir | Out-Null +$modelDest = Join-Path $modelsDir $tiny.fileName + +$modelCacheDir = Join-Path $repoRoot '.local-temp\model-cache' +New-Item -ItemType Directory -Force -Path $modelCacheDir | Out-Null +$modelCached = Join-Path $modelCacheDir "$($tiny.sha256)-$($tiny.fileName)" + +if (-not (Test-Path $modelCached) -or $Force) { + Write-Host " Downloading $($tiny.fileName) (~$([math]::Round($tiny.sizeBytes / 1MB, 0)) MB) from $($tiny.url)" + $previousProgressPreference = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + try { + Invoke-WebRequest -Uri $tiny.url -OutFile $modelCached -UseBasicParsing + } + finally { + $ProgressPreference = $previousProgressPreference + } } -New-Item -ItemType Directory -Force -Path $payload | Out-Null - -$cliExeSearch = Get-ChildItem -Path $whisperBuild -Recurse -Filter 'whisper-cli.exe' | Select-Object -First 1 -if (-not $cliExeSearch) { - throw "whisper-cli.exe not produced. Check cmake build output above." +else { + Write-Host " Cached: $modelCached" -ForegroundColor Green } -# Rename to whisper.exe so the C# runner finds it under the documented name. -Copy-Item $cliExeSearch.FullName (Join-Path $payload 'whisper.exe') - -# All DLLs the cli depends on at runtime (whisper, ggml, plus any GGML backends). -$dllSources = Get-ChildItem -Path $whisperBuild -Recurse -Filter '*.dll' | - Where-Object { $_.FullName -notmatch '\\Test' } -foreach ($dll in $dllSources) { - Copy-Item $dll.FullName $payload -Force +# SHA gate — fail loudly if HuggingFace served a different file (mirror change, +# upstream re-upload, MITM). Forces the bundle to match what runtime ModelManager +# will verify the file against on launch. +$actualTinySha = (Get-FileHash -Algorithm SHA256 -LiteralPath $modelCached).Hash.ToLowerInvariant() +if ($actualTinySha -ne $tiny.sha256) { + Remove-Item -LiteralPath $modelCached -ErrorAction SilentlyContinue + throw "tiny.en SHA mismatch. Expected $($tiny.sha256), got $actualTinySha. Cache file deleted; rerun." } -# ── 9. SHA-256 manifest ────────────────────────────────────────────────────── -Write-Step "Computing SHA-256 manifest..." +Copy-Item -LiteralPath $modelCached -Destination $modelDest -Force +Write-Host " Bundled: $modelDest" + +# ── 10. SHA-256 manifest ────────────────────────────────────────────────── +# Lists whisper.exe + DLLs + bundled model.bin so the installer can publish +# a single integrity manifest. WhisperRunner reads this at startup. +Write-Step "Computing SHA-256 manifest" $shaPath = Join-Path $payload 'SHA256SUMS' -Get-ChildItem -Path $payload -File | - Where-Object { $_.Name -ne 'SHA256SUMS' } | +$lines = Get-ChildItem -Path $payload -File -Recurse | + Where-Object { $_.Name -notin @('SHA256SUMS', '.tag') } | + Sort-Object FullName | ForEach-Object { $h = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLowerInvariant() - "$h $($_.Name)" - } | Set-Content -Encoding utf8 $shaPath - + # Relative path so SHA256SUMS lines aren't tied to build-machine layout. + $rel = $_.FullName.Substring($payload.Length).TrimStart('\').Replace('\', '/') + "$h $rel" + } +Set-Content -LiteralPath $shaPath -Value $lines -Encoding utf8 Write-Host "" -Get-Content $shaPath | ForEach-Object { Write-Host " $_" } +$lines | ForEach-Object { Write-Host " $_" } -# ── 10. Smoke test ─────────────────────────────────────────────────────────── -Write-Step "Smoke testing whisper.exe -h..." +# ── 11. Smoke test ──────────────────────────────────────────────────────── +Write-Step "Smoke testing whisper.exe -h" $exe = Join-Path $payload 'whisper.exe' -& $exe -h | Out-Null -if ($LASTEXITCODE -ne 0) { - throw "Smoke test failed: '$exe -h' exited with code $LASTEXITCODE." +# Use Start-Process (not `& $exe`) so this works in BOTH PowerShell 5.1 +# (Windows PowerShell) AND PowerShell 7 (pwsh). PS 5.1 wraps native-command +# stderr as ErrorRecords which trip $ErrorActionPreference='Stop' even when +# the exe exits 0 — Start-Process bypasses that wrapping by going through +# Process.Start directly. +# Accept exit codes 0 OR 1: +# - 0: whisper-cli (v1.7+) success for -h +# - 1: legacy main.exe (≤v1.6) or deprecation-stub responds 1 with usage +# to stderr, but it loaded its DLLs and ran arg parser successfully +# Hard DLL-load crashes show up as large negative exit codes (e.g. +# -1073741515 STATUS_DLL_NOT_FOUND), which we DO want to fail on. +$smokeStdout = New-TemporaryFile +$smokeStderr = New-TemporaryFile +try { + $proc = Start-Process -FilePath $exe -ArgumentList '-h' -Wait -PassThru -NoNewWindow ` + -RedirectStandardOutput $smokeStdout.FullName ` + -RedirectStandardError $smokeStderr.FullName + $smokeExit = $proc.ExitCode +} +finally { + Remove-Item $smokeStdout.FullName, $smokeStderr.FullName -ErrorAction SilentlyContinue } +if ($smokeExit -notin @(0, 1)) { + throw "Smoke test failed: '$exe -h' exited with code $smokeExit (expected 0 or 1; large negatives mean missing DLL)." +} +$global:LASTEXITCODE = 0 Write-Host "" -Write-Step "Done. whisper.exe + DLLs are in: $payload" -Write-Host " Update src/KusPus.Whisper/Resources/models.json: replace TODO_PIN with the" -Write-Host " huggingface.co/ggerganov/whisper.cpp commit SHA you're pinning to, and the" -Write-Host " TODO_FILL_AFTER_DOWNLOADING_VERIFIED_MODEL sha256 fields with the real hashes." +Write-Step "Done." +Write-Host " whisper.exe + DLLs: $payload" +Write-Host " Pinned to tag: $Tag" +Write-Host " SHA256 manifest: $shaPath" +Write-Host "" +Write-Host "Next: tools/IconBuilder for icon.ico, then iscc.exe installer/KusPus.iss"