> For the complete documentation index, see [llms.txt](https://docs.kouch.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.kouch.dev/docs/release.md).

# Releasing Kouch

How to turn the tree into something Steam can ship. Kouch is pre-alpha and has no Steam app id yet (Steam Direct submitted 2026-09-17); every step that needs the id is marked **(app id)**.

## 1. Build

```powershell
scripts\build-release.ps1                 # depot only
scripts\build-release.ps1 -Installer      # + NSIS installer
scripts\build-release.ps1 -AppId 1234560 -DepotId 1234561   # real ids in the SteamPipe scripts
```

The script:

* builds the UI and the shell in release mode with `--no-default-features`, so the dev-only borrowed-app mode (`dev-borrowed`, `crates/kouch-steam/src/borrowed.rs`) is **compiled out**;
* embeds the UI in the exe (no dev server; `tauri build` runs the UI build first);
* merges `app/src-tauri/tauri.release.conf.json` (publisher, descriptions, the NSIS target, and the files that sit beside the exe);
* lays out `dist\windows\` and writes `dist\steampipe\` (see below);
* refuses to put a `steam_appid.txt`, `.pdb` or any user config in the depot;
* fails if any DLL the shipped binaries import (`dumpbin /dependents`) is neither part of Windows nor in the depot. A build once left out `SDL3.dll` unnoticed, because the smoke test ran from `target\release`, where the DLL sits.

Always run the full local gate from `CLAUDE.md` first.

From bash, run it as `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1 …` and don't wrap it in a PowerShell 5.1 command that adds `2>&1`: 5.1 turns the tauri CLI's informational stderr lines into errors and the build stops. Send output to a file with `*>` instead, or let it print.

### What ships (`dist\windows\`)

| File                      | Why                                                                                                                                |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `Kouch.exe`               | the app (UI embedded)                                                                                                              |
| `steam_api64.dll`         | Steamworks redistributable; must sit beside the exe                                                                                |
| `SDL3.dll`                | SDL3, built from source and linked dynamically on Windows                                                                          |
| `vcruntime140.dll`        | the VC++ runtime `SDL3.dll` needs, copied app-local from Visual Studio's redist folder (the VC++ redistributable terms allow that) |
| `steam\kouch_actions.vdf` | the Steam Input action manifest (`input.rs` looks beside the exe)                                                                  |

About 32 MB (Kouch.exe is 27 MB); the NSIS installer is about 8.5 MB and installs the same files. The exe is `Kouch.exe` in both, and it's the Steam launch option too (`tauri.release.conf.json` `mainBinaryName` renames cargo's `kouch-app.exe`; dev builds keep the cargo name). Nothing else. WebView2 is part of Windows 10/11 (the installer bootstraps it on the rare machine without it); Steam does not ship it.

`steam_appid.txt` is never shipped: when Steam launches Kouch it sets the id itself. Release builds never create one, at build time (`app/src-tauri/build.rs`) or at run time (`input.rs` writes it only with the `dev-borrowed` feature). Started outside Steam, a release build simply runs with OS-only input.

## 2. Smoke test without Steam

A release exe with a throwaway identifier, config and data dir, so it can run next to an installed Kouch without single-instance handing off to it:

```powershell
scripts\build-release.ps1 -Identifier app.kouch.smoke
$tmp = New-Item -ItemType Directory "$env:TEMP\kouch-smoke-$(Get-Random)"
$env:KOUCH_NO_STEAM = '1'
$env:KOUCH_CONFIG_DIR = "$tmp\config"
$env:KOUCH_DATA_DIR = "$tmp\data"
$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--remote-debugging-port=9231'
Copy-Item -Recurse dist\windows "$tmp\depot"   # run a copy, never from target\release
& "$tmp\depot\Kouch.exe"
```

Then once through the installer, into a temp folder and back out:

```powershell
scripts\build-release.ps1 -Installer -Identifier app.kouch.smoke
target\release\bundle\nsis\Kouch_0.1.0_x64-setup.exe /S /D=$tmp\installed
& "$tmp\installed\Kouch.exe"         # walk it, then Power › Exit
& "$tmp\installed\uninstall.exe" /S  # leaves no folder and no uninstall key
```

Walk it (by pad, keyboard, or the CDP `attach.mjs` driver on port 9231): first-run wizard → Home → Library → a game page → Settings (every category; Theme with a sample theme in `$tmp\config\themes\<folder>`) → Power › Exit. Check `$tmp\data\logs\` for warnings, and that no `steam_appid.txt` appeared beside the exe.

## 3. SteamPipe **(app id)**

In Steamworks:

1. **App › Installation › General:** launch option `Kouch.exe`, working directory the install folder, OS Windows 64-bit.
2. **App › Steam Input:** enable **Steam Input API** for the app, and set "Steam Input default configuration" to **forced on** for every controller family (Kouch reads every pad through Steam Input; see `docs/EARLY_TESTS.md` Early Test 0). Upload `steam\kouch_actions.vdf` as the action manifest if the partner site asks for it.
3. **SteamPipe › Depots:** one Windows depot (all languages).
4. **SteamPipe › Builds:** create a **private branch** (e.g. `alpha`, password protected) for the first uploads; never set a build live on `default` until the checklist below passes.

Upload with the SteamCMD content builder:

```powershell
scripts\build-release.ps1 -AppId <app id> -DepotId <depot id>
steamcmd +login <builder account> +run_app_build "$PWD\dist\steampipe\app_build.vdf" +quit
```

`dist\steampipe\app_build.vdf` points `ContentRoot` at `dist\windows\` and excludes `*.pdb` and `steam_appid.txt` again as a second guard. Then set the new build live on the private branch in the partner site.

## 4. When the app id arrives

From `CLAUDE.md` ("Running the real app under Steam"):

* [ ] Enable "Steam Input API" in Steamworks (step 3.2 above).
* [ ] Upload a build to a private branch and install it through Steam.
* [ ] Delete `crates/kouch-steam/src/borrowed.rs`, the `dev-borrowed` feature (`crates/kouch-steam`, `app/src-tauri/Cargo.toml` `[features]`) and the `KOUCH_APP_ID` / `KOUCH_MANIFEST*` overrides (`input.rs`, `tools/kouch-lab`, `scripts/kouch-lab-steam.cmd`).
* [ ] Rerun Early Tests 0–1 through the installed build (`docs/EARLY_TESTS.md`).
* [ ] Set up Steam Cloud as below, then run its checks.
* [ ] Verify what else the borrowed id couldn't: Workshop browse/subscribe/publish, lobbies and invites, Remote Play Together, rich presence.
* [ ] Run the acceptance run (`docs/PLAN_V2.md`) on the Steam build.

### Steam Cloud: API only, no Auto-Cloud

Kouch writes and reads its cloud files itself through `ISteamRemoteStorage` (`app/src-tauri/src/cloud.rs` on top of `cloud_sync.rs`). **Do not configure Auto-Cloud.** Auto-Cloud would copy whole folders by path whenever Kouch starts and quits, with no way for Kouch to:

* keep `system` and a profile's `user_data.private` paths off the cloud (Auto-Cloud has include patterns, not Kouch's refusals);
* keep both copies when a save changed on two PCs (Kouch names the older one `<file>.conflict-<time>`; Steam's own conflict prompt keeps one side);
* sync before a launch and after a game exits, while Kouch keeps running.

Kouch also doesn't rely on Steam picking up files dropped straight into `<Steam>\userdata\<account>\<app id>\remote\` — that's Auto-Cloud's job, and this setup doesn't use it. The `saves\<emu>` junction points at `%APPDATA%\kouch\cloud\<steam id>\<emu>` instead, and `cloud.rs` syncs that.

Steamworks › App Admin › **Steam Cloud**:

| Setting                                  | Value                                                                    |
| ---------------------------------------- | ------------------------------------------------------------------------ |
| Byte quota per user                      | **1 GB** to start (each profile stops at `cloud_cap_mb`, default 200 MB) |
| Number of files allowed per user         | **10000** (memory-card and screenshot folders hold many small files)     |
| Enable cloud support for developers only | **on** while testing, **off** at release                                 |
| Root paths (Auto-Cloud)                  | **none**                                                                 |

Then **Publish** the app's changes (Steamworks › Publish). Kouch needs no other setting: files are named `<profile id>/<folder key>/<path>` and sync on every platform (the `SetSyncPlatforms` default).

Checks (they need two PCs, or one PC and two Steam accounts):

* [ ] Play, quit: `cloud:progress` reports `done` with files > 0, and the file shows up in the Steam web UI (store.steampowered.com/account/remotestorage).
* [ ] Second PC: launch the same game; the save is there before the emulator starts.
* [ ] Change the save on both PCs offline, then sync: both copies survive, the older as `<file>.conflict-<time>`, with a warning toast.
* [ ] Temporarily set the quota to 1 MB: a bigger save gives the "cloud space full" toast and no failed state loops.
* [ ] Steam › Settings › Cloud off: sync reports that Steam Cloud is off, nothing uploads.
* [ ] Sign in with a second Steam account on the same PC: `saves\<emu>` now points at that account's folder and shows none of the first account's saves.
* [ ] A profile with `user_data.private` paths: none of them appear in the Steam web UI.

## Linux / SteamOS

```powershell
scripts\linux-build.ps1 bundle      # from a clone outside Dropbox, in PowerShell
scripts\linux-build.ps1 smoke
scripts\linux-build.ps1 depot       # the Linux Steam depot (see "Steam (app id)" below)
```

The build runs in Docker (`scripts/linux/Dockerfile`: Rust 1.93 on Debian bookworm with the Tauri 2 / WebKitGTK 4.1, SDL3 and bindgen dependencies), capped at `-Jobs 4 -Memory 8g` by default. Like Windows it builds with `--no-default-features` (the dev-only borrowed-app mode is compiled out) and merges `app/src-tauri/tauri.linux-release.conf.json`. `-DevVpad` builds a separate, never-shipped `dist\linux\Kouch-dev-x86_64.AppImage` with the dev virtual controller (`KOUCH_DEV_VPAD=1`), for driving a Deck without hands.

### What ships (`target\linux-bundle\`, and `dist\linux\Kouch-x86_64.AppImage`)

| File                         | Size   | Contents                                                                                                                       |
| ---------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `Kouch_0.1.0_amd64.AppImage` | 152 MB | the app plus its WebKitGTK/GTK stack and GStreamer plugins, `usr/lib/libsteam_api.so`, `usr/lib/Kouch/steam/kouch_actions.vdf` |
| `Kouch_0.1.0_amd64.deb`      | 13 MB  | `/usr/bin/kouch-app`, `/usr/lib/kouch/libsteam_api.so`, `/usr/lib/Kouch/steam/kouch_actions.vdf`; WebKitGTK from the system    |
| `kouch-app`                  | 32 MB  | the binary alone (UI embedded)                                                                                                 |

The AppImage carries WebKitGTK 4.1 with its helper processes and GStreamer (the `bundleMediaFramework` plugins: sounds, music, hero clips), and needs glibc ≥ 2.36. From the host it takes the graphics stack (mesa EGL/GL/GBM, libdrm, **libwayland**, X11/xcb) and the usual desktop libraries (ALSA, fontconfig, freetype, harfbuzz, fribidi). `scripts/linux/fix-appimage.sh` removes the bundled libwayland after Tauri's bundle: the host's mesa needs its own, and the older bundled copy made EGL fail on newer hosts. SteamOS ships no WebKitGTK, so both points matter there.

The same script puts a snapshot of the host environment at the top of `AppRun` (`KOUCH_HOST_ENV`, base64 of `env -0`), before the AppImage points `PATH`, `LD_LIBRARY_PATH`, `GTK_*`, `GST_*` and `XDG_DATA_DIRS` into its mount. Everything Kouch starts on the host (emulators, Flatpak apps, Steam, `xdg-open`) starts from that snapshot, without `APPDIR`/`APPIMAGE`/`ARGV0`/`OWD` (`kouch_launch::HOST_ENV_VAR`); with the AppImage's own environment, the host's `flatpak` loaded the bundled glib and failed. The .deb needs none of this.

It also keeps WebKit's GStreamer registry warm across launches (lines just before `AppRun`'s final `exec`): the registry scan is on the first-paint path, and the per-launch mount path made every start rescan all 104 plugins, one scanner process each, into the host's own registry. Now Kouch has its own registry (`~/.cache/kouch/gstreamer-registry.bin`), reaches the plugins through a stable symlink (`~/.cache/kouch/gst-plugins`) and reuses one scanner, so a version is scanned once. Measured under Xvfb with a new extract path each run: before 1039/1535/2165 ms to first paint; after 767 ms cold, then 451–509 ms, with the registry file never rewritten. The smoke step requires "first paint reported" in the log.

SDL3 is linked statically on Linux. `libsteam_api.so` is found through the binary's RUNPATH (`$ORIGIN:$ORIGIN/../lib/kouch`); the action manifest through Tauri's resource dir. No `steam_appid.txt` ships.

### Smoke test

`smoke` runs the AppImage from `dist/linux/` for 20 s under Xvfb (1280×800) with `KOUCH_NO_STEAM=1` and temp config/data dirs, and fails if it exits or the UI never reports its first paint. (Until 2026-09-26 it ran the raw binary, which hid that the AppImage rendered nothing on a host without its own WebKitGTK.)

Stock hosts, 2026-09-26: the AppImage renders the first-run wizard on Arch (glibc 2.44, mesa 26, no WebKitGTK or GStreamer installed) and on Debian bookworm without WebKitGTK; screenshots read back. Expected in the container: D-Bus session warnings (no session bus) and the root notice (the container runs as root).

### Steam (app id): ship the extracted tree, not the AppImage

`depot` (after `bundle`) extracts the fixed AppImage into `dist\linux-depot\` and writes `dist\steampipe\app_build_linux.vdf` + `depot_linux.vdf` (`-AppId`, `-LinuxDepotId`; placeholders until the app id exists). The depot is that tree: `AppRun` (the launch target), `usr/bin/kouch-app`, `usr/lib` with WebKitGTK, GStreamer and `libsteam_api.so`, and `usr/lib/Kouch/steam/kouch_actions.vdf`.

Why not the AppImage itself: its squashfs is decompressed on every launch. On the Deck in Game Mode (same build, 2026-09-27) the extracted tree painted its first frame at 1.11 s against 1.58 s from the AppImage, the first launch after a deploy 1.14 s against 2.16 s, and the AppImage adds \~0.7 s of mount before Kouch's code runs. The AppImage and the .deb stay for testers and other distributions.

`AppRun` works extracted because it sets `APPDIR` from its own folder before the linuxdeploy hooks run (`fix-appimage.sh`). Without it the GStreamer hook pointed the plugin scanner and plugin paths at the host's `/usr/lib`. `depot` runs the tree headless and fails if the running process's `APPDIR` or GStreamer paths point anywhere but the tree or Kouch's plugin cache.

In Steamworks: a Linux depot (OS Linux 64-bit), launch option `AppRun`, working directory the install folder. The tree carries its own WebKitGTK and GStreamer, so it doesn't need a Steam Linux Runtime compatibility tool: verify both ways on the Deck with the real app id, and keep the faster one. Verify the Phase 1 gate in Game Mode.

**Upload from Linux, not Windows:** a depot uploaded from Windows loses the executable bits (`AppRun`, `usr/bin/kouch-app`, the WebKit helper processes). Run `steamcmd +run_app_build` with `app_build_linux.vdf` inside the build container (`linux-build.ps1 shell`) or on a Linux machine.

### Deck / Linux verification (before a Linux build goes to testers)

On a Steam Deck in Game Mode, Kouch started from Steam (the depot tree's `AppRun`; until the app id exists, a non-Steam shortcut to it). The app log is `~/.local/share/kouch/logs/kouch.<date>.log`; the Kouch Lab rig (`gm-run.sh`) automates these, but each can be done by hand.

* [ ] **Start-up:** `grep -E "startup|first paint|quick menu window" <log>` gives `first paint reported … since_launch_ms` around 1.1 s on a warm launch from the extracted tree (about 1.6 s from the AppImage), and `startup: quick menu window created` after it. The first launch after an install is slower (the GStreamer registry is built once).
* [ ] **Sound:** menu cues play. The perf tour's line `PERF_TOUR phase=audio … keep_alive=1 web_audio=1` says they go through Web Audio, not the slower `<audio>` fallback.
* [ ] **GStreamer stays in the tree:** `~/.cache/kouch/gstreamer-registry.bin` exists after the first launch. From the extracted tree no `~/.cache/kouch/gst-plugins-*` folder is created. From the AppImage there is one (at most two while an older version is kept), and it holds the bundled plugins, never the host's.
* [ ] **Quick Menu over a game:** launch a game, press the Quick Menu chord. The panel draws over the game, pinned at the left (a full-composition capture: `GAMESCOPE_WAYLAND_DISPLAY=gamescope-0 gamescopectl screenshot <file> 3`). The emulator's processes are stopped (`T` in `ps`) while it is open, or paused through its hotkey, and run again after B closes it.
* [ ] **Early open:** start Kouch with `--launch=<stable id>` (a Steam library shortcut) and press the chord about 2 s in: the menu opens over the game and pauses it.
* [ ] **Emulators get the host environment:** a Flatpak emulator launches, registers as a DSU client (`app_diagnostics` `dsu_clients`), and quits gracefully from the Quick Menu.
* [ ] **Smoothness:** the perf tour's phases at or above the 90 Hz bar (PLAN\_V2 Phase 1; `docs/HOME_LIBRARY_PLAN.md` H9 has the latest numbers).
* [ ] **Exit:** Main menu › Power › Exit leaves no `kouch-app` or WebKit process.

## 5. Before anything goes public

Cross-check `docs/GO_PUBLIC_CHECKLIST.md`. For a store release in particular:

* [ ] `brand-lint` over the whole repo, and the store page text and every screenshot/trailer frame checked against `docs/brand-blocklist.txt` (ground rule 1). Built-in themes and art are Kouch's own; third-party imagery only ever arrives in Workshop themes flagged `device_imagery` (ADR 0009).
* [ ] Nothing ROM/BIOS/firmware/key-related ships, and no game names or art for specific titles (ground rule 2). The depot is the five files above.
* [ ] The license, the Steamworks redistributable terms and the bundled font license (`app/ui/public/fonts/LICENSE.txt`) are in place.
* [ ] `profiles/` stays out of the build (it never enters the depot; the script copies only the files listed above).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.kouch.dev/docs/release.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
