# v2.20.0 — the Windows release that actually works

**Released 2026-08-14.** Upgrade:

```sh
sudo snap refresh yazses     # snap
pipx upgrade yazses          # or: uv tool upgrade yazses
```

Most of this release comes from one thing that had not happened before: people running
YazSes on Windows and telling us what they saw. Every fix below was reported from a live
install, and each is proven on a Windows runner rather than asserted.

## A blocked model download killed the daemon instead of explaining (#310)

Reported by [@AtmanActive](https://github.com/AtmanActive) on Windows 10 behind the
[Fort](https://github.com/tnodir/fort) firewall: on first run the daemon tried to fetch the
Whisper model, the firewall refused the socket (`WinError 10013`), and the process died with
a raw `huggingface_hub` traceback — which the PyInstaller bundle renders as a modal
*"Failed to execute script"*. Nothing on screen said what YazSes wanted, why, or what to do
about it.

`core/daemon.py::run()` wrapped `_build_pipeline()` in `try`/`finally` with no `except`, so
**every** startup failure escaped to the top. Notably `stt/factory.py` already got this right
for the Parakeet and Moonshine engines ("dictation must still come up"); the default engine
had no such guard.

Now `ModelUnavailableError` carries ready-to-print guidance — the cause, a firewall hint when
the cause reads like one, and the three ways to get the model. The daemon holds itself in
`ERROR` state with that text attached rather than exiting, so the tray turns red with the
reason and `yazses status` can answer. Exiting would have taken the tray down too, leaving a
vanished window as the only symptom.

## `yazses model download` handles speech models, not just SLMs

The model can now be fetched as a deliberate, watchable step instead of a side effect of the
first dictation — the only workable route on a firewalled or air-gapped machine.
`yazses model list` gained a speech-to-text section showing every model, whether it is
already present, and the cache path.

The name→repository mapping is **not** derivable: `large` resolves to `large-v3`, `turbo`
comes from a different organisation, and the distil models use another prefix — so a URL
built from a template would have sent three of them to a 404. It is mirrored from
faster-whisper's private table and kept honest by a test that fails on drift.

Relatedly, the documented model-cache path was wrong on Windows: the docs said
`%LOCALAPPDATA%\huggingface\hub`, but huggingface_hub uses `~/.cache/huggingface/hub` on
every platform — so anyone placing a model by hand was putting it where nothing would read
it.

## Windows shipped without its icon, and the tray showed a blank disc

Reported with screenshots. Two separate causes, both long-standing.

**`assets/yazses.ico` never existed.** The Windows spec had referenced it since the file was
written, behind `icon=str(ICON) if ICON.exists() else None` — so every build silently passed
`icon=None` and shipped PyInstaller's default artwork to the desktop shortcut, the Start
menu, the taskbar and Add/Remove Programs. The macOS spec carried the identical dangling
`assets/yazses.icns`. Both now **fail the build** rather than degrading in silence, and the
containers are generated and committed.

**The tray glyph is now the YazSes mark** — a rounded badge with the white "Y", matching
Linux — instead of a bare disc. Pillow anti-aliases nothing, which is where the jagged edge
came from; the mark is supersampled and downsampled as coverage, so it has neither jagged
edges nor the dark halo the naive fix introduces.

**The Windows tray now obeys the shared colour policy.** It had kept a private seven-entry
table that bypassed `tray/menu.py::icon_spec`, so Windows showed different colours from Linux
for the same state, had **no** command-mode purple and **no** "no text field focused" yellow,
and rendered five of the twelve tray states — including Meeting Mode — as idle blue.

## Every CLI command was unreachable on the Windows installer

`yazses doctor` on Windows printed nothing and then died in a message box with
`AttributeError: 'NoneType' object has no attribute 'isatty'`. Two defects, stacked:

**The console shim could never win.** The bundle ships two binaries on purpose — a windowed
one for the tray/daemon and `yazses-cli.exe` for the CLI — with a `yazses.cmd` shim putting
the console one on `PATH`. But the windowed binary was named `YazSes.exe`, Windows resolves a
bare `yazses` through `PATHEXT` (which lists `.EXE` before `.CMD`), and NTFS is
case-insensitive. `YazSes.exe` therefore answered to `yazses` and shadowed the shim in the
same directory. Every `yazses <command>` reached the *windowed* binary, which has no console
— so `yazses doctor` and `yazses -h` printed nothing whatsoever. The windowed binary is now
`YazSesApp.exe`, and the installer deletes a leftover `YazSes.exe` on upgrade.

**A missing stdout was fatal rather than degrading.** `sys.stdout` is `None` in a
GUI-subsystem PyInstaller build, so `sys.stdout.isatty()` raised instead of answering "not a
tty". New `system/streams.py` centralises that policy, and `system/wincon.py` adds a second
line of defence: a CLI command that reaches the windowed binary anyway now borrows the
launching terminal's console via `AttachConsole`.

!!! note "Upgrading from 2.18.2 or earlier on Windows"

    The windowed binary was renamed from `YazSes.exe` to `YazSesApp.exe`. The installer
    removes the old file for you — an orphaned `YazSes.exe` would keep shadowing the CLI
    shim. See [Install on Windows](../windows-install.md).

The full list for this release is in
[CHANGELOG.md](https://github.com/MSKazemi/yazses/blob/main/CHANGELOG.md).
