# План 6 — v0.2 Boot-конвейер (Stage 0/1/2 + splash) > REQUIRED SUB-SKILL: `executing-plans` (или `subagent-driven-development`) + **TDD**. Спека: `docs/specs/v0.2-boot-pipeline.md`. > Шаги — чекбоксы `- [ ]`. Часть «verify в Lima» (P6.5) — тяжёлая (vm-reset + e2e), но VM уже поднята. **Goal:** превратить плоский `shturman.target` (v0.1) в фазовый конвейер: Stage 0 (splash) → Stage 1 (ядро+кадр) → Stage 2 (warmup), с мгновенным splash до первого кадра и деферредом фона после. **Architecture:** общий headless-render хелпер (Slint software-renderer → PNG) выделяется из `shturman-shell` в крейт `shturman-render`; новый `shturman-splash` его использует. systemd: зонтик `shturman.target` тянет три под-таргета; splash `Before=shell`, warmup `After=shell`. Новой D-Bus-поверхности нет. **Tech Stack:** Rust, Slint 1.16 (software-renderer + `png`), systemd (targets/oneshot), Lima VM, bash E2E. --- ## File Structure - **Create** `crates/apps/shturman-render/` — `Cargo.toml`, `src/lib.rs` (хелпер `render_to_png`), `tests/render.rs`. - **Create** `crates/apps/shturman-splash/` — `Cargo.toml`, `src/lib.rs` (Slint splash + `render_splash`), `src/main.rs` (CLI), `tests/screenshot.rs`. - **Modify** `crates/apps/shturman-shell/src/lib.rs` — использовать `shturman_render::render_to_png` (убрать своё плумбинг-дублирование), `Cargo.toml` (+ dep `shturman-render`). - **Modify** `Cargo.toml` (workspace) — добавить два крейта в `members`. - **Create** `systemd/`: `shturman-stage0.target`, `shturman-stage1.target`, `shturman-stage2.target`, `shturman-splash.service`, `shturman-stage2-warmup.service`, `tmpfiles-shturman.conf`. - **Modify** `systemd/shturman.target` (→ зонтик), `shturman-{firstboot,machineid,power,settings,shell}.service` (`WantedBy=` → `shturman-stage1.target`), `shturman-shell.service` (убрать `RuntimeDirectory`, `/run/shturman` даёт tmpfiles). - **Modify** `justfile` (+ `splash-frame`), `lima/shturman.yaml` (разложить новые юниты + tmpfiles), `tests/e2e/run.sh` (+ блок Stage 0/1/2 + install splash-бинаря). --- ## P6.1: крейт `shturman-render` — общий headless-рендер (рефактор из shell) **Files:** Create `crates/apps/shturman-render/{Cargo.toml,src/lib.rs,tests/render.rs}`; Modify workspace `Cargo.toml`, `crates/apps/shturman-shell/{Cargo.toml,src/lib.rs}`. - [ ] **Шаг 1 — падающий тест** `crates/apps/shturman-render/tests/render.rs`: ```rust use shturman_render::render_to_png; slint::slint! { export component Probe inherits Window { width: 64px; height: 48px; background: #101418; Rectangle { background: #ffffff; width: 20px; height: 20px; } } } #[test] fn renders_component_to_nonempty_png() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("probe.png"); render_to_png(|| Ok(Probe::new()?), 64, 48, &path).expect("render"); let dec = png::Decoder::new(std::fs::File::open(&path).unwrap()); let mut r = dec.read_info().unwrap(); assert_eq!((r.info().width, r.info().height), (64, 48)); let mut buf = vec![0u8; r.output_buffer_size()]; let info = r.next_frame(&mut buf).unwrap(); let px = &buf[..info.buffer_size()]; assert!(px.iter().any(|&b| b != px[0]), "кадр одноцветный"); } ``` - [ ] **Шаг 2 — `Cargo.toml` крейта** (`crates/apps/shturman-render/Cargo.toml`): ```toml [package] name = "shturman-render" version = "0.0.0" edition.workspace = true license.workspace = true [dependencies] anyhow.workspace = true slint.workspace = true png = "0.17" [dev-dependencies] tempfile.workspace = true ``` Добавить `"crates/apps/shturman-render"` в `members` корневого `Cargo.toml`. - [ ] **Шаг 3 — прогнать тест, убедиться что НЕ компилируется/падает.** Run: `cargo test -p shturman-render`. Expected: FAIL (нет `render_to_png`). - [ ] **Шаг 4 — реализация** `crates/apps/shturman-render/src/lib.rs` (вынести из текущего `shturman-shell/src/lib.rs`): ```rust //! Headless software-render Slint-компонента в PNG (без дисплея/композитора). //! Общий для shturman-shell (первый кадр) и shturman-splash (Stage 0). Спека v0.2 §4.1. use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType}; use slint::platform::{Platform, PlatformError, WindowAdapter}; use slint::ComponentHandle; use std::path::Path; use std::rc::Rc; use std::sync::Once; thread_local! { static WINDOW: Rc = MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer); } struct SwPlatform; impl Platform for SwPlatform { fn create_window_adapter(&self) -> Result, PlatformError> { Ok(WINDOW.with(|w| w.clone())) } } fn ensure_platform() { static ONCE: Once = Once::new(); ONCE.call_once(|| { let _ = slint::platform::set_platform(Box::new(SwPlatform)); }); } /// Построить компонент (ВНУТРИ — после `set_platform`) и отрендерить его кадр в PNG. /// `build` зовётся после установки software-platform (порядок обязателен для Slint). pub fn render_to_png( build: impl FnOnce() -> anyhow::Result, w: u32, h: u32, path: &Path, ) -> anyhow::Result<()> { ensure_platform(); let ui = build()?; let window = WINDOW.with(|x| x.clone()); window.set_size(slint::PhysicalSize::new(w, h)); ui.show()?; ui.window().request_redraw(); let mut buf = vec![slint::Rgb8Pixel { r: 0, g: 0, b: 0 }; (w * h) as usize]; let drawn = window.draw_if_needed(|r| { r.render(buf.as_mut_slice(), w as usize); }); ui.hide()?; if !drawn { anyhow::bail!("software-renderer не отрисовал кадр"); } write_png(path, w, h, &buf) } fn write_png(path: &Path, w: u32, h: u32, buf: &[slint::Rgb8Pixel]) -> anyhow::Result<()> { let mut enc = png::Encoder::new(std::io::BufWriter::new(std::fs::File::create(path)?), w, h); enc.set_color(png::ColorType::Rgb); enc.set_depth(png::BitDepth::Eight); let mut writer = enc.write_header()?; let mut data = Vec::with_capacity((w * h * 3) as usize); for px in buf { data.extend_from_slice(&[px.r, px.g, px.b]); } writer.write_image_data(&data)?; Ok(()) } ``` - [ ] **Шаг 5 — прогнать тест: PASS.** Run: `cargo test -p shturman-render`. Expected: PASS. - [ ] **Шаг 6 — рефактор shell на хелпер.** В `crates/apps/shturman-shell/Cargo.toml` добавить `shturman-render = { path = "../shturman-render" }`. В `crates/apps/shturman-shell/src/lib.rs`: удалить локальные `SCREENSHOT_WINDOW`/`ScreenshotPlatform`/`ensure_screenshot_platform`/`write_png`; `render_screenshot` стал: ```rust pub fn render_screenshot(initial: &Initial, hour: u8, path: &Path) -> anyhow::Result<()> { let (_, clock) = utc_hh_mm(); shturman_render::render_to_png(|| build_ui(initial, hour, &clock), FRAME_W, FRAME_H, path)?; tracing::info!(path = %path.display(), "кадр записан (software-render)"); Ok(()) } ``` (Импорты `slint::platform::*`, `std::rc::Rc`, `std::sync::Once` в shell больше не нужны — убрать.) - [ ] **Шаг 7 — прогнать тесты shell + воркспейс.** Run: `cargo test -p shturman-shell && cargo test --workspace`. Expected: PASS (screenshot-тест shell зелёный на новом хелпере). - [ ] **Шаг 8 — commit.** ```bash git add crates/apps/shturman-render crates/apps/shturman-shell Cargo.toml Cargo.lock git commit -s -m "refactor(v0.2): вынести headless render в shturman-render (shell использует)" ``` --- ## P6.2: `shturman-splash` — Stage-0 splash-бинарь **Files:** Create `crates/apps/shturman-splash/{Cargo.toml,src/lib.rs,src/main.rs,tests/screenshot.rs}`; Modify workspace `Cargo.toml`. - [ ] **Шаг 1 — падающий тест** `crates/apps/shturman-splash/tests/screenshot.rs`: ```rust use shturman_splash::render_splash; #[test] fn renders_dark_branded_splash() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("splash.png"); render_splash(&path).expect("render_splash"); let dec = png::Decoder::new(std::fs::File::open(&path).unwrap()); let mut r = dec.read_info().unwrap(); assert_eq!((r.info().width, r.info().height), (1024, 600)); let mut buf = vec![0u8; r.output_buffer_size()]; let info = r.next_frame(&mut buf).unwrap(); let px = &buf[..info.buffer_size()]; // фон тёмный (угол) + не одноцветный (wordmark отрисован) assert!(px[0] < 64 && px[1] < 64 && px[2] < 64, "splash фон не тёмный: {},{},{}", px[0], px[1], px[2]); assert!(px.iter().any(|&b| b != px[0]), "splash одноцветный — wordmark не отрисован"); } ``` - [ ] **Шаг 2 — `Cargo.toml`** (`crates/apps/shturman-splash/Cargo.toml`) + добавить в workspace `members`: ```toml [package] name = "shturman-splash" version = "0.0.0" edition.workspace = true license.workspace = true [dependencies] shturman-render = { path = "../shturman-render" } shturman-common = { path = "../../shturman-common" } anyhow.workspace = true tracing.workspace = true slint.workspace = true [dev-dependencies] tempfile.workspace = true png = "0.17" ``` - [ ] **Шаг 3 — тест падает.** Run: `cargo test -p shturman-splash`. Expected: FAIL (нет `render_splash`). - [ ] **Шаг 4 — реализация** `crates/apps/shturman-splash/src/lib.rs`: ```rust //! `shturman-splash` (lib) — Stage-0 splash-кадр (A05). Статичный брендовый кадр (без шины → «мгновенно»). //! Визуальные токены — каркас (язык design-system — гейт v0.5). Спека v0.2 §6. use std::path::Path; slint::slint! { export component SplashWindow inherits Window { in property brand: "Штурман"; width: 1024px; height: 600px; background: #0e1014; Text { text: root.brand; font-size: 64px; color: #e8eaed; horizontal-alignment: center; vertical-alignment: center; } } } const W: u32 = 1024; const H: u32 = 600; /// Headless software-render splash-кадра в PNG (без дисплея/композитора). pub fn render_splash(path: &Path) -> anyhow::Result<()> { shturman_render::render_to_png(|| Ok(SplashWindow::new()?), W, H, path)?; tracing::info!(path = %path.display(), "splash записан (software-render)"); Ok(()) } ``` - [ ] **Шаг 5 — `main.rs`** `crates/apps/shturman-splash/src/main.rs`: ```rust //! `shturman-splash` (bin) — Stage-0 splash. `--screenshot ` → headless PNG (VM/E2E); //! без аргументов — интерактив (dev/HW; в v0.6 VM используется только screenshot-режим). use shturman_splash::render_splash; use std::path::PathBuf; fn main() -> anyhow::Result<()> { shturman_common::init_tracing("shturman-splash"); match parse_screenshot_arg() { Some(path) => { render_splash(&path)?; println!("{}", path.display()); } None => { // интерактив придёт с живым дисплеем (v0.5); в v0 VM splash — только screenshot. anyhow::bail!("ожидался --screenshot (интерактивный splash — v0.5)"); } } Ok(()) } fn parse_screenshot_arg() -> Option { let mut args = std::env::args().skip(1); while let Some(a) = args.next() { if a == "--screenshot" { return args.next().map(PathBuf::from); } if let Some(p) = a.strip_prefix("--screenshot=") { return Some(PathBuf::from(p)); } } None } ``` - [ ] **Шаг 6 — тест PASS + lint.** Run: `cargo test -p shturman-splash && cargo clippy -p shturman-splash --all-targets -- -D warnings`. Expected: PASS, без warnings. - [ ] **Шаг 7 — глазами (опц.).** Run: `cargo run -p shturman-splash -- --screenshot target/splash.png` → открыть PNG (тёмный фон + «Штурман»). - [ ] **Шаг 8 — commit.** ```bash git add crates/apps/shturman-splash Cargo.toml Cargo.lock git commit -s -m "feat(v0.2): shturman-splash — Stage-0 splash (software-render → PNG)" ``` --- ## P6.3: systemd — фазовые таргеты + splash/warmup + рефактор (зонтик) **Files:** Create 6 файлов в `systemd/`; Modify `shturman.target` + 5 сервисов. - [ ] **Шаг 1 — `systemd/tmpfiles-shturman.conf`** (создаёт `/run/shturman` на boot — общий для splash/shell/warmup; tmpfs/volatile, A11): ``` # /run/shturman — volatile-каталог кадров/маркеров (splash.png, frame.png, stage2.ready). A11. d /run/shturman 0755 root root - ``` - [ ] **Шаг 2 — `systemd/shturman-stage0.target`:** ```ini [Unit] Description=Штурман Stage 0 — splash (мгновенно) Wants=shturman-splash.service ``` - [ ] **Шаг 3 — `systemd/shturman-stage1.target`** (нынешний critical set v0.1): ```ini [Unit] Description=Штурман Stage 1 — ядро + первый кадр Requires=data.mount After=data.mount Wants=shturman-firstboot.service shturman-machineid.service shturman-power.service shturman-settings.service shturman-shell.service ``` - [ ] **Шаг 4 — `systemd/shturman-stage2.target`:** ```ini [Unit] Description=Штурман Stage 2 — фон (после интерактива) After=shturman-stage1.target Wants=shturman-stage2-warmup.service ``` - [ ] **Шаг 5 — `systemd/shturman-splash.service`** (Stage 0; минимум зависимостей; до первого кадра): ```ini [Unit] Description=Штурман splash (Stage 0, software-render → PNG) # «Мгновенно»: без Requires=data.mount/dbus — стартует рано, параллельно critical set. # Before=shell гарантирует splash.png раньше frame.png. /run/shturman даёт tmpfiles. After=systemd-tmpfiles-setup.service Before=shturman-shell.service [Service] Type=oneshot RemainAfterExit=yes ExecStart=/usr/local/bin/shturman-splash --screenshot /run/shturman/splash.png TimeoutStartSec=15 [Install] WantedBy=shturman-stage0.target ``` - [ ] **Шаг 6 — `systemd/shturman-stage2-warmup.service`** (плейсхолдер фона; после кадра): ```ini [Unit] Description=Штурман Stage 2 warmup (плейсхолдер фона) After=shturman-shell.service [Service] Type=oneshot RemainAfterExit=yes ExecStart=/bin/sh -c 'echo "stage2 warmup" | systemd-cat -t shturman-stage2; : > /run/shturman/stage2.ready' [Install] WantedBy=shturman-stage2.target ``` - [ ] **Шаг 7 — рефактор `systemd/shturman.target` → зонтик:** ```ini [Unit] Description=Штурман — v0 boot-конвейер (зонтик фаз Stage 0/1/2) Requires=data.mount After=data.mount Wants=shturman-stage0.target shturman-stage1.target shturman-stage2.target [Install] WantedBy=multi-user.target ``` - [ ] **Шаг 8 — переключить членство сервисов на Stage 1.** В `systemd/shturman-firstboot.service`, `shturman-machineid.service`, `shturman-power.service`, `shturman-settings.service`, `shturman-shell.service` заменить `WantedBy=shturman.target` → `WantedBy=shturman-stage1.target`. (Ordering внутри Stage 1 — без изменений: `After=`/`Requires=` в самих юнитах.) - [ ] **Шаг 9 — у `shturman-shell.service` убрать `RuntimeDirectory=shturman`** (каталог теперь от tmpfiles; иначе рестарт shell снёс бы splash.png). Строку `RuntimeDirectory=shturman` удалить. - [ ] **Шаг 10 — commit** (конфиги; проверка — в P6.5/Lima). ```bash git add systemd/ git commit -s -m "feat(v0.2): фазовые systemd-таргеты Stage 0/1/2 + splash/warmup + зонтик" ``` --- ## P6.4: justfile + lima provisioning + E2E-блок Stage 0/1/2 **Files:** Modify `justfile`, `lima/shturman.yaml`, `tests/e2e/run.sh`. - [ ] **Шаг 1 — `justfile`: цель `splash-frame`** (после `shell-frame`): ```just # инспекция splash-кадра: headless software-render → PNG splash-frame path="target/splash-frame.png": cargo run -q -p shturman-splash -- --screenshot {{path}} @echo "splash записан: {{path}}" ``` - [ ] **Шаг 2 — `lima/shturman.yaml`: разложить новые юниты.** В provision-скрипте, рядом с установкой `systemd/`-юнитов, добавить tmpfiles (новые `.target`/`.service` ставятся тем же `install -m644 /shturman/systemd/shturman-*.service` + явно таргеты): ```bash install -m644 /shturman/systemd/shturman-stage0.target /etc/systemd/system/ install -m644 /shturman/systemd/shturman-stage1.target /etc/systemd/system/ install -m644 /shturman/systemd/shturman-stage2.target /etc/systemd/system/ install -d /etc/tmpfiles.d install -m644 /shturman/systemd/tmpfiles-shturman.conf /etc/tmpfiles.d/shturman.conf systemd-tmpfiles --create /etc/tmpfiles.d/shturman.conf || true ``` (Существующий `install -m644 /shturman/systemd/shturman-*.service` уже подхватит `shturman-splash.service`/`shturman-stage2-warmup.service`.) - [ ] **Шаг 3 — `tests/e2e/run.sh`: установить splash-бинарь.** В цикле install (`for b in firstboot settings power shell`) добавить `splash`: ```bash for b in firstboot settings power shell splash; do sudo install -m755 "$CARGO_TARGET_DIR/release/shturman-$b" /usr/local/bin/ || fail "install shturman-$b" done ``` - [ ] **Шаг 4 — `tests/e2e/run.sh`: разложить новые юниты + tmpfiles.** ⚠️ `run.sh` ставит сервисы **явным списком** (НЕ glob — в отличие от lima yaml), поэтому splash/warmup добавляем явно. В блоке раскладки юнитов: (а) к строке `install … shturman.target … data.mount …` добавить три таргета; (б) к строке install сервисов добавить splash+warmup; (в) добавить tmpfiles. Итог блока: ```bash sudo install -m644 systemd/shturman.target systemd/data.mount \ systemd/shturman-stage0.target systemd/shturman-stage1.target systemd/shturman-stage2.target \ /etc/systemd/system/ sudo install -m644 systemd/shturman-firstboot.service systemd/shturman-machineid.service \ systemd/shturman-power.service systemd/shturman-settings.service \ systemd/shturman-shell.service systemd/shturman-splash.service \ systemd/shturman-stage2-warmup.service /etc/systemd/system/ sudo install -d /etc/tmpfiles.d sudo install -m644 systemd/tmpfiles-shturman.conf /etc/tmpfiles.d/shturman.conf sudo systemd-tmpfiles --create /etc/tmpfiles.d/shturman.conf || true ``` - [ ] **Шаг 5 — `tests/e2e/run.sh`: блок «Stage 0/1/2»** (вставить после §7 «первый кадр», до §8): ```bash # ---- Stage 0/1/2 разделены (v0.2) ---- info "Stage 0/1/2: фазы разделены + splash до кадра + warmup после" for t in shturman-stage0 shturman-stage1 shturman-stage2; do systemctl is-active --quiet "$t.target" || fail "$t.target не достигнут ($(systemctl is-active "$t.target" 2>&1))" pass "$t.target reached" done sudo test -f /run/shturman/splash.png || fail "нет splash.png (Stage 0)" sudo head -c8 /run/shturman/splash.png | od -An -tx1 | tr -d ' \n' | grep -qi 89504e47 || fail "splash.png не PNG" sp=$(sudo stat -c %Y /run/shturman/splash.png); fr=$(sudo stat -c %Y /run/shturman/frame.png) [ "$sp" -le "$fr" ] || fail "splash.png позже frame.png ($sp > $fr)" sudo test -f /run/shturman/stage2.ready || fail "нет stage2.ready (warmup не отработал)" w2=$(sudo stat -c %Y /run/shturman/stage2.ready) [ "$w2" -ge "$fr" ] || fail "stage2.ready ($w2) раньше кадра ($fr)" pass "порядок фаз: splash($sp) ≤ frame($fr) ≤ stage2($w2)" # boot-тайминг (функц., НЕ гейт; вердикт — RK3588) echo " $(systemd-analyze time 2>/dev/null | head -1 || echo 'systemd-analyze н/д')" ``` - [ ] **Шаг 6 — commit.** ```bash git add justfile lima/shturman.yaml tests/e2e/run.sh git commit -s -m "feat(v0.2): splash-frame + lima/E2E раскладка Stage 0/1/2" ``` --- ## P6.5: verify в Lima + acceptance + синхронизация доков - [ ] **Шаг 1 — host-гейт.** Run: `just ci`. Expected: exit 0 (все unit-тесты, включая `shturman-render`/`shturman-splash`; clippy; deny). - [ ] **Шаг 2 — чистый E2E с нуля.** Run: `just vm-reset && just e2e`. Expected: exit 0; в выводе — `shturman-stage0/1/2.target reached`, `splash($sp) ≤ frame($fr) ≤ stage2($w2)`, **вся приёмка v0.1/v0.6 зелёная** (нет регресса), `E2E OK ✅`. - [ ] **Шаг 3 — если падёт — итерировать** по реальным ошибкам (ordering таргетов, glob юнитов, tmpfiles, splash before shell) и повторить P6.5 шаг 2. (Систематически: один симптом → одна правка.) - [ ] **Шаг 4 — синхронизация доков (швы спеки §10):** в `docs/domains/a-base-system.md` §4 / `docs/architecture.md` §6 — пометка «dev-VM Stage-0-splash = systemd software-render PNG; U-Boot framebuffer — HW»; `docs/roadmap.md` §v0.2 → ✅; `docs/specs/v0.1-v0.6-foundation.md` §13 — шов «shturman.target → зонтик, critical set → stage1.target»; `CLAUDE.md` — статус v0.2 готово → следующее v0.3/v0.5. - [ ] **Шаг 5 — commit доков.** ```bash git add docs/ CLAUDE.md git commit -s -m "docs(v0.2): синхронизация швов boot-конвейера + статус" ``` - [ ] **Шаг 6 — finishing-a-development-branch** (merge/PR — спросить пользователя; в `main` без явного «ок» не мержить). --- ## Acceptance (спека v0.2 §9.3) - [ ] `shturman.target` = зонтик; `shturman-stage0/1/2.target` достижимы и разделены (per-target active). - [ ] Splash-кадр (`splash.png`) непустой и рендерится **раньше** первого кадра Shell (`frame.png`). - [ ] Stage 2 (warmup) стартует **после** первого кадра (`stage2.ready` mtime ≥ frame). - [ ] Boot-тайминг логируется (`systemd-analyze`); <10 c — пометка «вердикт на RK3588». - [ ] Вся приёмка v0.1/v0.6 (foundation §9.4) — зелёная на фазовой раскладке (нет регресса). - [ ] `just ci` зелёный; красные линии целы (нет CAN/actuator).