v0.1.4-pre.008

This commit is contained in:
2026-08-16 12:32:11 +02:00
parent ac8867d5a1
commit 1825676ff9
24 changed files with 1102 additions and 61 deletions

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-app-config-desk/Cargo.toml
# version: 3
# version: 4
[package]
name = "ksp-app-config-desk"
@@ -29,6 +29,7 @@ ksp-logging-lib = { path = "../ksp-logging-lib" }
serde.workspace = true
tauri.workspace = true
tauri-plugin-tracing.workspace = true
tokio.workspace = true
ts-rs.workspace = true
[lints]

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/README.md -->
<!-- version: 6 -->
<!-- version: 7 -->
# `ksp-app-config-desk`
@@ -21,7 +21,7 @@ Le gabarit desktop actif fournit maintenant :
- le verrou single-instance ;
- l'assemblage Tauri centralisé dans `src/tauri.rs` ;
- les fenêtres `splash` et `main` ;
- les fenêtres `splash` et `main`, orchestrées par `tw_splash.rs` et `tw_main.rs` ;
- le frontend Vanilla TypeScript + Vite sous `frontend/` ;
- SCSS, Bootstrap et Font Awesome ;
- SimpleBar + `resize-observer-polyfill` pour le scrolling du shell ;
@@ -29,7 +29,8 @@ Le gabarit desktop actif fournit maintenant :
- les ports dédiés `1430` pour Vite HTTP et `1431` pour le WebSocket de développement ;
- la destination frontend externe commune à Tauri et Vite;
- le bootstrap Config/Logging réel, un `AppState` durable et le premier contrat Tauri/TS-RS `get_app_snapshot`;
- le bridge de logging frontend KSP `frontend_log.ts`, routé par `emit_frontend_log` vers des targets statiques whitelistés de `ksp-logging-lib`.
- le bridge de logging frontend KSP `frontend_log.ts`, routé par `emit_frontend_log` vers des targets statiques whitelistés de `ksp-logging-lib` ;
- le lifecycle configurable `splash -> main` avec navigation monofenêtre et instrumentation `debug`/`trace` des interactions frontend.
## Layout frontend
@@ -46,6 +47,7 @@ frontend/
│ └── splash.scss
├── ts/
│ ├── frontend_log.ts
│ ├── invoke.ts
│ ├── main.ts
│ └── splash.ts
├── main.html
@@ -110,6 +112,23 @@ Si le document Logging, sa résolution environnementale ou l'initialisation du r
Le `LoggingGuard` est conservé dans `AppState` pour préparer le hot reload ultérieur.
## Lifecycle des fenêtres
`tw_splash.rs` valide l'origine de `splash_frontend_ready`, ignore les readiness dupliquées et orchestre le passage à `main`. Les timings sont résolus par `ksp-config-lib` depuis :
```text
KSP_DESK_SPLASH_MINIMUM_MS=1200
KSP_DESK_SPLASH_FADE_MS=300
```
Après la durée minimale, le splash reçoit son fade-out, `tw_main.rs` affiche/focalise `main`, puis le splash est détruit. Une valeur splash invalide ne bloque pas le manager : des timings de fallback sûrs restent en mémoire afin que la future surface `.env` puisse permettre la réparation.
Le shell principal expose les cinq routes de référence `Vue d'ensemble`, `Documents`, `Profils`, `Environnement / .env` et `Logging`. Les routes non encore fonctionnelles affichent un placeholder sans réimplémenter les services prévus par les prereleases suivantes.
## Traçabilité frontend
Les actions utilisateur significatives et changements d'état sont journalisés en `debug`; les opérations plus fines (rendu/remplacement DOM, étapes IPC, animations et événements fréquents) en `trace`. Le wrapper `invoke.ts` trace début/fin des commandes sans journaliser leurs arguments, afin de ne pas créer ultérieurement de fuite de valeurs sensibles.
## Police du splash
Le splash utilise `DOS_Amazigh.ttf` (`font-family: Dos Amazigh`) pour son titre, via le `@font-face` déclaré dans `frontend/sass/splash.scss`. Le répertoire `frontend/fonts/` documente la provenance et l'empreinte de l'asset de référence. Le lifecycle final du splash reste traité dans la tranche dédiée `pre.008`.
Le splash utilise `DOS_Amazigh.ttf` (`font-family: Dos Amazigh`) pour son titre, via le `@font-face` déclaré dans `frontend/sass/splash.scss`. Le répertoire `frontend/fonts/` documente la provenance et l'empreinte de l'asset de référence. Le lifecycle du splash est désormais actif et utilise cette police pendant la transition vers la fenêtre principale.

View File

@@ -1,13 +1,12 @@
<!-- file: crates/ksp-app-config-desk/TODO.md -->
<!-- version: 5 -->
<!-- version: 6 -->
# TODO `ksp-app-config-desk`
Le gabarit frontend, le bootstrap Config/Logging, `AppState`, le `LoggingGuard` durable, les premiers DTO TS-RS et le bridge frontend logging KSP sont maintenant présents.
Le gabarit frontend, le bootstrap Config/Logging, `AppState`, le `LoggingGuard` durable, les premiers DTO TS-RS, le bridge frontend logging KSP et le lifecycle `splash -> main` sont maintenant présents.
Les prochaines tranches doivent encore :
- finaliser le lifecycle splash configurable sans temporisation environnementale codée en dur ;
- développer les panneaux Documents, Profils, Environnement/`.env`, Secrets et Logging ;
- introduire DataTables au premier tableau qui exige tri, filtrage, sélection ou checkbox ;
- démontrer le hot reload Logging et le panneau de test de routage.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
<!-- version: 6 -->
<!-- version: 7 -->
# Utilisation de `ksp-app-config-desk`
@@ -7,7 +7,7 @@
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont présents. Le backend initialise désormais Config, le runtime Logging et `AppState`; les panneaux métier Config restent volontairement absents à ce stade.
La fenêtre `splash` est la fenêtre visible au démarrage. La fenêtre `main` est déjà construite et bundlée, mais son lifecycle d'affichage est finalisé dans la tranche splash/shell dédiée afin de ne pas introduire une temporisation codée en dur.
La fenêtre `splash` est visible au démarrage. Après readiness du frontend et temporisation résolue par Config, l'application effectue le fade-out, affiche/focalise `main` puis détruit `splash`. La fenêtre principale expose immédiatement la navigation monofenêtre de référence.
## Première installation des dépendances frontend
@@ -106,6 +106,33 @@ Si cette configuration ne peut pas être utilisée, l'application doit rester d
La commande Tauri `get_app_snapshot` expose un état sûr du bootstrap. Ses types TypeScript sont générés par `cargo test -p ksp-app-config-desk` sous `frontend/ts/bindings/`.
## Lifecycle et timings du splash
Les variables communes sont :
```text
KSP_DESK_SPLASH_MINIMUM_MS=1200
KSP_DESK_SPLASH_FADE_MS=300
```
La priorité est celle de Config : process > `.env` > fallback. Les valeurs sont lues uniquement via `ksp-config-lib`. Une valeur non numérique ou hors borne déclenche un fallback runtime en mémoire plutôt qu'un refus de démarrer Config Desk.
Le frontend `splash.ts` installe son listener puis appelle `splash_frontend_ready`. Le backend vérifie que l'appel provient réellement de la WebView `splash`; une readiness dupliquée (par exemple après reload Vite) est ignorée.
## Navigation principale
La barre principale contient :
```text
Vue d'ensemble
Documents
Profils
Environnement / .env
Logging
```
Seule la vue d'ensemble consomme déjà `get_app_snapshot`; les autres routes restent des placeholders jusqu'à leurs tranches fonctionnelles. Chaque clic de navigation est tracé en `debug` et chaque remplacement/rendu de section en `trace`. Les appels Tauri partagés utilisent `frontend/ts/invoke.ts`, qui journalise le début et la fin d'une commande sans journaliser ses arguments.
## Asset font du splash
La police bot3 de référence est `DOS_Amazigh.ttf`. Vérifier l'asset local contre l'empreinte documentée dans `frontend/fonts/README.md` ; `frontend/sass/splash.scss` l'applique déjà au titre du splash. La tranche `pre.008` reste consacrée au lifecycle et à la finition visuelle.
La police bot3 de référence est `DOS_Amazigh.ttf`. Vérifier l'asset local contre l'empreinte documentée dans `frontend/fonts/README.md` ; `frontend/sass/splash.scss` l'applique au titre du splash.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/frontend/main.html -->
<!-- version: 1 -->
<!-- version: 2 -->
<!DOCTYPE html>
<html lang="fr">
@@ -13,12 +13,19 @@
<body>
<header class="app-header">
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
<div class="container-fluid px-4">
<div class="navbar-brand d-flex align-items-center">
<nav class="navbar h-100 py-0 bg-light text-dark">
<div class="container-fluid px-4 flex-nowrap">
<div class="navbar-brand d-flex align-items-center me-4">
<img alt="KSP" src="imgs/logo.png" class="app-logo">
<span class="ps-2 fs-4 fw-bold text-primary">KSP — <span class="badge text-bg-primary">Config Desk</span></span>
</div>
<div class="app-nav nav nav-pills flex-nowrap gap-1" aria-label="Navigation Config Desk">
<button class="nav-link app-nav-button active" type="button" data-view="overview">Vue d'ensemble</button>
<button class="nav-link app-nav-button" type="button" data-view="documents">Documents</button>
<button class="nav-link app-nav-button" type="button" data-view="profiles">Profils</button>
<button class="nav-link app-nav-button" type="button" data-view="environment">Environnement / .env</button>
<button class="nav-link app-nav-button" type="button" data-view="logging">Logging</button>
</div>
</div>
</nav>
</header>
@@ -28,10 +35,30 @@
<div class="container-fluid px-4 py-4">
<div class="card app-shell-card mx-auto shadow-sm border-0">
<div class="card-body p-4">
<h1 class="h3 mb-3">Configuration KSP</h1>
<p class="text-body-secondary mb-4">
Le shell desktop est prêt. Les panneaux Documents, Profils, Environnement et Logging seront ajoutés dans les prereleases suivantes.
</p>
<h1 id="viewTitle" class="h3 mb-2">Vue d'ensemble</h1>
<p id="viewDescription" class="text-body-secondary mb-4">État sûr du bootstrap Config Desk et du runtime Logging actuellement actif.</p>
<section id="overviewPanel" aria-label="Vue d'ensemble">
<dl class="row mb-4 app-overview-list">
<dt class="col-sm-5">Version application</dt>
<dd id="overviewVersion" class="col-sm-7 text-sm-end"></dd>
<dt class="col-sm-5">Descripteurs Config enregistrés</dt>
<dd id="overviewDocuments" class="col-sm-7 text-sm-end"></dd>
<dt class="col-sm-5">Profil Logging actif</dt>
<dd id="overviewLoggingProfile" class="col-sm-7 text-sm-end"></dd>
<dt class="col-sm-5">Génération Logging</dt>
<dd id="overviewLoggingGeneration" class="col-sm-7 text-sm-end"></dd>
<dt class="col-sm-5 mb-0">Fallback Logging actif</dt>
<dd id="overviewLoggingFallback" class="col-sm-7 text-sm-end mb-0"></dd>
</dl>
</section>
<section id="placeholderPanel" hidden aria-live="polite">
<div class="alert alert-secondary mb-4" role="status">
Cette route du shell est prête ; son contenu fonctionnel sera ajouté dans la prerelease prévue par le plan 0.1.4.
</div>
</section>
<div class="alert alert-primary mb-0" role="status">
<i class="fa-solid fa-desktop me-2" aria-hidden="true"></i>
<span id="shellStatus" class="app-shell-status">Initialisation du frontend...</span>

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/frontend/sass/_app.scss
// version: 1
// version: 2
$app-header-height: 72px;
$app-footer-height: 42px;
@@ -58,3 +58,17 @@ body {
.app-shell-status {
font-family: var(--bs-font-monospace);
}
.app-nav {
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
}
.app-nav-button {
white-space: nowrap;
}
.app-overview-list dd {
font-family: var(--bs-font-monospace);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/frontend/sass/splash.scss
// version: 2
// version: 3
@font-face {
font-family: "Dos Amazigh";
@@ -26,7 +26,7 @@ body {
position: relative;
width: 960px;
height: 637px;
opacity: 1;
opacity: 0;
}
#splash-image {
@@ -62,4 +62,4 @@ body {
background: rgba(0, 0, 0, 0.35);
font-family: monospace;
font-size: 0.82rem;
}
}

View File

@@ -0,0 +1,19 @@
// file: crates/ksp-app-config-desk/frontend/ts/invoke.ts
// version: 1
//! Shared Tauri invoke wrapper with bounded frontend lifecycle tracing.
import { invoke } from "@tauri-apps/api/core";
import { frontendDebug, frontendError, frontendTrace, type FrontendLogTargetId } from "./frontend_log";
export async function invokeKsp<T>(targetId: FrontendLogTargetId, command: string, args?: Record<string, unknown>): Promise<T> {
frontendDebug(targetId, "Frontend IPC command requested", { command });
try {
const result = await invoke<T>(command, args);
frontendTrace(targetId, "Frontend IPC command completed", { command });
return result;
} catch (caughtError) {
frontendError(targetId, "Frontend IPC command failed", { command });
throw caughtError;
}
}

View File

@@ -1,20 +1,140 @@
// file: crates/ksp-app-config-desk/frontend/ts/main.ts
// version: 2
// version: 3
import "bootstrap";
import ResizeObserver from "resize-observer-polyfill";
import "simplebar";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { frontendInfo, installFrontendConsoleBridge } from "./frontend_log";
import type { AppSnapshotDto } from "./bindings/ksp_app_config_desk/dto_common/AppSnapshotDto.ts";
import { frontendDebug, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
import { invokeKsp } from "./invoke";
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
installFrontendConsoleBridge("main");
document.addEventListener("DOMContentLoaded", () => {
const windowLabel = getCurrentWindow().label;
const status = document.querySelector<HTMLElement>("#shellStatus");
if (status) {
status.textContent = `Frontend chargé dans la fenêtre '${windowLabel}'.`;
type ViewId = "overview" | "documents" | "profiles" | "environment" | "logging";
const viewCopy: Record<ViewId, { title: string; description: string }> = {
overview: {
title: "Vue d'ensemble",
description: "État sûr du bootstrap Config Desk et du runtime Logging actuellement actif.",
},
documents: {
title: "Documents",
description: "Inventaire, diagnostics et réparation des documents Config — surface fonctionnelle prévue en pre.009.",
},
profiles: {
title: "Profils",
description: "Sélection et provenance des profils Config — surface fonctionnelle prévue en pre.010.",
},
environment: {
title: "Environnement / .env",
description: "Rapports et management des variables KSP/KSPB — surfaces fonctionnelles prévues en pre.011/pre.012.",
},
logging: {
title: "Logging",
description: "Édition des profils et hot reload du runtime Logging — surfaces fonctionnelles prévues en pre.014 à pre.017.",
},
};
function isViewId(value: string): value is ViewId {
return Object.prototype.hasOwnProperty.call(viewCopy, value);
}
function activateView(viewId: ViewId, source: "startup" | "user"): void {
if (source === "user") {
frontendDebug("main", "Main navigation activated", { viewId });
}
const copy = viewCopy[viewId];
const title = document.querySelector<HTMLElement>("#viewTitle");
const description = document.querySelector<HTMLElement>("#viewDescription");
if (title) {
title.textContent = copy.title;
}
if (description) {
description.textContent = copy.description;
}
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
const active = button.dataset.view === viewId;
button.classList.toggle("active", active);
button.setAttribute("aria-current", active ? "page" : "false");
});
const overview = document.querySelector<HTMLElement>("#overviewPanel");
if (overview) {
overview.hidden = viewId !== "overview";
}
const placeholder = document.querySelector<HTMLElement>("#placeholderPanel");
if (placeholder) {
placeholder.hidden = viewId === "overview";
}
frontendTrace("main", "Main view DOM updated", { viewId, source });
}
function bindNavigation(): void {
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
button.addEventListener("click", () => {
const requestedView = button.dataset.view;
if (requestedView && isViewId(requestedView)) {
activateView(requestedView, "user");
}
});
});
frontendTrace("main", "Main navigation handlers installed");
}
function renderSnapshot(snapshot: AppSnapshotDto): void {
const version = document.querySelector<HTMLElement>("#overviewVersion");
const documents = document.querySelector<HTMLElement>("#overviewDocuments");
const profile = document.querySelector<HTMLElement>("#overviewLoggingProfile");
const generation = document.querySelector<HTMLElement>("#overviewLoggingGeneration");
const fallback = document.querySelector<HTMLElement>("#overviewLoggingFallback");
if (version) {
version.textContent = snapshot.applicationVersion;
}
if (documents) {
documents.textContent = snapshot.configDocumentCount.toString();
}
if (profile) {
profile.textContent = snapshot.activeLoggingProfile ?? "fallback transitoire";
}
if (generation) {
generation.textContent = snapshot.loggingGeneration.toString();
}
if (fallback) {
fallback.textContent = snapshot.fallbackLoggingActive ? "oui" : "non";
}
frontendTrace("main", "Application snapshot rendered", {
loggingGeneration: snapshot.loggingGeneration,
fallbackLoggingActive: snapshot.fallbackLoggingActive,
});
}
async function loadSnapshot(): Promise<void> {
const status = document.querySelector<HTMLElement>("#shellStatus");
const snapshot = await invokeKsp<AppSnapshotDto>("main", "get_app_snapshot");
renderSnapshot(snapshot);
if (status) {
status.textContent = "Shell principal prêt.";
}
frontendTrace("main", "Main shell status replaced", { status: "ready" });
}
async function initializeMain(): Promise<void> {
const windowLabel = getCurrentWindow().label;
frontendInfo("main", "Config Desk main frontend loaded", { windowLabel });
bindNavigation();
activateView("overview", "startup");
try {
await loadSnapshot();
} catch {
const status = document.querySelector<HTMLElement>("#shellStatus");
if (status) {
status.textContent = "Le snapshot initial n'a pas pu être chargé.";
}
frontendTrace("main", "Main shell status replaced", { status: "snapshot_error" });
}
}
document.addEventListener("DOMContentLoaded", () => {
void initializeMain();
});

View File

@@ -1,16 +1,113 @@
// file: crates/ksp-app-config-desk/frontend/ts/splash.ts
// version: 2
// version: 3
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { frontendInfo, installFrontendConsoleBridge } from "./frontend_log";
import type { SplashOrderDto } from "./bindings/ksp_app_config_desk/splash/SplashOrderDto.ts";
import { frontendDebug, frontendError, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
import { invokeKsp } from "./invoke";
installFrontendConsoleBridge("splash");
document.addEventListener("DOMContentLoaded", () => {
const windowLabel = getCurrentWindow().label;
const defaultFadeDurationMs = 300;
let activeOpacityFrame: number | null = null;
function normalizeDurationMs(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return defaultFadeDurationMs;
}
return value;
}
function easeInOut(progress: number): number {
if (progress < 0.5) {
return 2 * progress * progress;
}
return 1 - Math.pow(-2 * progress + 2, 2) / 2;
}
async function animateOpacity(element: HTMLElement, fromOpacity: number, toOpacity: number, durationMs: number): Promise<void> {
frontendTrace("splash", "Splash opacity animation started", { fromOpacity, toOpacity, durationMs });
if (activeOpacityFrame !== null) {
cancelAnimationFrame(activeOpacityFrame);
activeOpacityFrame = null;
}
element.style.opacity = fromOpacity.toString();
element.style.willChange = "opacity";
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
await new Promise<void>(resolve => {
const startedAt = performance.now();
const opacityDelta = toOpacity - fromOpacity;
const updateOpacity = (currentTime: number): void => {
const elapsedMs = currentTime - startedAt;
const rawProgress = Math.min(elapsedMs / durationMs, 1);
element.style.opacity = (fromOpacity + opacityDelta * easeInOut(rawProgress)).toString();
if (rawProgress >= 1) {
element.style.opacity = toOpacity.toString();
element.style.willChange = "auto";
activeOpacityFrame = null;
resolve();
return;
}
activeOpacityFrame = requestAnimationFrame(updateOpacity);
};
activeOpacityFrame = requestAnimationFrame(updateOpacity);
});
frontendTrace("splash", "Splash opacity animation completed", { toOpacity, durationMs });
}
function replaceStatus(message: string | null): void {
if (!message) {
return;
}
const status = document.querySelector<HTMLElement>("#splash-status");
if (status) {
status.textContent = `Frontend chargé dans la fenêtre '${windowLabel}'. Lifecycle complet prévu en pre.008.`;
status.textContent = message;
frontendTrace("splash", "Splash status replaced", { message });
}
}
async function handleSplashOrder(order: SplashOrderDto): Promise<void> {
frontendTrace("splash", "Splash order received", { action: order.action });
const container = document.querySelector<HTMLElement>("#splash-container");
replaceStatus(order.message);
if (!container) {
return;
}
if (order.action === "fade_in") {
await animateOpacity(container, 0, 1, normalizeDurationMs(order.durationMs));
return;
}
if (order.action === "fade_out") {
await animateOpacity(container, 1, 0, normalizeDurationMs(order.durationMs));
}
}
async function initializeSplash(): Promise<void> {
const windowLabel = getCurrentWindow().label;
frontendInfo("splash", "Config Desk splash frontend loaded", { windowLabel });
const container = document.querySelector<HTMLElement>("#splash-container");
if (container) {
container.style.opacity = "0";
container.style.willChange = "opacity";
frontendTrace("splash", "Splash container prepared for managed fade-in");
}
await listen<SplashOrderDto>("ksp-splash-order", event => {
void handleSplashOrder(event.payload);
});
frontendDebug("splash", "Splash lifecycle listener installed");
try {
await invokeKsp<void>("splash", "splash_frontend_ready");
} catch {
replaceStatus("Le lifecycle du splash n'a pas pu démarrer.");
if (container) {
container.style.opacity = "1";
container.style.willChange = "auto";
}
frontendError("splash", "Splash readiness command failed");
}
}
document.addEventListener("DOMContentLoaded", () => {
void initializeSplash();
});

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/app_state.rs
// version: 1
// version: 2
//! Shared backend state owned by the Tauri application.
@@ -15,6 +15,8 @@ struct LoggingRuntimeState {
pub(crate) struct AppState {
config_management: ksp_config_lib::ConfigManagement,
logging_runtime: std::sync::Mutex<LoggingRuntimeState>,
splash_settings: crate::SplashSettings,
splash_sequence_started: std::sync::atomic::AtomicBool,
}
impl AppState {
@@ -30,6 +32,27 @@ impl AppState {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let splash_settings = crate::SplashSettings::load();
let splash_settings = match splash_settings {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
error_domain = error.code().domain(),
error_code = error.code().code(),
"managed splash timings are invalid; using transient in-memory defaults"
);
crate::SplashSettings::fallback()
},
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
minimum_ms = splash_settings.minimum_ms(),
fade_ms = splash_settings.fade_ms(),
"resolved Config Desk splash timings"
);
return std::result::Result::Ok(Self {
config_management,
logging_runtime: std::sync::Mutex::new(LoggingRuntimeState {
@@ -39,6 +62,8 @@ impl AppState {
fallback_active: logging_startup.fallback_active,
startup_diagnostic: logging_startup.startup_diagnostic,
}),
splash_settings,
splash_sequence_started: std::sync::atomic::AtomicBool::new(false),
});
}
@@ -75,4 +100,18 @@ impl AppState {
startup_diagnostic: runtime.startup_diagnostic.clone(),
});
}
/// Returns the resolved splash timings captured during application bootstrap.
#[must_use]
pub(crate) const fn splash_settings(&self) -> crate::SplashSettings {
return self.splash_settings;
}
/// Marks the one-shot splash lifecycle as started and reports whether this caller won the transition.
pub(crate) fn begin_splash_sequence(&self) -> bool {
return self
.splash_sequence_started
.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire)
.is_ok();
}
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/constants.rs
// version: 2
// version: 3
//! Application-owned tracing targets and domains.
@@ -15,3 +15,5 @@ pub(crate) const TRACING_TARGET_FRONTEND_SPLASH: &str = "ksp-app-config-desk.fro
pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "config.bootstrap";
/// Structured domain used by technical frontend events.
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
/// Structured domain used by Tauri window lifecycle operations.
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/errors.rs
// version: 3
// version: 4
//! Application-local error codes for the configuration desktop shell.
@@ -15,3 +15,12 @@ pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp
pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "frontend_log_level_invalid");
/// Frontend logging requested a target outside the application whitelist.
pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "frontend_log_target_invalid");
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
pub(crate) const ERROR_CODE_SPLASH_SETTING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "splash_setting_invalid");
/// Splash readiness was invoked from a window other than the splash window.
pub(crate) const ERROR_CODE_SPLASH_ORIGIN_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "splash_origin_invalid");
/// A required Tauri window is missing from the configured application runtime.
pub(crate) const ERROR_CODE_TAURI_WINDOW_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_missing");
/// A Tauri window show/focus/destroy/event operation failed.
pub(crate) const ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_operation_failed");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/lib.rs
// version: 3
// version: 4
//! Tauri desktop application for managing and validating KSP configuration.
@@ -13,7 +13,10 @@ mod constants;
mod dto_common;
mod errors;
mod frontend_logging;
mod splash;
mod tauri;
mod tw_main;
mod tw_splash;
/// Runs the KSP configuration desktop application.
pub use self::tauri::run;
@@ -23,6 +26,7 @@ pub(crate) use self::bootstrap::config_management;
pub(crate) use self::bootstrap::initialize_logging;
pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND;
pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS;
pub(crate) use self::constants::TRACING_TARGET;
pub(crate) use self::constants::TRACING_TARGET_FRONTEND;
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN;
@@ -34,6 +38,16 @@ pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED;
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID;
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID;
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;
pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID;
pub(crate) use self::errors::ERROR_CODE_SPLASH_SETTING_INVALID;
pub(crate) use self::errors::ERROR_CODE_TAURI_RUNTIME_FAILED;
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_MISSING;
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED;
pub(crate) use self::frontend_logging::FrontendLogPayloadDto;
pub(crate) use self::frontend_logging::emit_frontend_log_event;
pub(crate) use self::splash::SplashOrderDto;
pub(crate) use self::splash::SplashSettings;
pub(crate) use self::tw_main::WINDOW_LABEL_MAIN;
pub(crate) use self::tw_main::show_and_focus as show_main_window;
pub(crate) use self::tw_splash::WINDOW_LABEL_SPLASH;
pub(crate) use self::tw_splash::frontend_ready as splash_frontend_ready_service;

View File

@@ -0,0 +1,138 @@
// file: crates/ksp-app-config-desk/src/splash.rs
// version: 1
//! Common splash settings and frontend event contracts for Config Desk.
use ts_rs::TS; // rust-rules: derive-import
const ENV_SPLASH_MINIMUM_MS: &str = "KSP_DESK_SPLASH_MINIMUM_MS";
const ENV_SPLASH_FADE_MS: &str = "KSP_DESK_SPLASH_FADE_MS";
const DEFAULT_SPLASH_MINIMUM_MS: u64 = 1200;
const DEFAULT_SPLASH_FADE_MS: u32 = 300;
const MAX_SPLASH_MINIMUM_MS: u64 = 60_000;
const MAX_SPLASH_FADE_MS: u32 = 10_000;
/// Runtime timings used by the common desk splash lifecycle.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct SplashSettings {
minimum_ms: u64,
fade_ms: u32,
}
impl SplashSettings {
/// Resolves splash timings through the Config-owned environment snapshot.
pub(crate) fn load() -> ksp_core_lib::Result<Self> {
let environment = ksp_config_lib::ConfigEnvironment::load();
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let minimum = environment.resolve_variable(ENV_SPLASH_MINIMUM_MS, std::option::Option::Some("1200"));
let minimum = match minimum {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let fade = environment.resolve_variable(ENV_SPLASH_FADE_MS, std::option::Option::Some("300"));
let fade = match fade {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let minimum_ms = parse_u64_setting(ENV_SPLASH_MINIMUM_MS, minimum.value(), MAX_SPLASH_MINIMUM_MS);
let minimum_ms = match minimum_ms {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let fade_ms = parse_u32_setting(ENV_SPLASH_FADE_MS, fade.value(), MAX_SPLASH_FADE_MS);
let fade_ms = match fade_ms {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { minimum_ms, fade_ms });
}
/// Returns safe in-memory timings used when the managed environment cannot be resolved.
#[must_use]
pub(crate) const fn fallback() -> Self {
return Self { minimum_ms: DEFAULT_SPLASH_MINIMUM_MS, fade_ms: DEFAULT_SPLASH_FADE_MS };
}
/// Returns the minimum visible duration after splash frontend readiness.
#[must_use]
pub(crate) const fn minimum_ms(self) -> u64 {
return self.minimum_ms;
}
/// Returns the fade duration used for both fade-in and fade-out.
#[must_use]
pub(crate) const fn fade_ms(self) -> u32 {
return self.fade_ms;
}
}
/// Command emitted by Rust to the splash frontend.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/splash/SplashOrderDto.ts")]
pub(crate) struct SplashOrderDto {
/// Splash action name.
pub(crate) action: String,
/// Optional status text.
pub(crate) message: std::option::Option<String>,
/// Optional animation duration in milliseconds.
pub(crate) duration_ms: std::option::Option<u32>,
}
impl SplashOrderDto {
#[must_use]
pub(crate) fn new(action: &str, message: std::option::Option<&str>, duration_ms: std::option::Option<u32>) -> Self {
return Self { action: action.to_owned(), message: message.map(std::string::ToString::to_string), duration_ms };
}
}
fn parse_u64_setting(variable_name: &str, value: &str, maximum: u64) -> ksp_core_lib::Result<u64> {
let parsed = value.parse::<u64>();
let parsed = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
.with_context("variable_name", variable_name)
.with_source(error),
);
},
};
if parsed > maximum {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
.with_context("variable_name", variable_name)
.with_context("maximum_ms", maximum),
);
}
return std::result::Result::Ok(parsed);
}
fn parse_u32_setting(variable_name: &str, value: &str, maximum: u32) -> ksp_core_lib::Result<u32> {
let parsed = value.parse::<u32>();
let parsed = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
.with_context("variable_name", variable_name)
.with_source(error),
);
},
};
if parsed > maximum {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
.with_context("variable_name", variable_name)
.with_context("maximum_ms", maximum),
);
}
return std::result::Result::Ok(parsed);
}
#[cfg(test)]
#[path = "../unit_tests/splash.rs"]
mod tests;

View File

@@ -1,10 +1,8 @@
// file: crates/ksp-app-config-desk/src/tauri.rs
// version: 4
// version: 5
//! Tauri runtime assembly for the KSP configuration desktop application.
use tauri::Manager; // rust-rules: trait-import
/// Runs the configuration desktop application.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<()> {
@@ -38,16 +36,18 @@ fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<taur
}
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
return builder.invoke_handler(tauri::generate_handler![get_app_snapshot, emit_frontend_log]);
return builder.invoke_handler(tauri::generate_handler![get_app_snapshot, emit_frontend_log, splash_frontend_ready]);
}
fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
return builder.setup(|app| {
if app.get_webview_window("splash").is_none() {
return std::result::Result::Err(std::boxed::Box::new(std::io::Error::new(std::io::ErrorKind::NotFound, "splash window is missing")));
let splash = crate::tw_splash::require_window(app);
if let std::result::Result::Err(error) = splash {
return std::result::Result::Err(std::boxed::Box::new(error));
}
if app.get_webview_window("main").is_none() {
return std::result::Result::Err(std::boxed::Box::new(std::io::Error::new(std::io::ErrorKind::NotFound, "main window is missing")));
let main = crate::tw_main::require_window(app);
if let std::result::Result::Err(error) = main {
return std::result::Result::Err(std::boxed::Box::new(error));
}
return std::result::Result::Ok(());
});
@@ -70,3 +70,16 @@ fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Resu
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
async fn splash_frontend_ready(
app: tauri::AppHandle,
webview_window: tauri::WebviewWindow,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<(), crate::CommandErrorDto> {
let result = crate::splash_frontend_ready_service(app, webview_window, &state).await;
return match result {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}

View File

@@ -0,0 +1,50 @@
// file: crates/ksp-app-config-desk/src/tw_main.rs
// version: 1
//! Tauri-window helpers for the Config Desk main window.
use tauri::Manager; // rust-rules: trait-import
pub(crate) const WINDOW_LABEL_MAIN: &str = "main";
pub(crate) fn require_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
let window = manager.get_webview_window(WINDOW_LABEL_MAIN);
return match window {
std::option::Option::Some(value) => std::result::Result::Ok(value),
std::option::Option::None => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "Config Desk main window is missing")
.with_context("window_label", WINDOW_LABEL_MAIN),
),
};
}
pub(crate) fn show_and_focus(app: &tauri::AppHandle) -> ksp_core_lib::Result<()> {
let window = require_window(app);
let window = match window {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let shown = window.show();
if let std::result::Result::Err(error) = shown {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot show Config Desk main window")
.with_context("window_label", WINDOW_LABEL_MAIN)
.with_source(error),
);
}
let focused = window.set_focus();
if let std::result::Result::Err(error) = focused {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot focus Config Desk main window")
.with_context("window_label", WINDOW_LABEL_MAIN)
.with_source(error),
);
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_MAIN,
"main window shown and focused"
);
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,105 @@
// file: crates/ksp-app-config-desk/src/tw_splash.rs
// version: 1
//! Tauri-window lifecycle for the Config Desk splash window.
use tauri::Emitter; // rust-rules: trait-import
use tauri::Manager; // rust-rules: trait-import
pub(crate) const WINDOW_LABEL_SPLASH: &str = "splash";
const SPLASH_EVENT_NAME: &str = "ksp-splash-order";
pub(crate) fn require_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
let window = manager.get_webview_window(WINDOW_LABEL_SPLASH);
return match window {
std::option::Option::Some(value) => std::result::Result::Ok(value),
std::option::Option::None => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "Config Desk splash window is missing")
.with_context("window_label", WINDOW_LABEL_SPLASH),
),
};
}
pub(crate) async fn frontend_ready(app: tauri::AppHandle, invoking_window: tauri::WebviewWindow, state: &crate::AppState) -> ksp_core_lib::Result<()> {
if invoking_window.label() != WINDOW_LABEL_SPLASH {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_ORIGIN_INVALID, "Splash readiness may only originate from the splash window")
.with_context("window_label", invoking_window.label()),
);
}
if !state.begin_splash_sequence() {
ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_SPLASH,
"duplicate splash frontend readiness ignored"
);
return std::result::Result::Ok(());
}
let settings = state.splash_settings();
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
minimum_ms = settings.minimum_ms(),
fade_ms = settings.fade_ms(),
"starting splash to main lifecycle"
);
let fade_in = emit_order(
&invoking_window,
crate::SplashOrderDto::new("fade_in", std::option::Option::Some("Initialisation de KSP Config Desk..."), std::option::Option::Some(settings.fade_ms())),
);
if let std::result::Result::Err(error) = fade_in {
return std::result::Result::Err(error);
}
tokio::time::sleep(std::time::Duration::from_millis(settings.minimum_ms())).await;
let fade_out = emit_order(
&invoking_window,
crate::SplashOrderDto::new("fade_out", std::option::Option::Some("Initialisation terminée."), std::option::Option::Some(settings.fade_ms())),
);
if let std::result::Result::Err(error) = fade_out {
return std::result::Result::Err(error);
}
tokio::time::sleep(std::time::Duration::from_millis(u64::from(settings.fade_ms()))).await;
let show_main = crate::show_main_window(&app);
if let std::result::Result::Err(error) = show_main {
return std::result::Result::Err(error);
}
let destroyed = invoking_window.destroy();
if let std::result::Result::Err(error) = destroyed {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot destroy Config Desk splash window")
.with_context("window_label", WINDOW_LABEL_SPLASH)
.with_source(error),
);
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_SPLASH,
"splash window destroyed after main activation"
);
return std::result::Result::Ok(());
}
fn emit_order(window: &tauri::WebviewWindow, order: crate::SplashOrderDto) -> ksp_core_lib::Result<()> {
let action = order.action.clone();
let emitted = window.emit(SPLASH_EVENT_NAME, order);
return match emitted {
std::result::Result::Ok(()) => {
ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_SPLASH,
action = action.as_str(),
"splash order emitted"
);
std::result::Result::Ok(())
},
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot emit Config Desk splash order")
.with_context("window_label", WINDOW_LABEL_SPLASH)
.with_context("action", action)
.with_source(error),
),
};
}

View File

@@ -0,0 +1,26 @@
// file: crates/ksp-app-config-desk/unit_tests/splash.rs
// version: 1
#[test]
fn fallback_splash_settings_are_short_and_ordered() {
let settings = crate::SplashSettings::fallback();
assert_eq!(settings.minimum_ms(), 1200);
assert_eq!(settings.fade_ms(), 300);
assert!(settings.minimum_ms() >= u64::from(settings.fade_ms()));
}
#[test]
fn splash_order_contract_keeps_action_message_and_duration() {
let order = crate::SplashOrderDto::new("fade_out", std::option::Option::Some("done"), std::option::Option::Some(300));
assert_eq!(order.action, "fade_out");
assert_eq!(order.message.as_deref(), std::option::Option::Some("done"));
assert_eq!(order.duration_ms, std::option::Option::Some(300));
}
#[test]
fn splash_duration_parser_rejects_invalid_or_unbounded_values() {
let malformed = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "invalid", 60_000);
let oversized = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_MS", "10001", 10_000);
assert!(malformed.is_err());
assert!(oversized.is_err());
}