feat(v0.6): Lima E2E зелёный с нуля + shell software-render screenshot

План 5 ч.2: поднял Lima-VM и довёл сквозной E2E до зелёного из чистого yaml
(just vm-reset && just e2e — exit 0). Приёмка §9.4 (v0.1 + v0.6 + шагающий скелет).

Shell (lib+bin split):
- режим --screenshot <path>: headless software-render первого кадра в PNG
  (Slint software-renderer, без дисплея/композитора, §6); TDD-тест «кадр не
  пустой + тема отражена», зелёный и на dev-Mac, и в VM (Linux).
- shturman-shell.service → oneshot software-render → /run/shturman/frame.png
  (RemainAfterExit → is-active детерминированно, без хрупкого weston;
  живой weston-shell — v0.5). just shell-frame — инспекция кадра.

E2E (tests/e2e/run.sh, двухфазно pre→reboot→post):
- /data+power-safe опции, volatile-tmpfs, first-boot идемпотентность, per-unit
  active, имена на шине + GetPowerState, fake-ACC SetAcc→AccChanged, первый кадр
  PNG, base-бюджеты (journald volatile / zram / oomd / fake-hwclock→/data /
  eMMC-прокси), персист Settings + machine-id every-boot bind после reboot.

Провижининг (lima/shturman.yaml) — правки по реальным ошибкам Lima:
- build-deps Slint/winit на Linux (libfontconfig1-dev/libxkbcommon-dev/libwayland-dev);
- linux-modules-extra (zram/vcan не в vz-ядре); systemd-oomd; rm стокового
  /etc/fake-hwclock.data (A11); VM-локальный CARGO_TARGET_DIR.

Док-синхронизация (спека §13/§8.1/§7.5 + CLAUDE.md): швы реализации, eMMC-порог
T=4096 сект, fake-hwclock masked-в-Lima, dev-mock policy не нужен.

Перф-вердикт — на RK3588 (в VM — функционально, performance §2). just ci зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
This commit is contained in:
2026-06-24 17:14:31 +03:00
parent a9aad21636
commit 9b87751ab8
11 changed files with 609 additions and 209 deletions
+5
View File
@@ -11,3 +11,8 @@ tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
slint.workspace = true
# PNG-кодек для headless software-render кадра (E2E-ассерт «кадр не пустой», спека §6).
png = "0.17"
[dev-dependencies]
tempfile.workspace = true
+230
View File
@@ -0,0 +1,230 @@
//! `shturman-shell` (lib) — первый Slint-кадр (срезы C03/C04/C05/C07/C02) + headless
//! software-render кадра в PNG (спека §6). Bin (`main.rs`) — тонкая обёртка над этим API:
//! интерактивный `run` (dev: weston/нативно) либо `--screenshot <path>` (E2E/CI, без композитора).
//! v0: одноразовое чтение `ui.theme`/Power при старте (best-effort; без шины — дефолты, #4).
mod theme;
use std::path::Path;
use std::rc::Rc;
use std::sync::Once;
use std::time::{SystemTime, UNIX_EPOCH};
use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType};
use slint::platform::{Platform, PlatformError, WindowAdapter};
use slint::ComponentHandle;
slint::slint! {
import { VerticalBox, HorizontalBox } from "std-widgets.slint";
export component AppWindow inherits Window {
in property <bool> is-night: false;
in property <string> clock: "--:--";
in property <string> network: "unknown";
in property <string> ignition: "unknown";
in property <[string]> tiles: ["Навигация", "Музыка", "Телефон", "Ассистент", "Машина", "Настройки"];
title: "Штурман";
width: 1024px;
height: 600px;
background: root.is-night ? #0e1014 : #f4f5f7;
VerticalBox {
padding: 16px;
spacing: 16px;
HorizontalBox {
height: 44px;
Text {
text: root.clock;
font-size: 22px;
color: root.is-night ? #f0f0f0 : #1a1a1a;
vertical-alignment: center;
}
Rectangle { }
Text {
text: "сеть: " + root.network;
color: root.is-night ? #9aa0a6 : #5f6368;
vertical-alignment: center;
}
Text {
text: "зажигание: " + root.ignition;
color: root.is-night ? #9aa0a6 : #5f6368;
vertical-alignment: center;
}
}
HorizontalBox {
spacing: 16px;
for tile in root.tiles : Rectangle {
background: root.is-night ? #1b1e24 : #ffffff;
border-radius: 16px;
Text {
text: tile;
font-size: 18px;
color: root.is-night ? #e8eaed : #202124;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
/// Размер первого кадра (логические пиксели = физические при scale 1.0).
const FRAME_W: u32 = 1024;
const FRAME_H: u32 = 600;
/// Начальное состояние кадра, прочитанное с шины (best-effort). Без шины — дефолты (#4).
#[derive(Debug, Clone)]
pub struct Initial {
pub theme: String,
pub ignition: String,
pub network: String,
}
impl Default for Initial {
fn default() -> Self {
Self {
theme: "auto".into(),
ignition: "unknown".into(),
network: "unknown".into(),
}
}
}
/// Одноразовое чтение состояния с шины (best-effort). Без шины/сервисов — дефолты (#4).
pub fn read_initial() -> Initial {
// current-thread рантайм: одно best-effort чтение на холодном старте, без пула потоков (#11).
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return Initial::default(),
};
rt.block_on(async {
match connect_and_read().await {
Ok(i) => i,
Err(e) => {
tracing::warn!(error = %e, "нет шины/сервисов — дефолты кадра");
Initial::default()
}
}
})
}
async fn connect_and_read() -> anyhow::Result<Initial> {
let def = Initial::default(); // единый источник дефолтов (без дублей литералов)
let conn = shturman_sdk::connect().await?;
let settings = shturman_sdk::SettingsClient::new(&conn).await?;
let theme = settings
.get("ui.theme")
.await
.ok()
.and_then(|v| String::try_from(v).ok())
.unwrap_or(def.theme);
let power = shturman_sdk::PowerClient::new(&conn).await?;
let ignition = power
.ignition_state()
.await
.map(|i| i.as_str().to_string())
.unwrap_or(def.ignition);
Ok(Initial {
theme,
ignition,
network: def.network,
})
}
/// Часы UTC `HH:MM` без tz-зависимостей (локальная tz — позже, a-base §7). Возвращает (час, строка).
pub fn utc_hh_mm() -> (u8, String) {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let h = ((secs / 3600) % 24) as u8;
let m = ((secs / 60) % 60) as u8;
(h, format!("{h:02}:{m:02}"))
}
/// Создать и наполнить окно (общий код интерактива и скриншота).
fn build_ui(initial: &Initial, hour: u8, clock: &str) -> anyhow::Result<AppWindow> {
let ui = AppWindow::new()?;
ui.set_is_night(theme::resolve_night(&initial.theme, hour));
ui.set_clock(clock.into());
ui.set_network(initial.network.as_str().into());
ui.set_ignition(initial.ignition.as_str().into());
Ok(ui)
}
/// Интерактивный запуск (dev: weston в VM / нативно на хосте). Блокирующий event-loop.
pub fn run_interactive(initial: &Initial, hour: u8, clock: &str) -> anyhow::Result<()> {
let ui = build_ui(initial, hour, clock)?;
tracing::info!("первый Slint-кадр (интерактивно)");
ui.run()?;
Ok(())
}
// --- headless software-render (спека §6: основной автотест кадра, композитор не нужен) ---
thread_local! {
static SCREENSHOT_WINDOW: Rc<MinimalSoftwareWindow> =
MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer);
}
struct ScreenshotPlatform;
impl Platform for ScreenshotPlatform {
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, PlatformError> {
Ok(SCREENSHOT_WINDOW.with(|w| w.clone()))
}
}
/// Установить software-platform один раз на процесс. `--screenshot` зовётся в свежем процессе
/// первым делом; в мультитестовом процессе повтор терпим (`Once` + терпимый результат).
fn ensure_screenshot_platform() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = slint::platform::set_platform(Box::new(ScreenshotPlatform));
});
}
/// Headless software-render первого кадра в PNG (спека §6). Без дисплей-сервера/композитора.
/// `hour` задаёт тему для `auto` (тест — детерминированно); `clock` берётся из текущего времени.
pub fn render_screenshot(initial: &Initial, hour: u8, path: &Path) -> anyhow::Result<()> {
ensure_screenshot_platform();
let (_, clock) = utc_hh_mm();
let ui = build_ui(initial, hour, &clock)?;
let window = SCREENSHOT_WINDOW.with(|w| w.clone());
window.set_size(slint::PhysicalSize::new(FRAME_W, FRAME_H));
ui.show()?;
ui.window().request_redraw(); // форсим перерисовку (повторный рендер в том же потоке)
let mut buf = vec![slint::Rgb8Pixel { r: 0, g: 0, b: 0 }; (FRAME_W * FRAME_H) as usize];
let drawn = window.draw_if_needed(|renderer| {
renderer.render(buf.as_mut_slice(), FRAME_W as usize);
});
ui.hide()?; // освободить окно для следующего рендера в том же потоке
if !drawn {
anyhow::bail!("software-renderer не отрисовал кадр");
}
write_png(path, FRAME_W, FRAME_H, &buf)?;
tracing::info!(path = %path.display(), "кадр записан (software-render)");
Ok(())
}
fn write_png(path: &Path, w: u32, h: u32, buf: &[slint::Rgb8Pixel]) -> anyhow::Result<()> {
let file = std::fs::File::create(path)?;
let mut enc = png::Encoder::new(std::io::BufWriter::new(file), 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(())
}
+27 -143
View File
@@ -1,152 +1,36 @@
//! `shturman-shell` — первый Slint-кадр (срезы C03/C04/C05/C07/C02). На SDK (architecture §1).
//! v0: одноразовое чтение `ui.theme`/Power при старте (best-effort; без шины — дефолты, #4); рендер.
//! Live-обновления (Changed/AccChanged) и локальная tz часов — позже (v0.5 / a-base §7).
//! `shturman-shell` (bin) — тонкая обёртка над `shturman_shell` (lib):
//! - по умолчанию: интерактивный первый Slint-кадр (dev: weston в VM / нативно на хосте);
//! - `--screenshot <path>`: headless software-render кадра в PNG (E2E/CI, без композитора — §6).
mod theme;
use std::time::{SystemTime, UNIX_EPOCH};
slint::slint! {
import { VerticalBox, HorizontalBox } from "std-widgets.slint";
export component AppWindow inherits Window {
in property <bool> is-night: false;
in property <string> clock: "--:--";
in property <string> network: "unknown";
in property <string> ignition: "unknown";
in property <[string]> tiles: ["Навигация", "Музыка", "Телефон", "Ассистент", "Машина", "Настройки"];
title: "Штурман";
width: 1024px;
height: 600px;
background: root.is-night ? #0e1014 : #f4f5f7;
VerticalBox {
padding: 16px;
spacing: 16px;
HorizontalBox {
height: 44px;
Text {
text: root.clock;
font-size: 22px;
color: root.is-night ? #f0f0f0 : #1a1a1a;
vertical-alignment: center;
}
Rectangle { }
Text {
text: "сеть: " + root.network;
color: root.is-night ? #9aa0a6 : #5f6368;
vertical-alignment: center;
}
Text {
text: "зажигание: " + root.ignition;
color: root.is-night ? #9aa0a6 : #5f6368;
vertical-alignment: center;
}
}
HorizontalBox {
spacing: 16px;
for tile in root.tiles : Rectangle {
background: root.is-night ? #1b1e24 : #ffffff;
border-radius: 16px;
Text {
text: tile;
font-size: 18px;
color: root.is-night ? #e8eaed : #202124;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
#[derive(Debug)]
struct Initial {
theme: String,
ignition: String,
network: String,
}
impl Default for Initial {
fn default() -> Self {
Self {
theme: "auto".into(),
ignition: "unknown".into(),
network: "unknown".into(),
}
}
}
/// Одноразовое чтение состояния с шины (best-effort). Без шины/сервисов — дефолты (#4).
fn read_initial() -> Initial {
// current-thread рантайм: одно best-effort чтение на холодном старте, без пула потоков (#11).
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return Initial::default(),
};
rt.block_on(async {
match connect_and_read().await {
Ok(i) => i,
Err(e) => {
tracing::warn!(error = %e, "нет шины/сервисов — дефолты кадра");
Initial::default()
}
}
})
}
async fn connect_and_read() -> anyhow::Result<Initial> {
let def = Initial::default(); // единый источник дефолтов (без дублей литералов)
let conn = shturman_sdk::connect().await?;
let settings = shturman_sdk::SettingsClient::new(&conn).await?;
let theme = settings
.get("ui.theme")
.await
.ok()
.and_then(|v| String::try_from(v).ok())
.unwrap_or(def.theme);
let power = shturman_sdk::PowerClient::new(&conn).await?;
let ignition = power
.ignition_state()
.await
.map(|i| i.as_str().to_string())
.unwrap_or(def.ignition);
Ok(Initial {
theme,
ignition,
network: def.network,
})
}
/// Часы UTC `HH:MM` без tz-зависимостей (локальная tz — позже, a-base §7). Возвращает (час, строка).
fn utc_hh_mm() -> (u8, String) {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let h = ((secs / 3600) % 24) as u8;
let m = ((secs / 60) % 60) as u8;
(h, format!("{h:02}:{m:02}"))
}
use shturman_shell::{read_initial, render_screenshot, run_interactive, utc_hh_mm};
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
shturman_common::init_tracing("shturman-shell");
let screenshot = parse_screenshot_arg();
let initial = read_initial();
let (hour, clock) = utc_hh_mm();
let ui = AppWindow::new()?;
ui.set_is_night(theme::resolve_night(&initial.theme, hour));
ui.set_clock(clock.into());
ui.set_network(initial.network.into());
ui.set_ignition(initial.ignition.into());
tracing::info!("первый Slint-кадр");
ui.run()?;
match screenshot {
Some(path) => {
render_screenshot(&initial, hour, &path)?;
println!("{}", path.display()); // путь PNG — для E2E-скрипта
}
None => run_interactive(&initial, hour, &clock)?,
}
Ok(())
}
/// Разобрать `--screenshot <path>` / `--screenshot=<path>` (без внешних зависимостей).
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
}
@@ -0,0 +1,71 @@
//! Headless software-render первого кадра в PNG (спека §6 / §9.3 п.6 / §9.4 «шагающий скелет»).
//! Работает без дисплея (и на dev-Mac, и в Lima): Slint software-renderer → PNG.
//!
//! Один тест намеренно: Slint-платформа процесс-глобальна и ставится один раз, а `render_screenshot`
//! в проде зовётся ровно раз на процесс (oneshot-сервис / `just shell-frame`) — параллельный прогон
//! нескольких рендер-тестов в одном бинаре ушёл бы в дефолтный winit-бэкенд (на macOS — только main-thread).
use shturman_shell::{render_screenshot, Initial};
use std::path::Path;
/// Декодировать PNG → (ширина, высота, RGB-байты).
fn decode(path: &Path) -> (u32, u32, Vec<u8>) {
let dec = png::Decoder::new(std::fs::File::open(path).unwrap());
let mut reader = dec.read_info().unwrap();
let (w, h) = (reader.info().width, reader.info().height);
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
(w, h, buf)
}
#[test]
fn renders_first_frame_reflecting_theme() {
let dir = tempfile::tempdir().unwrap();
// --- ночь: кадр не пустой, верный размер, тёмный фон ---
let night = dir.path().join("night.png");
render_screenshot(
&Initial {
theme: "night".into(),
ignition: "running".into(),
network: "unknown".into(),
},
12,
&night,
)
.expect("render ночь");
let (w, h, npx) = decode(&night);
assert_eq!((w, h), (1024, 600), "размер кадра");
// «не пустой» содержательно: кадр не одноцветный (нарисованы тайлы/текст, не только фон).
let first = npx[0];
assert!(
npx.iter().any(|&b| b != first),
"кадр одноцветный — рендер пустой"
);
// угол (0,0) = фон окна — ночью тёмный (#0e1014).
let (nr, ng, nb) = (npx[0], npx[1], npx[2]);
assert!(
nr < 64 && ng < 64 && nb < 64,
"ночной фон не тёмный: {nr},{ng},{nb}"
);
// --- день: тот же угол — светлый (#f4f5f7) — тема отражена ---
let day = dir.path().join("day.png");
render_screenshot(
&Initial {
theme: "day".into(),
..Default::default()
},
12,
&day,
)
.expect("render день");
let (_, _, dpx) = decode(&day);
let (dr, dg, db) = (dpx[0], dpx[1], dpx[2]);
assert!(
dr > 192 && dg > 192 && db > 192,
"дневной фон не светлый: {dr},{dg},{db}"
);
}