diff --git a/.gitignore b/.gitignore index d0a7170..011cf44 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,9 @@ Thumbs.db *.njsproj *.sln *.sw? + +# Claude Code local state — session worktrees are git repos of their own, and +# settings.local.json is per-machine. Scoped, not the whole `.claude/`: shared +# project config (settings.json, agents/, skills/) is meant to be committed. +.claude/worktrees/ +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 459aa18..834318b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,8 +95,9 @@ This site is optimized for classic search (SEO) **and** generative answer engine - **Q&A blocks earn their keep twice** — they render as a human FAQ *and* feed `FAQPage` schema *and* are the single most-quoted structure in AI answers. Phrase questions the way a user would type them ("Can WaveKat Voice connect to the same SIP provider as Linphone?"), and answer in 1–3 plain sentences. - **Comparison tables are extractable gold.** Keep cells short, factual, and parallel across rows; models lift table rows almost verbatim into "X vs Y" answers. - **Be specific and honest.** Concrete specifics (platforms, versions, prices, "records every call automatically") get quoted; vague superlatives get skipped. On comparison pages, name what the competitor is genuinely good at — fair framing reads as a trustworthy source to both readers and models, and avoids the "marketing fluff" discount. -- **Keep entity naming consistent.** Always "WaveKat Voice" (not "the app", "Voice", "WaveKat" interchangeably) so engines bind the facts to one entity. Same for platform claims — match the truth in `voice/index.astro` (Mac and Linux today; Windows when there's demand). -- **Don't target a single platform in copy.** Voice is Mac + Linux today; write "your computer" / "desktop" in body copy and put the platforms in a table row or a "Mac & Linux" qualifier. Titles may still include "Mac" to catch the high-volume "… for Mac" queries, but never *exclude* Linux. +- **Keep entity naming consistent.** Always "WaveKat Voice" (not "the app", "Voice", "WaveKat" interchangeably) so engines bind the facts to one entity. Same for platform claims — match the truth in `voice/index.astro` (**Mac, Windows and Linux**; Windows ships an x64 and an ARM64 installer, is younger than the other two, and isn't code-signed yet). +- **Don't target a single platform in copy.** Voice runs on all three desktops; write "your computer" / "desktop" in body copy and put the platforms in a table row or a "Mac, Windows & Linux" qualifier. Titles may still include "Mac" to catch the high-volume "… for Mac" queries, but never *exclude* the other two. +- **A platform claim is never one sentence.** Promoting a platform means the lead, the meta description, the FAQ answer, *and* the clause after it ("works on both" → "all three") — in all nine locales. Changing only the FAQ leaves the page contradicting itself, and the lead is the passage answer engines quote first. `grep` the whole post, not the section you came for. When you add a page that doesn't fit the patterns above, mirror the closest existing one (`voice/alternatives/[slug].astro` is the current best example: clear `

`, self-contained intro, comparison table, fair "what it is", Q&A, and `FAQPage` + `BreadcrumbList` schema). @@ -176,8 +177,9 @@ is the same page scrolled to the assistants section (a scene `scroll` hint, sinc it's below the 960×640 fold). - **Framed, not bare.** These use the pipeline's **Ubuntu/GNOME-framed** output - (`screenshots/framed/ubuntu/…`), so the window chrome is real, not CSS — we're - a Mac + Linux product and the author runs Ubuntu. No site-drawn frame. + (`screenshots/framed/ubuntu/…`), so the window chrome is real, not CSS — the + app is a desktop app on every platform and the author runs Ubuntu. No + site-drawn frame. - **Single theme (light), per language.** A baked-in frame can't follow the page's dark/light toggle, so we pick light and keep it consistent — but each localized surface shows the app in *its* language (`/screenshots//.webp`). diff --git a/docs/05-windows-downloads.md b/docs/05-windows-downloads.md new file mode 100644 index 0000000..a77025b --- /dev/null +++ b/docs/05-windows-downloads.md @@ -0,0 +1,349 @@ +# 05 — Windows downloads + +Offer the Windows installers we already build, pick the right architecture for +the machine asking, and stop telling readers — and answer engines — that +WaveKat Voice is Mac and Linux only. + +## 1. Where this starts + +We have been building a Windows client on every release for months. It is +packaged, uploaded, and published to an R2 update feed by the same workflow +that ships Mac and Linux, and `wavekat-voice/docs/site/installation.md` walks a +Windows reader through installing it. `wavekat-voice` `docs/54` puts the +resulting state plainly: + +> We already pay ~100% of the engineering and CI cost of a Windows client, and +> capture ~0% of the revenue, because the website will not offer a Windows +> visitor the installer we built for them twenty minutes ago. + +A Windows visitor to `/voice/download/` is handed a `.dmg`. This doc is that +document's Phase 1a and Phase 4, done together: offer the build, and say we +offer it. + +Two things changed recently that make this cheap: + +- **The platform already resolves Windows.** `docs/04` moved every download to + `platform.wavekat.com/api/voice/download/latest/{platform}`, and + `lib/downloads/latest.ts` shipped with `windows` in `PUBLISHED` + (`voice/latest.yml`, ext `exe`) and a comment saying which platforms get a + button is the site's decision. So the API side of the button already works. +- **Windows became two installers.** wavekat-voice `a3bc34c` added a native + Windows-on-ARM build: one electron-builder run now emits + `WaveKat Voice Setup -x64.exe` and `…-arm64.exe`, both listed in a + single `latest.yml`. + +That second change is what stops this from being a one-line site edit. + +## 2. The architecture problem + +`installerFromFeed()` picks the installer by extension: + +```ts +const name = files.find((f) => f.toLowerCase().endsWith(suffix)); +``` + +With one `.exe` in the feed that is correct. With two it is whichever +electron-builder happened to list first. An x64 visitor handed `arm64.exe` does +not get a slow download — they get a file Windows refuses to run. + +So the resolver has to name an architecture, and something has to decide which +one a given visitor gets. The visitor is never asked: the words "Intel or AMD" +don't appear on the page, because naming the chip in the common path makes a +decision out of something that isn't one. + +**The chip is not in the user-agent string, and never will be.** A Windows 11 +machine on ARM64 reports `Windows NT 10.0; Win64; x64` — Windows masks the +architecture on purpose so x64 software keeps working. UA sniffing cannot find +it at any length. The only place it is exposed is +`navigator.userAgentData.getHighEntropyValues(['architecture', 'bitness'])`, +which is Chromium-only and returns a promise. + +That signal is partial and slow, so it can only be acted on where being wrong +is free — and here it is, in one direction. The two files are not symmetric: an +x64 installer runs on every Windows PC we support (natively on Intel and AMD, +emulated on Windows 11 for ARM) while an arm64 installer runs on almost none of +them. Only one of the two mistakes is fatal, and it isn't the one x64 makes. + +So **x64 is the download until something proves otherwise**, and it stays the +download for every browser that can't answer. A machine that answers +`architecture: 'arm', bitness: '64'` is upgraded to the native build a few +milliseconds later — the one case where the safe default is also the wrong one, +and the only case worth a second pass. Windows on ARM is overwhelmingly a +Chromium-browser population (Edge is the default and ships as ARM64), so the +probe reaches close to all of the people it exists for. + +Both bitness and architecture are checked: 32-bit ARM Windows exists and cannot +run this installer, so `arm` alone is not enough. + +## 3. Platform — targets instead of platforms + +`Published` stops meaning "operating system" and starts meaning "a thing you +can download": + +```ts +export type Published = 'mac' | 'linux' | 'windows-x64' | 'windows-arm64'; + +export const PUBLISHED: Record = { + mac: { feedKey: 'voice/latest-mac.yml', ext: 'dmg' }, + linux: { feedKey: 'voice/latest-linux.yml', ext: 'deb' }, + 'windows-x64': { feedKey: 'voice/latest.yml', ext: 'exe', arch: 'x64' }, + 'windows-arm64': { feedKey: 'voice/latest.yml', ext: 'exe', arch: 'arm64' }, +}; +``` + +Selection filters on extension **and** architecture, read through `classify()` +— which already parses `-x64` / `-arm64` out of a filename, and is the same +function that dimensions the row the download is logged on. Order in the feed +stops mattering, which is the point. + +### 3.1 The legacy-feed rule + +Every Windows release up to and including 0.0.45 was named +`WaveKat Voice Setup .exe` — no arch token, because +electron-builder's arch suffix is empty for the default arch. `classify()` +reads that as `arch: null`. + +`windows-x64` therefore accepts `x64` **or** `null`: an unsuffixed Windows +installer has always meant x64. `windows-arm64` requires an exact match, so it +resolved to nothing until 0.0.46 — the first release built with the new +`artifactName` — published one. + +The rule stops being load-bearing for the current feed the moment 0.0.46 is +live, and stays correct for anyone still sitting on 0.0.45. + +Without this rule the Windows button 404s on the currently-published feed, and +it would 404 quietly — the failure arrives at the visitor, not at us. + +### 3.2 `windows` stays as an alias + +`adminVoiceDownloads.ts` builds `latestUrl` from `classify()`'s platform, which +is still `mac | linux | windows`, so the admin artifacts page links at +`/download/latest/windows`. The download route normalises `windows` → +`windows-x64` before resolving. One line, and the alternative is an admin page +that 404s on a link nobody would think to test. + +`classify()`'s own `Platform` type is untouched. A download's *platform* is +still `windows`; only the *target you can ask for* is finer-grained. + +### 3.3 `no_arch` is a 404, not a 500 + +A feed listing no installer at all is a half-published release — our +problem, and a `500`. A feed listing installers but not this architecture +is a release that did not build one, which is every Windows feed published +before this change. That is a `404`: the visitor asked for something that +does not exist, not something that broke. Both reasons are surfaced on the +admin artifacts page, which now shows one row per target. + +### 3.4 `GET /api/voice/releases/latest` + +Emits the four target keys, each with its own size — 0.0.46, the first +two-architecture release, as actually served: + +```json +{ "mac": { "version": "0.0.46", "sizeBytes": 125596265 }, + "linux": { "version": "0.0.46", "sizeBytes": 106842816 }, + "windows-x64": { "version": "0.0.46", "sizeBytes": 98710571 }, + "windows-arm64": { "version": "0.0.46", "sizeBytes": 104657605 } } +``` + +which resolve to: + +``` +windows-x64 302 → dl.wavekat.com/voice/WaveKat Voice Setup 0.0.46-x64.exe +windows-arm64 302 → dl.wavekat.com/voice/WaveKat Voice Setup 0.0.46-arm64.exe +windows 302 → dl.wavekat.com/voice/WaveKat Voice Setup 0.0.46-x64.exe +``` + +Per-target rather than per-platform because the two Windows installers differ +in size and the menu prints the size next to each choice. A platform that +cannot be resolved is still nulled rather than failing the response, so an +arm64 entry that does not exist yet costs nothing. + +## 4. Site — one button per platform + +### 4.1 `src/lib/voice-download.ts` + +`PlatformKey` gains both Windows targets, and the two named getters collapse +into `getDownload(key)`. The fallback constants keep one entry per target. + +### 4.2 `src/components/VoiceDownload.astro` + +Windows joins Mac and Linux as one plain pill that downloads on click — +"Download for Windows", the same shape as the other two, wired to +`windows-x64`: + +``` +[ ⊞ Download for Windows ] Other Platforms ▾ + The Windows builds aren't ┌──────────────────────────────────────────┐ + code-signed yet… │ Download for Mac 120 MB │ + │ Download for Linux 102 MB │ + │ Download for Windows on ARM EARLY 92 MB │ + └──────────────────────────────────────────┘ +``` + +Every row in the menu is a download, so every row is worded like one. The ARM +build is the one entry that isn't a platform of its own, but a menu of three +buttons where the third drops the verb reads as an oversight rather than as a +distinction — the requirement line under each label already says which machine +it is for. + +Every row is both a candidate primary and a menu row, and the two are exact +opposites — the menu lists precisely what the button isn't downloading. One +`entries` list drives both, keyed on `(platform, archKey)`, so the pair cannot +drift into offering a file twice or not at all. A Windows visitor on Intel sees +the ARM build in the menu; an ARM visitor sees x64 there, for the same reason +in reverse. It is the one choice their primary can't make for them. + +Promotion runs in two passes, for the reason in §2: + +1. **Platform**, synchronously as the markup is parsed, by plain UA sniffing + (`/Win(dows|32|64)/` alongside the existing Linux branch). No flash of the + wrong default. +2. **Chip**, on Windows only, when `navigator.userAgentData` answers `arm` + + `64`. Re-runs the same `resolve()` with `activeArch = 'arm64'`. + +`resolve()` is idempotent and re-entrant by design, which is what makes the +second pass a three-line addition rather than a second code path. It also +falls back per call: if the arch it wants isn't published in the current +release it reverts to `default`, so an ARM visitor is never handed a button +that 404s. Primaries carry `data-dl-avail`, which the release refresh updates +before calling `window.__wkResolveDownload()` — so a target appearing or +vanishing between deploys moves the button too, not just the menu row. + +Windows rows carry an **Early** tag, and one line says the builds are not +code-signed so Windows warns on first launch, linking to the SmartScreen +walkthrough (§4.4). It covers **both** Windows builds, so inside the menu it +caps the group at the foot of the panel rather than hanging off one row: +attached to the x64 row it rendered *between* the two Windows entries, reading +as a caveat about x64 alone and cutting the ARM row loose from the group above +it. It appears **twice on purpose**: inside the menu, for +someone choosing Windows from a Mac, and under the button, for the visitor +the promotion script just handed a Windows download — the person about to +meet SmartScreen is exactly the one whose primary says Windows. The +under-button copy toggles with `data-dl-note`, on the same rule as the +version captions. + +`