---
name: codoceh-stol-dev
description: "Разработка репозитория CodocehStol (Android APK ru.codoceh.stol — proot-Debian десктоп в одном приложении): архитектура магазина/образа и как реально тестировать без боли с эмулятором"
metadata: 
  node_type: memory
  type: project
  modified: 2026-08-24T09:40:22.255Z
  originSessionId: 1f3a6f65-acf1-47fc-be99-74d26d2d0ce8
---

Репозиторий: `~/Разработки/CodocehStol` (git). Это ИСХОДНИКИ приложения `ru.codoceh.stol`
(«стол» на телефоне владельца, см. [[codoceh_fold_linux_desktop]] — та память про
использование готового стола, эта — про его разработку). Канон правил проекта — свой
`AGENTS.md` в корне репозитория, огромный и стоит читать целиком перед правками: там
годы расследований по кликам/фокусу/proot, а не только архитектура.

**Архитектура для быстрой ориентации:**
- Java-приложение (`app/src/main/java/ru/codoceh/stol/`) — свой RFB/VNC-клиент
  (`screen/RfbClient.java`, `DesktopView.java`, `TouchTranslator.java`, `Keysyms.java`),
  НЕ AVNC. `MainActivity.java` — оверлейная панель `controlsBar` (zoom/клавиатура/тач-мышь)
  поверх RFB-канвы.
- Образ — `rootfs.tar.zst` (debootstrap Debian 13 trixie, ~460 МБ сжатый), собирается
  `tools/mkimage.sh` (~20 минут, запускать через `screen -dmS`, агентский таймаут иначе
  убивает сборку), кладётся в `app/src/main/assets/`. Разворачивает —
  `setup/RootfsInstaller.java` (verify sha256 → unpack в `.partial` → rename → метка
  `READY_MARKER` последней). До 2026-08-20 умел только «не установлено → поставить»,
  повтор — no-op.
- Магазин («Магазин КодоЦех») — ОТДЕЛЬНЫЙ механизм, НЕ Android-код: Python/Tkinter
  (`store/codoceh-store.py` + `store/backend.py`), запускается как X11-приложение внутри
  самого стола. Каталог — `store/catalog.json`, установка — `apt-get`/`deb-url`/
  `deb-tar`/`apt-repo`/`appimage-extract` через `run_install_item()`. Есть НЕИСПОЛЬЗУЕМЫЙ
  параллельный Java-каталог `apps/AppsCatalog.java`+`AppSpec.java` с ДРУГИМИ именами kind
  (`deb`/`apt`/`archive` вместо `apt-repo`/`deb-tar`) — ничто в `app/src/main` его не
  вызывает, только собственные unit-тесты; не путать с живым магазином при правке каталога.

**2026-08-20 — добавлены: кнопка клавиатуры, пакет Tailscale в магазин, механизм
`update()` (пакеты + overlay-слой вместо только полной переустановки).**
- Кнопка клавиатуры: `DesktopView.showKeyboard()/hideKeyboard()` УЖЕ существовали
  полностью реализованными (IME → `Keysyms` → RFB) — просто не было кнопки в
  `controlsBar`. Добавлена рядом с тач/мышь-тумблером в `MainActivity.java`.
- Tailscale: `store/catalog.json` (`kind: apt-repo`, ключ+репозиторий pkgs.tailscale.com,
  `apt: ["ncat"]`, новое поле `post_install: "tailscale-userspace"`, новое поле `guide` —
  текст показывается `messagebox.showinfo` после установки в `codoceh-store.py`).
  `backend.py`: `_setup_tailscale_userspace()` — proot не даёт открыть `/dev/net/tun`
  даже под root (`Permission denied`, владелец `system:vpn`), поэтому `tailscaled`
  поднимается в `--tun=userspace-networking` с SOCKS5 на `:1055`; автозапуск —
  идемпотентный блок в `.bashrc` (другого init в образе нет). Подключаться с этого стола
  на другую машину — `ncat --proxy 127.0.0.1:1055 --proxy-type socks5 %h %p` как
  `ProxyCommand` в `~/.ssh/config`. Полный рабочий рецепт впервые обкатан руками на
  Fold8 в [[codoceh_fold_linux_desktop]] — здесь просто автоматизирован в магазин.
- `update()`: `RootfsInstaller.installedVersion()`/`update()` + необязательные поля
  `overlay_asset`/`overlay_sha256`/`overlay_bytes` в `ImageManifest`/`image.json`. Логика:
  не собрано → как `install()`; версия совпадает → ничего не трогает; версия другая, а
  оверлея в сборке APK нет → просто поднимает записанную версию (обновлять нечем);
  версия другая и оверлей есть → проверяет sha256 ДО распаковки, разворачивает оверлей
  ПРЯМО в существующий `rootfs` (не через `.partial`+rename — это только для полного
  образа), `/root` оверлей принципиально не касается. **НЕ РЕАЛИЗОВАНО ЕЩЁ**: сама
  генерация `overlay.tar.zst` в `tools/mkimage.sh` (из тех же путей `reference/` +
  `store/catalog.json`, что и полный образ) и правило в `tools/checkimage.sh`,
  запрещающее оверлею трогать пути домашнего каталога; плюс UI-кнопка «Проверить
  обновления» (естественное место — `AboutActivity`, не проверено где именно). Ядро
  (`RootfsInstaller.update()`) покрыто 5 новыми тестами в `RootfsInstallerTest.java`,
  все зелёные (21/21 в файле).

**Как реально тестировать эту установку/магазин — эмулятор для GUI-кликов ненадёжен,
lima+proot надёжен.** `adb shell input tap` на `stol_phone_api35` (программная отрисовка,
SwiftShader) регулярно НЕ доводит клик до Tk-кнопок магазина: ни разу не долетело до
«Поставить» ни через `input tap`, ни через `input swipe X Y X Y 50` — при этом курсор
визуально двигался (значит указатель до X11 доезжает), а лог установки не создавался
вовсе. Подозрение по `AGENTS.md` (там же описан похожий, но исправленный баг с
превращением тапа в правый клик по `ViewConfiguration.getLongPressTimeout()` от
`getEventTime()-getDownTime()`) — не подтверждено экспериментом, но похоже по
симптомам. Отдельно подтверждено ЖИВЬЁМ: появление/скрытие экранной клавиатуры двигает
и масштабирует весь рендер стола на экране — координаты, посчитанные по скриншоту ДО
показа клавиатуры, после её появления/скрытия обязательно устаревают.

**Рабочий обход, использованный для проверки `store/backend.py` (каталог + Tailscale)
без единого клика по GUI:** `store/test-store.sh` уже использует lima-виртуалку
(`STOL_LIMA_VM`, по умолчанию `ceho`, живёт постоянно на этой машине) с proot-входом,
повторяющим то, что делает APK. Если `build/image/rootfs.tar.zst` уже собран и уже
развёрнут в виртуалке (проверить `deployed.sha` в `$WORK_VM` против
`rootfs.tar.zst.sha256`), можно НЕ трогать `test-store.sh` целиком, а руками:
1. `limactl copy store/backend.py ceho:/tmp/... && limactl shell ceho -- sudo cp ... /var/tmp/stol-store/rootfs/opt/stol/store/` — закинуть свои правки прямо в уже развёрнутый rootfs;
2. написать свой `login.sh` (см. `put_login()` в `test-store.sh` — это ровно та же
   команда `proot --kill-on-exit --link2symlink -0 -r "$R" -b /dev -b /proc -b /sys
   -b "$R/root:/dev/shm" -w /root /usr/bin/env -i HOME=/root ... /bin/bash -l -c "..."`)
   и вызвать `python3 /opt/stol/store/backend.py install-item <id> --log ...` напрямую;
3. проверить реальный dpkg/файлы (`dpkg -l`, `ls`, `cat .bashrc`) внутри того же proot-входа.
Это доказывает саму логику `backend.py`/`catalog.json` (реальный apt, реальные файлы) на
порядок быстрее и надёжнее, чем гонять GUI-магазин через adb-тапы — GUI-клики стоит
проверять отдельно и только если меняется сам `codoceh-store.py`/UI, не установочная
логика. **После такого теста — обязательно откатить** пакеты/файлы в общей `ceho`
(другие люди/прогоны её тоже используют): `apt-get purge`, снести `.bashrc`-блок и
apt-репозиторий, но backend.py/catalog.json в развёрнутом rootfs можно оставить —
`deployed.sha` не меняется от правки файлов внутри уже развёрнутого дерева, так что
следующий `test-store.sh` их не перезатрёт и не переразвернёт заново.

**2026-08-24 — добавлен пункт `codex` (ChatGPT Desktop/Codex, официальный .deb OpenAI,
kind `deb-url`), и на нём же нашлись два реальных, а не гипотетических, дефекта.**

1. **`kind: deb-url` наследует ту же порчу dpkg под proot, что уже была решена только
   для `deb-tar`/hangover.** У `chatgpt_arm64.deb` распакованный размер ~1,3–1,4 ГБ —
   выше того самого порога «malloc(): corrupted top size» из комментария в
   `backend.py` про hangover. `apt-get install ./chatgpt_arm64.deb` ставит все мелкие
   зависимости нормально и падает именно на распаковке `data.tar.xz` самого пакета.
   Починено **в самом `backend.py`**, не только в каталоге: добавлена `_unpack_deb_file()`
   (обобщение уже существующего `_unpack_deb()` на одиночный файл на диске, не внутри
   `.tar`) и запасной путь в ветке `kind == "deb-url"` — если `apt-get install` ушёл
   дальше загрузки и вернул код ошибки, а файл-маркер так и не появился, пакет
   раскладывается вручную тем же приёмом, что и hangover. Мелкие зависимости всё
   равно ставятся штатным apt (они и раньше ставились без проблем) — обходится только
   распаковка большого файла. Это исправление общее для ЛЮБОГО будущего пункта
   `deb-url` с большим `.deb`, не разовый костыль под codex.
2. **`[ -x путь ]` лжёт не только там, где раньше замечено (`/etc/profile.d`), но и на
   бинаре, который сам код разложил через `tarfile.extract()` в обход dpkg** — при этом
   тот же файл прекрасно запускается напрямую (`ChatGPT --version` работает, код
   возврата 0) и `os.access(path, os.X_OK)` в питоне отвечает `True`. Разошлось даже
   с `chmod 755` повторно на уже лежащем файле. Обычный `/bin/bash` (пришедший с
   debootstrap, не через наш `tarfile`) на `[ -x ]` отвечает верно — значит ложь как-то
   привязана к тому, что файл распакован НАШИМ кодом внутри proot-сессии, а не к типу
   проверки или размеру файла вообще. Лечение по прямому указанию AGENTS.md
   («проверяйте наличие иначе») — в новом wrapper `/usr/local/bin/chatgpt`
   (`image/start-gui.sh`) используется `[ -f ... ]`, не `[ -x ... ]`. Существующие
   wrapper'ы (claude-desktop/code/antigravity/obsidian/zcode) свои `[ -x ]`-проверки НЕ
   трогал (Правило 3) — они через обычный dpkg, не через ручную распаковку, и не
   доказано, что там та же ложь; если когда-то всплывёт «нажал на столе — не открылось»
   именно у одного из НИХ, первым делом проверить этот же угол.
3. **Окно ChatGPT/Codex не появляется даже после успешной установки — это НЕ баг
   установки, а недоделанный вопрос про GPU.** Стандартная для этого проекта четвёрка
   флагов (`--no-sandbox --disable-gpu --disable-software-rasterizer
   --disable-dev-shm-usage`, та же, что у claude-desktop/code/antigravity/obsidian/zcode)
   у ChatGPT даёт необработанный reject промиса прямо на старте: `Error: GPU access not
   allowed. Reason: GPU access is disabled through commandline switch --disable-gpu and
   --disable-software-rasterizer` (стек в `window-all-closed-*.js`) — и `wmctrl -lx`
   окна не видит вовсе, хотя процессы (main, zygote×2, gpu-process, utility) висят живыми
   секундами. Без `--disable-software-rasterizer` (только `--disable-gpu
   --disable-dev-shm-usage`) — та же ошибка слово в слово: значит дело не в наборе
   флагов конкретно, а в том, что этот бинарь (модифицированный Chromium 151, свой
   форк «owl/browser/api», не ванильный Electron — видно по путям в трассировке и по
   отдельному namespace `owl::`) на старте требует подтверждённой возможности хоть
   какого-то раст­еризатора (аппаратного или swiftshader) и падает раньше, чем те же
   флаги успевали сработать у эталонных пяти программ. Не проверено: явные
   `--use-gl=swiftshader`/`--use-angle=swiftshader`, состояние `libvk_swiftshader.so`
   внутри `/usr/lib/chatgpt/` (лежит, не проверялось на факт загрузки), сравнение с тем,
   реально ли у эталонных пяти программ окно рисуется именно в lima-виртуалке (а не
   только на настоящем Android) — возможно, дело не в codex, а в том, что у lima нет
   вообще никакого GL-стека и остальные пять там тоже не рисуют окно, просто это не
   проверялось. Это отдельная задача, если понадобится не просто «ставится», а
   «открывается»: искать в сторону GL/Vulkan-стека для этого конкретного форка
   Chromium, не в сторону каталога/backend.py — та часть (установка) доказана рабочей
   и живым запуском бинаря, и живым `apt-get check` (чисто) после.

Проверялось ровно тем самым обходом (`test-store.sh`, п. «рабочий обход» выше): копия
`backend.py`+`catalog.json` в уже развёрнутый `rootfs` виртуалки `ceho`,
`install-item codex` через свой `login.sh`, затем живой подъём стола
(`/opt/stol/start-gui.sh` в фоновом proot) и запуск `/usr/local/bin/chatgpt` с
`DISPLAY=:1` — issue 1 и 2 воспроизведены и исправлены живьём (`EXIT_CODE=0`, маркер
на месте, повторный запуск не переустанавливает), issue 3 воспроизведена и НЕ
исправлена. После проверки тестовая установка (`/usr/lib/chatgpt`,
`/usr/local/bin/chatgpt`, `.desktop`, `~/.config/ChatGPT`) убрана из `ceho`, стол
погашен — правки в `backend.py`/`catalog.json`/`image/start-gui.sh` в самом
репозитории (не в rootfs виртуалки) оставлены.

**2026-08-24 (тем же вечером) — добавлен `opencode` (OpenCode Desktop, официальный
`.deb` `opencode-desktop-linux-arm64.deb` с GitHub-релиза `sst/opencode`, обычный
Electron ~454 МБ распакованным — под порог порчи dpkg, ставится штатным путём без
обхода из п.1 выше), `zcode` перенесён из «Разработка» в «ИИ и Агенты». Заодно
всплыли и закрыты два системных пробела, не специфичных для одной программы:**

1. **Ярлык на столе после установки появлялся только у «исходной пятёрки»
   (claude/code/antigravity.../obsidian/zcode) — у них файл `.desktop` лежит в
   `reference/desktop-entries/` и попадает на стол ПРИ СБОРКЕ ОБРАЗА
   (`tools/mkimage.sh`), список программ там захардкожен по именам. Всё, что
   добавлено в `catalog.json` ПОСЛЕ сборки (Tailscale, теперь Codex/OpenCode) — такого
   файла не имеет, и `install_app_icon_and_desktop()` в `backend.py` только ПРАВИЛ
   существующий `.desktop` (правил `Icon=`), а не создавал его. Починено дженерик, не
   хардкодом на два новых имени: у `source` появилось поле `desktop_exec` (имя команды
   из `/usr/local/bin/...`, которое `start-gui.sh` заводит для программы) и опциональное
   `icon_name` (реальное имя файла значка внутри пакета, если оно не совпадает с `id` —
   у OpenCode это `ai.opencode.desktop`, у Codex — `chatgpt`); если ярлыка нет и
   `desktop_exec` задан — `install_app_icon_and_desktop()` теперь сам пишет минимальный
   `.desktop` (тот же формат, что у эталонных, `Exec=<desktop_exec> %U`). Любая будущая
   позиция каталога получает рабочий ярлык без правки `mkimage.sh`/`reference/`, если
   в её `source` указан `desktop_exec`.
2. **Кнопки «Убрать» не было вовсе ни у одной позиции со своим `source`** — окно
   магазина (`codoceh-store.py`) при `backend.source_of(item)` истинном просто
   выходило из функции без кнопки, для ЛЮБОЙ из claude/code/antigravity.../obsidian/
   zcode/tailscale/windows/codex/opencode. Причина, видимо, в том, что `run_remove()`
   умеет только `apt-get remove <пакет>`, а часть этих позиций (appimage-extract) вовсе
   не в dpkg — проще было не показывать кнопку никому, чем чинить по одному. Добавлена
   `run_remove_item()` в `backend.py` (симметрична `run_install_item()`, тоже CLI-действие
   `remove-item`): для appimage-extract чистит `target` целиком, для остальных — best-effort
   `apt-get purge` ПЛЮС явный список `remove_paths` из `source` (нужен для deb-url —
   dpkg не гарантирует полную запись, особенно после обхода из п.1 выше про порчу), плюс
   `post_remove` (пока только `tailscale-userspace` — глушит `tailscaled`, снимает блок из
   `.bashrc`, зеркально `post_install`), плюс всегда убирает ярлык и скопированные значки
   (`_remove_app_icon_and_desktop()`, симметрия к `install_app_icon_and_desktop()`).
   **Осознанно НЕ покрыт `kind: deb-tar` (стек windows-программ)** — он разложен по
   системным путям четырьмя `.deb` без единой записи в dpkg и без `target`; у окна
   магазина для него кнопки «Убрать» по-прежнему нет (явная проверка `kind == "deb-tar"`
   в `codoceh-store.py`), это названная граница, а не забытый случай — пытаться его
   разобрать назад было бы риском стереть что-то за пределами того, что сам стек
   принёс.
3. **OpenCode реально открывает окно** тем же набором флагов
   (`--no-sandbox --disable-gpu --disable-software-rasterizer --disable-dev-shm-usage`),
   которым Codex не открывает (см. issue 3 выше) — `wmctrl -lx` показал живое окно
   `ai.opencode.desktop.ai.opencode.desktop | OpenCode` секунд через 10-15 после
   запуска обёртки. Разница подтверждает, что дело в конкретном форке Chromium у
   Codex («owl/browser/api», не ванильный Electron), а не в среде lima/proot вообще —
   один и тот же набор флагов у одной программы даёт окно, у другой не даёт.

Проверено живьём тем же lima-обходом (`install-item`/`remove-item` через
`backend.py` напрямую, без GUI-кликов — для этой части достаточно, GUI не менялся
кроме подписи кнопки): install→ярлык с верной иконкой→remove→все файлы, ярлык и
значок исчезли, `apt-get check` чист — для codex, opencode и tailscale (у
tailscale — появление/исчезновение блока в `.bashrc` и самого процесса
`tailscaled`). Тестовые установки из `ceho` убраны после проверки. Все четыре
изменённых файла репозитория (`backend.py`, `catalog.json`, `codoceh-store.py`,
`image/start-gui.sh`) залиты и на настоящий телефон (Fold8, `ru.codoceh.stol`) тем
же приёмом `adb push` + `run-as cp` — без пересборки APK, см. [[claude_config_sync]]-
соседнюю технику USB-релея, только тут файлы магазина, не CLAUDE.md.

**2026-08-24 (позже тем же вечером) — issue 3 из раздела выше (окно ChatGPT/Codex не
открывается) РЕШЕНА.** Причина была не в наборе флагов «включить/выключить», а в том,
что современный Chromium (этот форк — тоже, значит стандартный, не свой код OpenAI:
сообщение `GPU access is disabled through commandline switch...` — штатный текст самого
Chromium) с некоторых версий блокирует программный растеризатор (swiftshader) флагом
по умолчанию, и включить его обратно одним лишь снятием `--disable-software-rasterizer`
недостаточно — нужен явный `--enable-unsafe-swiftshader`, иначе Chromium либо падает с
той же ошибкой, либо (что и произошло при первой живой пробе на телефоне) зависает
насмерть, пытаясь безуспешно найти рабочий GL-бэкенд другим путём.

Рабочий набор флагов для `/usr/local/bin/chatgpt` (правился в `image/start-gui.sh`,
строки с `codex-launcher`/`ChatGPT`):
```
--no-sandbox --user-data-dir=/root/.config/ChatGPT --disable-gpu \
--enable-unsafe-swiftshader --use-gl=angle --use-angle=swiftshader \
--disable-dev-shm-usage
```
(было: `--disable-gpu --disable-software-rasterizer --disable-dev-shm-usage`).
Не хардкод-обход именно для Codex — эталонная пятёрка (claude-desktop/code/
antigravity/obsidian/zcode) и opencode этот новый набор не трогали, они и так
открывались штатной четвёркой; если у будущей позиции каталога всплывёт та же ошибка
про GPU — сначала пробовать именно эту тройку флагов, а не гадать по-новой.

Перед правкой пробовал сравнить официальные пакеты `chatgpt` и `opencode` напрямую
(распаковка `.deb` на Mac, без proot) — оба несут одинаковый набор файлов swiftshader
(`libEGL.so`, `libGLESv2.so`, `libvk_swiftshader.so`, `libvulkan.so.1`,
`vk_swiftshader_icd.json`), то есть версия «у codex просто нет нужных файлов» не
подтвердилась; ни `postinst` того, ни другого пакета никак не трогает GPU-окружение.
Пробовал воспроизвести зависание в тестовой машине `ceho` (см. [[codoceh_fold_linux_desktop]]
про саму виртуалку) без риска для телефона — не получилось: системный `proot` там
другой версии (5.4.0, без `--sysvipc`, ванильный, не тот бинарь из Termux, что реально
в APK — тот собран под Android/Bionic и физически не запускается на обычном Linux),
и на нём всплыли свои, не относящиеся к делу баги (SIGBUS на голом `dash` из-за
seccomp-акселерации ptrace — лечится `PROOT_NO_SECCOMP=1`; ложный отказ Xtigervnc
писать lock-файл под `--link2symlink`, который сам proot подменяет хардлинк на
symlink). Довести чистый повтор зависания в этой виртуалке не вышло, поэтому финальная
проверка — только живым запуском на настоящем телефоне: правка `image/start-gui.sh` →
`adb push` + `run-as cp` в `files/rootfs/opt/stol/start-gui.sh` → `am force-stop` (чтобы
`/usr/local/bin/chatgpt` точно переписался заново при следующем запуске — см. issue 3 и
[[claude_config_sync]] про то, что обёртки не обновляются на лету) → `am start` →
терминал внутри стола → `chatgpt` — окно открылось, показало экран входа OpenAI,
клик по «Продолжите, чтобы войти» открыл живое встроенное окно браузера
(`auth.openai.com/log-in-or-create-account`) с рабочей формой — без входа, без ввода
почты, отменено кнопкой «Отменить вход» и закрыто. Никаких данных не вводилось.

Открытый вопрос на будущее, если понадобится копать глубже: почему именно этому форку
Chromium (`owl/browser/api`) не хватает старой связки флагов, а ванильному Electron
(opencode и остальные пять) — хватает. Не выяснялось специально, не мешает работе
после фикса.

**2026-08-24 (та же сессия) — полная пересборка APK (`tools/mkimage.sh` → `ci/
build-release.sh` → `adb install -r`) с фиксом Codex и правкой экрана запуска. Всплыли
и закрыты ДВА независимых бага сборки, ни разу не пойманных раньше, потому что полную
сборку с чистого места по факту не гоняли с 20 августа — весь предыдущий цикл правок
шёл через `adb push`+`run-as cp` в уже развёрнутый на телефоне rootfs, не через APK.**

1. **`tools/mkimage.sh` падал на шаге 9/9 (манифест) при первом чистом прогоне.**
   Причина — коммит от 20 августа (`69bed5f`, задача 02: mimeapps.list и обёртки
   chromium) дописал в образ файл `/root/.config/mimeapps.list`, но не добавил его в
   белый список путей `manifest_lines()` — с тех пор скрипт был обречён падать на
   первой же чистой сборке, просто её ни разу не гоняли. Починено одной строкой в
   `manifest_lines()`. Не имеет отношения к правке Codex — самостоятельный, ранее не
   пойманный баг сборки; если такое всплывёт снова (новый файл в образ добавлен, а
   `mkimage.sh` падает на шаге 9/9) — первым делом сверить `manifest_lines()`.
2. **`ci/build-release.sh` собрал APK 894 МБ вместо ожидаемых ~450 — потолок R01.3i
   (500 МБ) снесён, хотя сам образ внутри не выріс.** Причина — инкрементальная
   упаковка Gradle (zipflinger) при смене крупного ассета (`rootfs.tar.zst`, ~440 МБ)
   не переписывает APK с нуля, а ДОПИСЫВАЕТ новую версию файла, оставляя старую
   физически внутри архива как мёртвый груз (не видна в `unzip -l` — только в
   центральном каталоге новая запись, но байты старой всё ещё лежат в самом файле).
   Итоговый размер APK ≈ сумма старого и нового `rootfs.tar.zst`. Лечится только
   `./gradlew clean` перед `ci/build-release.sh`, когда меняется именно этот большой
   ассет (не при обычной правке кода — там дельта маленькая и вопрос не всплывает).
   **Правило на будущее: если правил `image/` (то есть менял содержимое
   `rootfs.tar.zst`) — `./gradlew clean` перед сборкой релизного APK обязателен, иначе
   R01.3i может ложно сработать или, хуже, APK молча растолстеет и это не поймают.**

Экран запуска (кузница) отдельно доработан по прямому запросу — без противоречия с
issue 3 выше (там про сам Chromium GPU, здесь про Android UI лаунчера):
- `MainActivity.createWaitingView()` — молот с наковальней (`ForgeView`) теперь
  появляется из темноты: `setAlpha(0f)` + `.animate().alpha(1f)` на 1.6 сек с
  задержкой 0.2 сек, а не сразу видимый на полную яркость.
- Кнопка «О программе» убрана с этого экрана целиком (мешала, когда там должна быть
  только анимация) и перенесена в новый минимальный экран `ru.codoceh.stol.settings.
  SettingsActivity` («Настройки стола» — раньше такого экрана в приложении не было
  вовсе). Вход в него — маленькая шестерёнка (⚙) в панели управления столом (там же,
  где кнопки клавиатуры и тач/мышь), а не на экране кузницы: решение по своей
  инициативе (пользователь явно сказал «можно куда-нибудь», не указав куда) — если
  окажется неудобно, перенести легко, логика в одном месте (`showDesktop()` в
  `MainActivity`).
- Кнопка «Остановить стол»/«Собрать»/«Повторить» (`actionButton`) на экране кузницы
  НЕ трогалась — она не украшение, а единственный способ прервать/повторить сборку и
  запуск, реальной альтернативы ей пока нет.

Проверено живьём на настоящем телефоне (Fold8) после `adb install -r`: `.stol-ready`
уже стоял (стол разворачивался раньше в этой же сессии) — значит мастер первого
запуска НЕ переигрывается на обновлении APK, стол просто поднимается на уже
развёрнутом rootfs — фикс Codex туда уже был занесён вручную раньше в этой сессии,
поэтому переустановка APK его поведение не меняет. Новый код (анимация,
кнопка-шестерёнка, экран настроек) подтверждён тапами через `adb shell input tap`:
кузница проступает на тёмном фоне, кнопки «О программе» на ней больше нет, шестерёнка
в углу стола открывает «Настройки» → «О программе» открывается и работает как раньше.

**Тем же вечером, вторая правка экрана запуска — по прямой отсылке к заставке
загрузки Windows 11: настоящий чёрный фон и вообще без единой кнопки, не только без
«О программе».**
- `colors.xml`: `forge_bg` было `#141916` (тёмно-зелёно-серый) → `#000000` (чистый
  чёрный). Цвет общий для всего native-UI приложения (экран кузницы, «О программе»,
  «Настройки») — правка одной строкой каскадом ушла во все три экрана, отдельно
  ничего не трогалось.
- `MainActivity.renderLauncherState()`: кнопка «Остановить стол» (`Action.STOP`)
  убрана из обычного цикла загрузки (`STARTING`/`WAITING_BEACON`) — теперь кнопка
  показывается ТОЛЬКО при `Action.RETRY` (реальный отказ, `FAILED`). Логика в
  `DesktopLauncher.State.action()` не менялась (там источник обоих действий) — фильтр
  добавлен на стороне экрана: пока стол поднимается штатно, кнопки нет вовсе; если
  что-то реально не собралось — кнопка «Повторить» вернётся, отказ без выхода не
  оставляем. Мастер первого запуска (`SetupWizard`) уже был без кнопки на фазе
  `BUILDING` — трогать не пришлось, там кнопка и раньше была только на `NOT_BUILT`/
  `FAILED`.
- Пересобрано без `./gradlew clean` (менялся только код, не `rootfs.tar.zst` — тот
  самый большой ассет из бага zipflinger выше не менялся, вес APK не раздулся: 463М).
  Проверено живьём: чёрный фон, при обычной загрузке кнопок нет вовсе, стол поднимается
  до рабочего стола как раньше.
