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 `
.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.
+
+`