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>
23 KiB
План 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(+ depshturman-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) + добавить в workspacemembers:
[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.rscrates/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.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).
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.readymtime ≥ frame). - Boot-тайминг логируется (
systemd-analyze); <10 c — пометка «вердикт на RK3588». - Вся приёмка v0.1/v0.6 (foundation §9.4) — зелёная на фазовой раскладке (нет регресса).
just ciзелёный; красные линии целы (нет CAN/actuator).