diff --git a/Cargo.toml b/Cargo.toml index 8af313f..d1b00b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 286 +# version: 287 [workspace] resolver = "3" -members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] +members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.2.12-pre.1" +version = "0.2.12-pre.2" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-app-config-desk/frontend/splash.html b/crates/ksp-app-config-desk/frontend/splash.html index 20ce78c..8dbc18f 100644 --- a/crates/ksp-app-config-desk/frontend/splash.html +++ b/crates/ksp-app-config-desk/frontend/splash.html @@ -1,11 +1,12 @@ - + + KSP Config Desk — Chargement diff --git a/crates/ksp-app-solprices-desk/Cargo.toml b/crates/ksp-app-solprices-desk/Cargo.toml new file mode 100644 index 0000000..d0d0a2f --- /dev/null +++ b/crates/ksp-app-solprices-desk/Cargo.toml @@ -0,0 +1,40 @@ +# file: crates/ksp-app-solprices-desk/Cargo.toml +# version: 1 + +[package] +name = "ksp-app-solprices-desk" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[lib] +name = "ksp_app_solprices_desk_lib" +path = "src/lib.rs" +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name = "ksp-app-solprices-desk" +path = "src/main.rs" + +[build-dependencies] +tauri-build.workspace = true + +[dependencies] +chrono = { workspace = true, features = ["std", "now"] } +fs2.workspace = true +ksp-config-lib = { path = "../ksp-config-lib" } +ksp-core-lib = { path = "../ksp-core-lib" } +ksp-logging-lib = { path = "../ksp-logging-lib" } +serde = { workspace = true, features = ["derive"] } +tauri.workspace = true +tauri-plugin-tracing.workspace = true +tokio = { workspace = true, features = ["time"] } +ts-rs.workspace = true + +[dev-dependencies] +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/ksp-app-solprices-desk/README.md b/crates/ksp-app-solprices-desk/README.md new file mode 100644 index 0000000..143aaf4 --- /dev/null +++ b/crates/ksp-app-solprices-desk/README.md @@ -0,0 +1,78 @@ + + + +# `ksp-app-solprices-desk` + +`ksp-app-solprices-desk` est le Desk Tauri spécialisé destiné à visualiser les prix SOL/USD fournis par `ksp-offchain-transport-lib` sans exposer les détails propres aux providers au frontend. + +`0.2.12-pre.002` crée uniquement le **scaffold desktop**. La composition Off-chain, les DTO prix, la table provider-neutral et les commandes de refresh appartiennent aux tranches suivantes. + +## Package + +```text +package : ksp-app-solprices-desk +lib : ksp_app_solprices_desk_lib +bin : ksp-app-solprices-desk +``` + +Le binaire reste un launcher mince. La bibliothèque applicative possède l'assemblage Tauri, l'état de bootstrap, le bridge Logging et le lifecycle des fenêtres. + +## Surface du scaffold + +```text +splash + -> Config standard + Logging + -> main + -> Prices placeholder non fonctionnel en pre.002 + -> Diagnostics identité/runtime Logging sûre +``` + +Le scaffold ne possède encore aucun `MarketPriceService`, aucun fetch prix, aucun provider, aucun endpoint, aucun credential et aucun scheduler. + +## Frontières desktop + +Le frontend utilise uniquement les capabilities : + +```text +core:default +tracing:default +``` + +Aucun plugin dialog, filesystem, HTTP ou shell n'est accordé au WebView. Le bridge frontend Logging passe par des commandes Rust centralisées dans `tauri.rs`. + +Les ports réservés sont : + +```text +Vite HTTP : 1434 +Vite WS : 1435 +``` + +Le port Vite est strict et le frontend build est isolé sous : + +```text +builds/khadhroony-solana-project/ksp-app-solprices-desk/dist +``` + +## Config et packaging + +Pendant `pre.002`, le Desk utilise le document Logging standard pour établir le gabarit commun. Le composite dédié `cfg.composite.ksp-app-solprices-desk` n'existe pas encore et reste réservé à `pre.003`. + +Le bundle embarque les dix ressources correspondant au registry Config courant. `pre.003` ajoutera atomiquement le onzième document composite et synchronisera les resources des Desks concernés. + +## Développement + +Le cycle Tauri est crate-local : + +```bash +(cd crates/ksp-app-solprices-desk && cargo tauri dev) +``` + +`-c/--config` n'est pas utilisé comme sélecteur d'application. En développement, le launcher normalise le current working directory vers la racine du workspace avant le bootstrap Config. + +Le build production final sera exécuté dans le couloir de validation de la release : + +```bash +(cd crates/ksp-app-solprices-desk && cargo tauri build) +``` + +`USAGE.md` sera finalisé lorsque l'application sera fonctionnellement complète, conformément au workflow de clôture des applications KSP. diff --git a/crates/ksp-app-solprices-desk/build.rs b/crates/ksp-app-solprices-desk/build.rs new file mode 100644 index 0000000..2dea881 --- /dev/null +++ b/crates/ksp-app-solprices-desk/build.rs @@ -0,0 +1,12 @@ +// file: crates/ksp-app-solprices-desk/build.rs +// version: 1 + +//! Build script for the KSP SOL prices desktop application. + +#![forbid(unsafe_code)] +#![deny(unreachable_pub)] +#![warn(missing_docs)] + +fn main() { + tauri_build::build() +} diff --git a/crates/ksp-app-solprices-desk/capabilities/default.json b/crates/ksp-app-solprices-desk/capabilities/default.json new file mode 100644 index 0000000..5a39d86 --- /dev/null +++ b/crates/ksp-app-solprices-desk/capabilities/default.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability for KSP SOL Prices Desk", + "windows": [ + "splash", + "main" + ], + "permissions": [ + "core:default", + "tracing:default" + ] +} diff --git a/crates/ksp-app-solprices-desk/frontend/fonts/DOS_Amazigh.ttf b/crates/ksp-app-solprices-desk/frontend/fonts/DOS_Amazigh.ttf new file mode 100644 index 0000000..e558a29 Binary files /dev/null and b/crates/ksp-app-solprices-desk/frontend/fonts/DOS_Amazigh.ttf differ diff --git a/crates/ksp-app-solprices-desk/frontend/imgs/logo.png b/crates/ksp-app-solprices-desk/frontend/imgs/logo.png new file mode 100644 index 0000000..94e89ca Binary files /dev/null and b/crates/ksp-app-solprices-desk/frontend/imgs/logo.png differ diff --git a/crates/ksp-app-solprices-desk/frontend/imgs/splash.png b/crates/ksp-app-solprices-desk/frontend/imgs/splash.png new file mode 100644 index 0000000..a27d1eb Binary files /dev/null and b/crates/ksp-app-solprices-desk/frontend/imgs/splash.png differ diff --git a/crates/ksp-app-solprices-desk/frontend/main.html b/crates/ksp-app-solprices-desk/frontend/main.html new file mode 100644 index 0000000..c5735d8 --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/main.html @@ -0,0 +1,103 @@ + + + + + + + + + + + SOL Prices Desk — Prices + + + +
+ +
+ +
+
+ +
+
+
+
+
+
+
+

Prices

+

Scaffold desktop actif. Le runtime Off-chain et la table SOL/USD arrivent dans les tranches suivantes.

+
+
+
+
+ +

Surface prix volontairement inactive

+

`pre.002` ne crée ni `MarketPriceService`, ni table provider, ni refresh réseau.

+
+
+
+ +
+
+
+
+
+
+ + + + + + diff --git a/crates/ksp-app-solprices-desk/frontend/sass/_app.scss b/crates/ksp-app-solprices-desk/frontend/sass/_app.scss new file mode 100644 index 0000000..4a5840f --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/sass/_app.scss @@ -0,0 +1,94 @@ +// file: crates/ksp-app-solprices-desk/frontend/sass/_app.scss +// version: 2 + +$app-header-height: 72px; +$app-footer-height: 42px; +$app-sidebar-width: 250px; + +html, +body { + width: 100%; + height: 100%; +} + +body { + margin: 0; + overflow: hidden; + background: $gray-100; +} + +.app-header { + position: fixed; + inset: 0 0 auto; + height: $app-header-height; + z-index: 1020; + border-bottom: 2px solid rgba($primary, 0.3); +} + +.app-footer { + position: fixed; + inset: auto 0 0; + height: $app-footer-height; + z-index: 1020; + border-top: 2px solid rgba($primary, 0.3); +} + +.app-main { + position: relative; + height: calc(100vh - $app-header-height - $app-footer-height); + margin-top: $app-header-height; + margin-bottom: $app-footer-height; + overflow: hidden; +} + +.app-main > .row { + flex-wrap: nowrap; + min-width: 0; +} + +.app-scrollable { + height: 100%; + max-height: 100%; +} + +.app-logo { + display: block; + width: auto; + height: 42px; +} + +.app-shell-card { + max-width: 1320px; +} + +.app-shell-status { + font-family: var(--bs-font-monospace); +} + +.app-sidebar { + width: $app-sidebar-width; + min-width: $app-sidebar-width; + max-width: $app-sidebar-width; +} + +.app-sidebar-scroll, +.app-content { + height: 100%; + max-height: 100%; +} + +.app-content { + min-width: 0; +} + +.app-sidebar .nav-link { + white-space: nowrap; +} + +.app-overview-list dd { + font-family: var(--bs-font-monospace); +} + +.scaffold-placeholder { + min-height: 280px; +} diff --git a/crates/ksp-app-solprices-desk/frontend/sass/_bootswatch.scss b/crates/ksp-app-solprices-desk/frontend/sass/_bootswatch.scss new file mode 100644 index 0000000..20f8f9c --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/sass/_bootswatch.scss @@ -0,0 +1,160 @@ +// file: crates/ksp-app-solprices-desk/frontend/sass/_bootswatch.scss +// version: 1 + +// Pulse 5.3.8 +// Bootswatch + + +// Variables + +// Buttons + +.btn { + + &:focus, + &:active, + &:active:focus, + &.active:focus { + outline: none; + } + + &-secondary { + color: $gray-900; + background-color: $white; + border-color: #ccc; + + &:hover { + color: $gray-900; + background-color: $gray-300; + border-color: $gray-500; + } + + &.disabled { + color: tint-color($gray-900, 5%); + background-color: $white; + border-color: tint-color(#ccc, 5%); + } + } + + &-warning { + color: $white; + } + + &-primary:focus { + box-shadow: 0 0 5px tint-color($primary, 10%); + } + + &-secondary:focus { + box-shadow: 0 0 5px $gray-400; + } + + &-success:focus { + box-shadow: 0 0 5px tint-color($success, 10%); + } + + &-info:focus { + box-shadow: 0 0 5px tint-color($info, 10%); + } + + &-warning:focus { + box-shadow: 0 0 5px tint-color($warning, 10%); + } + + &-danger:focus { + box-shadow: 0 0 5px tint-color($danger, 10%); + } + + &.disabled:focus { + box-shadow: none; + } +} + +// Tables + +.table .thead-dark th { + background-color: $secondary; + border-color: $table-border-color; +} + +.table-primary, +.table-secondary, +.table-success, +.table-warning, +.table-danger, +.table-info, +.table-light { + --#{$prefix}table-color: #{$body-color}; +} + +// Forms + +.form-control:focus { + box-shadow: 0 0 5px rgba(100, 65, 164, .4); +} + +// Navs + +.nav-tabs { + + .nav-link, + .nav-link.active { + border-width: 0 0 1px; + } + + .nav-link:hover, + .nav-link.active, + .nav-link.active:hover, + .nav-link.active:focus { + border-bottom: 1px solid $primary; + } + + .nav-item+.nav-item { + margin-left: 0; + } +} + +.breadcrumb { + &-item.active { + color: $gray-700; + } +} + +// Indicators + +.badge { + &.bg-light { + color: $dark; + } +} + +// Progress bars + +.progress { + height: 8px; +} + +// Containers + +.list-group { + &-item { + color: rgba(255, 255, 255, .8); + + &.active, + &:hover, + &:focus { + color: $white; + } + + &.active { + font-weight: 700; + + &:hover { + background-color: $list-group-hover-bg; + } + } + + &.disabled:hover { + color: $list-group-disabled-color; + } + } +} diff --git a/crates/ksp-app-solprices-desk/frontend/sass/_fontawesome.scss b/crates/ksp-app-solprices-desk/frontend/sass/_fontawesome.scss new file mode 100644 index 0000000..86c1fa4 --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/sass/_fontawesome.scss @@ -0,0 +1,19 @@ +// file: crates/ksp-app-solprices-desk/frontend/sass/_fontawesome.scss +// version: 1 + +//@use '@fortawesome/fontawesome-free/scss/variables' with ( +// // customizing $font-path - make sure it points to where your webfonts are stored in your project +// $font-path: '../webfonts', +//); +@use '@fortawesome/fontawesome-free/scss/variables' with ( + // use fonts from @fortawesome/fontawesome-free + $font-path: '@fortawesome/fontawesome-free/webfonts', +); +// load Font Awesome core +@use '@fortawesome/fontawesome-free/scss/fontawesome'; + +// load and make available Font Awesome helpers (mixins, functions, and variables) +@use '@fortawesome/fontawesome-free/scss/fa' as fa; +@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands; +@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular; +@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid; diff --git a/crates/ksp-app-solprices-desk/frontend/sass/_simplebar.scss b/crates/ksp-app-solprices-desk/frontend/sass/_simplebar.scss new file mode 100644 index 0000000..5025e62 --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/sass/_simplebar.scss @@ -0,0 +1,248 @@ +// file: crates/ksp-app-solprices-desk/frontend/sass/_simplebar.scss +// version: 1 + +/* Rtl support */ +[data-simplebar] { + position: relative; + flex-direction: column; + flex-wrap: wrap; + justify-content: flex-start; + align-content: flex-start; + align-items: flex-start; +} + +.simplebar-wrapper { + overflow: hidden; + width: inherit; + height: inherit; + max-width: inherit; + max-height: inherit; +} + +.simplebar-mask { + direction: inherit; + position: absolute; + overflow: hidden; + padding: 0; + margin: 0; + left: 0; + top: 0; + bottom: 0; + right: 0; + width: auto !important; + height: auto !important; +// z-index: 0; + inset: 0; +} + +.simplebar-offset { + direction: inherit !important; + box-sizing: inherit !important; + resize: none !important; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + padding: 0; + margin: 0; + -webkit-overflow-scrolling: touch; + inset: 0; +} + +.simplebar-content-wrapper { + direction: inherit; + box-sizing: border-box !important; + position: relative; + display: block; + height: 100%; + width: auto; + max-width: 100%; + max-height: 100%; + overflow: auto; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } +} + +.simplebar-hide-scrollbar { + &::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } + + position: fixed; + left: 0; + visibility: hidden; + overflow-y: scroll; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.simplebar-content { + &:before { + content: ' '; + display: table; + } + + &:after { + content: ' '; + display: table; + } +} + +.simplebar-placeholder { + max-height: 100%; + max-width: 100%; + width: 100%; + pointer-events: none; +} + +.simplebar-height-auto-observer-wrapper { + box-sizing: inherit !important; + height: 100%; + width: 100%; + max-width: 1px; + position: relative; + float: left; + max-height: 1px; + overflow: hidden; +// z-index: -1; + padding: 0; + margin: 0; + pointer-events: none; + flex-grow: inherit; + flex-shrink: 0; + flex-basis: 0; +} + +.simplebar-height-auto-observer { + box-sizing: inherit; + display: block; + opacity: 0; + position: absolute; + top: 0; + left: 0; + height: 1000%; + width: 1000%; + min-height: 1px; + min-width: 1px; + overflow: hidden; + pointer-events: none; +// z-index: -1; +} + +.simplebar-track { +// z-index: 1; + position: absolute; + right: 0; + bottom: 0; + pointer-events: none; + overflow: hidden; +} + +[data-simplebar].simplebar-dragging { + pointer-events: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + + .simplebar-content { + pointer-events: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + .simplebar-track { + pointer-events: all; + } +} + +.simplebar-scrollbar { + position: absolute; + left: 0; + right: 0; + min-height: 10px; + + + &:before { + position: absolute; + content: ''; + background: black; + border-radius: 7px; + left: 2px; + right: 2px; + opacity: 0; + transition: opacity 0.2s 0.5s linear; + top: 2px; + bottom: 2px; + } +} + +.simplebar-scrollbar.simplebar-visible { + &:before { + opacity: 0.5; + transition-delay: 0s; + transition-duration: 0s; + } +} + +.simplebar-track.simplebar-vertical { + top: 0; + width: 11px; +} + +.simplebar-track.simplebar-horizontal { + left: 0; + height: 11px; + + .simplebar-scrollbar { + right: auto; + left: 0; + top: 0; + bottom: 0; + min-height: 0; + min-width: 10px; + width: auto; + } +} + +[data-simplebar-direction='rtl'] { + .simplebar-track.simplebar-vertical { + right: auto; + left: 0; + } +} + +.simplebar-dummy-scrollbar-size { + direction: rtl; + position: fixed; + opacity: 0; + visibility: hidden; + height: 500px; + width: 500px; + overflow-y: hidden; + overflow-x: scroll; + -ms-overflow-style: scrollbar !important; + + >div { + width: 200%; + height: 200%; + margin: 10px 0; + } +} + +.simplebar-hover { + cursor: pointer; +} diff --git a/crates/ksp-app-solprices-desk/frontend/sass/_variables.scss b/crates/ksp-app-solprices-desk/frontend/sass/_variables.scss new file mode 100644 index 0000000..66ccd8e --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/sass/_variables.scss @@ -0,0 +1,95 @@ +// file: crates/ksp-app-solprices-desk/frontend/sass/_variables.scss +// version: 1 + +// Pulse 5.3.8 +// Bootswatch + +$theme: "pulse" !default; + +// +// Color system +// + +$white: #fff !default; +$gray-100: #fafafa !default; +$gray-200: #f9f8fc !default; +$gray-300: #ededed !default; +$gray-400: #cbc8d0 !default; +$gray-500: #adb5bd !default; +$gray-600: #868e96 !default; +$gray-700: #444 !default; +$gray-800: #343a40 !default; +$gray-900: #17141f !default; +$black: #000 !default; + +$blue: #007bff !default; +$indigo: #6610f2 !default; +$purple: #593196 !default; +$pink: #e83e8c !default; +$red: #fc3939 !default; +$orange: #fd7e14 !default; +$yellow: #efa31d !default; +$green: #13b955 !default; +$teal: #20c997 !default; +$cyan: #009cdc !default; + +$primary: $purple !default; +$secondary: #a991d4 !default; +$success: $green !default; +$info: $cyan !default; +$warning: $yellow !default; +$danger: $red !default; +$light: $gray-200 !default; +$dark: $gray-900 !default; + +$min-contrast-ratio: 2.1 !default; + +// Options + +$enable-rounded: false !default; + +// Body + +$body-color: $gray-700 !default; + +// Links + +$link-hover-color: $primary !default; + +// Tables + +$table-color: initial !default; + +$table-border-color: rgba(0, 0, 0, .05) !default; + +// Forms + +$input-focus-border-color: $primary !default; + +// Dropdowns + +$dropdown-link-hover-color: $white !default; +$dropdown-link-hover-bg: $primary !default; + +// Navs + +$nav-tabs-border-color: $gray-300 !default; +$nav-tabs-link-hover-border-color: $primary !default; + +// Navbar + +$navbar-padding-y: 1.2rem !default; + +// Progress bars + +$progress-bg: $gray-300 !default; +$progress-bar-bg: $primary !default; + +// List group + +$list-group-bg: $gray-900 !default; +$list-group-border-color: transparent !default; +$list-group-hover-bg: lighten($list-group-bg, 10%) !default; +$list-group-active-color: $white !default; +$list-group-active-bg: $list-group-bg !default; +$list-group-disabled-color: lighten($list-group-bg, 30%) !default; diff --git a/crates/ksp-app-solprices-desk/frontend/sass/main.scss b/crates/ksp-app-solprices-desk/frontend/sass/main.scss new file mode 100644 index 0000000..0a7d00a --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/sass/main.scss @@ -0,0 +1,10 @@ +// file: crates/ksp-app-solprices-desk/frontend/sass/main.scss +// version: 1 + +@import "bootstrap/scss/functions"; +@import "variables"; +@import "fontawesome"; +@import "simplebar"; +@import "bootstrap/scss/bootstrap"; +@import "bootswatch"; +@import "app"; diff --git a/crates/ksp-app-solprices-desk/frontend/sass/splash.scss b/crates/ksp-app-solprices-desk/frontend/sass/splash.scss new file mode 100644 index 0000000..2273413 --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/sass/splash.scss @@ -0,0 +1,137 @@ +// file: crates/ksp-app-solprices-desk/frontend/sass/splash.scss +// version: 2 + +@font-face { + font-family: "Dos Amazigh"; + src: url("../fonts/DOS_Amazigh.ttf") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +body { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + font-family: Arial, sans-serif; +} + +#splash-container { + position: relative; + width: 960px; + max-width: 100%; + height: 637px; + max-height: 100%; + overflow: hidden; + opacity: 0; + will-change: opacity; +} + +#splash-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 1; + object-fit: contain; + user-select: none; + -webkit-user-select: none; + -webkit-user-drag: none; + pointer-events: none; +} + +#app-name { + position: absolute; + top: 50%; + left: 50%; + z-index: 2; + transform: translate(-50%, -50%); + color: #fff; + font-family: "Dos Amazigh", sans-serif; + font-size: 76px; + font-weight: bold; + text-align: center; + text-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + white-space: nowrap; + user-select: none; + -webkit-user-select: none; + -webkit-user-drag: none; + pointer-events: none; +} + +#debug-info { + position: absolute; + top: 0; + left: 0; + z-index: 3; + width: 100%; + max-height: 30%; + overflow-y: auto; + padding: 8px; + color: #fff; + background: rgba(0, 0, 0, 0.18); + font-family: monospace; + font-size: 12px; + scrollbar-width: none; +} + +#messages-container { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 3; + max-height: 30%; + overflow-y: auto; + padding: 10px 10px 36px; + background: rgba(0, 0, 0, 0.18); + font-family: Arial, sans-serif; + font-size: 14px; + scrollbar-width: none; +} + +#debug-info::-webkit-scrollbar, +#messages-container::-webkit-scrollbar { + display: none; +} + +.splash-message { + margin-bottom: 8px; + padding: 6px 10px; + border-radius: 4px; + color: #fff; + font-weight: bold; + animation: splashMessageFadeIn 0.3s ease-in-out; +} + +.splash-message.info { background: rgba(33, 150, 243, 0.22); } +.splash-message.warning { background: rgba(255, 152, 0, 0.22); } +.splash-message.error { background: rgba(244, 67, 54, 0.22); } +.splash-message.success { background: rgba(76, 175, 80, 0.22); } + +@keyframes splashMessageFadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/crates/ksp-app-solprices-desk/frontend/splash.html b/crates/ksp-app-solprices-desk/frontend/splash.html new file mode 100644 index 0000000..750f941 --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/splash.html @@ -0,0 +1,24 @@ + + + + + + + + + + + Chargement — SOL Prices Desk + + + +
+ Chargement de KSP Sol Prices Desk +
SOL Prices Desk
+ +
+
+ + + + diff --git a/crates/ksp-app-solprices-desk/frontend/ts/frontend_log.ts b/crates/ksp-app-solprices-desk/frontend/ts/frontend_log.ts new file mode 100644 index 0000000..ce22f57 --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/ts/frontend_log.ts @@ -0,0 +1,110 @@ +// file: crates/ksp-app-solprices-desk/frontend/ts/frontend_log.ts +// version: 1 + +import { invoke } from "@tauri-apps/api/core"; +import type { FrontendLogPayloadDto } from "./bindings/ksp_app_solprices_desk/frontend_logging/FrontendLogPayloadDto.ts"; + +export type FrontendLogLevel = "trace" | "debug" | "info" | "warn" | "error"; +export type FrontendLogTargetId = "frontend" | "main" | "splash"; + +type ConsoleMethod = (...items: unknown[]) => void; + +const originalConsole = { + trace: console.trace.bind(console), + debug: console.debug.bind(console), + log: console.log.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), +}; + +function stringifyItem(item: unknown): string { + if (item instanceof Error) { + return item.stack ?? item.message; + } + if (typeof item === "string") { + return item; + } + try { + const serialized = JSON.stringify(item); + return serialized ?? String(item); + } catch { + return String(item); + } +} + +function formatMessage(items: unknown[]): string { + return items.map(item => stringifyItem(item)).join(" "); +} + +async function sendFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise { + const payload: FrontendLogPayloadDto = { + level, + targetId, + message, + }; + await invoke("emit_frontend_log", { payload }); +} + +function writeOriginalConsole(level: FrontendLogLevel, message: string): void { + return level === "trace" + ? originalConsole.trace(message) + : level === "debug" + ? originalConsole.debug(message) + : level === "info" + ? originalConsole.info(message) + : level === "warn" + ? originalConsole.warn(message) + : originalConsole.error(message); +} + +export async function emitFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise { + writeOriginalConsole(level, message); + await sendFrontendLog(level, targetId, message); +} + +export function frontendTrace(targetId: FrontendLogTargetId, ...items: unknown[]): void { + const message = formatMessage(items); + originalConsole.trace(message); + void sendFrontendLog("trace", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError)); +} + +export function frontendDebug(targetId: FrontendLogTargetId, ...items: unknown[]): void { + const message = formatMessage(items); + originalConsole.debug(message); + void sendFrontendLog("debug", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError)); +} + +export function frontendInfo(targetId: FrontendLogTargetId, ...items: unknown[]): void { + const message = formatMessage(items); + originalConsole.info(message); + void sendFrontendLog("info", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError)); +} + +export function frontendWarn(targetId: FrontendLogTargetId, ...items: unknown[]): void { + const message = formatMessage(items); + originalConsole.warn(message); + void sendFrontendLog("warn", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError)); +} + +export function frontendError(targetId: FrontendLogTargetId, ...items: unknown[]): void { + const message = formatMessage(items); + originalConsole.error(message); + void sendFrontendLog("error", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError)); +} + +function buildConsoleBridge(level: FrontendLogLevel, targetId: FrontendLogTargetId, original: ConsoleMethod): ConsoleMethod { + return (...items: unknown[]) => { + original(...items); + void sendFrontendLog(level, targetId, formatMessage(items)).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError)); + }; +} + +export function installFrontendConsoleBridge(targetId: FrontendLogTargetId): void { + console.trace = buildConsoleBridge("trace", targetId, originalConsole.trace); + console.debug = buildConsoleBridge("debug", targetId, originalConsole.debug); + console.log = buildConsoleBridge("info", targetId, originalConsole.log); + console.info = buildConsoleBridge("info", targetId, originalConsole.info); + console.warn = buildConsoleBridge("warn", targetId, originalConsole.warn); + console.error = buildConsoleBridge("error", targetId, originalConsole.error); +} diff --git a/crates/ksp-app-solprices-desk/frontend/ts/invoke.ts b/crates/ksp-app-solprices-desk/frontend/ts/invoke.ts new file mode 100644 index 0000000..4ced47a --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/ts/invoke.ts @@ -0,0 +1,17 @@ +// file: crates/ksp-app-solprices-desk/frontend/ts/invoke.ts +// version: 1 + +import { invoke } from "@tauri-apps/api/core"; +import { frontendDebug, frontendError, frontendTrace, type FrontendLogTargetId } from "./frontend_log"; + +export async function invokeKsp(targetId: FrontendLogTargetId, command: string, args?: Record): Promise { + frontendDebug(targetId, "Frontend IPC command requested", { command }); + try { + const result = await invoke(command, args); + frontendTrace(targetId, "Frontend IPC command completed", { command }); + return result; + } catch (caughtError) { + frontendError(targetId, "Frontend IPC command failed", { command }); + throw caughtError; + } +} diff --git a/crates/ksp-app-solprices-desk/frontend/ts/main.ts b/crates/ksp-app-solprices-desk/frontend/ts/main.ts new file mode 100644 index 0000000..461fcb4 --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/ts/main.ts @@ -0,0 +1,80 @@ +// file: crates/ksp-app-solprices-desk/frontend/ts/main.ts +// version: 1 + +import "bootstrap"; +import "simplebar"; +import type { RuntimeStatusDto } from "./bindings/ksp_app_solprices_desk/dto_common/RuntimeStatusDto.ts"; +import { frontendDebug, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log"; +import { invokeKsp } from "./invoke"; + +installFrontendConsoleBridge("main"); + +const viewTitles: Record = { + prices: "Prices", + diagnostics: "Diagnostics", +}; + +function activateView(viewId: string): void { + if (!(viewId in viewTitles)) { + return; + } + document.querySelectorAll("[data-view-panel]").forEach(panel => { + panel.hidden = panel.dataset.viewPanel !== viewId; + }); + document.querySelectorAll("[data-view]").forEach(button => { + button.classList.toggle("active", button.dataset.view === viewId); + }); + const header = document.querySelector("#headerViewTitle"); + if (header) { + header.textContent = viewTitles[viewId]; + } + frontendDebug("main", "SOL Prices Desk view activated", { viewId }); +} + +function renderRuntimeStatus(status: RuntimeStatusDto): void { + const values: Record = { + runtimeVersion: status.applicationVersion, + runtimeShellPhase: status.shellPhase, + runtimeConfigDocuments: status.configDocumentCount.toString(), + runtimeLoggingProfile: status.activeLoggingProfile ?? "fallback", + runtimeLoggingFallback: status.fallbackLoggingActive ? "oui" : "non", + }; + for (const [elementId, value] of Object.entries(values)) { + const element = document.querySelector(`#${elementId}`); + if (element) { + element.textContent = value; + } + } + const badge = document.querySelector("#runtimeVersionBadge"); + if (badge) { + badge.textContent = status.applicationVersion; + } + const diagnostic = document.querySelector("#runtimeDiagnostic"); + if (diagnostic) { + diagnostic.hidden = status.startupDiagnostic === null; + diagnostic.textContent = status.startupDiagnostic === null ? "" : `${status.startupDiagnostic.domain}:${status.startupDiagnostic.code} — ${status.startupDiagnostic.message}`; + } + frontendTrace("main", "SOL Prices Desk scaffold runtime status rendered", { + fallbackLoggingActive: status.fallbackLoggingActive, + configDocumentCount: status.configDocumentCount, + }); +} + +async function initializeMain(): Promise { + frontendInfo("main", "SOL Prices Desk scaffold frontend loaded"); + document.querySelectorAll("[data-view]").forEach(button => { + button.addEventListener("click", () => { + const viewId = button.dataset.view; + if (viewId) { + activateView(viewId); + } + }); + }); + const status = await invokeKsp("main", "runtime_status"); + renderRuntimeStatus(status); + activateView("prices"); +} + +document.addEventListener("DOMContentLoaded", () => { + void initializeMain(); +}); diff --git a/crates/ksp-app-solprices-desk/frontend/ts/splash.ts b/crates/ksp-app-solprices-desk/frontend/ts/splash.ts new file mode 100644 index 0000000..28daf47 --- /dev/null +++ b/crates/ksp-app-solprices-desk/frontend/ts/splash.ts @@ -0,0 +1,131 @@ +// file: crates/ksp-app-solprices-desk/frontend/ts/splash.ts +// version: 1 + +import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { SplashOrderDto } from "./bindings/ksp_app_solprices_desk/splash/SplashOrderDto.ts"; +import { frontendDebug, frontendError, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log"; +import { invokeKsp } from "./invoke"; + +installFrontendConsoleBridge("splash"); + +let activeOpacityFrame: number | null = null; + +function easeInOut(value: number): number { + return value < 0.5 ? 2 * value * value : 1 - Math.pow(-2 * value + 2, 2) / 2; +} + +function normalizeDurationMs(value: number | null): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; +} + +async function animateOpacity(element: HTMLElement, fromOpacity: number, toOpacity: number, durationMs: number): Promise { + 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(resolve => requestAnimationFrame(() => resolve())); + await new Promise(resolve => { + const startedAt = performance.now(); + const opacityDelta = toOpacity - fromOpacity; + const updateOpacity = (currentTime: number): void => { + const elapsedMs = currentTime - startedAt; + const rawProgress = durationMs === 0 ? 1 : 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 scrollToLatest(element: HTMLElement): void { + element.scrollTop = element.scrollHeight; +} + +function addMessage(message: string, status: string | null): void { + const container = document.querySelector("#messages-container"); + if (!container) { + return; + } + const line = document.createElement("div"); + const safeStatus = status === "warning" || status === "error" || status === "success" ? status : "info"; + line.className = `splash-message ${safeStatus}`; + line.textContent = message; + container.appendChild(line); + scrollToLatest(container); +} + +function addDebugMessage(message: string): void { + const container = document.querySelector("#debug-info"); + if (!container) { + return; + } + container.hidden = false; + const line = document.createElement("div"); + line.textContent = `${new Date().toLocaleTimeString()}: ${message}`; + container.appendChild(line); + scrollToLatest(container); +} + +async function handleSplashOrder(order: SplashOrderDto): Promise { + frontendTrace("splash", "Splash order received", { action: order.action }); + const container = document.querySelector("#splash-container"); + if (order.action === "add_message" && order.message) { + addMessage(order.message, order.status); + return; + } + if (order.action === "add_debug" && order.message) { + addDebugMessage(order.message); + return; + } + 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 { + const windowLabel = getCurrentWindow().label; + frontendInfo("splash", "SOL Prices Desk splash frontend loaded", { windowLabel }); + const container = document.querySelector("#splash-container"); + if (container) { + container.style.opacity = "0"; + container.style.willChange = "opacity"; + frontendTrace("splash", "Splash container prepared for managed fade-in"); + } + await listen("ksp-splash-order", event => { + void handleSplashOrder(event.payload); + }); + frontendDebug("splash", "Splash lifecycle listener installed"); + try { + await invokeKsp("splash", "splash_frontend_ready"); + } catch { + addMessage("Le lifecycle du splash n'a pas pu démarrer.", "error"); + if (container) { + container.style.opacity = "1"; + container.style.willChange = "auto"; + } + frontendError("splash", "Splash readiness command failed"); + } +} + +document.addEventListener("DOMContentLoaded", () => { + void initializeSplash(); +}); diff --git a/crates/ksp-app-solprices-desk/icons/favicon.ico b/crates/ksp-app-solprices-desk/icons/favicon.ico new file mode 100644 index 0000000..9dfa172 Binary files /dev/null and b/crates/ksp-app-solprices-desk/icons/favicon.ico differ diff --git a/crates/ksp-app-solprices-desk/icons/favicon.png b/crates/ksp-app-solprices-desk/icons/favicon.png new file mode 100644 index 0000000..94e89ca Binary files /dev/null and b/crates/ksp-app-solprices-desk/icons/favicon.png differ diff --git a/crates/ksp-app-solprices-desk/package.json b/crates/ksp-app-solprices-desk/package.json new file mode 100644 index 0000000..520f109 --- /dev/null +++ b/crates/ksp-app-solprices-desk/package.json @@ -0,0 +1,26 @@ +{ + "name": "ksp-app-solprices-desk", + "private": true, + "version": "0.2.12-pre.2", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build" + }, + "dependencies": { + "@fltsci/tauri-plugin-tracing": "^0.3", + "@fortawesome/fontawesome-free": "^7.3", + "@tauri-apps/api": "^2.11", + "bootstrap": "^5.3", + "resize-observer-polyfill": "^1.5", + "simplebar": "^6.3" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.11", + "@types/bootstrap": "^5.2", + "@types/node": "^26.1", + "sass-embedded": "^1.102", + "typescript": "^7.0", + "vite": "^8.2" + } +} diff --git a/crates/ksp-app-solprices-desk/src/app_state.rs b/crates/ksp-app-solprices-desk/src/app_state.rs new file mode 100644 index 0000000..18a8223 --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/app_state.rs @@ -0,0 +1,111 @@ +// file: crates/ksp-app-solprices-desk/src/app_state.rs +// version: 1 + +//! Shared backend state owned by the SOL Prices Desk Tauri application. + +/// Shared SOL Prices Desk application state managed by Tauri. +pub(crate) struct AppState { + config_management: ksp_config_lib::ConfigManagement, + logging_runtime: std::sync::Mutex, + splash_settings: crate::SplashSettings, + splash_sequence_started: std::sync::atomic::AtomicBool, +} + +impl AppState { + /// Initializes Config ownership, Logging and the common desktop splash state without creating the Off-chain runtime reserved for `pre.003`. + pub(crate) fn initialize(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result { + let config_management = crate::config_management(arguments); + let config_management = match config_management { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let runtime_identity = crate::launch_identity(); + let runtime_identity = match runtime_identity { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let logging_startup = crate::initialize_logging(&config_management, &runtime_identity); + let logging_startup = match logging_startup { + 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(), minimum_source = splash_settings.minimum_source(), fade_in_ms = splash_settings.fade_in_ms(), fade_in_source = splash_settings.fade_in_source(), fade_out_ms = splash_settings.fade_out_ms(), fade_out_source = splash_settings.fade_out_source(), expected_backend_lifecycle_ms = splash_settings.expected_backend_lifecycle_ms(), "resolved SOL Prices Desk splash timings"); + return std::result::Result::Ok(Self { + config_management, + logging_runtime: std::sync::Mutex::new(LoggingRuntimeState { + guard: logging_startup.guard, + active_profile_id: logging_startup.active_profile_id, + fallback_active: logging_startup.fallback_active, + startup_diagnostic: logging_startup.startup_diagnostic, + }), + splash_settings, + splash_sequence_started: std::sync::atomic::AtomicBool::new(false), + }); + } + + /// Builds the safe scaffold runtime status exposed before market-price composition is introduced. + pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result { + let document_count = self.config_management.engine().registry().descriptors().count(); + let document_count = u32::try_from(document_count); + let document_count = match document_count { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new( + crate::ERROR_CODE_APP_STATE_INVALID, + "Config registry contains too many descriptors for the SOL Prices Desk scaffold DTO", + ) + .with_source(error), + ); + }, + }; + let runtime = self.logging_runtime.lock(); + let runtime = match runtime { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_APP_STATE_LOCK_FAILED, + "SOL Prices Desk Logging runtime state lock is poisoned", + )); + }, + }; + let _keep_guard_alive = &runtime.guard; + return std::result::Result::Ok(crate::RuntimeStatusDto { + application_version: env!("CARGO_PKG_VERSION").to_owned(), + active_logging_profile: runtime.active_profile_id.clone(), + config_document_count: document_count, + fallback_logging_active: runtime.fallback_active, + shell_phase: "pre.002-scaffold".to_owned(), + 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(); + } +} + +struct LoggingRuntimeState { + guard: ksp_logging_lib::LoggingGuard, + active_profile_id: std::option::Option, + fallback_active: bool, + startup_diagnostic: std::option::Option, +} diff --git a/crates/ksp-app-solprices-desk/src/bootstrap.rs b/crates/ksp-app-solprices-desk/src/bootstrap.rs new file mode 100644 index 0000000..8231499 --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/bootstrap.rs @@ -0,0 +1,135 @@ +// file: crates/ksp-app-solprices-desk/src/bootstrap.rs +// version: 1 + +//! Config and Logging bootstrap for the SOL Prices Desk scaffold. + +/// Crate-internal Logging startup state shared by the application state. +pub(crate) struct LoggingStartup { + /// Guard that keeps the installed Logging runtime alive. + pub(crate) guard: ksp_logging_lib::LoggingGuard, + /// Active managed profile when Config resolved one successfully. + pub(crate) active_profile_id: std::option::Option, + /// Whether a transient in-memory fallback was installed. + pub(crate) fallback_active: bool, + /// Safe startup diagnostic retained for the shell. + pub(crate) startup_diagnostic: std::option::Option, +} + +enum LoggingStartupPlan { + Managed { + active_profile_id: String, + settings: ksp_logging_lib::LoggingSettings, + }, + Fallback { + initial_error: ksp_core_lib::Error, + diagnostic: crate::CommandErrorDto, + settings: ksp_logging_lib::LoggingSettings, + }, +} + +/// Builds the Config management facade from the common KSP CLI bootstrap contract. +pub(crate) fn config_management(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result { + let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_args(arguments); + let bootstrap = match bootstrap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let registry = ksp_config_lib::ConfigFileRegistry::from_args(arguments); + let registry = match registry { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry); + return std::result::Result::Ok(ksp_config_lib::ConfigManagement::new(engine)); +} + +/// Initializes Logging from the standard Config document until the SOL Prices composite is introduced by `pre.003`. +pub(crate) fn initialize_logging( + management: &ksp_config_lib::ConfigManagement, + runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity, +) -> ksp_core_lib::Result { + let plan = resolve_logging_startup(management); + return match plan { + LoggingStartupPlan::Managed { active_profile_id, settings } => { + let guard = ksp_logging_lib::initialize_with_identity(&settings, runtime_identity); + match guard { + std::result::Result::Ok(guard) => { + ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_BOOTSTRAP, active_profile = active_profile_id.as_str(), "initialized SOL Prices Desk scaffold logging from managed configuration"); + std::result::Result::Ok(LoggingStartup { + guard, + active_profile_id: std::option::Option::Some(active_profile_id), + fallback_active: false, + startup_diagnostic: std::option::Option::None, + }) + }, + std::result::Result::Err(error) => initialize_fallback_logging(error, runtime_identity), + } + }, + LoggingStartupPlan::Fallback { initial_error, diagnostic, settings } => { + initialize_planned_fallback_logging(initial_error, diagnostic, settings, runtime_identity) + }, + }; +} + +fn fallback_logging_settings() -> ksp_logging_lib::LoggingSettings { + return ksp_logging_lib::LoggingSettings::new( + ksp_logging_lib::LogFilterLevel::Info, + ksp_logging_lib::SpanEvents::Off, + std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()), + std::vec::Vec::new(), + ); +} + +fn resolve_logging_startup(management: &ksp_config_lib::ConfigManagement) -> LoggingStartupPlan { + let environment = ksp_config_lib::ConfigEnvironment::load(); + let environment = match environment { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return fallback_startup_plan(error), + }; + let resolved = management.engine().load_resolved_logging_config(std::option::Option::None, &environment); + let resolved = match resolved { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return fallback_startup_plan(error), + }; + return LoggingStartupPlan::Managed { active_profile_id: resolved.profile_id().to_owned(), settings: resolved.into_settings() }; +} + +fn fallback_startup_plan(initial_error: ksp_core_lib::Error) -> LoggingStartupPlan { + let diagnostic = crate::CommandErrorDto::from_error(&initial_error); + return LoggingStartupPlan::Fallback { initial_error, diagnostic, settings: fallback_logging_settings() }; +} + +fn initialize_fallback_logging( + initial_error: ksp_core_lib::Error, + runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity, +) -> ksp_core_lib::Result { + let diagnostic = crate::CommandErrorDto::from_error(&initial_error); + return initialize_planned_fallback_logging(initial_error, diagnostic, fallback_logging_settings(), runtime_identity); +} + +fn initialize_planned_fallback_logging( + initial_error: ksp_core_lib::Error, + diagnostic: crate::CommandErrorDto, + settings: ksp_logging_lib::LoggingSettings, + runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity, +) -> ksp_core_lib::Result { + let guard = ksp_logging_lib::initialize_with_identity(&settings, runtime_identity); + let guard = match guard { + std::result::Result::Ok(value) => value, + std::result::Result::Err(fallback_error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED, "Cannot initialize SOL Prices Desk fallback Logging runtime") + .with_context("initial_error_domain", initial_error.code().domain()) + .with_context("initial_error_code", initial_error.code().code()) + .with_source(fallback_error), + ); + }, + }; + ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_BOOTSTRAP, error_domain = diagnostic.domain.as_str(), error_code = diagnostic.code.as_str(), "managed Logging configuration is unavailable; using transient in-memory fallback"); + return std::result::Result::Ok(LoggingStartup { + guard, + active_profile_id: std::option::Option::None, + fallback_active: true, + startup_diagnostic: std::option::Option::Some(diagnostic), + }); +} diff --git a/crates/ksp-app-solprices-desk/src/constants.rs b/crates/ksp-app-solprices-desk/src/constants.rs new file mode 100644 index 0000000..2f9ee40 --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/constants.rs @@ -0,0 +1,21 @@ +// file: crates/ksp-app-solprices-desk/src/constants.rs +// version: 1 + +//! Application-owned tracing targets and domains. + +/// Structured domain used while bootstrapping Config and Logging. +pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "solprices.bootstrap"; +/// Structured domain used by technical frontend events. +pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend"; +/// Structured domain used by the SOL Prices Desk shell. +pub(crate) const TRACING_DOMAIN_SHELL: &str = "solprices.shell"; +/// Structured domain used by Tauri window lifecycle operations. +pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window"; +/// Owning target for backend events emitted by SOL Prices Desk. +pub(crate) const TRACING_TARGET: &str = "ksp-app-solprices-desk"; +/// Owning target for generic frontend events emitted through the KSP bridge. +pub(crate) const TRACING_TARGET_FRONTEND: &str = "ksp-app-solprices-desk.frontend"; +/// Owning target for main-window frontend events. +pub(crate) const TRACING_TARGET_FRONTEND_MAIN: &str = "ksp-app-solprices-desk.frontend.main"; +/// Owning target for splash-window frontend events. +pub(crate) const TRACING_TARGET_FRONTEND_SPLASH: &str = "ksp-app-solprices-desk.frontend.splash"; diff --git a/crates/ksp-app-solprices-desk/src/dto_common.rs b/crates/ksp-app-solprices-desk/src/dto_common.rs new file mode 100644 index 0000000..310123d --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/dto_common.rs @@ -0,0 +1,54 @@ +// file: crates/ksp-app-solprices-desk/src/dto_common.rs +// version: 1 + +//! Common Tauri DTOs shared by the SOL Prices Desk scaffold. + +use ts_rs::TS; // rust-rules: trait-import + +/// Safe command error projection that never serializes arbitrary KSP error context or source values. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/dto_common/CommandErrorDto.ts")] +pub(crate) struct CommandErrorDto { + /// Stable KSP error domain. + pub(crate) domain: String, + /// Stable KSP error code within the domain. + pub(crate) code: String, + /// Human-readable error message without arbitrary context fields. + pub(crate) message: String, +} + +impl CommandErrorDto { + /// Builds a bounded safe projection from a KSP error. + #[must_use] + pub(crate) fn from_error(error: &ksp_core_lib::Error) -> Self { + return Self { + domain: error.code().domain().to_owned(), + code: error.code().code().to_owned(), + message: error.message().to_owned(), + }; + } +} + +/// Safe scaffold/runtime snapshot exposed to the SOL Prices Desk shell before Off-chain bootstrap exists. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/dto_common/RuntimeStatusDto.ts")] +pub(crate) struct RuntimeStatusDto { + /// Cargo application version. + pub(crate) application_version: String, + /// Active configured Logging profile, or `None` while transient fallback Logging is active. + pub(crate) active_logging_profile: std::option::Option, + /// Number of logical Config resources registered by the current scaffold runtime. + pub(crate) config_document_count: u32, + /// Whether SOL Prices Desk had to install its transient in-memory Logging fallback. + pub(crate) fallback_logging_active: bool, + /// Current implementation phase exposed by the scaffold shell. + pub(crate) shell_phase: String, + /// Safe startup diagnostic that caused fallback Logging, when applicable. + pub(crate) startup_diagnostic: std::option::Option, +} + +#[cfg(test)] +#[path = "../unit_tests/dto_common.rs"] +mod tests; diff --git a/crates/ksp-app-solprices-desk/src/errors.rs b/crates/ksp-app-solprices-desk/src/errors.rs new file mode 100644 index 0000000..6617431 --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/errors.rs @@ -0,0 +1,27 @@ +// file: crates/ksp-app-solprices-desk/src/errors.rs +// version: 1 + +//! Application-local error codes for SOL Prices Desk scaffold and desktop runtime surfaces. + +/// Shared SOL Prices Desk application state is internally inconsistent. +pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "app_state_invalid"); +/// Shared SOL Prices Desk runtime state cannot be locked safely. +pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "app_state_lock_failed"); +/// Frontend logging requested an unsupported level. +pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_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("solprices_desk", "frontend_log_target_invalid"); +/// SOL Prices Desk could not install the managed Logging runtime or its safe fallback. +pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "logging_bootstrap_failed"); +/// 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("solprices_desk", "splash_origin_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("solprices_desk", "splash_setting_invalid"); +/// Tauri runtime assembly or execution failed. +pub(crate) const ERROR_CODE_TAURI_RUNTIME_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "tauri_runtime_failed"); +/// 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("solprices_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("solprices_desk", "tauri_window_operation_failed"); diff --git a/crates/ksp-app-solprices-desk/src/frontend_logging.rs b/crates/ksp-app-solprices-desk/src/frontend_logging.rs new file mode 100644 index 0000000..7300bfe --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/frontend_logging.rs @@ -0,0 +1,205 @@ +// file: crates/ksp-app-solprices-desk/src/frontend_logging.rs +// version: 1 + +//! KSP-owned bridge for technical log events emitted by SOL Prices Desk frontend scripts. + +use ts_rs::TS; // rust-rules: trait-import + +/// Log payload sent by SOL Prices Desk frontend scripts. +#[derive(Clone, Debug, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/frontend_logging/FrontendLogPayloadDto.ts")] +pub(crate) struct FrontendLogPayloadDto { + /// Lowercase KSP log level. + pub(crate) level: String, + /// Whitelisted logical frontend target identifier. + pub(crate) target_id: String, + /// Rendered technical frontend message. + pub(crate) message: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FrontendLogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FrontendLogTarget { + Frontend, + Main, + Splash, +} + +/// Emits one validated frontend event through the KSP Logging facade. +pub(crate) fn emit_frontend_log_event(payload: FrontendLogPayloadDto) -> ksp_core_lib::Result<()> { + let level = parse_level(payload.level.as_str()); + let level = match level { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let target = parse_target(payload.target_id.as_str()); + let target = match target { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + emit_validated_frontend_log(level, target, payload.message.as_str()); + return std::result::Result::Ok(()); +} + +fn parse_level(level: &str) -> ksp_core_lib::Result { + return match level.trim().to_ascii_lowercase().as_str() { + "trace" => std::result::Result::Ok(FrontendLogLevel::Trace), + "debug" => std::result::Result::Ok(FrontendLogLevel::Debug), + "info" => std::result::Result::Ok(FrontendLogLevel::Info), + "warn" => std::result::Result::Ok(FrontendLogLevel::Warn), + "error" => std::result::Result::Ok(FrontendLogLevel::Error), + _ => std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID, "Frontend log level is not supported")), + }; +} + +fn parse_target(target_id: &str) -> ksp_core_lib::Result { + return match target_id.trim().to_ascii_lowercase().as_str() { + "frontend" => std::result::Result::Ok(FrontendLogTarget::Frontend), + "main" => std::result::Result::Ok(FrontendLogTarget::Main), + "splash" => std::result::Result::Ok(FrontendLogTarget::Splash), + _ => { + std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID, "Frontend log target identifier is not supported")) + }, + }; +} + +fn emit_validated_frontend_log(level: FrontendLogLevel, target: FrontendLogTarget, message: &str) { + return match target { + FrontendLogTarget::Frontend => emit_frontend_target(level, message), + FrontendLogTarget::Main => emit_main_target(level, message), + FrontendLogTarget::Splash => emit_splash_target(level, message), + }; +} + +fn emit_frontend_target(level: FrontendLogLevel, message: &str) { + return match level { + FrontendLogLevel::Trace => ksp_logging_lib::trace!( + target: crate::TRACING_TARGET_FRONTEND, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "frontend", + "{message}" + ), + FrontendLogLevel::Debug => ksp_logging_lib::debug!( + target: crate::TRACING_TARGET_FRONTEND, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "frontend", + "{message}" + ), + FrontendLogLevel::Info => ksp_logging_lib::info!( + target: crate::TRACING_TARGET_FRONTEND, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "frontend", + "{message}" + ), + FrontendLogLevel::Warn => ksp_logging_lib::warn!( + target: crate::TRACING_TARGET_FRONTEND, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "frontend", + "{message}" + ), + FrontendLogLevel::Error => ksp_logging_lib::error!( + target: crate::TRACING_TARGET_FRONTEND, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "frontend", + "{message}" + ), + }; +} + +fn emit_main_target(level: FrontendLogLevel, message: &str) { + return match level { + FrontendLogLevel::Trace => ksp_logging_lib::trace!( + target: crate::TRACING_TARGET_FRONTEND_MAIN, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "main", + "{message}" + ), + FrontendLogLevel::Debug => ksp_logging_lib::debug!( + target: crate::TRACING_TARGET_FRONTEND_MAIN, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "main", + "{message}" + ), + FrontendLogLevel::Info => ksp_logging_lib::info!( + target: crate::TRACING_TARGET_FRONTEND_MAIN, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "main", + "{message}" + ), + FrontendLogLevel::Warn => ksp_logging_lib::warn!( + target: crate::TRACING_TARGET_FRONTEND_MAIN, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "main", + "{message}" + ), + FrontendLogLevel::Error => ksp_logging_lib::error!( + target: crate::TRACING_TARGET_FRONTEND_MAIN, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "main", + "{message}" + ), + }; +} + +fn emit_splash_target(level: FrontendLogLevel, message: &str) { + return match level { + FrontendLogLevel::Trace => ksp_logging_lib::trace!( + target: crate::TRACING_TARGET_FRONTEND_SPLASH, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "splash", + "{message}" + ), + FrontendLogLevel::Debug => ksp_logging_lib::debug!( + target: crate::TRACING_TARGET_FRONTEND_SPLASH, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "splash", + "{message}" + ), + FrontendLogLevel::Info => ksp_logging_lib::info!( + target: crate::TRACING_TARGET_FRONTEND_SPLASH, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "splash", + "{message}" + ), + FrontendLogLevel::Warn => ksp_logging_lib::warn!( + target: crate::TRACING_TARGET_FRONTEND_SPLASH, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "splash", + "{message}" + ), + FrontendLogLevel::Error => ksp_logging_lib::error!( + target: crate::TRACING_TARGET_FRONTEND_SPLASH, + domain = crate::TRACING_DOMAIN_FRONTEND, + action = "frontend_log", + target_id = "splash", + "{message}" + ), + }; +} + +#[cfg(test)] +#[path = "../unit_tests/frontend_logging.rs"] +mod tests; diff --git a/crates/ksp-app-solprices-desk/src/lib.rs b/crates/ksp-app-solprices-desk/src/lib.rs new file mode 100644 index 0000000..e9c357a --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/lib.rs @@ -0,0 +1,90 @@ +// file: crates/ksp-app-solprices-desk/src/lib.rs +// version: 1 + +//! Tauri desktop application scaffold for provider-neutral SOL/USD price visualization. + +#![forbid(unsafe_code)] +#![deny(unreachable_pub)] +#![warn(missing_docs)] + +mod app_state; +mod bootstrap; +mod constants; +mod dto_common; +mod errors; +mod frontend_logging; +mod logging_runtime; +mod splash; +mod tauri; +mod tw_main; +mod tw_splash; + +/// Runs the KSP SOL prices desktop application. +pub use self::tauri::run; + +/// Shared SOL Prices Desk application state managed by Tauri. +pub(crate) use self::app_state::AppState; +/// Crate-internal Logging startup state shared by the application state. +pub(crate) use self::bootstrap::LoggingStartup; +/// Builds the Config management facade from the common KSP CLI bootstrap contract. +pub(crate) use self::bootstrap::config_management; +/// Initializes the scaffold Logging runtime from Config, with a bounded in-memory fallback. +pub(crate) use self::bootstrap::initialize_logging; +/// Structured domain used while bootstrapping Config and Logging. +pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP; +/// Structured domain used by technical frontend events. +pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND; +/// Structured domain used by the SOL Prices Desk shell. +pub(crate) use self::constants::TRACING_DOMAIN_SHELL; +/// Structured domain used by Tauri window lifecycle operations. +pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS; +/// Owning target for backend events emitted by SOL Prices Desk. +pub(crate) use self::constants::TRACING_TARGET; +/// Owning target for generic frontend events emitted through the KSP bridge. +pub(crate) use self::constants::TRACING_TARGET_FRONTEND; +/// Owning target for main-window frontend events. +pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN; +/// Owning target for splash-window frontend events. +pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH; +/// Safe command error projection exposed to Tauri commands. +pub(crate) use self::dto_common::CommandErrorDto; +/// Safe scaffold runtime snapshot exposed to the shell. +pub(crate) use self::dto_common::RuntimeStatusDto; +/// Shared SOL Prices Desk application state is internally inconsistent. +pub(crate) use self::errors::ERROR_CODE_APP_STATE_INVALID; +/// Shared SOL Prices Desk runtime state cannot be locked safely. +pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED; +/// Frontend logging requested an unsupported level. +pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID; +/// Frontend logging requested a target outside the application whitelist. +pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID; +/// SOL Prices Desk could not install the managed Logging runtime or its safe fallback. +pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED; +/// Splash readiness was invoked from a window other than the splash window. +pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID; +/// A KSP desk splash environment duration is malformed or exceeds its safety bound. +pub(crate) use self::errors::ERROR_CODE_SPLASH_SETTING_INVALID; +/// Tauri runtime assembly or execution failed. +pub(crate) use self::errors::ERROR_CODE_TAURI_RUNTIME_FAILED; +/// A required Tauri window is missing from the configured application runtime. +pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_MISSING; +/// A Tauri window show/focus/destroy/event operation failed. +pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED; +/// Log payload sent by SOL Prices Desk frontend scripts. +pub(crate) use self::frontend_logging::FrontendLogPayloadDto; +/// Emits one validated frontend event through the KSP Logging facade. +pub(crate) use self::frontend_logging::emit_frontend_log_event; +/// Creates the stable runtime identity for this SOL Prices Desk process launch. +pub(crate) use self::logging_runtime::launch_identity; +/// Command emitted by Rust to the splash frontend. +pub(crate) use self::splash::SplashOrderDto; +/// Runtime timings used by the common desk splash lifecycle. +pub(crate) use self::splash::SplashSettings; +/// Resolves the required main window or returns a typed error. +pub(crate) use self::tw_main::require_main_window; +/// Shows and focuses the main SOL Prices Desk window. +pub(crate) use self::tw_main::show_main_window; +/// Resolves the required splash window or returns a typed error. +pub(crate) use self::tw_splash::require_splash_window; +/// Starts the one-shot splash lifecycle after the splash frontend reports readiness. +pub(crate) use self::tw_splash::splash_frontend_ready_service; diff --git a/crates/ksp-app-solprices-desk/src/logging_runtime.rs b/crates/ksp-app-solprices-desk/src/logging_runtime.rs new file mode 100644 index 0000000..86089a9 --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/logging_runtime.rs @@ -0,0 +1,10 @@ +// file: crates/ksp-app-solprices-desk/src/logging_runtime.rs +// version: 1 + +//! SOL Prices Desk Logging runtime identity helpers. + +/// Creates the stable runtime identity for this SOL Prices Desk process launch. +pub(crate) fn launch_identity() -> ksp_core_lib::Result { + let timestamp = format!("{}-p{}", chrono::Utc::now().format("%Y%m%d-%H%M%S%.3fZ"), std::process::id()); + return ksp_logging_lib::LoggingRuntimeIdentity::new(crate::TRACING_TARGET, timestamp); +} diff --git a/crates/ksp-app-solprices-desk/src/main.rs b/crates/ksp-app-solprices-desk/src/main.rs new file mode 100644 index 0000000..3af6300 --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/main.rs @@ -0,0 +1,58 @@ +// file: crates/ksp-app-solprices-desk/src/main.rs +// version: 1 + +//! Binary entry point for the KSP SOL prices desktop application. + +#![forbid(unsafe_code)] +#![deny(unreachable_pub)] +#![warn(missing_docs)] +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use fs2::FileExt; // rust-rules: trait-import + +fn main() -> std::process::ExitCode { + let working_directory = configure_runtime_working_directory(); + if let std::result::Result::Err(error) = working_directory { + eprintln!("cannot configure SOL Prices Desk runtime working directory: {error}"); + return std::process::ExitCode::FAILURE; + } + let mut lock_path = std::env::temp_dir(); + lock_path.push("com_sasedev_ksp_app_solprices_desk.lock"); + let lock_file = match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) { + std::result::Result::Ok(file) => file, + std::result::Result::Err(error) => { + eprintln!("cannot create application lock '{}': {error}", lock_path.display()); + return std::process::ExitCode::FAILURE; + }, + }; + if let std::result::Result::Err(error) = lock_file.try_lock_exclusive() { + if error.kind() == std::io::ErrorKind::WouldBlock { + eprintln!("another ksp-app-solprices-desk instance is already running"); + return std::process::ExitCode::FAILURE; + } + eprintln!("cannot acquire application lock '{}': {error}", lock_path.display()); + return std::process::ExitCode::FAILURE; + } + let _lock_file = lock_file; + let arguments = std::env::args_os().collect::>(); + let run_result = ksp_app_solprices_desk_lib::run(arguments.as_slice()); + return match run_result { + std::result::Result::Ok(()) => std::process::ExitCode::SUCCESS, + std::result::Result::Err(error) => { + eprintln!("application error: {error}"); + std::process::ExitCode::FAILURE + }, + }; +} + +fn configure_runtime_working_directory() -> std::io::Result<()> { + #[cfg(debug_assertions)] + { + let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + return std::env::set_current_dir(workspace_root); + } + #[cfg(not(debug_assertions))] + { + return std::result::Result::Ok(()); + } +} diff --git a/crates/ksp-app-solprices-desk/src/splash.rs b/crates/ksp-app-solprices-desk/src/splash.rs new file mode 100644 index 0000000..bec3df2 --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/splash.rs @@ -0,0 +1,214 @@ +// file: crates/ksp-app-solprices-desk/src/splash.rs +// version: 1 + +//! Common splash settings and frontend event contracts for SOL Prices Desk. + +use ts_rs::TS; // rust-rules: trait-import + +const DEFAULT_SPLASH_FADE_IN_MS: u32 = 300; +const DEFAULT_SPLASH_FADE_OUT_MS: u32 = 300; +const DEFAULT_SPLASH_MINIMUM_MS: u64 = 1200; +const ENV_SPLASH_FADE_IN_MS: &str = "KSP_DESK_SPLASH_FADE_IN_MS"; +const ENV_SPLASH_FADE_OUT_MS: &str = "KSP_DESK_SPLASH_FADE_OUT_MS"; +const ENV_SPLASH_MINIMUM_MS: &str = "KSP_DESK_SPLASH_MINIMUM_MS"; +const MAX_SPLASH_FADE_MS: u32 = 10_000; +const MAX_SPLASH_MINIMUM_MS: u64 = 60_000; + +/// Runtime timings used by the common desk splash lifecycle. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct SplashSettings { + fade_in_ms: u32, + minimum_ms: u64, + fade_out_ms: u32, + fade_in_source: ksp_config_lib::ConfigEnvironmentSource, + minimum_source: ksp_config_lib::ConfigEnvironmentSource, + fade_out_source: ksp_config_lib::ConfigEnvironmentSource, +} + +impl SplashSettings { + /// Resolves splash timings through the Config-owned environment snapshot. + pub(crate) fn load() -> ksp_core_lib::Result { + 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 fade_in = environment.resolve_variable(ENV_SPLASH_FADE_IN_MS, std::option::Option::Some("300")); + let fade_in = match fade_in { + 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_out = environment.resolve_variable(ENV_SPLASH_FADE_OUT_MS, std::option::Option::Some("300")); + let fade_out = match fade_out { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let fade_in_ms = parse_u32_setting(ENV_SPLASH_FADE_IN_MS, fade_in.value(), MAX_SPLASH_FADE_MS); + let fade_in_ms = match fade_in_ms { + 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_out_ms = parse_u32_setting(ENV_SPLASH_FADE_OUT_MS, fade_out.value(), MAX_SPLASH_FADE_MS); + let fade_out_ms = match fade_out_ms { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(Self { + fade_in_ms, + minimum_ms, + fade_out_ms, + fade_in_source: fade_in.source(), + minimum_source: minimum.source(), + fade_out_source: fade_out.source(), + }); + } + + /// Returns safe in-memory timings used when the managed environment cannot be resolved. + #[must_use] + pub(crate) const fn fallback() -> Self { + return Self { + fade_in_ms: DEFAULT_SPLASH_FADE_IN_MS, + minimum_ms: DEFAULT_SPLASH_MINIMUM_MS, + fade_out_ms: DEFAULT_SPLASH_FADE_OUT_MS, + fade_in_source: ksp_config_lib::ConfigEnvironmentSource::Fallback, + minimum_source: ksp_config_lib::ConfigEnvironmentSource::Fallback, + fade_out_source: ksp_config_lib::ConfigEnvironmentSource::Fallback, + }; + } + + /// Returns the configured fade-in duration. + #[must_use] + pub(crate) const fn fade_in_ms(self) -> u32 { + return self.fade_in_ms; + } + + /// Returns the minimum fully-visible splash duration. + #[must_use] + pub(crate) const fn minimum_ms(self) -> u64 { + return self.minimum_ms; + } + + /// Returns the configured fade-out duration. + #[must_use] + pub(crate) const fn fade_out_ms(self) -> u32 { + return self.fade_out_ms; + } + + /// Returns the safe provenance code for the fade-in duration. + #[must_use] + pub(crate) const fn fade_in_source(self) -> &'static str { + return environment_source_code(self.fade_in_source); + } + + /// Returns the safe provenance code for the minimum duration. + #[must_use] + pub(crate) const fn minimum_source(self) -> &'static str { + return environment_source_code(self.minimum_source); + } + + /// Returns the safe provenance code for the fade-out duration. + #[must_use] + pub(crate) const fn fade_out_source(self) -> &'static str { + return environment_source_code(self.fade_out_source); + } + + /// Returns the expected additive backend lifecycle from readiness through fade-out. + #[must_use] + pub(crate) fn expected_backend_lifecycle_ms(self) -> u64 { + return u64::from(self.fade_in_ms) + self.minimum_ms + u64::from(self.fade_out_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_solprices_desk/splash/SplashOrderDto.ts")] +pub(crate) struct SplashOrderDto { + /// Splash action name. + pub(crate) action: String, + /// Optional status text. + pub(crate) message: std::option::Option, + /// Optional message severity used by the bottom message feed. + pub(crate) status: std::option::Option, + /// Optional animation duration in milliseconds. + pub(crate) duration_ms: std::option::Option, +} + +impl SplashOrderDto { + /// Creates a new `SplashOrderDto` value. + #[must_use] + pub(crate) fn new(action: &str, message: std::option::Option<&str>, status: std::option::Option<&str>, duration_ms: std::option::Option) -> Self { + return Self { + action: action.to_owned(), + message: message.map(std::string::ToString::to_string), + status: status.map(std::string::ToString::to_string), + duration_ms, + }; + } +} + +const fn environment_source_code(source: ksp_config_lib::ConfigEnvironmentSource) -> &'static str { + return match source { + ksp_config_lib::ConfigEnvironmentSource::Process => "process", + ksp_config_lib::ConfigEnvironmentSource::DotEnv => "dotenv", + ksp_config_lib::ConfigEnvironmentSource::Fallback => "fallback", + }; +} + +fn parse_u64_setting(variable_name: &str, value: &str, maximum: u64) -> ksp_core_lib::Result { + let parsed = value.parse::(); + 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.to_string()), + ); + } + return std::result::Result::Ok(parsed); +} + +fn parse_u32_setting(variable_name: &str, value: &str, maximum: u32) -> ksp_core_lib::Result { + let parsed = value.parse::(); + 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.to_string()), + ); + } + return std::result::Result::Ok(parsed); +} + +#[cfg(test)] +#[path = "../unit_tests/splash.rs"] +mod tests; diff --git a/crates/ksp-app-solprices-desk/src/tauri.rs b/crates/ksp-app-solprices-desk/src/tauri.rs new file mode 100644 index 0000000..50e00ed --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/tauri.rs @@ -0,0 +1,121 @@ +// file: crates/ksp-app-solprices-desk/src/tauri.rs +// version: 1 + +//! Tauri runtime assembly for the KSP SOL prices desktop application. + +/// Runs the SOL prices desktop application. +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<()> { + let context = tauri::generate_context!(); + let runtime_layout = configure_packaged_runtime(&context); + if let std::result::Result::Err(error) = runtime_layout { + return std::result::Result::Err(error); + } + let app_state = crate::AppState::initialize(arguments); + let app_state = match app_state { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut builder = tauri::Builder::default(); + builder = configure_state(builder, app_state); + builder = configure_plugins(builder); + builder = configure_commands(builder); + builder = configure_setup(builder); + let run_result = builder.run(context); + return match run_result { + std::result::Result::Ok(()) => std::result::Result::Ok(()), + std::result::Result::Err(error) => std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot run KSP SOL Prices Desk Tauri runtime") + .with_context("tauri_error", error.to_string()), + ), + }; +} + +fn configure_packaged_runtime(context: &tauri::Context) -> ksp_core_lib::Result<()> { + if cfg!(debug_assertions) { + return std::result::Result::Ok(()); + } + let resource_root = tauri::utils::platform::resource_dir(context.package_info(), &tauri::Env::default()); + let resource_root = match resource_root { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot resolve packaged Tauri resource directory") + .with_context("tauri_error", error.to_string()), + ); + }, + }; + let layout = ksp_config_lib::prepare_packaged_runtime(resource_root.as_path()); + let layout = match layout { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let working_directory = std::env::set_current_dir(layout.runtime_root()); + return match working_directory { + std::result::Result::Ok(()) => std::result::Result::Ok(()), + std::result::Result::Err(error) => std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot activate packaged KSP runtime directory") + .with_context("runtime_root", layout.runtime_root().to_string_lossy().into_owned()) + .with_source(error), + ), + }; +} + +fn configure_state(builder: tauri::Builder, app_state: crate::AppState) -> tauri::Builder { + return builder.manage(app_state); +} + +fn configure_plugins(builder: tauri::Builder) -> tauri::Builder { + let tracing_plugin = tauri_plugin_tracing::Builder::new().build::(); + return builder.plugin(tracing_plugin); +} + +#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch. +fn configure_commands(builder: tauri::Builder) -> tauri::Builder { + return builder.invoke_handler(tauri::generate_handler![emit_frontend_log, runtime_status, splash_frontend_ready]); +} + +fn configure_setup(builder: tauri::Builder) -> tauri::Builder { + return builder.setup(|app| { + let splash = crate::require_splash_window(app); + if let std::result::Result::Err(error) = splash { + return std::result::Result::Err(std::boxed::Box::new(error)); + } + let main = crate::require_main_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(()); + }); +} + +#[tauri::command] +fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> { + let result = crate::emit_frontend_log_event(payload); + 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)), + }; +} + +#[tauri::command] +fn runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result::Result { + let result = state.runtime_status(); + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + 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)), + }; +} diff --git a/crates/ksp-app-solprices-desk/src/tw_main.rs b/crates/ksp-app-solprices-desk/src/tw_main.rs new file mode 100644 index 0000000..01b5760 --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/tw_main.rs @@ -0,0 +1,53 @@ +// file: crates/ksp-app-solprices-desk/src/tw_main.rs +// version: 1 + +//! Tauri-window helpers for the SOL Prices Desk main window. + +use tauri::Manager; // rust-rules: trait-import + +/// Crate-internal `WINDOW_LABEL_MAIN` constant. +pub(crate) const WINDOW_LABEL_MAIN: &str = "main"; + +/// Resolves the required main window or returns a typed error. +pub(crate) fn require_main_window(manager: &impl Manager) -> ksp_core_lib::Result { + 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, "SOL Prices Desk main window is missing") + .with_context("window_label", WINDOW_LABEL_MAIN), + ), + }; +} + +/// Shows main window. +pub(crate) fn show_main_window(app: &tauri::AppHandle) -> ksp_core_lib::Result<()> { + let window = crate::require_main_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 SOL Prices 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 SOL Prices 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(()); +} diff --git a/crates/ksp-app-solprices-desk/src/tw_splash.rs b/crates/ksp-app-solprices-desk/src/tw_splash.rs new file mode 100644 index 0000000..fd6f4a3 --- /dev/null +++ b/crates/ksp-app-solprices-desk/src/tw_splash.rs @@ -0,0 +1,173 @@ +// file: crates/ksp-app-solprices-desk/src/tw_splash.rs +// version: 1 + +//! Tauri-window lifecycle for the SOL Prices Desk splash window. + +use tauri::Emitter; // rust-rules: trait-import +use tauri::Manager; // rust-rules: trait-import + +/// Crate-internal splash window label. +pub(crate) const WINDOW_LABEL_SPLASH: &str = "splash"; + +const SPLASH_EVENT_NAME: &str = "ksp-splash-order"; + +/// Resolves the required splash window or returns a typed error. +pub(crate) fn require_splash_window(manager: &impl Manager) -> ksp_core_lib::Result { + 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, "SOL Prices Desk splash window is missing") + .with_context("window_label", WINDOW_LABEL_SPLASH), + ), + }; +} + +/// Starts the one-shot splash lifecycle after the splash frontend reports readiness. +pub(crate) async fn splash_frontend_ready_service( + 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(); + let lifecycle_started = std::time::Instant::now(); + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_WINDOWS, + window_label = WINDOW_LABEL_SPLASH, + fade_in_ms = settings.fade_in_ms(), + fade_in_source = settings.fade_in_source(), + minimum_ms = settings.minimum_ms(), + minimum_source = settings.minimum_source(), + fade_out_ms = settings.fade_out_ms(), + fade_out_source = settings.fade_out_source(), + expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(), + "splash frontend readiness accepted; starting splash to main lifecycle" + ); + let emitted = emit_order( + &invoking_window, + crate::SplashOrderDto::new("fade_in", std::option::Option::None, std::option::Option::None, std::option::Option::Some(settings.fade_in_ms())), + ); + if let std::result::Result::Err(error) = emitted { + return std::result::Result::Err(error); + } + let emitted = emit_message(&invoking_window, "Initialisation de SOL Prices Desk...", "info"); + if let std::result::Result::Err(error) = emitted { + return std::result::Result::Err(error); + } + let emitted = emit_debug(&invoking_window, "Listener frontend prêt; fade-in piloté par Rust."); + if let std::result::Result::Err(error) = emitted { + return std::result::Result::Err(error); + } + sleep_and_log(&invoking_window, "fade-in", u64::from(settings.fade_in_ms()), lifecycle_started).await; + let emitted = emit_message(&invoking_window, "Configuration KSP chargée.", "success"); + if let std::result::Result::Err(error) = emitted { + return std::result::Result::Err(error); + } + let emitted = emit_debug(&invoking_window, "Configuration et provenance des timings résolues via ksp-config-lib."); + if let std::result::Result::Err(error) = emitted { + return std::result::Result::Err(error); + } + let emitted = emit_message(&invoking_window, "Runtime Logging initialisé.", "success"); + if let std::result::Result::Err(error) = emitted { + return std::result::Result::Err(error); + } + let emitted = emit_message(&invoking_window, "Scaffold desktop prêt.", "success"); + if let std::result::Result::Err(error) = emitted { + return std::result::Result::Err(error); + } + sleep_and_log(&invoking_window, "minimum", settings.minimum_ms(), lifecycle_started).await; + let emitted = emit_message(&invoking_window, "Initialisation terminée.", "success"); + if let std::result::Result::Err(error) = emitted { + return std::result::Result::Err(error); + } + let emitted = emit_order( + &invoking_window, + crate::SplashOrderDto::new("fade_out", std::option::Option::None, std::option::Option::None, std::option::Option::Some(settings.fade_out_ms())), + ); + if let std::result::Result::Err(error) = emitted { + return std::result::Result::Err(error); + } + sleep_and_log(&invoking_window, "fade-out", u64::from(settings.fade_out_ms()), lifecycle_started).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 SOL Prices 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, + expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(), + actual_backend_lifecycle_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0, + "splash window destroyed after main activation" + ); + return std::result::Result::Ok(()); +} + +fn emit_message(window: &tauri::WebviewWindow, message: &str, status: &str) -> ksp_core_lib::Result<()> { + return emit_order( + window, + crate::SplashOrderDto::new("add_message", std::option::Option::Some(message), std::option::Option::Some(status), std::option::Option::None), + ); +} + +fn emit_debug(window: &tauri::WebviewWindow, message: &str) -> ksp_core_lib::Result<()> { + if !cfg!(debug_assertions) { + return std::result::Result::Ok(()); + } + return emit_order( + window, + crate::SplashOrderDto::new("add_debug", std::option::Option::Some(message), std::option::Option::None, std::option::Option::None), + ); +} + +async fn sleep_and_log(window: &tauri::WebviewWindow, phase: &str, duration_ms: u64, lifecycle_started: std::time::Instant) { + let wait_started = std::time::Instant::now(); + tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await; + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_WINDOWS, + window_label = window.label(), + phase, + configured_wait_ms = duration_ms, + actual_wait_ms = wait_started.elapsed().as_secs_f64() * 1000.0, + lifecycle_elapsed_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0, + "splash lifecycle wait completed" + ); + return; +} + +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 SOL Prices Desk splash order") + .with_context("window_label", WINDOW_LABEL_SPLASH) + .with_context("action", action) + .with_source(error), + ), + }; +} diff --git a/crates/ksp-app-solprices-desk/tauri.conf.json b/crates/ksp-app-solprices-desk/tauri.conf.json new file mode 100644 index 0000000..6e6cd3d --- /dev/null +++ b/crates/ksp-app-solprices-desk/tauri.conf.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "KSP SOL Prices Desk", + "version": "0.2.12-pre.2", + "identifier": "com.sasedev.ksp-app-solprices-desk", + "build": { + "beforeDevCommand": { + "script": "npm run dev", + "cwd": "." + }, + "devUrl": "http://localhost:1434", + "beforeBuildCommand": { + "script": "npm run build", + "cwd": "." + }, + "frontendDist": "../../../builds/khadhroony-solana-project/ksp-app-solprices-desk/dist" + }, + "app": { + "windows": [ + { + "label": "splash", + "url": "splash.html", + "title": "Chargement — KSP SOL Prices Desk", + "width": 960, + "height": 637, + "resizable": false, + "decorations": false, + "transparent": true, + "center": true, + "alwaysOnTop": true + }, + { + "label": "main", + "url": "main.html", + "title": "KSP SOL Prices Desk", + "width": 1500, + "height": 960, + "minWidth": 1024, + "minHeight": 768, + "center": true, + "visible": false + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/favicon.png", + "icons/favicon.ico" + ], + "resources": { + "../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json", + "../../config/std.logging.json": "config/std.logging.json", + "../../config/std.offchain_transport.json": "config/std.offchain_transport.json", + "../../config/std.transport.json": "config/std.transport.json", + "../../config/std.wallet.json": "config/std.wallet.json", + "../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json", + "../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json", + "../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json", + "../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json", + "../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json" + } + } +} diff --git a/crates/ksp-app-solprices-desk/tests/desktop_contract.rs b/crates/ksp-app-solprices-desk/tests/desktop_contract.rs new file mode 100644 index 0000000..4331bbf --- /dev/null +++ b/crates/ksp-app-solprices-desk/tests/desktop_contract.rs @@ -0,0 +1,100 @@ +// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs +// version: 1 + +//! Desktop scaffold contract audits for SOL Prices Desk `0.2.12-pre.002`. + +fn app_root() -> std::path::PathBuf { + return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); +} + +fn read_json(path: &std::path::Path) -> serde_json::Value { + let source = std::fs::read_to_string(path); + assert!(source.is_ok(), "unable to read {}", path.display()); + let source = match source { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return serde_json::Value::Null, + }; + let parsed = serde_json::from_str::(source.as_str()); + assert!(parsed.is_ok(), "unable to parse {}", path.display()); + return match parsed { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => serde_json::Value::Null, + }; +} + +fn read_text(path: &std::path::Path) -> String { + let source = std::fs::read_to_string(path); + assert!(source.is_ok(), "unable to read {}", path.display()); + return match source { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => String::new(), + }; +} + +#[test] +fn pre_002_shell_uses_reserved_ports_windows_and_owned_backend_paths() { + let root = app_root(); + let tauri = read_json(root.join("tauri.conf.json").as_path()); + assert_eq!(tauri.pointer("/productName").and_then(serde_json::Value::as_str), std::option::Option::Some("KSP SOL Prices Desk")); + assert_eq!(tauri.pointer("/identifier").and_then(serde_json::Value::as_str), std::option::Option::Some("com.sasedev.ksp-app-solprices-desk")); + assert_eq!(tauri.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some("0.2.12-pre.2")); + assert_eq!(tauri.pointer("/build/devUrl").and_then(serde_json::Value::as_str), std::option::Option::Some("http://localhost:1434")); + assert_eq!(tauri.pointer("/build/beforeDevCommand/script").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run dev")); + assert_eq!(tauri.pointer("/build/beforeDevCommand/cwd").and_then(serde_json::Value::as_str), std::option::Option::Some(".")); + assert_eq!(tauri.pointer("/build/beforeBuildCommand/script").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run build")); + assert_eq!(tauri.pointer("/build/beforeBuildCommand/cwd").and_then(serde_json::Value::as_str), std::option::Option::Some(".")); + assert_eq!( + tauri.pointer("/build/frontendDist").and_then(serde_json::Value::as_str), + std::option::Option::Some("../../../builds/khadhroony-solana-project/ksp-app-solprices-desk/dist"), + ); + let windows = tauri.pointer("/app/windows").and_then(serde_json::Value::as_array); + assert!(windows.is_some()); + let labels = match windows { + std::option::Option::Some(values) => { + values.iter().filter_map(|window| return window.get("label").and_then(serde_json::Value::as_str)).collect::>() + }, + std::option::Option::None => return, + }; + assert_eq!(labels, ["splash", "main"]); + let vite = read_text(root.join("vite.config.ts").as_path()); + assert!(vite.contains("port: 1434")); + assert!(vite.contains("port: 1435")); + assert!(vite.contains("strictPort: true")); + assert!(!vite.contains("ksp-app-wallet-desk")); +} + +#[test] +fn pre_002_package_is_mixed_lib_bin_and_frontend_is_scaffold_only() { + let root = app_root(); + let manifest = read_text(root.join("Cargo.toml").as_path()); + assert!(manifest.contains("name = \"ksp-app-solprices-desk\"")); + assert!(manifest.contains("name = \"ksp_app_solprices_desk_lib\"")); + assert!(manifest.contains("[[bin]]")); + let main_rs = read_text(root.join("src/main.rs").as_path()); + assert!(main_rs.contains("ksp_app_solprices_desk_lib::run")); + let lib_rs = read_text(root.join("src/lib.rs").as_path()); + assert!(lib_rs.contains("pub use self::tauri::run")); + let tauri_rs = read_text(root.join("src/tauri.rs").as_path()); + assert!(tauri_rs.contains("tauri::generate_handler![emit_frontend_log, runtime_status, splash_frontend_ready]")); + let main_html = read_text(root.join("frontend/main.html").as_path()); + assert!(main_html.contains("data-view=\"prices\"")); + assert!(main_html.contains("data-view=\"diagnostics\"")); + assert!(!main_html.contains(" std::path::PathBuf { + return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); +} + +fn collect_files(directory: &std::path::Path, extension: &str, files: &mut std::vec::Vec) { + let entries = std::fs::read_dir(directory); + let entries = match entries { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + for entry in entries { + let entry = match entry { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => continue, + }; + let path = entry.path(); + if path.is_dir() { + collect_files(path.as_path(), extension, files); + continue; + } + if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some(extension) { + files.push(path); + } + } +} + +fn read_text(path: &std::path::Path) -> String { + let source = std::fs::read_to_string(path); + assert!(source.is_ok(), "unable to read {}", path.display()); + return match source { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => String::new(), + }; +} + +#[test] +fn pre_002_capability_surface_is_core_plus_tracing_only() { + let root = app_root(); + let capability = read_text(root.join("capabilities/default.json").as_path()); + assert!(capability.contains("\"core:default\"")); + assert!(capability.contains("\"tracing:default\"")); + for forbidden in ["dialog:", "fs:", "http:", "shell:"] { + assert!(!capability.contains(forbidden)); + } + let manifest = read_text(root.join("Cargo.toml").as_path()); + assert!(manifest.contains("tauri-plugin-tracing.workspace = true")); + assert!(!manifest.contains("tauri-plugin-dialog")); + assert!(!manifest.contains("ksp-offchain-transport-lib")); + assert!(!manifest.contains("reqwest")); +} + +#[test] +fn pre_002_frontend_has_no_network_persistence_or_native_dialog_surface() { + let root = app_root(); + let mut typescript = std::vec::Vec::new(); + collect_files(root.join("frontend/ts").as_path(), "ts", &mut typescript); + assert!(!typescript.is_empty()); + for path in typescript { + let source = read_text(path.as_path()); + for forbidden in ["fetch(", "XMLHttpRequest", "localStorage", "sessionStorage", "window.alert(", "window.confirm(", "window.prompt("] { + assert!(!source.contains(forbidden), "{} contains forbidden scaffold surface {forbidden}", path.display()); + } + } +} + +#[test] +fn pre_002_tauri_commands_remain_centralized_and_price_runtime_is_absent() { + let root = app_root(); + let mut rust_files = std::vec::Vec::new(); + collect_files(root.join("src").as_path(), "rs", &mut rust_files); + let mut command_count = 0usize; + for path in rust_files { + let source = read_text(path.as_path()); + let count = source.matches("#[tauri::command]").count(); + if path.file_name().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("tauri.rs") { + command_count += count; + } else { + assert_eq!(count, 0, "{} declares a Tauri command outside tauri.rs", path.display()); + } + assert!(!source.contains("MarketPriceService"), "{} advances market-price runtime before pre.003", path.display()); + assert!(!source.contains("refresh_market_price"), "{} advances refresh commands before their planned tranche", path.display()); + } + assert_eq!(command_count, 3); +} diff --git a/crates/ksp-app-solprices-desk/tests/public_api.rs b/crates/ksp-app-solprices-desk/tests/public_api.rs new file mode 100644 index 0000000..3aec116 --- /dev/null +++ b/crates/ksp-app-solprices-desk/tests/public_api.rs @@ -0,0 +1,14 @@ +// file: crates/ksp-app-solprices-desk/tests/public_api.rs +// version: 1 + +//! Public API checks for the KSP SOL prices desktop application library. + +#![forbid(unsafe_code)] +#![deny(unreachable_pub)] +#![warn(missing_docs)] + +#[test] +fn run_entry_point_is_available_from_crate_root() { + let run_entry_point: fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()> = ksp_app_solprices_desk_lib::run; + assert_eq!(std::mem::size_of_val(&run_entry_point), std::mem::size_of:: ksp_core_lib::Result<()>>()); +} diff --git a/crates/ksp-app-solprices-desk/tsconfig.json b/crates/ksp-app-solprices-desk/tsconfig.json new file mode 100644 index 0000000..b5e3e21 --- /dev/null +++ b/crates/ksp-app-solprices-desk/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "types": [ + "vite/client", + "node" + ] + }, + "include": [ + "frontend", + "vite.config.ts" + ] +} diff --git a/crates/ksp-app-solprices-desk/unit_tests/dto_common.rs b/crates/ksp-app-solprices-desk/unit_tests/dto_common.rs new file mode 100644 index 0000000..95b574d --- /dev/null +++ b/crates/ksp-app-solprices-desk/unit_tests/dto_common.rs @@ -0,0 +1,11 @@ +// file: crates/ksp-app-solprices-desk/unit_tests/dto_common.rs +// version: 1 + +#[test] +fn command_error_projection_excludes_context_values() { + let error = ksp_core_lib::Error::new(ksp_core_lib::ErrorCode::new("test", "failure"), "safe message").with_context("secret_canary", "must-not-be-exported"); + let dto = crate::CommandErrorDto::from_error(&error); + assert_eq!(dto.domain, "test"); + assert_eq!(dto.code, "failure"); + assert_eq!(dto.message, "safe message"); +} diff --git a/crates/ksp-app-solprices-desk/unit_tests/frontend_logging.rs b/crates/ksp-app-solprices-desk/unit_tests/frontend_logging.rs new file mode 100644 index 0000000..e84c90b --- /dev/null +++ b/crates/ksp-app-solprices-desk/unit_tests/frontend_logging.rs @@ -0,0 +1,40 @@ +// file: crates/ksp-app-solprices-desk/unit_tests/frontend_logging.rs +// version: 1 + +#[test] +fn frontend_log_levels_are_explicit_and_case_insensitive() { + assert!(matches!(super::parse_level(" TRACE "), std::result::Result::Ok(super::FrontendLogLevel::Trace))); + assert!(matches!(super::parse_level("debug"), std::result::Result::Ok(super::FrontendLogLevel::Debug))); + assert!(matches!(super::parse_level("Info"), std::result::Result::Ok(super::FrontendLogLevel::Info))); + assert!(matches!(super::parse_level("warn"), std::result::Result::Ok(super::FrontendLogLevel::Warn))); + assert!(matches!(super::parse_level("ERROR"), std::result::Result::Ok(super::FrontendLogLevel::Error))); +} + +#[test] +fn unsupported_frontend_log_level_is_rejected() { + let result = super::parse_level("notice"); + assert!(result.is_err()); + let error = match result { + std::result::Result::Ok(_) => return, + std::result::Result::Err(error) => error, + }; + assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID); +} + +#[test] +fn frontend_log_targets_are_whitelisted() { + assert!(matches!(super::parse_target("frontend"), std::result::Result::Ok(super::FrontendLogTarget::Frontend))); + assert!(matches!(super::parse_target(" MAIN "), std::result::Result::Ok(super::FrontendLogTarget::Main))); + assert!(matches!(super::parse_target("Splash"), std::result::Result::Ok(super::FrontendLogTarget::Splash))); +} + +#[test] +fn arbitrary_frontend_log_target_is_rejected() { + let result = super::parse_target("ksp-app-solprices-desk.test.logging"); + assert!(result.is_err()); + let error = match result { + std::result::Result::Ok(_) => return, + std::result::Result::Err(error) => error, + }; + assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID); +} diff --git a/crates/ksp-app-solprices-desk/unit_tests/splash.rs b/crates/ksp-app-solprices-desk/unit_tests/splash.rs new file mode 100644 index 0000000..0f921c7 --- /dev/null +++ b/crates/ksp-app-solprices-desk/unit_tests/splash.rs @@ -0,0 +1,36 @@ +// file: crates/ksp-app-solprices-desk/unit_tests/splash.rs +// version: 1 + +#[test] +fn fallback_splash_settings_are_short_and_ordered() { + let settings = crate::SplashSettings::fallback(); + assert_eq!(settings.fade_in_ms(), 300); + assert_eq!(settings.minimum_ms(), 1200); + assert_eq!(settings.fade_out_ms(), 300); + assert_eq!(settings.expected_backend_lifecycle_ms(), 1800); + assert_eq!(settings.fade_in_source(), "fallback"); + assert_eq!(settings.minimum_source(), "fallback"); + assert_eq!(settings.fade_out_source(), "fallback"); +} + +#[test] +fn splash_order_contract_keeps_action_message_status_and_duration() { + let order = + crate::SplashOrderDto::new("add_message", std::option::Option::Some("ready"), std::option::Option::Some("success"), std::option::Option::Some(300)); + assert_eq!(order.action, "add_message"); + assert_eq!(order.message.as_deref(), std::option::Option::Some("ready")); + assert_eq!(order.status.as_deref(), std::option::Option::Some("success")); + 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_minimum = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "60001", 60_000); + let oversized_fade_in = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_IN_MS", "10001", 10_000); + let oversized_fade_out = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_OUT_MS", "10001", 10_000); + assert!(malformed.is_err()); + assert!(oversized_minimum.is_err()); + assert!(oversized_fade_in.is_err()); + assert!(oversized_fade_out.is_err()); +} diff --git a/crates/ksp-app-solprices-desk/vite.config.ts b/crates/ksp-app-solprices-desk/vite.config.ts new file mode 100644 index 0000000..8f361fd --- /dev/null +++ b/crates/ksp-app-solprices-desk/vite.config.ts @@ -0,0 +1,72 @@ +// file: crates/ksp-app-solprices-desk/vite.config.ts +// version: 1 + +import { NodePackageImporter } from "sass-embedded"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import { defineConfig, normalizePath } from "vite"; + +const appRoot = fileURLToPath(new URL(".", import.meta.url)); +const frontendRoot = normalizePath(resolve(appRoot, "frontend")); +const frontendDist = normalizePath(resolve(appRoot, "../../../builds/khadhroony-solana-project/ksp-app-solprices-desk/dist")); +const devHost = process.env.TAURI_DEV_HOST; + +export default defineConfig({ + clearScreen: false, + root: frontendRoot, + publicDir: false, + input: { + main: normalizePath(resolve(frontendRoot, "main.html")), + splash: normalizePath(resolve(frontendRoot, "splash.html")), + }, + build: { + outDir: frontendDist, + emptyOutDir: true, + minify: true, + sourcemap: false, + cssCodeSplit: true, + rolldownOptions: { + output: { + entryFileNames: "js/[name]-[hash].js", + chunkFileNames: "js/chunks/[name]-[hash].js", + assetFileNames: assetInfo => { + const originalName = assetInfo.names[0] ?? ""; + const extension = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase(); + if (extension === "css") { + return "css/[name]-[hash][extname]"; + } + if (["eot", "otf", "ttf", "woff", "woff2"].includes(extension)) { + return "fonts/[name]-[hash][extname]"; + } + if (["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(extension)) { + return "imgs/[name][extname]"; + } + return "otherassets/[name][extname]"; + }, + }, + }, + }, + css: { + preprocessorOptions: { + scss: { + quietDeps: true, + silenceDeprecations: ["import", "color-functions", "global-builtin"], + verbose: false, + importers: [new NodePackageImporter()], + }, + }, + }, + server: { + port: 1434, + strictPort: true, + host: devHost || false, + ws: { + protocol: "ws", + host: devHost || "localhost", + port: 1435, + }, + watch: { + ignored: ["**/src/**"], + }, + }, +}); diff --git a/deltas/0.2.12/pre.002.md b/deltas/0.2.12/pre.002.md new file mode 100644 index 0000000..3bde3a9 --- /dev/null +++ b/deltas/0.2.12/pre.002.md @@ -0,0 +1,248 @@ + + + +# Delta `0.2.12-pre.002` — scaffold `ksp-app-solprices-desk` + +## 1. Base requise + +```text +v0.2.11 ++ 0.2.12-pre.001 ++ 0.2.12-pre.001-fix.001 +``` + +La version workspace passe à : + +```text +0.2.12-pre.2 +``` + +conformément au workflow des prereleases non-fix. + +## 2. Objectif + +Créer uniquement le gabarit desktop Tauri de `ksp-app-solprices-desk` avant toute composition Off-chain fonctionnelle. + +La responsabilité de la tranche est limitée à : + +```text +package Rust mixte lib/bin +launcher single-instance mince +assemblage Tauri +splash commun KSP +shell principal Prices / Diagnostics +bootstrap Config standard + Logging +bridge Logging frontend +ports Vite/HMR 1434/1435 +capabilities minimales +packaging du registry Config courant +canaris desktop/security/public API +``` + +## 3. Frontières préservées + +`pre.002` n'introduit volontairement aucun : + +```text +MarketPriceService +ResolvedOffchainTransportConfig +composite cfg.composite.ksp-app-solprices-desk +table provider +DTO prix +refresh row/many/all +reqwest +provider SDK +fetch frontend +auto-refresh +localStorage/sessionStorage +plugin dialog/fs/http/shell frontend +``` + +Le composite SOL Prices Desk et son bootstrap Off-chain restent exclusivement propriétaires de `pre.003`. + +## 4. Scaffold Tauri + +Identité créée : + +```text +package Rust ksp-app-solprices-desk +lib ksp_app_solprices_desk_lib +bin ksp-app-solprices-desk +productName KSP SOL Prices Desk +identifier com.sasedev.ksp-app-solprices-desk +Vite HTTP 1434 strict +Vite HMR 1435 +frontendDist ../../../builds/khadhroony-solana-project/ksp-app-solprices-desk/dist +windows splash + main +permissions core:default + tracing:default +plugin desktop tauri-plugin-tracing uniquement +``` + +`main.rs` reste un launcher mince avec verrou single-instance et normalisation du current working directory de développement vers la racine workspace. + +Les commandes Tauri sont centralisées dans `src/tauri.rs` : + +```text +emit_frontend_log +runtime_status +splash_frontend_ready +``` + +`runtime_status` est volontairement limité au scaffold et ne projette encore aucun état Off-chain. + +## 5. Config et Logging transitoires de scaffold + +Le gabarit doit déjà respecter le splash commun et le runtime Logging KSP. Avant l'introduction du composite dédié en `pre.003`, le Desk résout donc le document Logging standard via `ksp-config-lib`, avec le même fallback console borné que les Desks existants. + +Cette décision ne préjuge pas la composition finale : `pre.003` remplacera ce bootstrap transitoire par le composite `logging + offchain_transport` prévu par le plan. + +Le bundle `pre.002` embarque les **10 ressources** du registry Config courant. Il n'invente pas le onzième composite avant son descriptor. La synchronisation à 11 ressources restera atomique dans `pre.003` pour Config Desk, Wallet Desk et SOL Prices Desk. + +## 6. Frontend + +Le frontend crée seulement le shell prévu : + +```text +Prices placeholder explicite non fonctionnel +Diagnostics version / phase / count Config / Logging sûr +``` + +Le header suit le contrat KSP `SOL Prices Desk — `. La navigation contient `Prices` et `Diagnostics` mais aucune table DataTables ni interaction prix. + +Le splash reprend le lifecycle commun piloté par Rust et le bridge Logging frontend. Aucun fichier de font n'est ajouté au nouveau package ; le scaffold utilise une police système pour le texte du splash. + +## 7. Dépendances + +La crate ajoute uniquement les dépendances nécessaires au gabarit actuel : + +```text +chrono +fs2 +ksp-config-lib +ksp-core-lib +ksp-logging-lib +serde +tauri +tauri-plugin-tracing +tokio(time) +ts-rs +``` + +Le frontend n'ajoute que les dépendances réellement utilisées par le scaffold : + +```text +@fortawesome/fontawesome-free +@tauri-apps/api +bootstrap +simplebar +``` + +DataTables et `tauri-plugin-dialog` ne sont pas ajoutés prématurément. + +## 8. Canaris ajoutés + +Tests unitaires : + +```text +CommandErrorDto sans context sensible +frontend log levels/targets contrôlés +splash timings/order bornés +``` + +Tests d'intégration : + +```text +public API run au crate-root +ports 1434/1435 + strictPort +product/identifier/frontendDist/hooks propres +lib + bin + main launcher mince +splash + main +10 ressources Config courantes, aucun composite solprices anticipé +capabilities core + tracing uniquement +absence Off-chain/reqwest/plugin dialog +absence fetch/storage/dialogues navigateur +commandes centralisées tauri.rs +absence MarketPriceService/refresh dans Rust applicatif +``` + +## 9. Fichiers ajoutés + +```text +crates/ksp-app-solprices-desk/** +deltas/0.2.12/pre.002.md +``` + +Les bindings TS-RS et autres artefacts générés ne sont pas versionnés. + +## 10. Fichiers modifiés + +```text +Cargo.toml +docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md +docs/validation/015-V0_2_12_SOL_PRICES_DESK.md +``` + +Le plan marque `pre.002` comme « implémenté ; gate opérateur à confirmer ». + +## 11. Fichiers volontairement inchangés + +```text +config/** +crates/ksp-app-config-desk/** +crates/ksp-app-wallet-desk/** +crates/ksp-config-lib/** +crates/ksp-offchain-transport-lib/** +ROADMAP.md +CHANGELOG.md +prompts/017-V0_2_12_START_PROMPT.md +``` + +Aucun document Config, registry ou resource d'un Desk existant n'est modifié avant la tranche atomique `pre.003`. + +## 12. Validations exécutées dans le sandbox + +Avant la rédaction finale du delta : + +```text +python3 scripts/audit_rust_workspace_rules.py PASS +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12 + PASS +``` + +Le sandbox ne possède ni `cargo` ni `rustc`. Les validations suivantes ne sont donc pas déclarées PASS localement : + +```text +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-app-solprices-desk +cargo test --workspace +``` + +Le build Tauri de production n'appartient pas à ce gate intermédiaire ; il reste réservé au couloir final du plan. + +## 13. Gate opérateur attendu + +Après application de l'archive : + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12 +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-app-solprices-desk +cargo test --workspace +``` + +Une correction découverte par ce gate reste un `pre.002-fix.NNN` et n'avance pas à `pre.003`. + +## 14. Suite + +La prochaine responsabilité reste : + +```text +0.2.12-pre.003 — composite Config + registry/resources atomiques + bootstrap Off-chain +``` + +Aucune table prix ni commande de refresh ne doit être fusionnée dans `pre.003`. diff --git a/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md b/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md index becde3b..fa80a29 100644 --- a/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md +++ b/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.2.12` — SOL Prices Desk + intégration prix Wallet Desk @@ -472,7 +472,7 @@ Remplacement du forecast tabulaire par des sous-sections éditables avec statut ### `pre.002` — Scaffold `ksp-app-solprices-desk` -**Statut : planifié.** +**Statut : implémenté ; gate opérateur à confirmer.** Création du scaffold strict de `ksp-app-solprices-desk` : crate lib/bin, shell Tauri, frontend de base, splash, logging et paire de ports réservée `1434/1435`. La tranche n'implémente encore ni table prix, ni `MarketPriceService`, ni refresh réseau. diff --git a/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md b/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md index af0f119..9d396e8 100644 --- a/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md +++ b/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md @@ -1,5 +1,5 @@ - + # Validation `0.2.12` — SOL Prices Desk + intégration Wallet Desk @@ -48,8 +48,8 @@ NOT RUN commande non exécutée dans le contexte indiqué | Surface | Contrat | Statut | |------------------------|-----------------------------------------|---------| -| splash Price Desk | lifecycle KSP commun | PENDING | -| main shell Price Desk | header/navigation KSP | PENDING | +| splash Price Desk | lifecycle KSP commun | PASS | +| main shell Price Desk | header/navigation KSP | PASS | | table providers | registry provider-neutral, ordre stable | PENDING | | prix exact | String issue de `MarketPriceDecimal` | PENDING | | availability/fraîcheur | état générique + timestamps réels | PENDING | @@ -119,8 +119,8 @@ NOT RUN commande non exécutée dans le contexte indiqué | double-click race | in-flight/order contrôlés | PENDING | | close pendant refresh | aucune persistance/sûreté frontend requise | PENDING | | CWD/resource confusion | canaris propres à chaque app | PENDING | -| Price Desk capabilities | `core:default`, `tracing:default` uniquement | PENDING | -| plugin dialog Price Desk | absent | PENDING | +| Price Desk capabilities | `core:default`, `tracing:default` uniquement | PASS | +| plugin dialog Price Desk | absent | PASS | | frontend sensitive logging | action/ID/count/code seulement | PENDING | | Wallet VIEW/OWNER/secrets | non-régression | PENDING | @@ -142,15 +142,15 @@ NOT RUN commande non exécutée dans le contexte indiqué | Contrat | Attendu | Statut | |------------------------------------|------------------------------------|---------| -| Price Desk ports | 1434/1435 strict | PENDING | -| windows | splash + main | PENDING | -| Rust package | lib + bin; `main.rs` mince | PENDING | -| commands | centralisées `tauri.rs` | PENDING | -| frontendDist | hors source, path Price Desk dédié | PENDING | -| hooks | npm dev/build cwd crate | PENDING | -| backend cross-app | aucune référence Wallet par erreur | PENDING | +| Price Desk ports | 1434/1435 strict | PASS | +| windows | splash + main | PASS | +| Rust package | lib + bin; `main.rs` mince | PASS | +| commands | centralisées `tauri.rs` | PASS | +| frontendDist | hors source, path Price Desk dédié | PASS | +| hooks | npm dev/build cwd crate | PASS | +| backend cross-app | aucune référence Wallet par erreur | PASS | | resources | registry complet packagé | PENDING | -| bundle | targets/icons cohérents | PENDING | +| bundle | targets/icons cohérents | PASS | | Tauri production build Price Desk | crate-local PASS attendu | PENDING | | Tauri production build Wallet Desk | crate-local PASS attendu | PENDING | @@ -195,3 +195,29 @@ pre.014 réconciliation documentaire finale pre.015 prompt suivant + CHANGELOG + ROADMAP uniquement rel.001 publication stable ``` + +## 13. Gate `0.2.12-pre.002` + +Le scaffold strict est matérialisé sans avancer la composition Off-chain : + +```text +crate mixte lib/bin ksp-app-solprices-desk PASS +ports Vite/HMR 1434/1435 strict PASS +fenêtres splash + main PASS +frontendDist/hook/backend propres PASS +bridge Logging + tauri-plugin-tracing PASS +capabilities core:default + tracing:default PASS +plugin dialog/fs/http/shell frontend absent +resources Config 10/10 registry courant; 11e composite réservé à pre.003 +MarketPriceService / refresh / table prix absent par contrat pre.002 +``` + +Validations sandbox après matérialisation : + +```text +python3 scripts/audit_rust_workspace_rules.py PASS +python3 scripts/audit_markdown_tables.py README.md ... deltas PASS +cargo fmt/check/clippy/test NOT RUN — cargo indisponible dans le sandbox +``` + +Le statut de `pre.002` reste donc « implémenté ; gate opérateur à confirmer » jusqu'au retour du gate Cargo de l'opérateur.