Files
shturman/docs/specs/plans/06-v0.2-boot-pipeline.md
T
kk0t9 e841c082b3 docs(v0.2): план реализации boot-конвейера (План 6)
P6.1 общий рендер-хелпер shturman-render (рефактор из shell) →
P6.2 shturman-splash (Stage 0) → P6.3 фазовые systemd-таргеты + splash/warmup +
зонтик → P6.4 justfile/lima/E2E-блок Stage 0/1/2 → P6.5 verify в Lima + acceptance.
TDD-шаги с полным кодом, без плейсхолдеров. Self-review: покрытие спеки полное.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 19:57:36 +03:00

23 KiB
Raw Blame History

План 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:
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):
[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):

//! 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> =
        MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer);
}

struct SwPlatform;
impl Platform for SwPlatform {
    fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, 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<C: ComponentHandle>(
    build: impl FnOnce() -> anyhow::Result<C>,
    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 стал:

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.

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:
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:
[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:

//! `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 <string> 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:
//! `shturman-splash` (bin) — Stage-0 splash. `--screenshot <path>` → 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 <path> (интерактивный splash — v0.5)");
        }
    }
    Ok(())
}

fn parse_screenshot_arg() -> Option<PathBuf> {
    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.

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:
[Unit]
Description=Штурман Stage 0 — splash (мгновенно)
Wants=shturman-splash.service
  • Шаг 3 — systemd/shturman-stage1.target (нынешний critical set v0.1):
[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:
[Unit]
Description=Штурман Stage 2 — фон (после интерактива)
After=shturman-stage1.target
Wants=shturman-stage2-warmup.service
  • Шаг 5 — systemd/shturman-splash.service (Stage 0; минимум зависимостей; до первого кадра):
[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 (плейсхолдер фона; после кадра):
[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 → зонтик:
[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.targetWantedBy=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).

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):
# инспекция 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 + явно таргеты):
      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:
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. Итог блока:
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):
# ---- 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.
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 доков.

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).