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:
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user