24 Commits

Author SHA1 Message Date
e42688794a v0.2.12-rel.001 2026-08-27 20:32:54 +02:00
c73e4c0aec v0.2.12-pre.011-fix.001 2026-08-27 20:31:50 +02:00
e701267135 v0.2.12-pre.011 2026-08-27 19:39:20 +02:00
4f71dbc62d v0.2.12-pre.010 2026-08-27 19:11:07 +02:00
ca6af3d904 v0.2.12-pre.009-fix.001 2026-08-27 17:57:50 +02:00
233aaf9bd8 v0.2.12-pre.009 2026-08-27 17:18:29 +02:00
d3bb026345 v0.2.12-pre.008-fix.003 2026-08-27 15:20:57 +02:00
d363991f78 v0.2.12-pre.008-fix.003 2026-08-27 15:20:45 +02:00
f0493df09e v0.2.12-pre.008-fix.002 2026-08-27 15:18:34 +02:00
fe46cbe3bd v0.2.12-pre.008-fix.001 2026-08-27 14:46:41 +02:00
f1e365ec97 v0.2.12-pre.008 2026-08-27 14:41:08 +02:00
2042482592 v0.2.12-pre.007 2026-08-27 14:35:18 +02:00
7d0fe8ac5f v0.2.12-pre.006 2026-08-27 11:27:39 +02:00
a00e485ebb v0.2.12-pre.005-fix.001 2026-08-27 10:51:47 +02:00
9d351dd1c3 v0.2.12-pre.005 2026-08-27 10:43:01 +02:00
5ac2197b6e v0.2.12-pre.004-fix.002 2026-08-27 10:22:53 +02:00
c5a70a2eff v0.2.12-pre.004-fix.001 2026-08-27 10:11:33 +02:00
e7c3379f44 v0.2.12-pre.004 2026-08-27 10:02:46 +02:00
afc50c1e48 v0.2.12-pre.003-fix.001 2026-08-27 09:37:55 +02:00
5a165296d9 v0.2.12-pre.003 2026-08-27 09:33:29 +02:00
0d01017c31 v0.2.12-pre.002-fix.001 2026-08-27 09:01:14 +02:00
d1d683d101 v0.2.12-pre.002 2026-08-27 08:38:22 +02:00
f7e4ea09bc v0.2.12-pre.001-fix.001 2026-08-26 20:55:18 +02:00
9a7eda1aa6 v0.2.12-pre.001 2026-08-26 20:41:45 +02:00
122 changed files with 12734 additions and 198 deletions

View File

@@ -1,8 +1,18 @@
<!-- file: CHANGELOG.md -->
<!-- version: 15 -->
<!-- version: 16 -->
# Changelog KSP
## 0.2.12 — SOL Prices Desk + projection prix Wallet — 2026-08-27
`0.2.12` ajoute `ksp-app-solprices-desk`, troisième Desk Tauri spécialisé de KSP, comme HID strictement provider-neutral au-dessus de `ksp-offchain-transport-lib`. Son composite dédié sélectionne `std.logging` et `std.offchain_transport`; la vue Prices expose linventaire des huit providers SOL/USD, disponibilité, sémantique, auth, prix exact sous forme de chaîne, timestamps provider/KSP distincts et retry deadline sans inventer de fraîcheur. Les refresh manuel individuel, sélectionné et global passent exclusivement par `MarketPriceService`; les batchs sont bornés à `1..=64`, les états `in_flight` restent backend-owned et aucun polling, scheduler, fallback, moyenne ou prix canonique nest introduit dans le Desk. Le frontend conserve labsence de réseau/persistence navigateur et journalise les interactions sans prix, credential, URL, header ou payload provider.
La même release étend `ksp-app-wallet-desk` de façon volontairement minimale. Le refresh de balance lance `getBalance` et le refresh SOL/USD générique, puis calcule côté Rust une moyenne arithmétique uniquement à partir des observations réussies du refresh courant et léquivalent USD exact du solde, sans `f64`/`f32`. Le prix reste auxiliaire : une indisponibilité Off-chain ninvalide jamais une balance Solana réussie et produit simplement `N.A.`. Le Wallet ne connaît aucun provider concret, nexpose aucune commande Market Price dédiée au frontend et najoute aucune valorisation persistée ou logique de consensus.
Le hardening final ajoute une garde IPC hostile avant parsing pour les sélections de plus de 64 providers, un smoke live de composition `SOL Prices composite -> Config -> MarketPriceService -> refresh_all`, les canaris de frontières et les builds Tauri Linux. `pre.009-fix.001` corrige en outre la politique de version desktop : la version KSP/bundle est Cargo-owned, `tauri.conf.json` omet désormais `version` et les `package.json` privés ne sont plus forcés à suivre chaque bump workspace. Le gate opérateur passe audits Rust/Markdown, `cargo check`, Clippy, tests ciblés, `cargo test --workspace`, smoke live, puis builds `.deb`/`.rpm`/`.AppImage` pour SOL Prices, Wallet et Config Desk.
La clôture documentaire réconcilie README/USAGE, architectures, plan et validation, puis prépare `prompts/018-V0_2_13_START_PROMPT.md`. Ce prompt ouvre `0.2.13 — Interface foundation` exclusivement depuis le tag stable `v0.2.12`. Une reconnaissance préalable de larchive historique `khadhroony-bot3` fournit des idées de contrats wire et dextension, mais `0.2.13-pre.001` doit les réauditer contre larchitecture KSP actuelle avant toute implémentation : `ksp-interface-lib` porte les données/wires publics, `ksp-program-api` reste réservé au comportement extensible de `0.2.14`, et les implémentations officielles `ksp-program-lib` arrivent plus tard.
## 0.2.11 — Off-chain price transport — 2026-08-26
`0.2.11` crée `ksp-offchain-transport-lib` avec une première surface volontairement bornée à **SOL/USD** et huit adapters REST V1 : CoinGecko, CoinMarketCap, CoinPaprika, Kraken, Coinbase Exchange, Jupiter Price V3, Birdeye et DexScreener. Les adapters utilisent `reqwest` directement, sans SDK provider, avec origines HTTPS possédées par la crate, DTOs wire privés et prix normalisés par `MarketPriceDecimal` sans passage canonique par `f64`. `MarketPriceObservation` conserve paire, sémantique, provenance et timestamps réellement disponibles afin de ne pas confondre prix agrégé, dernier trade d'exchange, heuristique Solana, spot provider et prix DEX pair. DexScreener exige une paire Solana explicite et ne réalise aucune discovery ou sélection automatique de pool.

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 285
# version: 302
[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.11"
version = "0.2.12"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md -->
<!-- version: 89 -->
<!-- version: 90 -->
# Roadmap KSP
@@ -56,9 +56,9 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes dupdate, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope.
- [X] `0.2.10` — OrbitFlare Yellowstone gRPC stable : profil Config V3 Devnet, License Key injectée comme metadata secrète `x-token`, smoke live `Subscribe -> Slot + Ping` validé deux fois, sans modification du moteur N1/N2 ni heartbeat provider.
- [X] `0.2.11` — Off-chain price transport stable : `ksp-offchain-transport-lib` expose SOL/USD via huit adapters REST `reqwest` sans SDK provider, décimal exact, sémantiques/provenance explicites, registry/availability/rate limits et refresh single/many/all génériques ; Config `std.offchain_transport` construit le service sans dépendance inverse, DexScreener reste lié à une paire explicite sans discovery, aucun consensus/fallback automatique nest introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
- [ ] `0.2.12`Introduire `ksp-app-solprices-desk` comme HID provider-agnostic consommant uniquement `ksp-offchain-transport-lib`, avec tableau prix/provider et refresh individuel/multiple ; puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation.
- [ ] `0.2.13` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
- [ ] `0.2.14` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
- [X] `0.2.12`SOL Prices Desk + projection prix Wallet stables : HID provider-neutral avec refresh row/selected/all et batch `1..=64`, observations/timestamps exacts sans polling/consensus, puis refresh balance Wallet enrichi dune moyenne SOL/USD consumer-owned et dun équivalent USD exact best-effort ; smoke live de composition, workspace complet et bundles Tauri Linux validés.
- [ ] `0.2.13` — Introduire la première surface de `ksp-interface-lib`, façade wire KSP publique et passive utilisable par les implémentations officielles et externes ; `pre.001` doit confronter larchitecture actuelle aux contrats utiles de lancien `khadhroony-bot3` sans recréer son monolithe `ks-lib`, et séparer explicitement données wire, comportement Program et implémentations.
- [ ] `0.2.14` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet ; les traits de reconnaissance/decode/build et autres comportements ne doivent pas être aspirés prématurément dans `ksp-interface-lib`.
### TODO/IDEAS — providers Yellowstone non planifiés

View File

@@ -0,0 +1,51 @@
{
"format_version": 1,
"default_profile": "public_keyless",
"profiles": [
{
"profile_id": "public_keyless",
"documents": [
{
"component_id": "logging",
"file_id": "cfg.std.logging",
"profile_id": "console_info"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
}
]
},
{
"profile_id": "all_free",
"documents": [
{
"component_id": "logging",
"file_id": "cfg.std.logging",
"profile_id": "console_info"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "all_free"
}
]
},
{
"profile_id": "tests",
"documents": [
{
"component_id": "logging",
"file_id": "cfg.std.logging",
"profile_id": "console_info"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
}
]
}
]
}

View File

@@ -10,6 +10,11 @@
"file_id": "cfg.std.logging",
"profile_id": "supertrace"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
},
{
"component_id": "transport",
"file_id": "cfg.std.transport",
@@ -30,6 +35,11 @@
"file_id": "cfg.std.logging",
"profile_id": "supertrace"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
},
{
"component_id": "transport",
"file_id": "cfg.std.transport",
@@ -50,6 +60,11 @@
"file_id": "cfg.std.logging",
"profile_id": "supertrace"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
},
{
"component_id": "transport",
"file_id": "cfg.std.transport",

View File

@@ -10,6 +10,11 @@
"file_id": "cfg.std.logging",
"profile_id": "supertrace"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
},
{
"component_id": "transport",
"file_id": "cfg.std.transport",
@@ -30,6 +35,11 @@
"file_id": "cfg.std.logging",
"profile_id": "supertrace"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
},
{
"component_id": "transport",
"file_id": "cfg.std.transport",
@@ -50,6 +60,11 @@
"file_id": "cfg.std.logging",
"profile_id": "supertrace"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
},
{
"component_id": "transport",
"file_id": "cfg.std.transport",

View File

@@ -1,11 +1,12 @@
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
<!-- version: 2 -->
<!-- version: 3 -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<link rel="icon" href="imgs/logo.png">
<link rel="stylesheet" href="sass/splash.scss">
<title>KSP Config Desk — Chargement</title>
</head>

View File

@@ -1,7 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KSP Config Desk",
"version": "0.2.6",
"identifier": "com.sasedev.ksp-app-config-desk",
"build": {
"beforeDevCommand": {
@@ -53,6 +52,7 @@
"icons/favicon.ico"
],
"resources": {
"../../config/composite.ksp-app-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
"../../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",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
// version: 8
// version: 10
//! Desktop build/shell contract audits for Config Desk.
@@ -26,43 +26,6 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
};
}
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
let core = match value.split_once('-') {
std::option::Option::Some((core, _)) => core,
std::option::Option::None => value,
};
let mut parts = core.split('.');
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
if parts.next().is_some() {
return std::option::Option::None;
}
return std::option::Option::Some((major, minor, patch));
}
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
assert!(value.is_some(), "{field} packaged version must exist");
let value = match value {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let parsed = parse_semver_core(value);
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
if let std::option::Option::Some(parsed) = parsed {
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
}
}
#[test]
fn tauri_and_frontend_build_contracts_remain_explicit() {
let root = app_root();
@@ -141,16 +104,17 @@ fn pre_014_template_uses_sidebar_navigation_and_kbot_style_splash_contract() {
fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() {
let root = app_root();
let tauri = read_json(root.join("tauri.conf.json").as_path());
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
assert!(tauri.pointer("/version").is_none(), "Config Desk must let Tauri derive the bundle version from Cargo");
let package = read_json(root.join("package.json").as_path());
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
assert_packaged_version_floor(package_version, "package.json");
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
assert_eq!(package.pointer("/private").and_then(serde_json::Value::as_bool), std::option::Option::Some(true));
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
assert!(resources.is_some(), "packaged Config resources map must exist");
if let std::option::Option::Some(resources) = resources {
assert_eq!(resources.len(), 10);
assert_eq!(resources.len(), 11);
assert_eq!(
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
);
assert_eq!(resources.get("../../config/std.logging.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.logging.json"));
assert_eq!(
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
// version: 6
// version: 7
#[test]
fn profile_inventory_exposes_registered_profile_documents() {
@@ -21,6 +21,9 @@ fn profile_inventory_exposes_registered_profile_documents() {
assert!(inventory.iter().any(|document| -> bool {
return document.file_id == ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
}));
assert!(inventory.iter().any(|document| -> bool {
return document.file_id == ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK;
}));
for document in inventory {
assert!(!document.default_profile.is_empty());
assert!(document.profile_ids.iter().any(|profile_id| -> bool {

View File

@@ -0,0 +1,41 @@
# file: crates/ksp-app-solprices-desk/Cargo.toml
# version: 2
[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" }
ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-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

View File

@@ -0,0 +1,154 @@
<!-- file: crates/ksp-app-solprices-desk/README.md -->
<!-- version: 2 -->
# `ksp-app-solprices-desk`
`ksp-app-solprices-desk` est l'application desktop spécialisée de visualisation et de refresh manuel des prix SOL/USD fournis par `ksp-offchain-transport-lib`.
L'application reste une **HID provider-neutral** : elle consomme le registry, les observations, les disponibilités et les opérations génériques du service Off-chain sans connaître les endpoints, credentials, limites ou wire formats propres aux providers.
## 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, le bootstrap Config/Logging/Off-chain, l'état de présentation en mémoire et les commands Tauri.
## Responsabilités
```text
ksp-config-lib -> composite, profils, secrets et runtime packagé
ksp-offchain-transport-lib -> providers, HTTP, registry, availability, limits et refresh
ksp-logging-lib -> logging/tracing applicatif
ksp-app-solprices-desk -> orchestration Tauri + projections provider-neutral sûres
```
Le Desk ne possède aucun client HTTP provider et ne réimplémente ni admission, ni cooldown, ni retry provider.
## Vues
### Prices
La vue `Prices` affiche l'inventaire SOL/USD dans l'ordre stable du registry et conserve chaque observation séparée.
La projection comprend notamment :
- identité/display provider et paire ;
- sémantique et mode d'authentification ;
- availability générique ;
- prix exact sous forme de `String` ;
- timestamp provider lorsqu'il existe ;
- timestamp de réception KSP ;
- `retry_at` lorsqu'il existe ;
- état `Refreshing` local à la présentation.
Aucune moyenne, aucun fallback automatique et aucun prix canonique ne sont produits par cette application.
Les actions manuelles sont :
```text
Refresh
Refresh selected
Refresh all
Select all providers
Clear selection
```
Le batch applicatif est borné à `1..=64` IDs opaques avant parsing/dispatch. Les doublons, IDs inconnus et conflits `in_flight` sont rejetés avant mutation partielle.
### Diagnostics
La vue `Diagnostics` expose uniquement des informations de runtime sûres : identité/version applicative, profils résolus et compte de providers. Aucun endpoint, header, API key, payload provider ou prix n'est journalisé comme diagnostic brut.
## Config et profils
Le composite dédié est :
```text
cfg.composite.ksp-app-solprices-desk
```
Il compose :
```text
logging
Off-chain Transport
```
Profils committed :
```text
public_keyless profil par défaut
all_free modes gratuits incluant les credentials Config requis
tests profil déterministe de composition
```
Le frontend ne sélectionne ni provider concret ni secret. La résolution des credentials reste entièrement possédée par Config.
## Frontières desktop
Le frontend utilise uniquement :
```text
core:default
tracing:default
```
Il ne possède aucun accès direct filesystem, HTTP, shell ou persistence navigateur. Les refresh passent uniquement par les commands Rust centralisées dans `tauri.rs`.
Il n'existe aucun polling, auto-refresh ou scheduler prix dans le Desk. Les deadlines/cooldowns affichés proviennent de `ksp-offchain-transport-lib`.
## Ports et build
Les ports réservés sont :
```text
Vite HTTP : 1434
Vite WS : 1435
```
Le frontend build est isolé sous :
```text
builds/khadhroony-solana-project/ksp-app-solprices-desk/dist
```
La version du binaire et des bundles Tauri provient de la version Cargo résolue. `tauri.conf.json` ne duplique pas cette version ; `package.json` reste un manifeste frontend privé non autoritatif pour la release KSP.
## Runtime packagé
Les onze resources Config/Schemas enregistrées sont embarquées dans le bundle. Au démarrage release, `ksp-config-lib` prépare la racine KSP user-writable commune, seed les documents absents, resynchronise les schemas et ne bundle jamais `.env`.
## Développement et validation
```bash
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
```
Gate Rust courant :
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-app-solprices-desk
cargo test --workspace
```
Smoke live de composition opt-in :
```bash
cargo test -p ksp-app-solprices-desk --test market_price_composition_live_smoke -- --ignored --nocapture
```
Build production :
```bash
(cd crates/ksp-app-solprices-desk && cargo tauri build)
```
Voir également [`USAGE.md`](USAGE.md), [`../ksp-offchain-transport-lib/README.md`](../ksp-offchain-transport-lib/README.md) et [`../../docs/validation/015-V0_2_12_SOL_PRICES_DESK.md`](../../docs/validation/015-V0_2_12_SOL_PRICES_DESK.md).

View File

@@ -0,0 +1,149 @@
<!-- file: crates/ksp-app-solprices-desk/USAGE.md -->
<!-- version: 1 -->
# Utilisation de `ksp-app-solprices-desk`
## 1. Lancement de développement
Depuis la racine du workspace :
```bash
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
```
Le launcher debug normalise son current working directory vers la racine du workspace avant le bootstrap Config.
Les ports réservés sont :
```text
Vite HTTP : 1434
Vite WS : 1435
```
## 2. Profil de prix
Le composite `cfg.composite.ksp-app-solprices-desk` utilise `public_keyless` par défaut.
Profils committed :
```text
public_keyless providers utilisables sans secret
all_free modes gratuits avec credentials Config lorsqu'ils sont requis
tests composition de test
```
La sélection d'un autre profil appartient aux mécanismes Config ; l'interface ne demande jamais directement une API key et ne choisit pas d'endpoint provider.
## 3. Vue Prices
La vue `Prices` présente une row par provider du registry Off-chain.
Les informations principales sont :
- paire SOL/USD ;
- sémantique de prix ;
- mode d'authentification ;
- availability ;
- prix exact lorsqu'une observation existe ;
- timestamp provider lorsqu'il est réellement fourni ;
- timestamp de réception KSP ;
- prochaine deadline `retry_at` lorsqu'elle existe.
`Not supplied` signifie qu'un provider n'a pas fourni de timestamp exploitable. Cette absence n'est jamais remplacée artificiellement par le timestamp KSP.
## 4. Refresh individuel
Le bouton `Refresh` d'une row déclenche uniquement le provider correspondant via `MarketPriceService::refresh`.
Pendant l'appel, la row passe temporairement à `Refreshing`. Le Desk ne garde aucun mutex de présentation pendant l'I/O réseau.
Si le service ne produit pas de nouvelle observation, la dernière observation réussie peut rester visible tandis que l'availability/retry est actualisée.
## 5. Refresh sélectionné
Cocher une ou plusieurs rows puis utiliser :
```text
Refresh selected
```
La sélection est reconstruite dans l'ordre courant du registry. Les doublons, IDs inconnus, conflits `in_flight`, batch vide et batch supérieur à 64 IDs sont rejetés côté Rust avant dispatch partiel.
Les contrôles :
```text
Select all providers
Clear selection
```
ne déclenchent aucun accès réseau par eux-mêmes.
## 6. Refresh global
```text
Refresh all
```
appelle la surface générique `MarketPriceService::refresh_all` et conserve l'ordre déterministe du service.
Les providers disabled, en cooldown ou temporairement indisponibles restent classifiés par Off-chain Transport. Le frontend ne contourne jamais ces règles et n'implémente aucun retry local.
## 7. Lecture des états
Les états visibles sont provider-neutral, notamment :
```text
Ready
Cooling down
Authentication unavailable
Disabled
Misconfigured
Quota unavailable
Temporarily unavailable
Refreshing
```
Le résumé en haut de la vue indique les nombres de providers, rows ready, observations disponibles, états nécessitant attention et refresh en cours.
Aucune moyenne ou fusion de prix n'est calculée dans SOL Prices Desk.
## 8. Diagnostics et sécurité
La vue `Diagnostics` expose uniquement les informations sûres de composition/runtime.
Le frontend :
- ne fait aucun `fetch`/XHR provider ;
- ne possède aucun endpoint ou header provider ;
- ne stocke aucun secret dans `localStorage`/`sessionStorage` ;
- n'utilise aucun scheduler ou polling prix ;
- ne journalise aucun prix ou payload provider brut.
Les actions utilisateur sont instrumentées via le bridge Logging KSP.
## 9. Runtime packagé
En build release, les documents Config et schemas enregistrés sont embarqués comme resources Tauri. `ksp-config-lib` prépare ensuite la racine KSP user-writable commune ; les Config utilisateur existantes sont conservées, les schemas sont resynchronisés et `.env` n'est jamais embarqué.
La version de bundle est dérivée de Cargo. `package.json` reste un manifeste frontend privé.
## 10. Validation de publication
Avant le build :
```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
cargo test -p ksp-app-solprices-desk --test market_price_composition_live_smoke -- --ignored --nocapture
```
Le build production est :
```bash
(cd crates/ksp-app-solprices-desk && cargo tauri build)
```

View File

@@ -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()
}

View File

@@ -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"
]
}

View File

@@ -0,0 +1,25 @@
<!-- file: crates/ksp-app-solprices-desk/frontend/fonts/README.md -->
<!-- version: 1 -->
# Fonts du splash SOL Prices Desk
SOL Prices Desk réutilise le gabarit de splash KSP déjà stabilisé dans Config Desk et Wallet Desk et conserve la police locale :
```text
DOS_Amazigh.ttf
font-family: Dos Amazigh
```
Destination :
```text
crates/ksp-app-solprices-desk/frontend/fonts/DOS_Amazigh.ttf
```
Empreinte SHA-256 de l'asset repris du gabarit :
```text
a01ff4b5a0d699db7cefcf5336bf0e379ff6fbf0d096087571a13c8782f9aa02
```
`splash.scss` déclare `Dos Amazigh` avec `@font-face` et l'applique au titre `#app-name`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

View File

@@ -0,0 +1,188 @@
<!-- file: crates/ksp-app-solprices-desk/frontend/main.html -->
<!-- version: 8 -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<link rel="icon" href="imgs/logo.png">
<link rel="stylesheet" href="sass/main.scss">
<title>SOL Prices Desk — Prices</title>
</head>
<body>
<header class="app-header">
<nav class="navbar h-100 py-0 bg-light text-dark">
<div class="container-fluid px-4 flex-nowrap">
<div class="navbar-brand d-flex align-items-center me-4 flex-nowrap">
<img alt="KSP" src="imgs/logo.png" class="app-logo">
<span class="ps-2 fs-4 fw-semibold text-primary text-nowrap">SOL Prices Desk</span>
<span class="mx-2 fs-5 text-body-secondary" aria-hidden="true"></span>
<span id="headerViewTitle" class="fs-5 text-body text-nowrap">Prices</span>
</div>
</div>
</nav>
</header>
<main class="app-main container-fluid p-0">
<div class="row g-0 h-100 flex-nowrap">
<aside class="col-auto app-sidebar border-end bg-body-tertiary">
<div class="app-sidebar-scroll h-100" data-simplebar>
<nav class="nav nav-pills flex-column gap-1 p-3" aria-label="Navigation SOL Prices Desk">
<button class="nav-link text-start active" type="button" data-view="prices">
<i class="fa-solid fa-dollar-sign me-2" aria-hidden="true"></i>Prices
</button>
<button class="nav-link text-start" type="button" data-view="diagnostics">
<i class="fa-solid fa-stethoscope me-2" aria-hidden="true"></i>Diagnostics
</button>
</nav>
</div>
</aside>
<section class="col app-content app-scrollable h-100" data-simplebar>
<div class="container-fluid px-4 py-4">
<div class="card app-shell-card mx-auto shadow-sm border-0">
<div class="card-body p-4">
<section data-view-panel="prices">
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-3">
<div>
<h1 class="h3 mb-1">Prices</h1>
<p class="text-body-secondary mb-0">Inventaire provider-neutral SOL/USD. Les observations restent distinctes : aucune moyenne, aucun fallback automatique et aucun polling.</p>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap app-market-toolbar">
<span id="selectedProviderCount" class="small text-body-secondary" aria-live="polite">0 selected</span>
<button id="clearSelectionButton" class="btn btn-sm btn-outline-secondary" type="button" disabled>
<i class="fa-solid fa-xmark me-1" aria-hidden="true"></i>Clear selection
</button>
<button id="refreshSelectedButton" class="btn btn-sm btn-outline-primary" type="button" disabled>
<i class="fa-solid fa-list-check me-1" aria-hidden="true"></i>Refresh selected
</button>
<button id="refreshAllButton" class="btn btn-sm btn-primary" type="button" disabled>
<i class="fa-solid fa-arrows-rotate me-1" aria-hidden="true"></i>Refresh all
</button>
</div>
</div>
<div id="pricesSummary" class="row g-2 mb-3" aria-label="Résumé des providers">
<div class="col-6 col-lg">
<div class="app-market-summary-card">
<span class="app-market-summary-label">Providers</span>
<strong id="pricesSummaryProviders">0</strong>
</div>
</div>
<div class="col-6 col-lg">
<div class="app-market-summary-card">
<span class="app-market-summary-label">Ready</span>
<strong id="pricesSummaryReady">0</strong>
</div>
</div>
<div class="col-6 col-lg">
<div class="app-market-summary-card">
<span class="app-market-summary-label">Observed</span>
<strong id="pricesSummaryObserved">0</strong>
</div>
</div>
<div class="col-6 col-lg">
<div class="app-market-summary-card">
<span class="app-market-summary-label">Attention</span>
<strong id="pricesSummaryAttention">0</strong>
</div>
</div>
<div class="col-12 col-lg">
<div class="app-market-summary-card">
<span class="app-market-summary-label">Refreshing</span>
<strong id="pricesSummaryLoading">0</strong>
</div>
</div>
</div>
<div id="pricesLoading" class="app-placeholder">
<div>
<i class="fa-solid fa-spinner fa-spin text-body-secondary mb-3" aria-hidden="true"></i>
<h2 class="h5">Chargement du registry</h2>
<p class="text-body-secondary mb-0">Projection de l'inventaire provider-neutral depuis le runtime backend.</p>
</div>
</div>
<div id="pricesEmpty" class="app-placeholder" hidden>
<div>
<i class="fa-solid fa-table-list text-body-secondary mb-3" aria-hidden="true"></i>
<h2 class="h5">Aucun provider configuré</h2>
<p class="text-body-secondary mb-0">Le runtime Off-chain ne contient actuellement aucune ligne provider.</p>
</div>
</div>
<div id="pricesDiagnostic" class="alert alert-warning mb-3" role="status" aria-live="polite" hidden></div>
<div id="pricesTableContainer" class="table-responsive app-market-table-container" hidden>
<table id="marketPriceTable" class="table table-hover align-middle mb-0 app-market-table">
<caption class="visually-hidden">Providers SOL/USD, disponibilité, prix exacts, fraîcheur et actions de refresh.</caption>
<thead>
<tr>
<th scope="col" class="app-market-selection-column">
<input id="selectAllProviders" class="form-check-input" type="checkbox" aria-label="Select all providers" disabled>
</th>
<th scope="col">Provider</th>
<th scope="col">Pair</th>
<th scope="col" class="d-none d-xl-table-cell">Semantics</th>
<th scope="col" class="d-none d-xl-table-cell">Auth</th>
<th scope="col">Availability</th>
<th scope="col">Exact price</th>
<th scope="col" class="app-market-time-column">Observation times</th>
<th scope="col" class="app-market-time-column">Retry</th>
<th scope="col" class="text-end">Action</th>
</tr>
</thead>
<tbody id="marketPriceRows"></tbody>
</table>
</div>
</section>
<section data-view-panel="diagnostics" hidden>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="h3 mb-1">Diagnostics</h1>
<p class="text-body-secondary mb-0">État sûr du bootstrap Config, Logging et runtime prix provider-neutral.</p>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header fw-semibold">Runtime</div>
<div class="card-body">
<dl class="row mb-0 app-runtime-list">
<dt class="col-sm-5">Version</dt>
<dd id="runtimeVersion" class="col-sm-7"></dd>
<dt class="col-sm-5">Phase</dt>
<dd id="runtimeShellPhase" class="col-sm-7"></dd>
<dt class="col-sm-5">Documents Config</dt>
<dd id="runtimeConfigDocuments" class="col-sm-7"></dd>
<dt class="col-sm-5">Profil composite</dt>
<dd id="runtimeCompositeProfile" class="col-sm-7"></dd>
<dt class="col-sm-5">Profil Off-chain</dt>
<dd id="runtimeOffchainProfile" class="col-sm-7"></dd>
<dt class="col-sm-5">Providers</dt>
<dd id="runtimeProviderCount" class="col-sm-7"></dd>
<dt class="col-sm-5">Providers ready</dt>
<dd id="runtimeReadyProviderCount" class="col-sm-7"></dd>
<dt class="col-sm-5">Providers unavailable</dt>
<dd id="runtimeUnavailableProviderCount" class="col-sm-7"></dd>
<dt class="col-sm-5">Profil Logging</dt>
<dd id="runtimeLoggingProfile" class="col-sm-7"></dd>
<dt class="col-sm-5">Fallback Logging</dt>
<dd id="runtimeLoggingFallback" class="col-sm-7"></dd>
</dl>
<div id="runtimeDiagnostic" class="alert alert-warning mt-3 mb-0" role="status" aria-live="polite" hidden></div>
</div>
</div>
</section>
</div>
</div>
</div>
</section>
</div>
</main>
<footer class="app-footer bg-dark text-light">
<div class="container h-100 d-flex align-items-center justify-content-center">
<small>&copy; 2026 SASEDEV</small>
</div>
</footer>
<script type="module" src="ts/main.ts" defer></script>
</body>
</html>

View File

@@ -0,0 +1,235 @@
// file: crates/ksp-app-solprices-desk/frontend/sass/_app.scss
// version: 4
$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-logo {
display: block;
width: auto;
height: 42px;
}
.app-scrollable {
height: 100%;
max-height: 100%;
}
.app-shell-card {
max-width: 1320px;
}
.app-runtime-list dd,
.app-market-code,
.app-market-price,
.app-market-time-exact {
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-placeholder {
min-height: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
border: 1px dashed var(--bs-border-color);
border-radius: var(--bs-border-radius-lg);
color: var(--bs-secondary-color);
background: var(--bs-body-bg);
text-align: center;
}
.app-placeholder i {
font-size: 2.5rem;
}
.app-market-toolbar {
justify-content: flex-end;
}
.app-market-summary-card {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 0.8rem;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
background: var(--bs-tertiary-bg);
}
.app-market-summary-card strong {
min-width: 2ch;
font-size: 1.15rem;
text-align: right;
}
.app-market-summary-label {
color: var(--bs-secondary-color);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.app-market-table-container {
max-height: min(62vh, 720px);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
}
.app-market-table {
min-width: 1080px;
}
.app-market-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--bs-tertiary-bg);
box-shadow: inset 0 -1px 0 var(--bs-border-color);
white-space: nowrap;
}
.app-market-table tbody tr[data-availability="ready"] {
--bs-table-accent-bg: rgba($success, 0.025);
}
.app-market-table tbody tr[data-availability="cooling_down"],
.app-market-table tbody tr[data-availability="temporarily_unavailable"] {
--bs-table-accent-bg: rgba($warning, 0.04);
}
.app-market-table tbody tr[data-availability="authentication_unavailable"],
.app-market-table tbody tr[data-availability="misconfigured"] {
--bs-table-accent-bg: rgba($danger, 0.035);
}
.app-market-selection-column {
width: 42px;
text-align: center;
}
.app-market-time-column {
min-width: 220px;
}
.app-market-provider-name,
.app-market-price {
font-weight: 600;
}
.app-market-code,
.app-market-time-exact {
color: var(--bs-secondary-color);
font-size: 0.75rem;
}
.app-market-price {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.app-market-time {
display: grid;
gap: 0.25rem;
}
.app-market-time-line {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.35rem;
align-items: baseline;
}
.app-market-time-label {
color: var(--bs-secondary-color);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
}
.app-market-time-value {
min-width: 0;
}
.app-market-row-loading {
opacity: 0.72;
}
@media (max-width: 991.98px) {
.app-sidebar {
width: 190px;
min-width: 190px;
max-width: 190px;
}
.app-market-toolbar {
width: 100%;
justify-content: flex-start;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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";

View File

@@ -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);
}
}

View File

@@ -0,0 +1,24 @@
<!-- file: crates/ksp-app-solprices-desk/frontend/splash.html -->
<!-- version: 2 -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<link rel="icon" href="imgs/logo.png">
<link rel="stylesheet" href="sass/splash.scss">
<title>KSP SOL Prices Desk — Chargement</title>
</head>
<body>
<div id="splash-container" style="opacity: 0;">
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP SOL Prices Desk">
<div id="app-name">SOL Prices Desk</div>
<div id="debug-info" aria-live="off" hidden></div>
<div id="messages-container" aria-live="polite"></div>
</div>
<script type="module" src="ts/splash.ts" defer></script>
</body>
</html>

View File

@@ -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<void> {
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<void> {
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);
}

View File

@@ -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<T>(targetId: FrontendLogTargetId, command: string, args?: Record<string, unknown>): Promise<T> {
frontendDebug(targetId, "Frontend IPC command requested", { command });
try {
const result = await invoke<T>(command, args);
frontendTrace(targetId, "Frontend IPC command completed", { command });
return result;
} catch (caughtError) {
frontendError(targetId, "Frontend IPC command failed", { command });
throw caughtError;
}
}

View File

@@ -0,0 +1,597 @@
// file: crates/ksp-app-solprices-desk/frontend/ts/main.ts
// version: 8
import "bootstrap";
import ResizeObserver from "resize-observer-polyfill";
import "simplebar";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { MarketPriceRuntimeStatusDto } from "./bindings/ksp_app_solprices_desk/dto_common/MarketPriceRuntimeStatusDto.ts";
import type { MarketPriceProviderRowDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceProviderRowDto.ts";
import type { MarketPriceRefreshManyRequestDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshManyRequestDto.ts";
import type { MarketPriceRefreshResultDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshResultDto.ts";
import { frontendDebug, frontendInfo, frontendTrace, frontendWarn, installFrontendConsoleBridge } from "./frontend_log";
import { invokeKsp } from "./invoke";
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
installFrontendConsoleBridge("main");
type ViewId = "prices" | "diagnostics";
type AvailabilityPresentation = {
badgeClass: string;
iconClass: string;
label: string;
};
const viewTitles: Record<ViewId, string> = {
prices: "Prices",
diagnostics: "Diagnostics",
};
const availabilityPresentations: Record<string, AvailabilityPresentation> = {
authentication_unavailable: { badgeClass: "text-bg-danger", iconClass: "fa-key", label: "Authentication unavailable" },
cooling_down: { badgeClass: "text-bg-warning", iconClass: "fa-clock", label: "Cooling down" },
disabled: { badgeClass: "text-bg-secondary", iconClass: "fa-ban", label: "Disabled" },
misconfigured: { badgeClass: "text-bg-danger", iconClass: "fa-triangle-exclamation", label: "Misconfigured" },
quota_unavailable: { badgeClass: "text-bg-warning", iconClass: "fa-gauge-high", label: "Quota unavailable" },
ready: { badgeClass: "text-bg-success", iconClass: "fa-circle-check", label: "Ready" },
temporarily_unavailable: { badgeClass: "text-bg-warning", iconClass: "fa-cloud-arrow-down", label: "Temporarily unavailable" },
unknown: { badgeClass: "text-bg-secondary", iconClass: "fa-circle-question", label: "Unknown" },
};
const authLabels: Record<string, string> = {
none: "Keyless",
optional_api_key: "Optional API key",
required_api_key: "API key required",
unknown: "Unknown",
};
const semanticsLabels: Record<string, string> = {
aggregated_market: "Aggregated market",
dex_pair_usd: "DEX pair USD",
exchange_last_trade: "Exchange last trade",
solana_heuristic: "Solana heuristic",
solana_spot: "Solana spot",
unknown: "Unknown",
};
const jsDateMaxUnixMillis = 8_640_000_000_000_000n;
let currentMarketPriceRows: MarketPriceProviderRowDto[] = [];
const selectedProviderIds = new Set<string>();
function isViewId(value: string | undefined): value is ViewId {
return value === "prices" || value === "diagnostics";
}
function activateView(viewId: ViewId, source: "startup" | "user"): void {
document.querySelectorAll<HTMLElement>("[data-view-panel]").forEach(panel => {
panel.hidden = panel.dataset.viewPanel !== viewId;
});
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
button.classList.toggle("active", button.dataset.view === viewId);
});
const header = document.querySelector<HTMLElement>("#headerViewTitle");
if (header) {
header.textContent = viewTitles[viewId];
}
document.title = `SOL Prices Desk — ${viewTitles[viewId]}`;
if (source !== "startup") {
frontendDebug("main", "SOL Prices Desk view activated", { viewId, source });
}
}
function bindNavigation(): void {
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
button.addEventListener("click", () => {
const viewId = button.dataset.view;
frontendDebug("main", "SOL Prices Desk navigation control clicked", { viewId: viewId ?? "unknown" });
if (isViewId(viewId)) {
activateView(viewId, "user");
}
});
});
frontendTrace("main", "SOL Prices Desk navigation handlers installed");
}
function clearPricesDiagnostic(): void {
const diagnostic = document.querySelector<HTMLElement>("#pricesDiagnostic");
if (diagnostic) {
diagnostic.hidden = true;
diagnostic.textContent = "";
}
}
function showPricesDiagnostic(message: string): void {
const diagnostic = document.querySelector<HTMLElement>("#pricesDiagnostic");
if (diagnostic) {
diagnostic.hidden = false;
diagnostic.textContent = message;
}
}
function selectedProviderIdsInRowOrder(): string[] {
return currentMarketPriceRows.filter(provider => selectedProviderIds.has(provider.providerId)).map(provider => provider.providerId);
}
function synchronizeSelectedProviders(): void {
const currentIds = new Set(currentMarketPriceRows.map(provider => provider.providerId));
for (const providerId of selectedProviderIds) {
if (!currentIds.has(providerId)) {
selectedProviderIds.delete(providerId);
}
}
}
function updateSummary(): void {
const summaryValues: Record<string, number> = {
pricesSummaryProviders: currentMarketPriceRows.length,
pricesSummaryReady: currentMarketPriceRows.filter(provider => provider.availability === "ready" && !provider.loading).length,
pricesSummaryObserved: currentMarketPriceRows.filter(provider => provider.refreshed).length,
pricesSummaryAttention: currentMarketPriceRows.filter(provider => provider.availability !== "ready").length,
pricesSummaryLoading: currentMarketPriceRows.filter(provider => provider.loading).length,
};
for (const [elementId, value] of Object.entries(summaryValues)) {
const element = document.querySelector<HTMLElement>(`#${elementId}`);
if (element) {
element.textContent = value.toString();
}
}
}
function synchronizeBatchControls(): void {
const selectedCount = selectedProviderIdsInRowOrder().length;
const providerCount = currentMarketPriceRows.length;
const anyLoading = currentMarketPriceRows.some(provider => provider.loading);
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
const clearButton = document.querySelector<HTMLButtonElement>("#clearSelectionButton");
const selectAll = document.querySelector<HTMLInputElement>("#selectAllProviders");
const selectedCountLabel = document.querySelector<HTMLElement>("#selectedProviderCount");
if (selectedButton) {
selectedButton.disabled = selectedCount === 0 || anyLoading;
}
if (allButton) {
allButton.disabled = providerCount === 0 || anyLoading;
}
if (clearButton) {
clearButton.disabled = selectedCount === 0 || anyLoading;
}
if (selectAll) {
selectAll.disabled = providerCount === 0 || anyLoading;
selectAll.checked = providerCount !== 0 && selectedCount === providerCount;
selectAll.indeterminate = selectedCount > 0 && selectedCount < providerCount;
}
if (selectedCountLabel) {
selectedCountLabel.textContent = `${selectedCount} selected`;
}
updateSummary();
}
function markProvidersLoading(providerIds: string[]): void {
const loadingIds = new Set(providerIds);
currentMarketPriceRows = currentMarketPriceRows.map(provider => {
return loadingIds.has(provider.providerId) ? { ...provider, loading: true } : provider;
});
renderMarketPriceRows(currentMarketPriceRows);
}
function applyRefreshResult(result: MarketPriceRefreshResultDto): void {
const refreshedRows = new Map<string, MarketPriceProviderRowDto>();
for (const provider of result.rows) {
refreshedRows.set(provider.providerId, provider);
}
currentMarketPriceRows = currentMarketPriceRows.map(provider => refreshedRows.get(provider.providerId) ?? provider);
renderMarketPriceRows(currentMarketPriceRows);
}
async function recoverMarketPriceRows(message: string): Promise<void> {
showPricesDiagnostic(message);
try {
await loadMarketPrices();
} catch {
frontendWarn("main", "SOL Prices Desk market-price refresh recovery reload failed");
}
}
async function refreshMarketPrice(providerId: string): Promise<void> {
frontendDebug("main", "SOL Prices Desk market-price refresh control clicked", { providerId });
clearPricesDiagnostic();
markProvidersLoading([providerId]);
try {
const refreshed = await invokeKsp<MarketPriceProviderRowDto>("main", "refresh_market_price", { providerId });
currentMarketPriceRows = currentMarketPriceRows.map(provider => {
return provider.providerId === refreshed.providerId ? refreshed : provider;
});
renderMarketPriceRows(currentMarketPriceRows);
frontendTrace("main", "SOL Prices Desk market-price refresh row applied", { providerId });
} catch {
frontendWarn("main", "SOL Prices Desk market-price refresh failed", { providerId });
await recoverMarketPriceRows("Le provider sélectionné n'a pas pu être rafraîchi. L'état précédent est conservé lorsqu'il reste disponible.");
}
}
async function refreshSelectedMarketPrices(): Promise<void> {
const providerIds = selectedProviderIdsInRowOrder();
frontendDebug("main", "SOL Prices Desk selected market-price refresh control clicked", { selectedCount: providerIds.length });
if (providerIds.length === 0) {
return;
}
clearPricesDiagnostic();
markProvidersLoading(providerIds);
const request: MarketPriceRefreshManyRequestDto = { providerIds };
try {
const result = await invokeKsp<MarketPriceRefreshResultDto>("main", "refresh_market_prices", { request });
applyRefreshResult(result);
frontendTrace("main", "SOL Prices Desk selected market-price refresh result applied", {
requestedCount: result.requestedCount,
refreshedCount: result.refreshedCount,
});
} catch {
frontendWarn("main", "SOL Prices Desk selected market-price refresh failed", { selectedCount: providerIds.length });
await recoverMarketPriceRows("La sélection n'a pas pu être rafraîchie. Les observations précédentes restent visibles avec leur état réel.");
}
}
async function refreshAllMarketPrices(): Promise<void> {
frontendDebug("main", "SOL Prices Desk global market-price refresh control clicked", { providerCount: currentMarketPriceRows.length });
if (currentMarketPriceRows.length === 0) {
return;
}
clearPricesDiagnostic();
markProvidersLoading(currentMarketPriceRows.map(provider => provider.providerId));
try {
const result = await invokeKsp<MarketPriceRefreshResultDto>("main", "refresh_all_market_prices");
applyRefreshResult(result);
frontendTrace("main", "SOL Prices Desk global market-price refresh result applied", {
requestedCount: result.requestedCount,
refreshedCount: result.refreshedCount,
});
} catch {
frontendWarn("main", "SOL Prices Desk global market-price refresh failed", { providerCount: currentMarketPriceRows.length });
await recoverMarketPriceRows("Le refresh global n'a pas pu être terminé. Les observations précédentes restent visibles avec leur état réel.");
}
}
function appendSelectionCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
cell.className = "app-market-selection-column";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "form-check-input";
checkbox.checked = selectedProviderIds.has(provider.providerId);
checkbox.disabled = provider.loading;
checkbox.setAttribute("aria-label", `Select ${provider.displayName}`);
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
selectedProviderIds.add(provider.providerId);
} else {
selectedProviderIds.delete(provider.providerId);
}
frontendDebug("main", "SOL Prices Desk market-price selection control changed", {
providerId: provider.providerId,
selected: checkbox.checked,
});
synchronizeBatchControls();
});
cell.append(checkbox);
row.append(cell);
}
function appendProviderCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
const name = document.createElement("div");
name.className = "app-market-provider-name";
name.textContent = provider.displayName;
const code = document.createElement("div");
code.className = "app-market-code";
code.textContent = provider.providerId;
cell.append(name, code);
row.append(cell);
}
function appendBadgeCell(row: HTMLTableRowElement, label: string, badgeClass = "text-bg-light", className?: string): void {
const cell = document.createElement("td");
if (className) {
cell.className = className;
}
const badge = document.createElement("span");
badge.className = `badge ${badgeClass}`;
badge.textContent = label;
cell.append(badge);
row.append(cell);
}
function availabilityPresentation(provider: MarketPriceProviderRowDto): AvailabilityPresentation {
if (provider.loading) {
return { badgeClass: "text-bg-info", iconClass: "fa-spinner fa-spin", label: "Refreshing" };
}
return availabilityPresentations[provider.availability] ?? availabilityPresentations.unknown;
}
function appendAvailabilityCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
const presentation = availabilityPresentation(provider);
const badge = document.createElement("span");
badge.className = `badge ${presentation.badgeClass}`;
const icon = document.createElement("i");
icon.className = `fa-solid ${presentation.iconClass} me-1`;
icon.setAttribute("aria-hidden", "true");
badge.append(icon, document.createTextNode(presentation.label));
cell.append(badge);
row.append(cell);
}
function appendPriceCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
if (provider.price === null) {
const empty = document.createElement("span");
empty.className = "text-body-secondary";
empty.textContent = "No observation";
cell.append(empty);
} else {
const price = document.createElement("span");
price.className = "app-market-price";
price.textContent = provider.price;
const currency = document.createElement("small");
currency.className = "text-body-secondary ms-1";
currency.textContent = "USD";
cell.append(price, currency);
}
row.append(cell);
}
function formatUnixMillis(value: string): { exact: string; iso: string; local: string } | null {
try {
const exact = BigInt(value);
if (exact < -jsDateMaxUnixMillis || exact > jsDateMaxUnixMillis) {
return null;
}
const date = new Date(Number(exact));
if (Number.isNaN(date.getTime())) {
return null;
}
return {
exact: value,
iso: date.toISOString(),
local: date.toLocaleString("fr-CH", { dateStyle: "short", timeStyle: "medium", hour12: false }),
};
} catch {
return null;
}
}
function appendTimestampValue(container: HTMLElement, label: string, value: string | null, emptyLabel: string): void {
const line = document.createElement("div");
line.className = "app-market-time-line";
const prefix = document.createElement("span");
prefix.className = "app-market-time-label";
prefix.textContent = label;
const valueContainer = document.createElement("span");
valueContainer.className = "app-market-time-value";
if (value === null) {
valueContainer.classList.add("text-body-secondary");
valueContainer.textContent = emptyLabel;
} else {
const formatted = formatUnixMillis(value);
if (formatted === null) {
const exact = document.createElement("span");
exact.className = "app-market-time-exact";
exact.textContent = `${value} ms`;
valueContainer.append(exact);
} else {
const time = document.createElement("time");
time.dateTime = formatted.iso;
time.textContent = formatted.local;
time.title = formatted.iso;
const exact = document.createElement("span");
exact.className = "app-market-time-exact d-block";
exact.textContent = `${formatted.exact} ms`;
valueContainer.append(time, exact);
}
}
line.append(prefix, valueContainer);
container.append(line);
}
function appendObservationTimeCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
cell.className = "app-market-time-column";
const time = document.createElement("div");
time.className = "app-market-time";
appendTimestampValue(time, "Provider", provider.providerTimestampUnixMillis, "Not supplied");
appendTimestampValue(time, "KSP", provider.receivedAtUnixMillis, "—");
cell.append(time);
row.append(cell);
}
function appendRetryCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
cell.className = "app-market-time-column";
const time = document.createElement("div");
time.className = "app-market-time";
appendTimestampValue(time, "Retry", provider.retryAtUnixMillis, "—");
cell.append(time);
row.append(cell);
}
function appendRefreshCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
cell.className = "text-end";
const button = document.createElement("button");
button.type = "button";
button.className = "btn btn-sm btn-outline-primary text-nowrap";
button.disabled = provider.loading;
button.dataset.providerId = provider.providerId;
button.setAttribute("aria-label", `Refresh ${provider.displayName}`);
button.innerHTML = provider.loading
? '<i class="fa-solid fa-spinner fa-spin me-1" aria-hidden="true"></i>Refreshing…'
: '<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>Refresh';
button.addEventListener("click", () => {
void refreshMarketPrice(provider.providerId);
});
cell.append(button);
row.append(cell);
}
function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void {
const loading = document.querySelector<HTMLElement>("#pricesLoading");
const empty = document.querySelector<HTMLElement>("#pricesEmpty");
const tableContainer = document.querySelector<HTMLElement>("#pricesTableContainer");
const tableBody = document.querySelector<HTMLTableSectionElement>("#marketPriceRows");
if (loading) {
loading.hidden = true;
}
if (!tableBody || !tableContainer || !empty) {
frontendWarn("main", "SOL Prices Desk market-price table elements are missing");
return;
}
currentMarketPriceRows = rows;
synchronizeSelectedProviders();
tableBody.replaceChildren();
empty.hidden = rows.length !== 0;
tableContainer.hidden = rows.length === 0;
for (const provider of rows) {
const row = document.createElement("tr");
row.dataset.providerId = provider.providerId;
row.dataset.availability = provider.availability;
row.classList.toggle("app-market-row-loading", provider.loading);
appendSelectionCell(row, provider);
appendProviderCell(row, provider);
appendBadgeCell(row, provider.pair, "text-bg-primary");
appendBadgeCell(row, semanticsLabels[provider.semantics] ?? semanticsLabels.unknown, "text-bg-light", "d-none d-xl-table-cell");
appendBadgeCell(row, authLabels[provider.authMode] ?? authLabels.unknown, "text-bg-light", "d-none d-xl-table-cell");
appendAvailabilityCell(row, provider);
appendPriceCell(row, provider);
appendObservationTimeCell(row, provider);
appendRetryCell(row, provider);
appendRefreshCell(row, provider);
tableBody.append(row);
}
synchronizeBatchControls();
frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", {
rowCount: rows.length,
observedCount: rows.filter(provider => provider.refreshed).length,
attentionCount: rows.filter(provider => provider.availability !== "ready").length,
});
}
function selectAllProviders(selected: boolean): void {
selectedProviderIds.clear();
if (selected) {
for (const provider of currentMarketPriceRows) {
selectedProviderIds.add(provider.providerId);
}
}
frontendDebug("main", "SOL Prices Desk select-all providers control changed", {
selected,
selectedCount: selectedProviderIds.size,
});
renderMarketPriceRows(currentMarketPriceRows);
}
function clearProviderSelection(): void {
const selectedCount = selectedProviderIds.size;
frontendDebug("main", "SOL Prices Desk clear provider selection control clicked", { selectedCount });
selectedProviderIds.clear();
renderMarketPriceRows(currentMarketPriceRows);
}
function bindMarketPriceControls(): void {
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
const clearButton = document.querySelector<HTMLButtonElement>("#clearSelectionButton");
const selectAll = document.querySelector<HTMLInputElement>("#selectAllProviders");
selectedButton?.addEventListener("click", () => {
void refreshSelectedMarketPrices();
});
allButton?.addEventListener("click", () => {
void refreshAllMarketPrices();
});
clearButton?.addEventListener("click", () => {
clearProviderSelection();
});
selectAll?.addEventListener("change", () => {
selectAllProviders(selectAll.checked);
});
synchronizeBatchControls();
frontendTrace("main", "SOL Prices Desk market-price controls installed");
}
function renderRuntimeStatus(status: MarketPriceRuntimeStatusDto): void {
const values: Record<string, string> = {
runtimeCompositeProfile: status.activeCompositeProfile,
runtimeOffchainProfile: status.activeOffchainProfile,
runtimeVersion: status.applicationVersion,
runtimeShellPhase: status.shellPhase,
runtimeConfigDocuments: status.configDocumentCount.toString(),
runtimeProviderCount: status.providerCount.toString(),
runtimeReadyProviderCount: status.readyProviderCount.toString(),
runtimeUnavailableProviderCount: status.unavailableProviderCount.toString(),
runtimeLoggingProfile: status.activeLoggingProfile ?? "fallback",
runtimeLoggingFallback: status.fallbackLoggingActive ? "oui" : "non",
};
for (const [elementId, value] of Object.entries(values)) {
const element = document.querySelector<HTMLElement>(`#${elementId}`);
if (element) {
element.textContent = value;
}
}
const diagnostic = document.querySelector<HTMLElement>("#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 market-price runtime status rendered", {
fallbackLoggingActive: status.fallbackLoggingActive,
configDocumentCount: status.configDocumentCount,
providerCount: status.providerCount,
readyProviderCount: status.readyProviderCount,
unavailableProviderCount: status.unavailableProviderCount,
});
}
async function loadMarketPrices(): Promise<void> {
frontendTrace("main", "SOL Prices Desk provider registry load started");
const rows = await invokeKsp<MarketPriceProviderRowDto[]>("main", "list_market_prices");
currentMarketPriceRows = rows;
renderMarketPriceRows(rows);
frontendTrace("main", "SOL Prices Desk provider registry load completed", { rowCount: rows.length });
}
async function loadRuntimeStatus(): Promise<void> {
const status = await invokeKsp<MarketPriceRuntimeStatusDto>("main", "get_runtime_status");
renderRuntimeStatus(status);
}
async function initializeMain(): Promise<void> {
const windowLabel = getCurrentWindow().label;
frontendInfo("main", "SOL Prices Desk main frontend loaded", { windowLabel });
bindNavigation();
bindMarketPriceControls();
activateView("prices", "startup");
try {
await loadRuntimeStatus();
} catch {
const diagnostic = document.querySelector<HTMLElement>("#runtimeDiagnostic");
if (diagnostic) {
diagnostic.hidden = false;
diagnostic.textContent = "Le statut runtime initial n'a pas pu être chargé.";
}
frontendWarn("main", "SOL Prices Desk startup runtime status load failed");
}
try {
await loadMarketPrices();
} catch {
const loading = document.querySelector<HTMLElement>("#pricesLoading");
if (loading) {
loading.hidden = true;
}
showPricesDiagnostic("L'inventaire provider-neutral n'a pas pu être chargé.");
frontendWarn("main", "SOL Prices Desk provider registry load failed");
}
}
document.addEventListener("DOMContentLoaded", () => {
void initializeMain();
});

View File

@@ -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<void> {
frontendTrace("splash", "Splash opacity animation started", { fromOpacity, toOpacity, durationMs });
if (activeOpacityFrame !== null) {
cancelAnimationFrame(activeOpacityFrame);
activeOpacityFrame = null;
}
element.style.opacity = fromOpacity.toString();
element.style.willChange = "opacity";
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
await new Promise<void>(resolve => {
const startedAt = performance.now();
const opacityDelta = toOpacity - fromOpacity;
const updateOpacity = (currentTime: number): void => {
const elapsedMs = currentTime - startedAt;
const rawProgress = 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<HTMLElement>("#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<HTMLElement>("#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<void> {
frontendTrace("splash", "Splash order received", { action: order.action });
const container = document.querySelector<HTMLElement>("#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<void> {
const windowLabel = getCurrentWindow().label;
frontendInfo("splash", "SOL Prices Desk splash frontend loaded", { windowLabel });
const container = document.querySelector<HTMLElement>("#splash-container");
if (container) {
container.style.opacity = "0";
container.style.willChange = "opacity";
frontendTrace("splash", "Splash container prepared for managed fade-in");
}
await listen<SplashOrderDto>("ksp-splash-order", event => {
void handleSplashOrder(event.payload);
});
frontendDebug("splash", "Splash lifecycle listener installed");
try {
await invokeKsp<void>("splash", "splash_frontend_ready");
} catch {
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();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,26 @@
{
"name": "ksp-app-solprices-desk",
"private": true,
"version": "0.2.12-pre.9",
"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"
}
}

View File

@@ -0,0 +1,158 @@
// file: crates/ksp-app-solprices-desk/src/app_state.rs
// version: 6
//! 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<LoggingRuntimeState>,
market_price_runtime: crate::MarketPriceRuntime,
splash_settings: crate::SplashSettings,
splash_sequence_started: std::sync::atomic::AtomicBool,
}
impl crate::AppState {
/// Initializes Config ownership, composite-managed Logging and the provider-neutral market-price runtime.
pub(crate) fn initialize(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
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 offchain_transport_startup = crate::initialize_offchain_transport(&config_management);
let offchain_transport_startup = match offchain_transport_startup {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let market_price_runtime = crate::MarketPriceRuntime::new(offchain_transport_startup);
let market_price_runtime = match market_price_runtime {
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,
}),
market_price_runtime,
splash_settings,
splash_sequence_started: std::sync::atomic::AtomicBool::new(false),
});
}
/// Lists the current provider-neutral market-price rows without triggering network refresh.
pub(crate) fn list_market_prices(&self) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderRowDto>> {
return self.market_price_runtime.list_rows();
}
/// Refreshes one provider by opaque identifier and returns its updated safe row projection.
pub(crate) async fn refresh_market_price(&self, provider_id: String) -> ksp_core_lib::Result<crate::MarketPriceProviderRowDto> {
return self.market_price_runtime.refresh_one(provider_id).await;
}
/// Refreshes a selected provider set in deterministic request order and returns only safe updated rows/counts.
pub(crate) async fn refresh_market_prices(
&self,
request: crate::MarketPriceRefreshManyRequestDto,
) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
return self.market_price_runtime.refresh_many(request.into_provider_ids()).await;
}
/// Refreshes every configured provider in the deterministic order owned by Off-chain Transport.
pub(crate) async fn refresh_all_market_prices(&self) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
return self.market_price_runtime.refresh_all().await;
}
/// Builds the safe runtime status exposed by the SOL Prices Desk shell.
pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result<crate::MarketPriceRuntimeStatusDto> {
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 runtime DTO",
)
.with_source(error),
);
},
};
let provider_counts = self.market_price_runtime.provider_counts();
let (provider_count, ready_provider_count, unavailable_provider_count) = match provider_counts {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(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;
let startup = self.market_price_runtime.startup();
let offchain = startup.resolved();
return std::result::Result::Ok(crate::MarketPriceRuntimeStatusDto {
active_composite_profile: startup.composite_profile_id().to_owned(),
active_logging_profile: runtime.active_profile_id.clone(),
active_offchain_profile: offchain.profile_id().to_owned(),
application_version: env!("CARGO_PKG_VERSION").to_owned(),
config_document_count: document_count,
fallback_logging_active: runtime.fallback_active,
provider_count,
ready_provider_count,
shell_phase: "pre.007-ux-instrumentation".to_owned(),
startup_diagnostic: runtime.startup_diagnostic.clone(),
unavailable_provider_count,
});
}
/// 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<String>,
fallback_active: bool,
startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
}

View File

@@ -0,0 +1,197 @@
// file: crates/ksp-app-solprices-desk/src/bootstrap.rs
// version: 2
//! Config composite and Logging bootstrap for SOL Prices Desk.
/// 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<String>,
/// 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<crate::CommandErrorDto>,
}
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<ksp_config_lib::ConfigManagement> {
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));
}
/// Loads the concrete SOL Prices Desk Config composite using its registered logical `file_id` and autonomous default profile.
pub(crate) fn load_solprices_desk_composite(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<ksp_config_lib::ResolvedConfigComposite> {
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return management.engine().load_resolved_composite(&file_id, std::option::Option::None);
}
/// Returns one required standard profile from the SOL Prices Desk composite after validating the referenced logical `file_id`.
pub(crate) fn required_composite_component_profile(
composite: &ksp_config_lib::ResolvedConfigComposite,
component_id: &str,
expected_file_id: &str,
) -> ksp_core_lib::Result<ksp_config_lib::ResolvedConfigProfile> {
let component = composite.component(component_id);
let component = match component {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_CONFIG_COMPOSITE_INVALID, "SOL Prices Desk composite is missing a required component")
.with_context("composite_file_id", composite.file_id().as_str())
.with_context("composite_profile_id", composite.profile_id())
.with_context("component_id", component_id),
);
},
};
if component.resolved().file_id().as_str() != expected_file_id {
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_CONFIG_COMPOSITE_INVALID,
"SOL Prices Desk composite component references an unexpected Config document",
)
.with_context("composite_file_id", composite.file_id().as_str())
.with_context("composite_profile_id", composite.profile_id())
.with_context("component_id", component_id)
.with_context("expected_file_id", expected_file_id)
.with_context("actual_file_id", component.resolved().file_id().as_str()),
);
}
return std::result::Result::Ok(component.resolved().clone());
}
/// Initializes Logging from the SOL Prices Desk composite, with the same bounded fallback policy as the other KSP desks.
pub(crate) fn initialize_logging(
management: &ksp_config_lib::ConfigManagement,
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
) -> ksp_core_lib::Result<crate::LoggingStartup> {
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 logging from composite-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 composite = crate::load_solprices_desk_composite(management);
let composite = match composite {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return fallback_startup_plan(error),
};
let logging_profile = crate::required_composite_component_profile(&composite, crate::COMPOSITE_COMPONENT_ID_LOGGING, ksp_config_lib::FILE_ID_STD_LOGGING);
let logging_profile = match logging_profile {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return fallback_startup_plan(error),
};
let offchain_profile = crate::required_composite_component_profile(
&composite,
crate::COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT,
ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT,
);
if let std::result::Result::Err(error) = offchain_profile {
return fallback_startup_plan(error);
}
let resolved = management.engine().resolve_logging_config_profile(&logging_profile, &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<crate::LoggingStartup> {
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<crate::LoggingStartup> {
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),
});
}

View File

@@ -0,0 +1,27 @@
// file: crates/ksp-app-solprices-desk/src/constants.rs
// version: 2
//! Application-owned composition identifiers, tracing targets and domains.
/// Composite-local identifier for the standard Logging component.
pub(crate) const COMPOSITE_COMPONENT_ID_LOGGING: &str = "logging";
/// Composite-local identifier for the standard Off-chain Transport component.
pub(crate) const COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT: &str = "offchain_transport";
/// 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 Config-selected Off-chain Transport runtime.
pub(crate) const TRACING_DOMAIN_OFFCHAIN_TRANSPORT: &str = "solprices.offchain_transport";
/// 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";

View File

@@ -0,0 +1,64 @@
// file: crates/ksp-app-solprices-desk/src/dto_common.rs
// version: 3
//! Common Tauri DTOs shared by the SOL Prices Desk shell.
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 crate::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 provider-neutral application/runtime snapshot exposed to the SOL Prices Desk shell.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/dto_common/MarketPriceRuntimeStatusDto.ts")]
pub(crate) struct MarketPriceRuntimeStatusDto {
/// SOL Prices Desk composite profile selected for this launch.
pub(crate) active_composite_profile: String,
/// Active configured Logging profile, or `None` while transient fallback Logging is active.
pub(crate) active_logging_profile: std::option::Option<String>,
/// Standard Off-chain Transport profile selected by the active composite.
pub(crate) active_offchain_profile: String,
/// Cargo application version.
pub(crate) application_version: String,
/// Number of logical Config resources registered by the current 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,
/// Total number of configured provider-neutral market-price rows.
pub(crate) provider_count: u32,
/// Number of providers currently classified as immediately ready.
pub(crate) ready_provider_count: u32,
/// Current implementation phase exposed by the shell.
pub(crate) shell_phase: String,
/// Safe startup diagnostic that caused fallback Logging, when applicable.
pub(crate) startup_diagnostic: std::option::Option<CommandErrorDto>,
/// Number of providers currently not classified as immediately ready.
pub(crate) unavailable_provider_count: u32,
}
#[cfg(test)]
#[path = "../unit_tests/dto_common.rs"]
mod tests;

View File

@@ -0,0 +1,35 @@
// file: crates/ksp-app-solprices-desk/src/errors.rs
// version: 3
//! Application-local error codes for SOL Prices Desk composition 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");
/// The SOL Prices Desk Config composite is missing or misroutes one required standard component.
pub(crate) const ERROR_CODE_CONFIG_COMPOSITE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "config_composite_invalid");
/// 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");
/// One requested market-price provider already has an explicit refresh in flight.
pub(crate) const ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("solprices_desk", "market_price_refresh_conflict");
/// A selected market-price refresh contains duplicate or unknown provider identifiers.
pub(crate) const ERROR_CODE_MARKET_PRICE_SELECTION_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("solprices_desk", "market_price_selection_invalid");
/// Splash readiness was invoked from a window other than the splash window.
pub(crate) const ERROR_CODE_SPLASH_ORIGIN_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("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");

View File

@@ -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<FrontendLogLevel> {
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<FrontendLogTarget> {
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;

View File

@@ -0,0 +1,120 @@
// file: crates/ksp-app-solprices-desk/src/lib.rs
// version: 4
//! 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 market_price_runtime;
mod offchain_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;
/// Loads the SOL Prices Desk composite selected by its registered logical file identifier.
pub(crate) use self::bootstrap::load_solprices_desk_composite;
/// Resolves one required standard Config profile from the SOL Prices Desk composite.
pub(crate) use self::bootstrap::required_composite_component_profile;
/// Composite-local identifier for the standard Logging component.
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_LOGGING;
/// Composite-local identifier for the standard Off-chain Transport component.
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT;
/// 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 Config-selected Off-chain Transport runtime.
pub(crate) use self::constants::TRACING_DOMAIN_OFFCHAIN_TRANSPORT;
/// 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 provider-neutral runtime snapshot exposed to the shell.
pub(crate) use self::dto_common::MarketPriceRuntimeStatusDto;
/// 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;
/// The SOL Prices Desk Config composite is missing or misroutes a required component.
pub(crate) use self::errors::ERROR_CODE_CONFIG_COMPOSITE_INVALID;
/// 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;
/// One requested market-price provider already has an explicit refresh in flight.
pub(crate) use self::errors::ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT;
/// A selected market-price refresh contains duplicate or unknown provider identifiers.
pub(crate) use self::errors::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID;
/// 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;
/// Safe provider-neutral market-price row exposed to the frontend.
pub(crate) use self::market_price_runtime::MarketPriceProviderRowDto;
/// Bounded provider-neutral request used by the selected-provider refresh command.
pub(crate) use self::market_price_runtime::MarketPriceRefreshManyRequestDto;
/// Safe result returned by selected/global market-price refresh commands.
pub(crate) use self::market_price_runtime::MarketPriceRefreshResultDto;
/// Backend application runtime owning the Config-selected service and in-memory market-price presentation state.
pub(crate) use self::market_price_runtime::MarketPriceRuntime;
/// Resolved composite and Off-chain Transport configuration retained by SOL Prices Desk.
pub(crate) use self::offchain_runtime::OffchainTransportStartup;
/// Resolves the composite-selected Off-chain Transport profile and constructs the Config-owned service.
pub(crate) use self::offchain_runtime::initialize_offchain_transport;
/// 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;

View File

@@ -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<ksp_logging_lib::LoggingRuntimeIdentity> {
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);
}

View File

@@ -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::<std::vec::Vec<std::ffi::OsString>>();
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(());
}
}

View File

@@ -0,0 +1,611 @@
// file: crates/ksp-app-solprices-desk/src/market_price_runtime.rs
// version: 4
//! Provider-neutral market-price presentation runtime owned by SOL Prices Desk.
use ts_rs::TS; // rust-rules: trait-import
const MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS: usize = 64;
/// Safe provider-neutral row projected to the SOL Prices Desk frontend.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceProviderRowDto.ts")]
pub(crate) struct MarketPriceProviderRowDto {
/// Generic authentication capability code for the configured provider mode.
pub(crate) auth_mode: String,
/// Generic current availability code.
pub(crate) availability: String,
/// Safe provider display name supplied by Off-chain Transport.
pub(crate) display_name: String,
/// Whether one refresh is currently in flight for this provider.
pub(crate) loading: bool,
/// Stable V1 pair label.
pub(crate) pair: String,
/// Exact canonical SOL/USD decimal string, when an observation exists.
pub(crate) price: std::option::Option<String>,
/// Opaque provider identifier accepted by refresh commands.
pub(crate) provider_id: String,
/// Provider-supplied observation timestamp in exact Unix milliseconds, when genuinely supplied.
pub(crate) provider_timestamp_unix_millis: std::option::Option<String>,
/// KSP receipt timestamp in exact Unix milliseconds, when an observation exists.
pub(crate) received_at_unix_millis: std::option::Option<String>,
/// Whether this row currently retains at least one successful observation.
pub(crate) refreshed: bool,
/// Known next retry timestamp in exact Unix milliseconds for cooldown/temporary states.
pub(crate) retry_at_unix_millis: std::option::Option<String>,
/// Generic price-semantics code retained from the provider descriptor.
pub(crate) semantics: String,
}
/// Bounded provider-neutral request used by the selected-provider refresh command.
#[derive(Debug, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshManyRequestDto.ts")]
pub(crate) struct MarketPriceRefreshManyRequestDto {
/// Opaque provider identifiers requested by the frontend in deterministic row order.
pub(crate) provider_ids: std::vec::Vec<String>,
}
impl crate::MarketPriceRefreshManyRequestDto {
/// Consumes the IPC DTO and returns the opaque provider identifiers for authoritative backend validation.
pub(crate) fn into_provider_ids(self) -> std::vec::Vec<String> {
return self.provider_ids;
}
}
/// Safe result of one selected/global refresh operation.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshResultDto.ts")]
pub(crate) struct MarketPriceRefreshResultDto {
/// Number of provider refreshes that produced a new normalized observation.
pub(crate) refreshed_count: u32,
/// Number of providers included in the validated operation.
pub(crate) requested_count: u32,
/// Updated provider-neutral rows in the exact dispatch/outcome order.
pub(crate) rows: std::vec::Vec<MarketPriceProviderRowDto>,
}
/// Backend application runtime that owns the Config-selected Off-chain service and current in-memory presentation state.
pub(crate) struct MarketPriceRuntime {
presentation: std::sync::Mutex<MarketPricePresentationState>,
startup: crate::OffchainTransportStartup,
}
impl crate::MarketPriceRuntime {
/// Builds the application presentation runtime from the already Config-resolved Off-chain startup.
pub(crate) fn new(startup: crate::OffchainTransportStartup) -> ksp_core_lib::Result<Self> {
let registry = startup.resolved().service().registry();
let mut entries = std::collections::BTreeMap::new();
for entry in registry.entries() {
let previous = entries
.insert(entry.descriptor().id().as_str().to_owned(), MarketPricePresentationEntry { loading: false, observation: std::option::Option::None });
if previous.is_some() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price runtime contains duplicate provider state")
.with_context("provider_id", entry.descriptor().id().as_str()),
);
}
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
provider_count = entries.len(),
"initialized SOL Prices Desk market-price presentation runtime"
);
return std::result::Result::Ok(Self { presentation: std::sync::Mutex::new(MarketPricePresentationState { entries }), startup });
}
/// Returns the Config/bootstrap metadata retained by the price runtime.
#[must_use]
pub(crate) const fn startup(&self) -> &crate::OffchainTransportStartup {
return &self.startup;
}
/// Returns total, ready and unavailable provider counts from the detached generic registry snapshot.
pub(crate) fn provider_counts(&self) -> ksp_core_lib::Result<(u32, u32, u32)> {
let registry = self.startup.resolved().service().registry();
let total = count_to_u32(registry.len(), "provider_count");
let total = match total {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut ready_count = 0usize;
for entry in registry.entries() {
if matches!(entry.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready) {
ready_count += 1;
}
}
let ready = count_to_u32(ready_count, "ready_provider_count");
let ready = match ready {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let unavailable = total.saturating_sub(ready);
return std::result::Result::Ok((total, ready, unavailable));
}
/// Projects the current registry and in-memory observation state as deterministic frontend rows without network dispatch.
pub(crate) fn list_rows(&self) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderRowDto>> {
let registry = self.startup.resolved().service().registry();
let presentation = lock_presentation(&self.presentation);
let presentation = match presentation {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut rows = std::vec::Vec::with_capacity(registry.len());
for entry in registry.entries() {
let provider_id = entry.descriptor().id().as_str();
let presentation_entry = presentation.entries.get(provider_id);
let presentation_entry = match presentation_entry {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price runtime is missing one registry row")
.with_context("provider_id", provider_id),
);
},
};
rows.push(project_registry_entry(entry, presentation_entry));
}
return std::result::Result::Ok(rows);
}
/// Refreshes one opaque provider through Off-chain Transport and updates only its in-memory presentation row.
pub(crate) async fn refresh_one(&self, provider_id: String) -> ksp_core_lib::Result<crate::MarketPriceProviderRowDto> {
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new(provider_id);
let provider_id = match provider_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_ids = [provider_id.clone()];
let loading = self.begin_refresh(provider_ids.as_slice());
if let std::result::Result::Err(error) = loading {
return std::result::Result::Err(error);
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
provider_id = provider_id.as_str(),
"started SOL Prices Desk single-provider market-price refresh"
);
let outcome = self.startup.resolved().service().refresh(&provider_id).await;
let outcome = match outcome {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
let cleared = self.clear_loading(provider_ids.as_slice());
if let std::result::Result::Err(clear_error) = cleared {
return std::result::Result::Err(clear_error);
}
return std::result::Result::Err(error);
},
};
let row = self.apply_outcome(&outcome);
let row = match row {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
let cleared = self.clear_loading(provider_ids.as_slice());
if let std::result::Result::Err(clear_error) = cleared {
return std::result::Result::Err(clear_error);
}
return std::result::Result::Err(error);
},
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
provider_id = provider_id.as_str(),
refreshed = outcome.refreshed(),
availability = row.availability.as_str(),
"completed SOL Prices Desk single-provider market-price refresh"
);
return std::result::Result::Ok(row);
}
/// Refreshes a frontend-selected provider set in deterministic request order through the generic Off-chain service.
pub(crate) async fn refresh_many(&self, provider_ids: std::vec::Vec<String>) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
let provider_ids = parse_provider_ids(provider_ids);
let provider_ids = match provider_ids {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let loading = self.begin_refresh(provider_ids.as_slice());
if let std::result::Result::Err(error) = loading {
return std::result::Result::Err(error);
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
provider_count = provider_ids.len(),
"started SOL Prices Desk selected-provider market-price refresh"
);
let outcomes = self.startup.resolved().service().refresh_many(provider_ids.as_slice()).await;
let outcomes = match outcomes {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
let cleared = self.clear_loading(provider_ids.as_slice());
if let std::result::Result::Err(clear_error) = cleared {
return std::result::Result::Err(clear_error);
}
return std::result::Result::Err(error);
},
};
let result = self.apply_outcomes(outcomes.as_slice());
let result = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
let cleared = self.clear_loading(provider_ids.as_slice());
if let std::result::Result::Err(clear_error) = cleared {
return std::result::Result::Err(clear_error);
}
return std::result::Result::Err(error);
},
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
provider_count = result.requested_count,
refreshed_count = result.refreshed_count,
"completed SOL Prices Desk selected-provider market-price refresh"
);
return std::result::Result::Ok(result);
}
/// Refreshes every configured provider through the generic Off-chain service in its stable provider order.
pub(crate) async fn refresh_all(&self) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
let registry = self.startup.resolved().service().registry();
let provider_ids = registry.entries().iter().map(|entry| return entry.descriptor().id().clone()).collect::<std::vec::Vec<_>>();
let loading = self.begin_refresh(provider_ids.as_slice());
if let std::result::Result::Err(error) = loading {
return std::result::Result::Err(error);
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
provider_count = provider_ids.len(),
"started SOL Prices Desk global market-price refresh"
);
let outcomes = self.startup.resolved().service().refresh_all().await;
let outcomes = match outcomes {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
let cleared = self.clear_loading(provider_ids.as_slice());
if let std::result::Result::Err(clear_error) = cleared {
return std::result::Result::Err(clear_error);
}
return std::result::Result::Err(error);
},
};
let result = self.apply_outcomes(outcomes.as_slice());
let result = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
let cleared = self.clear_loading(provider_ids.as_slice());
if let std::result::Result::Err(clear_error) = cleared {
return std::result::Result::Err(clear_error);
}
return std::result::Result::Err(error);
},
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
provider_count = result.requested_count,
refreshed_count = result.refreshed_count,
"completed SOL Prices Desk global market-price refresh"
);
return std::result::Result::Ok(result);
}
fn apply_outcome(&self, outcome: &ksp_offchain_transport_lib::MarketPriceRefreshOutcome) -> ksp_core_lib::Result<crate::MarketPriceProviderRowDto> {
let registry = self.startup.resolved().service().registry();
let registry_entry = registry.entry(outcome.provider_id());
let registry_entry = match registry_entry {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk refresh outcome references an unknown registry provider")
.with_context("provider_id", outcome.provider_id().as_str()),
);
},
};
let presentation = lock_presentation(&self.presentation);
let mut presentation = match presentation {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let applied = apply_outcome_to_state(&mut presentation, outcome);
if let std::result::Result::Err(error) = applied {
return std::result::Result::Err(error);
}
let presentation_entry = presentation.entries.get(outcome.provider_id().as_str());
return match presentation_entry {
std::option::Option::Some(value) => std::result::Result::Ok(project_registry_entry(registry_entry, value)),
std::option::Option::None => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price runtime is missing refreshed provider state")
.with_context("provider_id", outcome.provider_id().as_str()),
),
};
}
fn apply_outcomes(&self, outcomes: &[ksp_offchain_transport_lib::MarketPriceRefreshOutcome]) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
let registry = self.startup.resolved().service().registry();
let presentation = lock_presentation(&self.presentation);
let mut presentation = match presentation {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut rows = std::vec::Vec::with_capacity(outcomes.len());
let mut refreshed_count = 0usize;
for outcome in outcomes {
let registry_entry = registry.entry(outcome.provider_id());
let registry_entry = match registry_entry {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_APP_STATE_INVALID,
"SOL Prices Desk refresh outcome references an unknown registry provider",
)
.with_context("provider_id", outcome.provider_id().as_str()),
);
},
};
let applied = apply_outcome_to_state(&mut presentation, outcome);
if let std::result::Result::Err(error) = applied {
return std::result::Result::Err(error);
}
if outcome.refreshed() {
refreshed_count += 1;
}
let presentation_entry = presentation.entries.get(outcome.provider_id().as_str());
let presentation_entry = match presentation_entry {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_APP_STATE_INVALID,
"SOL Prices Desk market-price runtime is missing refreshed provider state",
)
.with_context("provider_id", outcome.provider_id().as_str()),
);
},
};
rows.push(project_registry_entry(registry_entry, presentation_entry));
}
let requested_count = count_to_u32(outcomes.len(), "refresh_requested_count");
let requested_count = match requested_count {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let refreshed_count = count_to_u32(refreshed_count, "refresh_success_count");
let refreshed_count = match refreshed_count {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::MarketPriceRefreshResultDto { refreshed_count, requested_count, rows });
}
fn begin_refresh(&self, provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId]) -> ksp_core_lib::Result<()> {
let presentation = lock_presentation(&self.presentation);
let mut presentation = match presentation {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return begin_refresh_in_state(&mut presentation, provider_ids);
}
fn clear_loading(&self, provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId]) -> ksp_core_lib::Result<()> {
let presentation = lock_presentation(&self.presentation);
let mut presentation = match presentation {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
for provider_id in provider_ids {
let cleared = set_loading_in_state(&mut presentation, provider_id.as_str(), false);
if let std::result::Result::Err(error) = cleared {
return std::result::Result::Err(error);
}
}
return std::result::Result::Ok(());
}
}
struct MarketPricePresentationEntry {
loading: bool,
observation: std::option::Option<ksp_offchain_transport_lib::MarketPriceObservation>,
}
struct MarketPricePresentationState {
entries: std::collections::BTreeMap<String, MarketPricePresentationEntry>,
}
fn apply_outcome_to_state(
presentation: &mut MarketPricePresentationState,
outcome: &ksp_offchain_transport_lib::MarketPriceRefreshOutcome,
) -> ksp_core_lib::Result<()> {
let entry = presentation.entries.get_mut(outcome.provider_id().as_str());
let entry = match entry {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price presentation state is missing refresh provider")
.with_context("provider_id", outcome.provider_id().as_str()),
);
},
};
entry.loading = false;
if let std::option::Option::Some(observation) = outcome.observation() {
entry.observation = std::option::Option::Some(observation.clone());
}
return std::result::Result::Ok(());
}
fn auth_mode_code(auth_mode: ksp_offchain_transport_lib::MarketPriceProviderAuthMode) -> &'static str {
return match auth_mode {
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None => "none",
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::OptionalApiKey => "optional_api_key",
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::RequiredApiKey => "required_api_key",
_ => "unknown",
};
}
fn availability_code(availability: ksp_offchain_transport_lib::MarketPriceProviderAvailability) -> &'static str {
return match availability {
ksp_offchain_transport_lib::MarketPriceProviderAvailability::AuthenticationUnavailable => "authentication_unavailable",
ksp_offchain_transport_lib::MarketPriceProviderAvailability::CoolingDown { .. } => "cooling_down",
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled => "disabled",
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Misconfigured => "misconfigured",
ksp_offchain_transport_lib::MarketPriceProviderAvailability::QuotaUnavailable => "quota_unavailable",
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready => "ready",
ksp_offchain_transport_lib::MarketPriceProviderAvailability::TemporarilyUnavailable { .. } => "temporarily_unavailable",
_ => "unknown",
};
}
fn begin_refresh_in_state(
presentation: &mut MarketPricePresentationState,
provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId],
) -> ksp_core_lib::Result<()> {
let mut seen = std::collections::BTreeSet::new();
for provider_id in provider_ids {
if !seen.insert(provider_id.as_str().to_owned()) {
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID,
"SOL Prices Desk market-price refresh selection contains a duplicate provider",
)
.with_context("provider_id", provider_id.as_str()),
);
}
let entry = presentation.entries.get(provider_id.as_str());
let entry = match entry {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID,
"SOL Prices Desk market-price refresh selection contains an unknown provider",
)
.with_context("provider_id", provider_id.as_str()),
);
},
};
if entry.loading {
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT,
"SOL Prices Desk market-price provider already has a refresh in flight",
)
.with_context("provider_id", provider_id.as_str()),
);
}
}
for provider_id in provider_ids {
let updated = set_loading_in_state(presentation, provider_id.as_str(), true);
if let std::result::Result::Err(error) = updated {
return std::result::Result::Err(error);
}
}
return std::result::Result::Ok(());
}
fn count_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result<u32> {
let converted = u32::try_from(value);
return match converted {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price runtime count exceeds DTO bounds")
.with_context("field", field)
.with_source(error),
),
};
}
fn lock_presentation(
presentation: &std::sync::Mutex<MarketPricePresentationState>,
) -> ksp_core_lib::Result<std::sync::MutexGuard<'_, MarketPricePresentationState>> {
return match presentation.lock() {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(_) => std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
"SOL Prices Desk market-price presentation state lock is poisoned",
)),
};
}
fn parse_provider_ids(provider_ids: std::vec::Vec<String>) -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderId>> {
if provider_ids.is_empty() || provider_ids.len() > MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS {
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID,
"SOL Prices Desk market-price refresh selection has an invalid provider count",
)
.with_context("provider_count", provider_ids.len().to_string())
.with_context("max_provider_count", MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS.to_string()),
);
}
let mut parsed = std::vec::Vec::with_capacity(provider_ids.len());
for provider_id in provider_ids {
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new(provider_id);
let provider_id = match provider_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
parsed.push(provider_id);
}
return std::result::Result::Ok(parsed);
}
fn project_registry_entry(
entry: &ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry,
presentation: &MarketPricePresentationEntry,
) -> crate::MarketPriceProviderRowDto {
let descriptor = entry.descriptor();
let availability = entry.state().availability();
let observation = presentation.observation.as_ref();
return crate::MarketPriceProviderRowDto {
auth_mode: auth_mode_code(descriptor.auth_mode()).to_owned(),
availability: availability_code(availability).to_owned(),
display_name: descriptor.display_name().to_owned(),
loading: presentation.loading,
pair: ksp_offchain_transport_lib::MarketPricePair::SolUsd.code().to_owned(),
price: observation.map(|value| return value.price().to_canonical_string()),
provider_id: descriptor.id().as_str().to_owned(),
provider_timestamp_unix_millis: observation.and_then(|value| return value.provider_timestamp()).map(|value| return value.unix_millis().to_string()),
received_at_unix_millis: observation.map(|value| return value.received_at().unix_millis().to_string()),
refreshed: observation.is_some(),
retry_at_unix_millis: availability.retry_at().map(|value| return value.unix_millis().to_string()),
semantics: semantics_code(descriptor.semantics()).to_owned(),
};
}
fn semantics_code(semantics: ksp_offchain_transport_lib::MarketPriceSemantics) -> &'static str {
return match semantics {
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket => "aggregated_market",
ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd => "dex_pair_usd",
ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade => "exchange_last_trade",
ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic => "solana_heuristic",
ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot => "solana_spot",
_ => "unknown",
};
}
fn set_loading_in_state(presentation: &mut MarketPricePresentationState, provider_id: &str, loading: bool) -> ksp_core_lib::Result<()> {
let entry = presentation.entries.get_mut(provider_id);
return match entry {
std::option::Option::Some(value) => {
value.loading = loading;
std::result::Result::Ok(())
},
std::option::Option::None => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price presentation state is missing provider")
.with_context("provider_id", provider_id),
),
};
}
#[cfg(test)]
#[path = "../unit_tests/market_price_runtime.rs"]
mod tests;

View File

@@ -0,0 +1,59 @@
// file: crates/ksp-app-solprices-desk/src/offchain_runtime.rs
// version: 1
//! Config-owned Off-chain Transport bootstrap for SOL Prices Desk.
/// Resolved SOL Prices Desk composite and Off-chain Transport configuration retained by application state.
pub(crate) struct OffchainTransportStartup {
composite_profile_id: String,
resolved: ksp_config_lib::ResolvedOffchainTransportConfig,
}
impl crate::OffchainTransportStartup {
/// Returns the composite profile selected for this application launch.
#[must_use]
pub(crate) fn composite_profile_id(&self) -> &str {
return self.composite_profile_id.as_str();
}
/// Returns the Config-resolved Off-chain Transport runtime configuration.
#[must_use]
pub(crate) const fn resolved(&self) -> &ksp_config_lib::ResolvedOffchainTransportConfig {
return &self.resolved;
}
}
/// Resolves the composite-selected Off-chain Transport profile and constructs the provider-neutral service owned by Config.
pub(crate) fn initialize_offchain_transport(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<crate::OffchainTransportStartup> {
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 composite = crate::load_solprices_desk_composite(management);
let composite = match composite {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let logging_profile = crate::required_composite_component_profile(&composite, crate::COMPOSITE_COMPONENT_ID_LOGGING, ksp_config_lib::FILE_ID_STD_LOGGING);
if let std::result::Result::Err(error) = logging_profile {
return std::result::Result::Err(error);
}
let offchain_profile = crate::required_composite_component_profile(
&composite,
crate::COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT,
ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT,
);
let offchain_profile = match offchain_profile {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let resolved = management.engine().resolve_offchain_transport_config_profile(&offchain_profile, &environment);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_count = resolved.service().registry().len();
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, composite_profile = composite.profile_id(), offchain_profile = resolved.profile_id(), provider_count, "initialized SOL Prices Desk Off-chain Transport from composite-managed configuration");
return std::result::Result::Ok(crate::OffchainTransportStartup { composite_profile_id: composite.profile_id().to_owned(), resolved });
}

View File

@@ -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<Self> {
let environment = ksp_config_lib::ConfigEnvironment::load();
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let 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<String>,
/// Optional message severity used by the bottom message feed.
pub(crate) status: std::option::Option<String>,
/// Optional animation duration in milliseconds.
pub(crate) duration_ms: std::option::Option<u32>,
}
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<u32>) -> 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<u64> {
let parsed = value.parse::<u64>();
let parsed = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
.with_context("variable_name", variable_name)
.with_source(error),
);
},
};
if parsed > maximum {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
.with_context("variable_name", variable_name)
.with_context("maximum_ms", maximum.to_string()),
);
}
return std::result::Result::Ok(parsed);
}
fn parse_u32_setting(variable_name: &str, value: &str, maximum: u32) -> ksp_core_lib::Result<u32> {
let parsed = value.parse::<u32>();
let parsed = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
.with_context("variable_name", variable_name)
.with_source(error),
);
},
};
if parsed > maximum {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
.with_context("variable_name", variable_name)
.with_context("maximum_ms", maximum.to_string()),
);
}
return std::result::Result::Ok(parsed);
}
#[cfg(test)]
#[path = "../unit_tests/splash.rs"]
mod tests;

View File

@@ -0,0 +1,204 @@
// file: crates/ksp-app-solprices-desk/src/tauri.rs
// version: 6
//! 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<tauri::Wry>) -> 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<tauri::Wry>, app_state: crate::AppState) -> tauri::Builder<tauri::Wry> {
return builder.manage(app_state);
}
fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
let tracing_plugin = tauri_plugin_tracing::Builder::new().build::<tauri::Wry>();
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::Wry>) -> tauri::Builder<tauri::Wry> {
return builder.invoke_handler(tauri::generate_handler![
emit_frontend_log,
get_runtime_status,
list_market_prices,
refresh_all_market_prices,
refresh_market_price,
refresh_market_prices,
splash_frontend_ready,
]);
}
fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
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));
}
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_SHELL, "SOL Prices Desk Tauri shell setup completed");
return std::result::Result::Ok(());
});
}
fn project_command_error(command: &'static str, domain: &'static str, error: &ksp_core_lib::Error) -> crate::CommandErrorDto {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = domain,
command = command,
error_domain = error.code().domain(),
error_code = error.code().code(),
"SOL Prices Desk Tauri command failed"
);
return crate::CommandErrorDto::from_error(error);
}
#[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(project_command_error("emit_frontend_log", crate::TRACING_DOMAIN_FRONTEND, &error)),
};
}
#[tauri::command]
fn get_runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::MarketPriceRuntimeStatusDto, crate::CommandErrorDto> {
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(project_command_error("get_runtime_status", crate::TRACING_DOMAIN_SHELL, &error)),
};
}
#[tauri::command]
fn list_market_prices(
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<std::vec::Vec<crate::MarketPriceProviderRowDto>, crate::CommandErrorDto> {
let result = state.list_market_prices();
return match result {
std::result::Result::Ok(value) => {
ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
row_count = value.len(),
"projected SOL Prices Desk market-price registry rows"
);
std::result::Result::Ok(value)
},
std::result::Result::Err(error) => {
std::result::Result::Err(project_command_error("list_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
},
};
}
#[tauri::command]
async fn refresh_all_market_prices(
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::MarketPriceRefreshResultDto, crate::CommandErrorDto> {
let result = state.refresh_all_market_prices().await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => {
std::result::Result::Err(project_command_error("refresh_all_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
},
};
}
#[tauri::command]
async fn refresh_market_price(
provider_id: String,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::MarketPriceProviderRowDto, crate::CommandErrorDto> {
let result = state.refresh_market_price(provider_id).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => {
std::result::Result::Err(project_command_error("refresh_market_price", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
},
};
}
#[tauri::command]
async fn refresh_market_prices(
request: crate::MarketPriceRefreshManyRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::MarketPriceRefreshResultDto, crate::CommandErrorDto> {
let result = state.refresh_market_prices(request).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => {
std::result::Result::Err(project_command_error("refresh_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &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(project_command_error("splash_frontend_ready", crate::TRACING_DOMAIN_WINDOWS, &error)),
};
}

View File

@@ -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<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
let window = manager.get_webview_window(WINDOW_LABEL_MAIN);
return match window {
std::option::Option::Some(value) => std::result::Result::Ok(value),
std::option::Option::None => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "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(());
}

View File

@@ -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<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
let window = manager.get_webview_window(WINDOW_LABEL_SPLASH);
return match window {
std::option::Option::Some(value) => std::result::Result::Ok(value),
std::option::Option::None => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "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),
),
};
}

View File

@@ -0,0 +1,68 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KSP SOL Prices Desk",
"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-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
"../../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"
}
}
}

View File

@@ -0,0 +1,94 @@
// file: crates/ksp-app-solprices-desk/tests/config_composition.rs
// version: 2
//! Config composition contracts for SOL Prices Desk `0.2.12-pre.003`.
fn workspace_engine() -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentEngine> {
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
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::defaults();
return match registry {
std::result::Result::Ok(value) => std::result::Result::Ok(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, value)),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn assert_component(composite: &ksp_config_lib::ResolvedConfigComposite, component_id: &str, file_id: &str, profile_id: &str) {
let component = composite.component(component_id);
assert!(component.is_some(), "missing component {component_id}");
if let std::option::Option::Some(component) = component {
assert_eq!(component.resolved().file_id().as_str(), file_id);
assert_eq!(component.resolved().profile_id(), profile_id);
assert_eq!(component.resolved().selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
}
}
#[test]
fn solprices_desk_composite_profiles_select_logging_and_offchain_transport_by_file_id() {
let engine = workspace_engine();
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
assert!(file_id.is_ok(), "SOL Prices Desk composite file_id should be valid: {file_id:?}");
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
for (selection, expected_composite_profile, expected_offchain_profile) in [
(std::option::Option::None, "public_keyless", "public_keyless"),
(std::option::Option::Some("all_free"), "all_free", "all_free"),
(std::option::Option::Some("tests"), "tests", "public_keyless"),
] {
let composite = engine.load_resolved_composite(&file_id, selection);
assert!(composite.is_ok(), "committed SOL Prices Desk composite profile should resolve: {composite:?}");
if let std::result::Result::Ok(composite) = composite {
assert_eq!(composite.profile_id(), expected_composite_profile);
assert_component(&composite, "logging", ksp_config_lib::FILE_ID_STD_LOGGING, "console_info");
assert_component(&composite, "offchain_transport", ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT, expected_offchain_profile);
}
}
}
#[test]
fn solprices_desk_default_composite_builds_provider_neutral_offchain_service() {
let engine = workspace_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let composite = engine.load_resolved_composite(&file_id, std::option::Option::None);
let composite = match composite {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let component = composite.component("offchain_transport");
let component = match component {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let environment = ksp_config_lib::ConfigEnvironment::load();
assert!(environment.is_ok(), "Config environment should load for the public_keyless profile");
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let resolved = engine.resolve_offchain_transport_config_profile(component.resolved(), &environment);
assert!(resolved.is_ok(), "public_keyless composite-selected Off-chain Transport should map: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "public_keyless");
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
assert_eq!(resolved.service().registry().len(), 8);
}
}

View File

@@ -0,0 +1,251 @@
// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs
// version: 9
//! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`.
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::<serde_json::Value>(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!(tauri.pointer("/version").is_none(), "SOL Prices Desk must let Tauri derive the bundle version from Cargo");
let package = read_json(root.join("package.json").as_path());
assert_eq!(package.pointer("/private").and_then(serde_json::Value::as_bool), std::option::Option::Some(true));
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::vec::Vec<_>>()
},
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("get_runtime_status"));
assert!(!tauri_rs.contains("fn runtime_status("));
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\""));
}
#[test]
fn pre_003_packaging_contains_current_eleven_config_resources() {
let root = app_root();
let tauri = read_json(root.join("tauri.conf.json").as_path());
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
assert!(resources.is_some());
if let std::option::Option::Some(resources) = resources {
assert_eq!(resources.len(), 11);
assert_eq!(
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
);
assert!(resources.contains_key("../../config/std.offchain_transport.json"));
assert!(resources.contains_key("../../config/schemas/std.offchain_transport.schema.json"));
}
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
assert!(tauri_source.contains("std::env::set_current_dir(layout.runtime_root())"));
}
#[test]
fn pre_002_fix_001_reuses_the_common_ksp_desktop_template() {
let root = app_root();
let package = read_text(root.join("package.json").as_path());
for dependency in ["@fltsci/tauri-plugin-tracing", "@fortawesome/fontawesome-free", "@tauri-apps/api", "bootstrap", "resize-observer-polyfill", "simplebar"]
{
assert!(package.contains(dependency), "missing common frontend dependency {dependency}");
}
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(main_typescript.contains(r#"import ResizeObserver from "resize-observer-polyfill""#));
assert!(main_typescript.contains(".ResizeObserver = ResizeObserver"));
assert!(main_typescript.contains("getCurrentWindow().label"));
assert!(main_typescript.contains(r#""get_runtime_status""#));
let main_html = read_text(root.join("frontend/main.html").as_path());
assert!(main_html.contains(r#"<footer class="app-footer bg-dark text-light">"#));
assert!(main_html.contains(r#"class="app-placeholder""#));
assert!(main_html.contains(r#"class="row mb-0 app-runtime-list""#));
assert!(!main_html.contains("runtimeVersionBadge"));
let splash_html = read_text(root.join("frontend/splash.html").as_path());
assert!(splash_html.contains("<title>KSP SOL Prices Desk — Chargement</title>"));
let splash_scss = read_text(root.join("frontend/sass/splash.scss").as_path());
assert!(splash_scss.contains("DOS_Amazigh.ttf"));
assert!(splash_scss.contains(r#"font-family: "Dos Amazigh", sans-serif"#));
let font_manifest = read_text(root.join("frontend/fonts/README.md").as_path());
assert!(font_manifest.contains("a01ff4b5a0d699db7cefcf5336bf0e379ff6fbf0d096087571a13c8782f9aa02"));
let font = std::fs::read(root.join("frontend/fonts/DOS_Amazigh.ttf"));
let config_font = std::fs::read(root.join("../ksp-app-config-desk/frontend/fonts/DOS_Amazigh.ttf"));
let wallet_font = std::fs::read(root.join("../ksp-app-wallet-desk/frontend/fonts/DOS_Amazigh.ttf"));
assert!(font.is_ok());
assert!(config_font.is_ok());
assert!(wallet_font.is_ok());
if let (std::result::Result::Ok(font), std::result::Result::Ok(config_font), std::result::Result::Ok(wallet_font)) = (font, config_font, wallet_font) {
assert_eq!(font, config_font);
assert_eq!(font, wallet_font);
}
for relative in ["_bootswatch.scss", "_fontawesome.scss", "_simplebar.scss", "_variables.scss", "main.scss", "splash.scss"] {
let sol = read_text(root.join("frontend/sass").join(relative).as_path());
let config = read_text(root.join("../ksp-app-config-desk/frontend/sass").join(relative).as_path());
let wallet = read_text(root.join("../ksp-app-wallet-desk/frontend/sass").join(relative).as_path());
let sol_body = sol.lines().skip(3).collect::<std::vec::Vec<_>>().join("\n");
let config_body = config.lines().skip(3).collect::<std::vec::Vec<_>>().join("\n");
let wallet_body = wallet.lines().skip(3).collect::<std::vec::Vec<_>>().join("\n");
assert_eq!(sol_body, config_body, "SOL Prices Desk diverges from Config Desk template in {relative}");
assert_eq!(sol_body, wallet_body, "SOL Prices Desk diverges from Wallet Desk template in {relative}");
}
}
#[test]
fn pre_005_market_price_runtime_preserves_single_refresh_contract() {
let root = app_root();
let manifest = read_text(root.join("Cargo.toml").as_path());
assert!(manifest.contains(r#"ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }"#));
assert!(!manifest.contains("reqwest"));
let runtime_rs = read_text(root.join("src/market_price_runtime.rs").as_path());
assert!(runtime_rs.contains("pub(crate) async fn refresh_one"));
assert!(runtime_rs.contains("service().refresh(&provider_id).await"));
assert!(runtime_rs.contains("self.begin_refresh(provider_ids.as_slice())"));
assert!(runtime_rs.contains("apply_outcome_to_state"));
let tauri_rs = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri_rs.contains("refresh_market_price"));
let main_html = read_text(root.join("frontend/main.html").as_path());
assert!(main_html.contains(r#"id="marketPriceTable""#));
assert!(main_html.contains(">Action</th>"));
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(main_typescript.contains(r#""refresh_market_price""#));
assert!(main_typescript.contains("SOL Prices Desk market-price refresh control clicked"));
assert!(main_typescript.contains("provider.providerId"));
assert!(main_typescript.contains("provider.loading"));
assert!(main_typescript.contains("Refreshing…"));
assert!(!main_typescript.contains("parseFloat("));
assert!(!main_typescript.contains("Number(provider.price"));
}
#[test]
fn pre_006_market_price_runtime_adds_selected_and_global_refresh_without_polling() {
let root = app_root();
let lib_rs = read_text(root.join("src/lib.rs").as_path());
assert!(lib_rs.contains("MarketPriceRefreshManyRequestDto"));
assert!(lib_rs.contains("MarketPriceRefreshResultDto"));
let runtime_rs = read_text(root.join("src/market_price_runtime.rs").as_path());
assert!(runtime_rs.contains("pub(crate) async fn refresh_many"));
assert!(runtime_rs.contains("service().refresh_many(provider_ids.as_slice()).await"));
assert!(runtime_rs.contains("pub(crate) async fn refresh_all"));
assert!(runtime_rs.contains("service().refresh_all().await"));
assert!(runtime_rs.contains("begin_refresh_in_state"));
let tauri_rs = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri_rs.contains("refresh_market_prices"));
assert!(tauri_rs.contains("refresh_all_market_prices"));
let main_html = read_text(root.join("frontend/main.html").as_path());
assert!(main_html.contains(r#"id="refreshSelectedButton""#));
assert!(main_html.contains(r#"id="refreshAllButton""#));
assert!(main_html.contains(r#"id="selectedProviderCount""#));
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(main_typescript.contains(r#""refresh_market_prices""#));
assert!(main_typescript.contains(r#""refresh_all_market_prices""#));
assert!(main_typescript.contains("selectedProviderIdsInRowOrder"));
assert!(main_typescript.contains("SOL Prices Desk selected market-price refresh control clicked"));
assert!(main_typescript.contains("SOL Prices Desk global market-price refresh control clicked"));
for forbidden in ["setInterval(", "setTimeout(", "requestAnimationFrame("] {
assert!(!main_typescript.contains(forbidden), "pre.006 must not add consumer scheduling: {forbidden}");
}
}
#[test]
fn pre_007_prices_view_is_responsive_and_projects_generic_states_without_price_coercion() {
let root = app_root();
let main_html = read_text(root.join("frontend/main.html").as_path());
for required in [
r#"id="pricesSummaryProviders""#,
r#"id="pricesSummaryReady""#,
r#"id="pricesSummaryObserved""#,
r#"id="pricesSummaryAttention""#,
r#"id="pricesSummaryLoading""#,
r#"id="selectAllProviders""#,
r#"id="clearSelectionButton""#,
"Observation times",
"app-market-table-container",
"visually-hidden",
] {
assert!(main_html.contains(required), "pre.007 Prices UX is missing {required}");
}
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
for required in [
"availabilityPresentations",
"Authentication unavailable",
"Cooling down",
"Quota unavailable",
"Temporarily unavailable",
"formatUnixMillis",
"BigInt(value)",
"Not supplied",
"selectAllProviders",
"clearProviderSelection",
"pricesSummaryObserved",
"app-market-row-loading",
] {
assert!(main_typescript.contains(required), "pre.007 Prices UX is missing {required}");
}
assert!(!main_typescript.contains("parseFloat("));
assert!(!main_typescript.contains("Number(provider.price"));
let app_scss = read_text(root.join("frontend/sass/_app.scss").as_path());
for required in ["position: sticky", "font-variant-numeric: tabular-nums", "app-market-summary-card", "app-market-table-container"] {
assert!(app_scss.contains(required), "pre.007 responsive styling is missing {required}");
}
}

View File

@@ -0,0 +1,201 @@
// file: crates/ksp-app-solprices-desk/tests/desktop_security.rs
// version: 8
//! Security, ownership and frontend instrumentation canaries for SOL Prices Desk.
fn app_root() -> 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<std::path::PathBuf>) {
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_005_tauri_commands_remain_centralized_and_single_refresh_is_preserved() {
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());
}
if path.file_name().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("tauri.rs") {
assert!(!source.contains("tauri::generate_handler!"), "{} registers Tauri handlers outside tauri.rs", path.display());
}
}
assert!(command_count >= 5);
let tauri = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri.contains("refresh_market_price"));
}
#[test]
fn pre_005_frontend_refresh_interactions_are_logged_without_business_values() {
let root = app_root();
let main = read_text(root.join("frontend/ts/main.ts").as_path());
let invoke = read_text(root.join("frontend/ts/invoke.ts").as_path());
assert!(main.contains(r#"button.addEventListener("click""#));
assert!(main.contains("SOL Prices Desk navigation control clicked"));
assert!(main.contains("frontendDebug"));
assert!(main.contains("SOL Prices Desk view activated"));
assert!(invoke.contains("Frontend IPC command requested"));
assert!(invoke.contains("Frontend IPC command completed"));
assert!(invoke.contains("frontendTrace"));
assert!(main.contains("SOL Prices Desk provider registry load started"));
assert!(main.contains("SOL Prices Desk market-price registry rows rendered"));
assert!(main.contains("SOL Prices Desk market-price refresh control clicked"));
assert!(main.contains("SOL Prices Desk market-price refresh row applied"));
assert!(main.contains("SOL Prices Desk market-price refresh failed"));
for forbidden in ["JSON.stringify(status)", "JSON.stringify(payload)", "apiKey", "authorization"] {
assert!(!main.contains(forbidden), "main frontend logging must not serialize business/secret values: {forbidden}");
}
}
#[test]
fn pre_006_selected_and_global_refresh_commands_are_centralized_and_bounded() {
let root = app_root();
let tauri = read_text(root.join("src/tauri.rs").as_path());
assert_eq!(tauri.matches("#[tauri::command]").count(), 7);
assert!(tauri.contains("refresh_market_price"));
assert!(tauri.contains("refresh_market_prices"));
assert!(tauri.contains("refresh_all_market_prices"));
let runtime = read_text(root.join("src/market_price_runtime.rs").as_path());
assert!(runtime.contains("ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT"));
assert!(runtime.contains("ERROR_CODE_MARKET_PRICE_SELECTION_INVALID"));
assert!(runtime.contains("refresh_many(provider_ids.as_slice()).await"));
assert!(runtime.contains("refresh_all().await"));
assert!(!runtime.contains("tokio::time::sleep"));
}
#[test]
fn pre_006_frontend_batch_controls_are_logged_without_price_or_provider_payloads() {
let root = app_root();
let main = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(main.contains("SOL Prices Desk market-price selection control changed"));
assert!(main.contains("SOL Prices Desk selected market-price refresh control clicked"));
assert!(main.contains("SOL Prices Desk global market-price refresh control clicked"));
assert!(main.contains("requestedCount: result.requestedCount"));
assert!(main.contains("refreshedCount: result.refreshedCount"));
for forbidden in ["JSON.stringify(result)", "JSON.stringify(currentMarketPriceRows)", "price: provider.price", "apiKey", "authorization"] {
assert!(!main.contains(forbidden), "batch frontend logging must not serialize price/provider/secret values: {forbidden}");
}
}
#[test]
fn pre_007_all_frontend_controls_and_backend_command_failures_are_instrumented_safely() {
let root = app_root();
let main = read_text(root.join("frontend/ts/main.ts").as_path());
for required in [
"SOL Prices Desk navigation control clicked",
"SOL Prices Desk market-price selection control changed",
"SOL Prices Desk select-all providers control changed",
"SOL Prices Desk clear provider selection control clicked",
"SOL Prices Desk market-price refresh control clicked",
"SOL Prices Desk selected market-price refresh control clicked",
"SOL Prices Desk global market-price refresh control clicked",
] {
assert!(main.contains(required), "pre.007 frontend control instrumentation is missing {required}");
}
for forbidden in [
"JSON.stringify(currentMarketPriceRows)",
"JSON.stringify(result)",
"price: provider.price",
"providerTimestampUnixMillis:",
"receivedAtUnixMillis:",
"retryAtUnixMillis:",
"apiKey",
"authorization",
] {
assert!(!main.contains(forbidden), "pre.007 frontend instrumentation must not log business/secret values: {forbidden}");
}
let tauri = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri.contains("fn project_command_error"));
assert!(tauri.contains("error_domain = error.code().domain()"));
assert!(tauri.contains("error_code = error.code().code()"));
assert!(tauri.contains("SOL Prices Desk Tauri command failed"));
assert!(!tauri.contains("error_message = error"));
}
#[test]
fn pre_007_remains_manual_without_browser_persistence_or_price_scheduler() {
let root = app_root();
let main = read_text(root.join("frontend/ts/main.ts").as_path());
for forbidden in ["setInterval(", "setTimeout(", "requestAnimationFrame(", "localStorage", "sessionStorage", "fetch(", "XMLHttpRequest"] {
assert!(!main.contains(forbidden), "pre.007 must remain manual and backend-owned: {forbidden}");
}
}
#[test]
fn pre_009_hardening_bounds_hostile_batches_and_preserves_manual_backend_owned_refresh() {
let root = app_root();
let runtime = read_text(root.join("src/market_price_runtime.rs").as_path());
assert!(runtime.contains("const MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS: usize = 64;"));
assert!(runtime.contains("provider_ids.is_empty() || provider_ids.len() > MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS"));
assert!(runtime.contains("ERROR_CODE_MARKET_PRICE_SELECTION_INVALID"));
let main = read_text(root.join("frontend/ts/main.ts").as_path());
for forbidden in ["setInterval(", "setTimeout(", "requestAnimationFrame(", "fetch(", "XMLHttpRequest", "localStorage", "sessionStorage"] {
assert!(!main.contains(forbidden), "release hardening must preserve manual backend-owned refresh: {forbidden}");
}
}

View File

@@ -0,0 +1,80 @@
// file: crates/ksp-app-solprices-desk/tests/market_price_composition_live_smoke.rs
// version: 1
//! Opt-in live smoke proving SOL Prices Desk composite -> Config -> generic MarketPriceService composition.
fn workspace_engine() -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentEngine> {
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
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::defaults();
return match registry {
std::result::Result::Ok(value) => std::result::Result::Ok(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, value)),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "opt-in live SOL Prices Desk composition smoke; resolves the committed public_keyless composite and performs external SOL/USD HTTPS refreshes"]
async fn committed_solprices_composite_refreshes_at_least_one_keyless_provider() {
let engine = workspace_engine();
assert!(engine.is_ok(), "workspace Config engine should be constructible");
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
assert!(file_id.is_ok(), "SOL Prices Desk composite file_id should be valid");
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let composite = engine.load_resolved_composite(&file_id, std::option::Option::None);
assert!(composite.is_ok(), "committed SOL Prices Desk composite should resolve");
let composite = match composite {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(composite.profile_id(), "public_keyless");
let component = composite.component("offchain_transport");
assert!(component.is_some(), "SOL Prices Desk composite should contain Off-chain Transport");
let component = match component {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let environment = ksp_config_lib::ConfigEnvironment::load();
assert!(environment.is_ok(), "Config-owned environment snapshot should load");
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let resolved = engine.resolve_offchain_transport_config_profile(component.resolved(), &environment);
assert!(resolved.is_ok(), "composite-selected public_keyless Off-chain Transport should map");
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(resolved.profile_id(), "public_keyless");
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
let registry = resolved.service().registry();
assert_eq!(registry.len(), 8);
let outcomes = resolved.service().refresh_all().await;
assert!(outcomes.is_ok(), "generic public_keyless refresh_all should complete without provider-specific application branching");
let outcomes = match outcomes {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(outcomes.len(), registry.len());
let refreshed_count = outcomes.iter().filter(|outcome| return outcome.refreshed()).count();
assert!(refreshed_count > 0, "at least one public_keyless provider should produce one live SOL/USD observation");
for outcome in outcomes {
assert!(registry.entry(outcome.provider_id()).is_some(), "live outcome provider must belong to the generic registry");
if let std::option::Option::Some(observation) = outcome.observation() {
assert_eq!(observation.pair(), ksp_offchain_transport_lib::MarketPricePair::SolUsd);
assert_eq!(observation.provider_id(), outcome.provider_id());
}
}
}

View File

@@ -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::<fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()>>());
}

View File

@@ -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"
]
}

View File

@@ -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");
}

View File

@@ -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);
}

View File

@@ -0,0 +1,302 @@
// file: crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs
// version: 4
fn descriptor() -> std::option::Option<ksp_offchain_transport_lib::MarketPriceProviderDescriptor> {
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("test-provider");
let provider_id = match provider_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
let rate_limit = ksp_offchain_transport_lib::MarketPriceProviderRateLimit::fixed(
10,
60,
std::option::Option::None,
ksp_offchain_transport_lib::MarketPriceProviderRateLimitScope::Unspecified,
);
let rate_limit = match rate_limit {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
let descriptor = ksp_offchain_transport_lib::MarketPriceProviderDescriptor::new(
provider_id,
"Test Provider",
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
rate_limit,
std::option::Option::None,
true,
);
return match descriptor {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(_) => std::option::Option::None,
};
}
#[test]
fn generic_codes_cover_current_provider_neutral_enums() {
assert_eq!(super::auth_mode_code(ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None), "none");
assert_eq!(super::auth_mode_code(ksp_offchain_transport_lib::MarketPriceProviderAuthMode::OptionalApiKey), "optional_api_key");
assert_eq!(super::auth_mode_code(ksp_offchain_transport_lib::MarketPriceProviderAuthMode::RequiredApiKey), "required_api_key");
assert_eq!(
super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::AuthenticationUnavailable),
"authentication_unavailable"
);
assert_eq!(
super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::CoolingDown {
retry_at: ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(123),
}),
"cooling_down"
);
assert_eq!(super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled), "disabled");
assert_eq!(super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::Misconfigured), "misconfigured");
assert_eq!(super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::QuotaUnavailable), "quota_unavailable");
assert_eq!(super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready), "ready");
assert_eq!(
super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None }),
"temporarily_unavailable"
);
assert_eq!(super::semantics_code(ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket), "aggregated_market");
assert_eq!(super::semantics_code(ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd), "dex_pair_usd");
assert_eq!(super::semantics_code(ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade), "exchange_last_trade");
assert_eq!(super::semantics_code(ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic), "solana_heuristic");
assert_eq!(super::semantics_code(ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot), "solana_spot");
}
#[test]
fn initial_registry_row_contains_no_observation_or_loading_state() {
let descriptor = descriptor();
assert!(descriptor.is_some());
let descriptor = match descriptor {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let entry =
ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry::new(descriptor, ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready);
let presentation = super::MarketPricePresentationEntry { loading: false, observation: std::option::Option::None };
let row = super::project_registry_entry(&entry, &presentation);
assert_eq!(row.provider_id, "test-provider");
assert_eq!(row.display_name, "Test Provider");
assert_eq!(row.pair, "SOL/USD");
assert_eq!(row.auth_mode, "none");
assert_eq!(row.availability, "ready");
assert_eq!(row.semantics, "aggregated_market");
assert!(!row.loading);
assert!(!row.refreshed);
assert_eq!(row.price, std::option::Option::None);
assert_eq!(row.provider_timestamp_unix_millis, std::option::Option::None);
assert_eq!(row.received_at_unix_millis, std::option::Option::None);
assert_eq!(row.retry_at_unix_millis, std::option::Option::None);
}
#[test]
fn observation_projection_keeps_exact_decimal_and_distinct_timestamps() {
let descriptor = descriptor();
assert!(descriptor.is_some());
let descriptor = match descriptor {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let provider_id = descriptor.id().clone();
let entry = ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry::new(
descriptor,
ksp_offchain_transport_lib::MarketPriceProviderAvailability::CoolingDown {
retry_at: ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(400),
},
);
let price = ksp_offchain_transport_lib::MarketPriceDecimal::parse("123.4500");
assert!(price.is_ok());
let price = match price {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let provenance = ksp_offchain_transport_lib::MarketPriceProvenance::new("unit-test");
assert!(provenance.is_ok());
let provenance = match provenance {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let observation = ksp_offchain_transport_lib::MarketPriceObservation::new(
provider_id,
price,
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(100),
ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(300),
std::option::Option::Some(ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(200)),
provenance,
);
assert!(observation.is_ok());
let observation = match observation {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let presentation = super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::Some(observation) };
let row = super::project_registry_entry(&entry, &presentation);
assert_eq!(row.price.as_deref(), std::option::Option::Some("123.45"));
assert_eq!(row.provider_timestamp_unix_millis.as_deref(), std::option::Option::Some("200"));
assert_eq!(row.received_at_unix_millis.as_deref(), std::option::Option::Some("300"));
assert_eq!(row.retry_at_unix_millis.as_deref(), std::option::Option::Some("400"));
assert!(row.loading);
assert!(row.refreshed);
}
#[tokio::test]
async fn disabled_refresh_clears_loading_without_inventing_observation() -> ksp_core_lib::Result<()> {
let settings = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service =
match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(settings),]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut entries = std::collections::BTreeMap::new();
entries.insert(provider_id.as_str().to_owned(), super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::None });
let mut presentation = super::MarketPricePresentationState { entries };
let outcome = match service.refresh(&provider_id).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!outcome.refreshed());
let applied = super::apply_outcome_to_state(&mut presentation, &outcome);
if let std::result::Result::Err(error) = applied {
return std::result::Result::Err(error);
}
let entry = presentation.entries.get(provider_id.as_str());
assert!(entry.is_some());
if let std::option::Option::Some(entry) = entry {
assert!(!entry.loading);
assert!(entry.observation.is_none());
}
return std::result::Result::Ok(());
}
#[tokio::test]
async fn non_refresh_outcome_preserves_last_successful_observation() -> ksp_core_lib::Result<()> {
let settings = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service =
match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(settings),]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let price = match ksp_offchain_transport_lib::MarketPriceDecimal::parse("101.2500") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provenance = match ksp_offchain_transport_lib::MarketPriceProvenance::new("unit-test") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let observation = match ksp_offchain_transport_lib::MarketPriceObservation::new(
provider_id.clone(),
price,
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(10),
ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(30),
std::option::Option::Some(ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(20)),
provenance,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut entries = std::collections::BTreeMap::new();
entries.insert(provider_id.as_str().to_owned(), super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::Some(observation) });
let mut presentation = super::MarketPricePresentationState { entries };
let outcome = match service.refresh(&provider_id).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!outcome.refreshed());
let applied = super::apply_outcome_to_state(&mut presentation, &outcome);
if let std::result::Result::Err(error) = applied {
return std::result::Result::Err(error);
}
let entry = presentation.entries.get(provider_id.as_str());
assert!(entry.is_some());
if let std::option::Option::Some(entry) = entry {
assert!(!entry.loading);
assert_eq!(entry.observation.as_ref().map(|value| return value.price().to_canonical_string()).as_deref(), std::option::Option::Some("101.25"));
}
return std::result::Result::Ok(());
}
#[test]
fn batch_loading_validation_rejects_duplicates_without_partial_state_mutation() -> ksp_core_lib::Result<()> {
let provider_id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut entries = std::collections::BTreeMap::new();
entries.insert(provider_id.as_str().to_owned(), super::MarketPricePresentationEntry { loading: false, observation: std::option::Option::None });
let mut presentation = super::MarketPricePresentationState { entries };
let result = super::begin_refresh_in_state(&mut presentation, &[provider_id.clone(), provider_id.clone()]);
assert!(result.is_err());
let entry = presentation.entries.get(provider_id.as_str());
assert!(entry.is_some());
if let std::option::Option::Some(entry) = entry {
assert!(!entry.loading);
}
return std::result::Result::Ok(());
}
#[test]
fn batch_loading_validation_rejects_in_flight_provider_without_marking_other_rows() -> ksp_core_lib::Result<()> {
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kraken = match ksp_offchain_transport_lib::MarketPriceProviderId::new("kraken") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut entries = std::collections::BTreeMap::new();
entries.insert(coinpaprika.as_str().to_owned(), super::MarketPricePresentationEntry { loading: false, observation: std::option::Option::None });
entries.insert(kraken.as_str().to_owned(), super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::None });
let mut presentation = super::MarketPricePresentationState { entries };
let result = super::begin_refresh_in_state(&mut presentation, &[coinpaprika.clone(), kraken.clone()]);
assert!(result.is_err());
let coinpaprika_entry = presentation.entries.get(coinpaprika.as_str());
let kraken_entry = presentation.entries.get(kraken.as_str());
assert!(coinpaprika_entry.is_some());
assert!(kraken_entry.is_some());
if let std::option::Option::Some(entry) = coinpaprika_entry {
assert!(!entry.loading);
}
if let std::option::Option::Some(entry) = kraken_entry {
assert!(entry.loading);
}
return std::result::Result::Ok(());
}
#[test]
fn hostile_batch_size_is_rejected_before_provider_id_parsing() {
let provider_ids = (0..=super::MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS)
.map(|index| return std::format!("invalid provider payload {index}"))
.collect::<std::vec::Vec<_>>();
let result = super::parse_provider_ids(provider_ids);
assert!(result.is_err());
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID);
assert_eq!(error.context().len(), 2);
}
}
#[test]
fn empty_batch_is_rejected_at_the_application_boundary() {
let result = super::parse_provider_ids(std::vec::Vec::new());
assert!(result.is_err());
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID);
}
}

View File

@@ -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());
}

View File

@@ -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/**"],
},
},
});

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-app-wallet-desk/Cargo.toml
# version: 5
# version: 6
[package]
name = "ksp-app-wallet-desk"
@@ -27,6 +27,7 @@ fs2.workspace = true
ksp-config-lib = { path = "../ksp-config-lib" }
ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-lib" }
ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
ksp-wallet-lib = { path = "../ksp-wallet-lib" }
serde = { workspace = true, features = ["derive"] }

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-wallet-desk/README.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# `ksp-app-wallet-desk`
@@ -21,6 +21,7 @@ Wallet Desk reste une couche d'interface et de composition. Il ne possède ni le
ksp-config-lib -> configuration/composite/.env
ksp-wallet-lib -> format, crypto, VIEW/OWNER, import/export, migration
ksp-onchain-transport-lib -> RPC Solana HTTP
ksp-offchain-transport-lib -> prix SOL/USD provider-neutral
ksp-logging-lib -> logging/tracing applicatif
ksp-app-wallet-desk -> orchestration Tauri + projections sûres
```
@@ -64,6 +65,19 @@ La surface validée comprend :
- export OWNER Solana CLI JSON/Base58 no-clobber ;
- wire V2 par défaut tout en conservant la compatibilité V1.
## Prix SOL/USD auxiliaire
Le refresh de balance compose deux capacités backend : `getBalance` et `MarketPriceService::refresh_all`. Les observations réussies du refresh courant sont moyennées côté Rust sans `f64`; la balance SOL reste autoritative et le prix reste auxiliaire.
Le frontend reçoit uniquement :
```text
SOL/USD moyen
Équivalent USD de la balance
```
ou `N.A.` lorsque le prix n'est pas disponible. Cette moyenne est une projection locale à Wallet Desk : elle ne devient ni un consensus, ni un fallback, ni un prix canonique de `ksp-offchain-transport-lib`. Aucun provider ID ou détail provider n'est exposé au frontend Wallet.
## Frontières desktop
Le frontend utilise Bootstrap, Font Awesome, DataTables/Select, SimpleBar et le bridge Logging KSP. Les capabilities Tauri du guest restent minimales :

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-wallet-desk/USAGE.md -->
<!-- version: 2 -->
<!-- version: 3 -->
# Utilisation de `ksp-app-wallet-desk`
@@ -82,9 +82,21 @@ migrate_wallet_file_v1_to_v2_in_place
Elles authentifient OWNER, reconstruisent un document cryptographique V2 neuf et conservent l'identité Solana ainsi que les metadata/note IDs. Lorsque VIEW est activé, un password VIEW cible doit être fourni. La copie est no-clobber ; le remplacement in-place est stale-protected.
## 7. Balance Solana
## 7. Balance Solana et prix auxiliaire
Une session VIEW ou OWNER autorisée peut demander `getBalance` via `ksp-onchain-transport-lib`. Le frontend ne choisit ni Pubkey arbitraire ni URL RPC : ces valeurs sont dérivées de l'état Rust autorisé et de Config.
Une session VIEW ou OWNER autorisée peut rafraîchir sa balance. Le backend compose alors `getBalance` via `ksp-onchain-transport-lib` et un `refresh_all` SOL/USD via `ksp-offchain-transport-lib`. Le frontend ne choisit ni Pubkey arbitraire, ni URL RPC, ni provider prix : ces valeurs sont dérivées de l'état Rust autorisé et de Config.
Lorsque des observations SOL/USD sont produites par le refresh courant, Wallet Desk calcule côté Rust une moyenne arithmétique exacte puis l'équivalent USD de la balance. Aucun `f64` n'est utilisé comme vérité métier.
L'interface affiche :
```text
Balance SOL
SOL/USD moyen
Équivalent USD
```
Si aucune observation prix exploitable n'est disponible, les deux champs USD affichent `N.A.` sans transformer un `getBalance` réussi en erreur Wallet. La moyenne est strictement une projection de consommation Wallet ; elle ne modifie pas le contrat provider-neutral de `ksp-offchain-transport-lib`.
## 8. Runtime packagé

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-wallet-desk/frontend/main.html -->
<!-- version: 5 -->
<!-- version: 7 -->
<!DOCTYPE html>
<html lang="fr">
<head>
@@ -53,7 +53,7 @@
<h1 id="viewTitle" class="h3 mb-1">Dashboard</h1>
<p class="text-body-secondary mb-0">Identité autorisée et balance SOL via le Transport HTTP composite sont actives.</p>
</div>
<span class="badge text-bg-secondary">0.2.6</span>
<span id="appVersionBadge" class="badge text-bg-secondary"></span>
</div>
<section data-view-panel="dashboard">
<div class="row g-3">
@@ -97,6 +97,8 @@
<dt class="col-5">Alias</dt><dd id="currentWalletAlias" class="col-7 text-break"></dd>
<dt class="col-5">Notes</dt><dd id="currentWalletNotes" class="col-7 text-break"></dd>
<dt class="col-5">Balance SOL</dt><dd id="currentWalletBalanceSol" class="col-7"></dd>
<dt class="col-5">SOL/USD moyen</dt><dd id="currentWalletSolUsdAverage" class="col-7">N.A.</dd>
<dt class="col-5">Équivalent USD</dt><dd id="currentWalletSolUsdEquivalent" class="col-7">N.A.</dd>
</dl>
</div>
</div>
@@ -256,6 +258,8 @@
<dt class="col-md-4">Notes</dt><dd id="detailsWalletNotes" class="col-md-8 text-break"></dd>
<dt class="col-md-4">Lamports</dt><dd id="detailsBalanceLamports" class="col-md-8"></dd>
<dt class="col-md-4">SOL</dt><dd id="detailsBalanceSol" class="col-md-8"></dd>
<dt class="col-md-4">SOL/USD moyen</dt><dd id="detailsSolUsdAverage" class="col-md-8">N.A.</dd>
<dt class="col-md-4">Équivalent USD</dt><dd id="detailsSolUsdEquivalent" class="col-md-8">N.A.</dd>
<dt class="col-md-4">RPC slot</dt><dd id="detailsBalanceSlot" class="col-md-8"></dd>
<dt class="col-md-4">RPC api_version</dt><dd id="detailsBalanceApiVersion" class="col-md-8"></dd>
<dt class="col-md-4">Transport</dt><dd id="detailsBalanceTransport" class="col-md-8"></dd>

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/frontend/ts/main.ts
// version: 16
// version: 18
import { Modal } from "bootstrap";
import DataTable from "datatables.net-bs5";
@@ -378,8 +378,12 @@ function clearStrongViewSensitiveInputs(): void {
function clearBalanceProjection(): void {
setText("#currentWalletBalanceSol", "—");
setText("#currentWalletSolUsdAverage", "N.A.");
setText("#currentWalletSolUsdEquivalent", "N.A.");
setText("#detailsBalanceLamports", "—");
setText("#detailsBalanceSol", "—");
setText("#detailsSolUsdAverage", "N.A.");
setText("#detailsSolUsdEquivalent", "N.A.");
setText("#detailsBalanceSlot", "—");
setText("#detailsBalanceApiVersion", "—");
setText("#detailsBalanceTransport", "—");
@@ -1379,13 +1383,17 @@ function renderWalletBalance(balance: WalletBalanceDto): void {
return;
}
setText("#currentWalletBalanceSol", balance.sol);
setText("#currentWalletSolUsdAverage", balance.solUsdAverage ? `${balance.solUsdAverage} USD/SOL` : "N.A.");
setText("#currentWalletSolUsdEquivalent", balance.solUsdEquivalent ? `${balance.solUsdEquivalent} USD` : "N.A.");
setText("#detailsBalanceLamports", balance.lamports);
setText("#detailsBalanceSol", balance.sol);
setText("#detailsSolUsdAverage", balance.solUsdAverage ? `${balance.solUsdAverage} USD/SOL` : "N.A.");
setText("#detailsSolUsdEquivalent", balance.solUsdEquivalent ? `${balance.solUsdEquivalent} USD` : "N.A.");
setText("#detailsBalanceSlot", balance.slot.toString());
setText("#detailsBalanceApiVersion", balance.apiVersion ?? "—");
setText("#detailsBalanceTransport", `${balance.transportProfile} / ${balance.transportRole}`);
setText("#balanceStatus", "Balance réseau actualisée.");
frontendInfo("main", "Wallet balance rendered", { lamports: balance.lamports, slot: balance.slot, walletId: balance.walletId });
setText("#balanceStatus", "Balance réseau, prix moyen SOL/USD et équivalent USD actualisés.");
frontendInfo("main", "Wallet balance rendered", { averageAvailable: balance.solUsdAverage !== null, equivalentAvailable: balance.solUsdEquivalent !== null, lamports: balance.lamports, slot: balance.slot, walletId: balance.walletId });
}
async function refreshWalletBalance(): Promise<void> {
@@ -1393,8 +1401,8 @@ async function refreshWalletBalance(): Promise<void> {
return;
}
const walletId = activeWalletId;
setText("#balanceStatus", "Interrogation getBalance en cours…");
frontendDebug("main", "Wallet balance refresh requested", { walletId });
setText("#balanceStatus", "Interrogation getBalance et refresh SOL/USD en cours…");
frontendDebug("main", "Wallet balance and SOL/USD refresh requested", { walletId });
try {
const balance = await invokeKsp<WalletBalanceDto>("main", "refresh_wallet_balance");
renderWalletBalance(balance);
@@ -1415,6 +1423,7 @@ function bindBalanceActions(): void {
}
function renderRuntimeStatus(status: RuntimeStatusDto): void {
setText("#appVersionBadge", status.applicationVersion);
setText("#runtimeVersion", status.applicationVersion);
setText("#runtimeCompositeProfile", status.activeCompositeProfile);
setText("#runtimeLoggingProfile", status.activeLoggingProfile ?? "fallback transitoire");

View File

@@ -1,7 +1,7 @@
{
"name": "ksp-app-wallet-desk",
"private": true,
"version": "0.2.6",
"version": "0.2.12-pre.9",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/app_state.rs
// version: 19
// version: 21
//! Shared backend state owned by the Wallet Desk Tauri application.
@@ -7,6 +7,7 @@
pub(crate) struct AppState {
config_management: ksp_config_lib::ConfigManagement,
logging_runtime: std::sync::Mutex<LoggingRuntimeState>,
offchain_runtime: std::option::Option<crate::OffchainTransportRuntime>,
splash_settings: crate::SplashSettings,
splash_sequence_started: std::sync::atomic::AtomicBool,
transport_runtime: crate::TransportRuntime,
@@ -41,6 +42,20 @@ impl AppState {
return std::result::Result::Err(error);
},
};
let offchain_runtime = crate::initialize_offchain_transport(&config_management);
let offchain_runtime = match offchain_runtime {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(error) => {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
error_domain = error.code().domain(),
error_code = error.code().code(),
"Wallet Desk Off-chain Transport is unavailable; SOL/USD average will be N.A."
);
std::option::Option::None
},
};
let wallet_config_startup = crate::initialize_wallet_config(&config_management);
let wallet_config_startup = match wallet_config_startup {
std::result::Result::Ok(value) => value,
@@ -66,6 +81,7 @@ impl AppState {
fallback_active: logging_startup.fallback_active,
startup_diagnostic: logging_startup.startup_diagnostic,
}),
offchain_runtime,
splash_settings,
splash_sequence_started: std::sync::atomic::AtomicBool::new(false),
transport_runtime,
@@ -1126,8 +1142,10 @@ impl AppState {
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
std::option::Option::None,
);
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_TRANSPORT, wallet_id = context.wallet_id.as_str(), capability = context.capability, transport_profile = self.transport_runtime.profile_id(), transport_role = self.transport_runtime.role().as_str(), "Wallet balance refresh started");
let result = self.transport_runtime.pool().get_balance(self.transport_runtime.role(), &context.pubkey, std::option::Option::Some(&config)).await;
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_TRANSPORT, wallet_id = context.wallet_id.as_str(), capability = context.capability, transport_profile = self.transport_runtime.profile_id(), transport_role = self.transport_runtime.role().as_str(), "Wallet balance and auxiliary SOL/USD refresh started");
let balance_future = self.transport_runtime.pool().get_balance(self.transport_runtime.role(), &context.pubkey, std::option::Option::Some(&config));
let price_future = self.refresh_sol_usd_average();
let (result, sol_usd_average) = tokio::join!(balance_future, price_future);
let result = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
@@ -1142,19 +1160,59 @@ impl AppState {
));
}
let lamports = result.value();
let sol_usd_equivalent = crate::sol_usd_equivalent(lamports, sol_usd_average.as_deref());
let dto = crate::WalletBalanceDto {
api_version: result.context().api_version().map(str::to_owned),
lamports: lamports.to_string(),
slot: result.context().slot(),
sol: crate::format_lamports_as_sol(lamports),
sol_usd_average,
sol_usd_equivalent,
transport_profile: self.transport_runtime.profile_id().to_owned(),
transport_role: self.transport_runtime.role().as_str().to_owned(),
wallet_id: context.wallet_id.clone(),
};
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_TRANSPORT, wallet_id = context.wallet_id.as_str(), capability = context.capability, lamports, slot = dto.slot, "Wallet balance refresh succeeded");
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_TRANSPORT, wallet_id = context.wallet_id.as_str(), capability = context.capability, lamports, slot = dto.slot, sol_usd_average_available = dto.sol_usd_average.is_some(), sol_usd_equivalent_available = dto.sol_usd_equivalent.is_some(), "Wallet balance refresh succeeded");
return std::result::Result::Ok(dto);
}
async fn refresh_sol_usd_average(&self) -> std::option::Option<String> {
let runtime = match &self.offchain_runtime {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let outcomes = runtime.service().refresh_all().await;
let outcomes = match outcomes {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
offchain_profile = runtime.profile_id(),
error_domain = error.code().domain(),
error_code = error.code().code(),
"Wallet Desk SOL/USD provider refresh failed; average is unavailable"
);
return std::option::Option::None;
},
};
let prices = outcomes
.iter()
.filter_map(|outcome| return outcome.observation().map(|observation| return observation.price()))
.collect::<std::vec::Vec<_>>();
let average = crate::average_sol_usd_prices(prices.as_slice());
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
offchain_profile = runtime.profile_id(),
provider_count = outcomes.len(),
observed_provider_count = prices.len(),
average_available = average.is_some(),
"Wallet Desk SOL/USD refresh completed"
);
return average;
}
fn authorized_balance_context(&self) -> ksp_core_lib::Result<WalletBalanceContext> {
let session = self.wallet_session.lock();
let session = match session {

View File

@@ -1,10 +1,12 @@
// file: crates/ksp-app-wallet-desk/src/constants.rs
// version: 8
// version: 9
//! Logging targets, domains and composite component identifiers owned by Wallet Desk.
/// Composite-local identifier for the Logging standard document.
pub(crate) const COMPOSITE_COMPONENT_ID_LOGGING: &str = "logging";
/// Composite-local identifier for the Off-chain Transport standard document.
pub(crate) const COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT: &str = "offchain_transport";
/// Composite-local identifier for the HTTP Transport standard document.
pub(crate) const COMPOSITE_COMPONENT_ID_TRANSPORT: &str = "transport";
/// Composite-local identifier for the Wallet standard document.
@@ -13,6 +15,8 @@ pub(crate) const COMPOSITE_COMPONENT_ID_WALLET: &str = "wallet";
pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "wallet.bootstrap";
/// Structured domain used by technical frontend events.
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
/// Structured domain used by Wallet Desk Off-chain Transport and SOL/USD average operations.
pub(crate) const TRACING_DOMAIN_OFFCHAIN_TRANSPORT: &str = "wallet.offchain_transport";
/// Structured domain used by the Wallet Desk shell.
pub(crate) const TRACING_DOMAIN_SHELL: &str = "wallet.shell";
/// Structured domain used by Wallet Desk HTTP Transport and balance operations.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/lib.rs
// version: 13
// version: 15
//! Tauri desktop application shell for KSP Wallet management and inspection.
@@ -14,6 +14,7 @@ mod dto_common;
mod errors;
mod frontend_logging;
mod logging_runtime;
mod offchain_runtime;
mod splash;
mod tauri;
mod transport_runtime;
@@ -47,6 +48,8 @@ pub(crate) use self::bootstrap::load_wallet_desk_composite;
pub(crate) use self::bootstrap::required_composite_component_profile;
/// Composite-local identifier for the Logging standard document.
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_LOGGING;
/// Composite-local identifier for the Off-chain Transport standard document.
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT;
/// Composite-local identifier for the HTTP Transport standard document.
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_TRANSPORT;
/// Composite-local identifier for the Wallet standard document.
@@ -55,6 +58,8 @@ pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_WALLET;
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 Wallet Desk Off-chain Transport and SOL/USD average operations.
pub(crate) use self::constants::TRACING_DOMAIN_OFFCHAIN_TRANSPORT;
/// Structured domain used by the Wallet Desk shell.
pub(crate) use self::constants::TRACING_DOMAIN_SHELL;
/// Structured domain used by Wallet Desk HTTP Transport and balance operations.
@@ -137,6 +142,10 @@ pub(crate) use self::frontend_logging::FrontendLogPayloadDto;
pub(crate) use self::frontend_logging::emit_frontend_log_event;
/// Creates the stable runtime identity for this Wallet Desk process launch.
pub(crate) use self::logging_runtime::launch_identity;
/// Provider-neutral Off-chain Transport runtime used by the Wallet balance projection.
pub(crate) use self::offchain_runtime::OffchainTransportRuntime;
/// Resolves the optional composite-selected Off-chain Transport runtime.
pub(crate) use self::offchain_runtime::initialize_offchain_transport;
/// Command emitted by Rust to the splash frontend.
pub(crate) use self::splash::SplashOrderDto;
/// Runtime timings used by the common desk splash lifecycle.
@@ -155,8 +164,12 @@ pub(crate) use self::tw_splash::require_splash_window;
pub(crate) use self::tw_splash::splash_frontend_ready_service;
/// Safe balance snapshot for the currently authorized Wallet.
pub(crate) use self::wallet_balance::WalletBalanceDto;
/// Computes the Wallet Desk SOL/USD arithmetic mean without floating point.
pub(crate) use self::wallet_balance::average_sol_usd_prices;
/// Formats exact lamports as a decimal SOL string without floating point.
pub(crate) use self::wallet_balance::format_lamports_as_sol;
/// Computes the exact USD equivalent of a lamport balance from the auxiliary SOL/USD average.
pub(crate) use self::wallet_balance::sol_usd_equivalent;
/// Resolved Wallet Config and directory preparation status captured during application bootstrap.
pub(crate) use self::wallet_config::WalletConfigStartup;
/// Resolves the composite-selected Wallet Config and prepares its application-owned directory tree.

View File

@@ -0,0 +1,60 @@
// file: crates/ksp-app-wallet-desk/src/offchain_runtime.rs
// version: 1
//! Optional composite-selected Off-chain Transport runtime used by Wallet Desk SOL/USD presentation.
/// Safe provider-neutral Off-chain Transport runtime retained by Wallet Desk when Config resolution succeeds.
pub(crate) struct OffchainTransportRuntime {
resolved: ksp_config_lib::ResolvedOffchainTransportConfig,
}
impl OffchainTransportRuntime {
/// Returns the generic market-price service built and owned by Config.
#[must_use]
pub(crate) const fn service(&self) -> &ksp_offchain_transport_lib::MarketPriceService {
return self.resolved.service();
}
/// Returns the composite-selected Off-chain Transport profile identifier.
#[must_use]
pub(crate) fn profile_id(&self) -> &str {
return self.resolved.profile_id();
}
}
/// Resolves the Wallet Desk Off-chain Transport component through Config.
pub(crate) fn initialize_offchain_transport(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<OffchainTransportRuntime> {
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 composite = crate::load_wallet_desk_composite(management);
let composite = match composite {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let profile = crate::required_composite_component_profile(
&composite,
crate::COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT,
ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT,
);
let profile = match profile {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let resolved = management.engine().resolve_offchain_transport_config_profile(&profile, &environment);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_count = resolved.service().registry().len();
ksp_logging_lib::info!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
offchain_profile = resolved.profile_id(),
provider_count,
"initialized Wallet Desk Off-chain Transport from composite-managed configuration"
);
return std::result::Result::Ok(OffchainTransportRuntime { resolved });
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/wallet_balance.rs
// version: 1
// version: 4
//! Safe Wallet balance projection produced by the typed HTTP Transport path.
@@ -18,6 +18,10 @@ pub(crate) struct WalletBalanceDto {
pub(crate) slot: u64,
/// Exact decimal SOL presentation derived from lamports without floating point.
pub(crate) sol: String,
/// Arithmetic mean of successful SOL/USD observations produced by this refresh, or unavailable when no provider returned one.
pub(crate) sol_usd_average: std::option::Option<String>,
/// Exact USD equivalent of the current SOL balance using `sol_usd_average`, or unavailable when the auxiliary price is unavailable.
pub(crate) sol_usd_equivalent: std::option::Option<String>,
/// Composite-selected Transport profile used for the RPC.
pub(crate) transport_profile: String,
/// Logical Transport role used for the RPC.
@@ -34,6 +38,150 @@ pub(crate) fn format_lamports_as_sol(lamports: u64) -> String {
return format!("{whole}.{fractional:09}");
}
/// Computes a deterministic SOL/USD arithmetic mean without IEEE-754 conversion.
///
/// The output is rounded half-up to at most eight fractional digits and trailing zeroes are removed. Arithmetic overflow degrades to `None`, which Wallet Desk
/// renders as `N.A.` without affecting the authoritative SOL balance.
#[must_use]
pub(crate) fn average_sol_usd_prices(prices: &[ksp_offchain_transport_lib::MarketPriceDecimal]) -> std::option::Option<String> {
const OUTPUT_SCALE: u8 = 8;
if prices.is_empty() {
return std::option::Option::None;
}
let max_scale = prices.iter().map(ksp_offchain_transport_lib::MarketPriceDecimal::scale).max();
let max_scale = match max_scale {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let mut sum = 0_u128;
for price in prices {
let exponent = u32::from(max_scale.saturating_sub(price.scale()));
let factor = 10_u128.checked_pow(exponent);
let factor = match factor {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let coefficient = price.coefficient().checked_mul(factor);
let coefficient = match coefficient {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
sum = match sum.checked_add(coefficient) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
}
let count = match u128::try_from(prices.len()) {
std::result::Result::Ok(value) if value > 0 => value,
_ => return std::option::Option::None,
};
let target_scale = OUTPUT_SCALE;
let (numerator, denominator) = if max_scale <= target_scale {
let factor = 10_u128.checked_pow(u32::from(target_scale - max_scale));
let factor = match factor {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let numerator = match sum.checked_mul(factor) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
(numerator, count)
} else {
let factor = 10_u128.checked_pow(u32::from(max_scale - target_scale));
let factor = match factor {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let denominator = match count.checked_mul(factor) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
(sum, denominator)
};
let quotient = numerator / denominator;
let remainder = numerator % denominator;
let round_up = remainder >= denominator.div_ceil(2);
let coefficient = if round_up {
match quotient.checked_add(1) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
}
} else {
quotient
};
return std::option::Option::Some(format_scaled_decimal(coefficient, target_scale));
}
/// Computes the exact USD equivalent of a lamport balance from the current SOL/USD average.
///
/// The output is rounded half-up to at most eight fractional digits. Missing or invalid auxiliary price data and arithmetic overflow degrade to `None`; the
/// authoritative SOL balance remains unaffected.
#[must_use]
pub(crate) fn sol_usd_equivalent(lamports: u64, sol_usd_average: std::option::Option<&str>) -> std::option::Option<String> {
const LAMPORT_SCALE: u8 = 9;
const OUTPUT_SCALE: u8 = 8;
let source = match sol_usd_average {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let average = match ksp_offchain_transport_lib::MarketPriceDecimal::parse(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
let coefficient = match u128::from(lamports).checked_mul(average.coefficient()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let source_scale = u16::from(LAMPORT_SCALE) + u16::from(average.scale());
let target_scale = u16::from(OUTPUT_SCALE);
let rounded_coefficient = if source_scale <= target_scale {
let exponent = u32::from(target_scale - source_scale);
let factor = match 10_u128.checked_pow(exponent) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
match coefficient.checked_mul(factor) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
}
} else {
let exponent = u32::from(source_scale - target_scale);
let divisor = match 10_u128.checked_pow(exponent) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let quotient = coefficient / divisor;
let remainder = coefficient % divisor;
if remainder >= divisor.div_ceil(2) {
match quotient.checked_add(1) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
}
} else {
quotient
}
};
return std::option::Option::Some(format_scaled_decimal(rounded_coefficient, OUTPUT_SCALE));
}
fn format_scaled_decimal(mut coefficient: u128, mut scale: u8) -> String {
while scale > 0 && coefficient.is_multiple_of(10) {
coefficient /= 10;
scale -= 1;
}
let digits = coefficient.to_string();
if scale == 0 {
return digits;
}
let scale = usize::from(scale);
if digits.len() > scale {
let split = digits.len() - scale;
return format!("{}.{}", &digits[..split], &digits[split..]);
}
return format!("0.{}{}", "0".repeat(scale - digits.len()), digits);
}
#[cfg(test)]
#[path = "../unit_tests/wallet_balance.rs"]
mod tests;

View File

@@ -1,7 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KSP Wallet Desk",
"version": "0.2.6",
"identifier": "com.sasedev.ksp-app-wallet-desk",
"build": {
"beforeDevCommand": {
@@ -53,6 +52,7 @@
"icons/favicon.ico"
],
"resources": {
"../../config/composite.ksp-app-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
"../../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",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/config_composition.rs
// version: 7
// version: 8
//! Config composition contracts introduced for Wallet Desk pre.003.
@@ -18,7 +18,7 @@ fn workspace_engine() -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentEngi
}
#[test]
fn wallet_desk_composite_selects_logging_transport_and_wallet_by_file_id() {
fn wallet_desk_composite_selects_logging_offchain_transport_transport_and_wallet_by_file_id() {
let engine = workspace_engine();
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
if let std::result::Result::Ok(engine) = engine {
@@ -31,6 +31,7 @@ fn wallet_desk_composite_selects_logging_transport_and_wallet_by_file_id() {
assert_eq!(composite.profile_id(), "devnet");
assert_component(&composite, "logging", ksp_config_lib::FILE_ID_STD_LOGGING, "supertrace");
assert_wallet_desk_supertrace_profile(&composite);
assert_component(&composite, "offchain_transport", ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT, "public_keyless");
assert_component(&composite, "transport", ksp_config_lib::FILE_ID_STD_TRANSPORT, "devnet_public");
assert_component(&composite, "wallet", ksp_config_lib::FILE_ID_STD_WALLET, "default");
}
@@ -172,3 +173,42 @@ fn wallet_desk_composite_transport_profile_builds_get_balance_pool() {
assert!(selection.is_ok(), "getBalance should route through the composite-selected Transport pool: {selection:?}");
}
}
#[test]
fn wallet_desk_composite_offchain_profile_builds_provider_neutral_service() {
let engine = workspace_engine();
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let composite = engine.load_resolved_composite(&file_id, std::option::Option::None);
let composite = match composite {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let component = composite.component("offchain_transport");
assert!(component.is_some(), "Wallet Desk composite should expose Off-chain Transport");
let component = match component {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let environment = ksp_config_lib::ConfigEnvironment::load();
assert!(environment.is_ok(), "public Config environment snapshot should load");
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let resolved = engine.resolve_offchain_transport_config_profile(component.resolved(), &environment);
assert!(resolved.is_ok(), "composite-selected Off-chain Transport should map");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "public_keyless");
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
assert_eq!(resolved.service().registry().len(), 8);
}
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
// version: 23
// version: 30
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
@@ -31,43 +31,6 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
};
}
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
let core = match value.split_once('-') {
std::option::Option::Some((core, _)) => core,
std::option::Option::None => value,
};
let mut parts = core.split('.');
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
if parts.next().is_some() {
return std::option::Option::None;
}
return std::option::Option::Some((major, minor, patch));
}
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
assert!(value.is_some(), "{field} packaged version must exist");
let value = match value {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let parsed = parse_semver_core(value);
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
if let std::option::Option::Some(parsed) = parsed {
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
}
}
#[test]
fn tauri_shell_uses_reserved_wallet_desk_ports_and_template_windows() {
let root = app_root();
@@ -77,6 +40,10 @@ fn tauri_shell_uses_reserved_wallet_desk_ports_and_template_windows() {
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-wallet-desk/dist"),
);
assert_eq!(tauri.pointer("/app/security/csp"), std::option::Option::Some(&serde_json::Value::Null));
let manifest = read_text(root.join("Cargo.toml").as_path());
assert!(manifest.contains("name = \"ksp-app-wallet-desk\""));
@@ -459,20 +426,17 @@ fn pre_017_wallet_desk_open_paths_remain_non_migrating() {
fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_version_coherent() {
let root = app_root();
let tauri = read_json(root.join("tauri.conf.json").as_path());
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
assert!(tauri.pointer("/version").is_none(), "Wallet Desk must let Tauri derive the bundle version from Cargo");
let package = read_json(root.join("package.json").as_path());
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
assert_packaged_version_floor(package_version, "package.json");
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
let packaged_version = match package_version {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
assert_eq!(package.pointer("/private").and_then(serde_json::Value::as_bool), std::option::Option::Some(true));
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
if let std::option::Option::Some(resources) = resources {
assert_eq!(resources.len(), 10);
assert_eq!(resources.len(), 11);
assert_eq!(
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
);
assert_eq!(
resources.get("../../config/composite.ksp-app-wallet-desk.json").and_then(serde_json::Value::as_str),
std::option::Option::Some("config/composite.ksp-app-wallet-desk.json"),
@@ -491,9 +455,43 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
);
}
let main = read_text(root.join("frontend/main.html").as_path());
assert!(main.contains(packaged_version));
let main_ts = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(main.contains(r#"id="appVersionBadge""#));
assert!(main_ts.contains("setText(\"#appVersionBadge\", status.applicationVersion);"));
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
assert!(tauri_source.contains("tauri::utils::platform::resource_dir"));
assert!(tauri_source.contains("std::env::set_current_dir(layout.runtime_root())"));
}
#[test]
fn v0_2_12_pre_008_wallet_balance_refresh_adds_average_and_exact_usd_equivalent() {
let root = app_root();
let manifest = read_text(root.join("Cargo.toml").as_path());
let composite = read_text(root.join("../../config/composite.ksp-app-wallet-desk.json").as_path());
let state = read_text(root.join("src/app_state.rs").as_path());
let balance = read_text(root.join("src/wallet_balance.rs").as_path());
let main = read_text(root.join("frontend/ts/main.ts").as_path());
let html = read_text(root.join("frontend/main.html").as_path());
assert!(manifest.contains(r#"ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }"#));
assert!(composite.contains(r#""component_id": "offchain_transport""#));
assert!(composite.contains(r#""profile_id": "public_keyless""#));
assert!(state.contains("initialize_offchain_transport"));
assert!(state.contains("refresh_sol_usd_average"));
assert!(state.contains("tokio::join!"));
assert!(state.contains("refresh_all().await"));
assert!(balance.contains("sol_usd_average"));
assert!(balance.contains("sol_usd_equivalent"));
assert!(balance.contains("average_sol_usd_prices"));
assert!(html.contains(r#"id="currentWalletSolUsdAverage""#));
assert!(html.contains(r#"id="currentWalletSolUsdEquivalent""#));
assert!(html.contains(r#"id="detailsSolUsdAverage""#));
assert!(html.contains(r#"id="detailsSolUsdEquivalent""#));
assert!(main.contains(r#"balance.solUsdAverage ? `${balance.solUsdAverage} USD/SOL` : "N.A.""#));
assert!(main.contains(r#"balance.solUsdEquivalent ? `${balance.solUsdEquivalent} USD` : "N.A.""#));
assert!(!main.contains("parseFloat("));
assert!(!main.contains("Number(balance.sol"));
assert!(!main.contains("refresh_market_price"));
assert!(!main.contains("refresh_all_market_prices"));
assert!(!main.contains("fetch("));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_security.rs
// version: 14
// version: 15
//! Static desktop security contracts for the Wallet Desk pre.002 shell.
@@ -122,9 +122,14 @@ fn balance_refresh_never_accepts_pubkey_or_endpoint_url_from_frontend() {
let refresh = &main[refresh_start.unwrap_or_default()..refresh_end.unwrap_or(main.len())];
assert!(refresh.contains("refresh_wallet_balance"));
assert!(!refresh.contains("pubkey"));
assert!(!refresh.contains("providerId"));
assert!(!refresh.contains("providerIds"));
assert!(!main.contains("transportUrl"));
assert!(!main.contains("endpointUrl"));
assert!(state.contains("wallet.pubkey()"));
assert!(state.contains("refresh_all().await"));
assert!(state.contains("average_sol_usd_prices"));
assert!(!state.contains("parse::<f64>"));
}
#[test]

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/release_compliance.rs
// version: 6
// version: 9
//! Release-wide deterministic compliance canaries for Wallet Desk.
@@ -81,6 +81,7 @@ fn backend_keeps_config_wallet_transport_and_logging_ownership_boundaries() {
assert!(source.contains("ksp_config_lib::ConfigEnvironment::load"));
assert!(source.contains("ksp_wallet_lib::"));
assert!(source.contains("ksp_onchain_transport_lib::"));
assert!(source.contains("ksp_offchain_transport_lib::"));
assert!(source.contains("ksp_logging_lib::"));
}
@@ -198,7 +199,7 @@ fn packaged_resources_include_only_registered_config_sources_and_schemas() {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
assert_eq!(resources.len(), 10);
assert_eq!(resources.len(), 11);
for (source, destination) in resources {
let destination = destination.as_str();
assert!(destination.is_some(), "resource destination must be textual");
@@ -214,3 +215,29 @@ fn packaged_resources_include_only_registered_config_sources_and_schemas() {
}
}
}
#[test]
fn v0_2_12_wallet_market_price_projection_remains_provider_neutral_and_exact() {
let root = app_root();
let source = read_rust_source_tree(root.join("src").as_path());
for forbidden in [
"MarketPriceBirdeye",
"MarketPriceCoinbaseExchange",
"MarketPriceCoinGecko",
"MarketPriceCoinMarketCap",
"MarketPriceCoinPaprika",
"MarketPriceDexScreener",
"MarketPriceJupiter",
"MarketPriceKraken",
] {
assert!(!source.contains(forbidden), "Wallet Desk must not branch on concrete market-price providers: {forbidden}");
}
let balance = read_text(root.join("src/wallet_balance.rs").as_path());
assert!(!balance.contains("f64"));
assert!(!balance.contains("f32"));
let main = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(!main.contains("parseFloat("));
assert!(!main.contains("Number(balance.sol"));
assert!(!main.contains("providerId"));
assert!(!main.contains("refresh_market_price"));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/unit_tests/wallet_balance.rs
// version: 1
// version: 3
//! Unit tests for exact Wallet balance presentation.
@@ -11,3 +11,33 @@ fn lamports_formatting_is_exact_and_uses_nine_fractional_digits() {
assert_eq!(crate::format_lamports_as_sol(1_234_567_890), "1.234567890");
assert_eq!(crate::format_lamports_as_sol(u64::MAX), "18446744073.709551615");
}
#[test]
fn sol_usd_average_is_deterministic_rounded_and_never_uses_f64() {
let values = ["103.96", "104.16", "104.07981394241013", "104.07595797523913", "103.92807280061784", "103.9"];
let prices = values.iter().filter_map(|value| return ksp_offchain_transport_lib::MarketPriceDecimal::parse(value).ok()).collect::<std::vec::Vec<_>>();
assert_eq!(prices.len(), values.len());
assert_eq!(crate::average_sol_usd_prices(prices.as_slice()), std::option::Option::Some("104.01730745".to_owned()));
}
#[test]
fn sol_usd_average_returns_none_without_observation_and_handles_integer_fraction() {
assert_eq!(crate::average_sol_usd_prices(&[]), std::option::Option::None);
let one = ksp_offchain_transport_lib::MarketPriceDecimal::parse("1");
let two = ksp_offchain_transport_lib::MarketPriceDecimal::parse("2");
assert!(one.is_ok());
assert!(two.is_ok());
if let (std::result::Result::Ok(one), std::result::Result::Ok(two)) = (one, two) {
assert_eq!(crate::average_sol_usd_prices(&[one, two]), std::option::Option::Some("1.5".to_owned()));
}
}
#[test]
fn sol_usd_equivalent_is_exact_rounded_and_unavailable_without_average() {
assert_eq!(crate::sol_usd_equivalent(0, std::option::Option::Some("104.01730745")), std::option::Option::Some("0".to_owned()));
assert_eq!(crate::sol_usd_equivalent(1_000_000_000, std::option::Option::Some("104.01730745")), std::option::Option::Some("104.01730745".to_owned()),);
assert_eq!(crate::sol_usd_equivalent(1_500_000_000, std::option::Option::Some("104.01730745")), std::option::Option::Some("156.02596118".to_owned()),);
assert_eq!(crate::sol_usd_equivalent(1, std::option::Option::Some("104.01730745")), std::option::Option::Some("0.0000001".to_owned()));
assert_eq!(crate::sol_usd_equivalent(1_000_000_000, std::option::Option::None), std::option::Option::None);
assert_eq!(crate::sol_usd_equivalent(1_000_000_000, std::option::Option::Some("invalid")), std::option::Option::None);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/lib.rs
// version: 19
// version: 20
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -150,6 +150,8 @@ pub use self::registry::ConfigFileId;
pub use self::registry::ConfigFileKind;
/// Registry of KSP-known logical Config files and their replaceable physical filenames.
pub use self::registry::ConfigFileRegistry;
/// Default physical filename for the SOL Prices Desk composite configuration document.
pub use self::registry::DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME;
/// Default physical filename for the Wallet Desk composite configuration document.
pub use self::registry::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME;
/// Default physical filename for the generic composite JSON Schema document.
@@ -170,6 +172,8 @@ pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME;
pub use self::registry::DEFAULT_STD_WALLET_FILENAME;
/// Default physical filename for the standard Wallet JSON Schema document.
pub use self::registry::DEFAULT_STD_WALLET_SCHEMA_FILENAME;
/// Logical file identifier for the SOL Prices Desk composite configuration document.
pub use self::registry::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK;
/// Logical file identifier for the Wallet Desk composite configuration document.
pub use self::registry::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
/// Logical file identifier for the generic composite JSON Schema document.

View File

@@ -1,8 +1,10 @@
// file: crates/ksp-config-lib/src/registry.rs
// version: 11
// version: 12
/// Bootstrap argument used to replace a known Config filename mapping.
pub const ARG_FILE_MAP: &str = "--filemap";
/// Default physical filename for the SOL Prices Desk composite configuration document.
pub const DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME: &str = "composite.ksp-app-solprices-desk.json";
/// Default physical filename for the Wallet Desk composite configuration document.
pub const DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME: &str = "composite.ksp-app-wallet-desk.json";
/// Default physical filename for the generic composite JSON Schema document.
@@ -23,6 +25,8 @@ pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.js
pub const DEFAULT_STD_WALLET_FILENAME: &str = "std.wallet.json";
/// Default physical filename for the standard Wallet JSON Schema document.
pub const DEFAULT_STD_WALLET_SCHEMA_FILENAME: &str = "std.wallet.schema.json";
/// Logical file identifier for the SOL Prices Desk composite configuration document.
pub const FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK: &str = "cfg.composite.ksp-app-solprices-desk";
/// Logical file identifier for the Wallet Desk composite configuration document.
pub const FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK: &str = "cfg.composite.ksp-app-wallet-desk";
/// Logical file identifier for the generic composite JSON Schema document.
@@ -148,13 +152,23 @@ pub struct ConfigFileRegistry {
impl ConfigFileRegistry {
/// Creates the registry containing the KSP default file mappings known in the current release.
pub fn defaults() -> ksp_core_lib::Result<Self> {
let composite = ConfigFileDescriptor::new(
let solprices_composite = ConfigFileDescriptor::new(
FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK,
ConfigFileKind::Config,
DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME,
std::option::Option::Some(FILE_ID_SCHEMA_COMPOSITE),
);
let solprices_composite = match solprices_composite {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let wallet_composite = ConfigFileDescriptor::new(
FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK,
ConfigFileKind::Config,
DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME,
std::option::Option::Some(FILE_ID_SCHEMA_COMPOSITE),
);
let composite = match composite {
let wallet_composite = match wallet_composite {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -233,7 +247,8 @@ impl ConfigFileRegistry {
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::build_registry([
composite,
solprices_composite,
wallet_composite,
composite_schema,
logging,
logging_schema,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/ownership.rs
// version: 7
// version: 8
//! Workspace ownership audits for KSP application configuration boundaries.
@@ -262,6 +262,7 @@ fn workspace_crates_do_not_hardcode_config_managed_physical_files() {
};
for token in [
"\".env\"",
"\"composite.ksp-app-solprices-desk.json\"",
"\"composite.ksp-app-wallet-desk.json\"",
"\"composite.schema.json\"",
"\"std.logging.json\"",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/public_api.rs
// version: 24
// version: 25
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
//! Logging/Transport adapters and management contracts.
@@ -80,18 +80,19 @@ fn registry_descriptor_inventory_is_available_from_crate_root() {
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect();
assert_eq!(descriptors.len(), 10);
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
let schema_file_id = descriptors[4].schema_file_id();
assert_eq!(descriptors.len(), 11);
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(descriptors[10].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
let schema_file_id = descriptors[5].schema_file_id();
assert!(schema_file_id.is_some(), "public Wallet descriptor should preserve schema association");
if let std::option::Option::Some(schema_file_id) = schema_file_id {
assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
@@ -160,6 +161,8 @@ fn composite_schema_and_provenance_contracts_are_available_from_crate_root() {
assert_eq!(descriptor.kind(), ksp_config_lib::ConfigFileKind::Schema);
}
}
assert_eq!(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK, "cfg.composite.ksp-app-solprices-desk");
assert_eq!(ksp_config_lib::DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME, "composite.ksp-app-solprices-desk.json");
assert_ne!(ksp_config_lib::ConfigProfileSelectionSource::Composite, ksp_config_lib::ConfigProfileSelectionSource::Explicit);
}

View File

@@ -0,0 +1,51 @@
{
"format_version": 1,
"default_profile": "public_keyless",
"profiles": [
{
"profile_id": "public_keyless",
"documents": [
{
"component_id": "logging",
"file_id": "cfg.std.logging",
"profile_id": "local_dev"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
}
]
},
{
"profile_id": "all_free",
"documents": [
{
"component_id": "logging",
"file_id": "cfg.std.logging",
"profile_id": "local_dev"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "all_free"
}
]
},
{
"profile_id": "tests",
"documents": [
{
"component_id": "logging",
"file_id": "cfg.std.logging",
"profile_id": "local_dev"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
}
]
}
]
}

View File

@@ -10,6 +10,11 @@
"file_id": "cfg.std.logging",
"profile_id": "local_dev"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
},
{
"component_id": "transport",
"file_id": "cfg.std.transport",
@@ -30,6 +35,11 @@
"file_id": "cfg.std.logging",
"profile_id": "local_dev"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
},
{
"component_id": "transport",
"file_id": "cfg.std.transport",
@@ -50,6 +60,11 @@
"file_id": "cfg.std.logging",
"profile_id": "local_dev"
},
{
"component_id": "offchain_transport",
"file_id": "cfg.std.offchain_transport",
"profile_id": "public_keyless"
},
{
"component_id": "transport",
"file_id": "cfg.std.transport",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/offchain_transport.rs
// version: 2
// version: 3
const TEST_PAIR: &str = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE";
@@ -31,6 +31,45 @@ fn committed_public_keyless_profile_maps_all_eight_providers_without_secret_envi
}
}
#[test]
fn committed_solprices_composite_preserves_offchain_profile_and_composite_provenance() {
let engine = committed_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let file_id = crate::ConfigFileId::new(crate::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
assert!(file_id.is_ok(), "SOL Prices Desk composite file_id should be valid: {file_id:?}");
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let composite = engine.load_resolved_composite(&file_id, std::option::Option::None);
assert!(composite.is_ok(), "committed SOL Prices Desk composite should resolve: {composite:?}");
let composite = match composite {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(composite.profile_id(), "public_keyless");
let component = composite.component("offchain_transport");
assert!(component.is_some(), "SOL Prices Desk composite should expose offchain_transport");
let component = match component {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
assert_eq!(component.resolved().file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
assert_eq!(component.resolved().profile_id(), "public_keyless");
assert_eq!(component.resolved().selection_source(), crate::ConfigProfileSelectionSource::Composite);
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = engine.resolve_offchain_transport_config_profile(component.resolved(), &environment);
assert!(resolved.is_ok(), "composite-selected Off-chain Transport should map: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "public_keyless");
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Composite);
assert_eq!(resolved.service().registry().len(), 8);
}
}
#[test]
fn committed_all_free_profile_requires_config_owned_secrets_and_public_pair_provenance() {
let engine = committed_engine();

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/registry.rs
// version: 9
// version: 10
#[test]
fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
@@ -7,23 +7,25 @@ fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let descriptors: std::vec::Vec<&crate::ConfigFileDescriptor> = registry.descriptors().collect();
assert_eq!(descriptors.len(), 10);
assert_eq!(descriptors[0].file_id().as_str(), crate::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
assert_eq!(descriptors[0].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME));
assert_eq!(descriptors.len(), 11);
assert_eq!(descriptors[0].file_id().as_str(), crate::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
assert_eq!(descriptors[0].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME));
assert_eq!(descriptors[0].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE));
assert_eq!(descriptors[1].file_id().as_str(), crate::FILE_ID_STD_LOGGING);
assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_STD_WALLET);
assert_eq!(descriptors[4].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
assert_eq!(descriptors[4].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET));
assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(descriptors[9].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET);
assert!(descriptors[0..5].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config));
assert!(descriptors[5..10].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema));
assert_eq!(descriptors[1].file_id().as_str(), crate::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
assert_eq!(descriptors[1].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME));
assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_LOGGING);
assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_STD_WALLET);
assert_eq!(descriptors[5].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
assert_eq!(descriptors[5].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET));
assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[9].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(descriptors[10].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET);
assert!(descriptors[0..6].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config));
assert!(descriptors[6..11].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema));
}
}
@@ -337,24 +339,33 @@ fn absolute_fixture_path() -> std::path::PathBuf {
}
#[test]
fn defaults_register_generic_composite_schema_and_wallet_desk_runtime_composite() {
fn defaults_register_generic_composite_schema_and_both_runtime_composites() {
let registry = crate::ConfigFileRegistry::defaults();
let schema_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_COMPOSITE);
let runtime_id = crate::ConfigFileId::new(crate::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
let solprices_id = crate::ConfigFileId::new(crate::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
let wallet_id = crate::ConfigFileId::new(crate::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
assert!(schema_id.is_ok(), "composite schema file_id should be valid: {schema_id:?}");
assert!(runtime_id.is_ok(), "Wallet Desk composite runtime file_id should be valid: {runtime_id:?}");
if let (std::result::Result::Ok(registry), std::result::Result::Ok(schema_id), std::result::Result::Ok(runtime_id)) = (registry, schema_id, runtime_id) {
assert!(solprices_id.is_ok(), "SOL Prices Desk composite runtime file_id should be valid: {solprices_id:?}");
assert!(wallet_id.is_ok(), "Wallet Desk composite runtime file_id should be valid: {wallet_id:?}");
if let (std::result::Result::Ok(registry), std::result::Result::Ok(schema_id), std::result::Result::Ok(solprices_id), std::result::Result::Ok(wallet_id)) =
(registry, schema_id, solprices_id, wallet_id)
{
let schema = registry.descriptor(&schema_id);
let runtime = registry.descriptor(&runtime_id);
let solprices = registry.descriptor(&solprices_id);
let wallet = registry.descriptor(&wallet_id);
assert!(schema.is_ok(), "generic composite schema should be registered: {schema:?}");
assert!(runtime.is_ok(), "Wallet Desk runtime composite should be registered: {runtime:?}");
if let (std::result::Result::Ok(schema), std::result::Result::Ok(runtime)) = (schema, runtime) {
assert!(solprices.is_ok(), "SOL Prices Desk runtime composite should be registered: {solprices:?}");
assert!(wallet.is_ok(), "Wallet Desk runtime composite should be registered: {wallet:?}");
if let (std::result::Result::Ok(schema), std::result::Result::Ok(solprices), std::result::Result::Ok(wallet)) = (schema, solprices, wallet) {
assert_eq!(schema.kind(), crate::ConfigFileKind::Schema);
assert_eq!(schema.filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_SCHEMA_FILENAME));
assert_eq!(runtime.kind(), crate::ConfigFileKind::Config);
assert_eq!(runtime.filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME));
assert_eq!(runtime.schema_file_id(), std::option::Option::Some(&schema_id));
assert_eq!(solprices.kind(), crate::ConfigFileKind::Config);
assert_eq!(solprices.filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME));
assert_eq!(solprices.schema_file_id(), std::option::Option::Some(&schema_id));
assert_eq!(wallet.kind(), crate::ConfigFileKind::Config);
assert_eq!(wallet.filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME));
assert_eq!(wallet.schema_file_id(), std::option::Option::Some(&schema_id));
}
}
}

View File

@@ -0,0 +1,154 @@
<!-- file: deltas/0.2.12/pre.001-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.12-pre.001-fix.001` — Forecast souple éditable et statuts
## 1. Base requise
Ce correctif s'applique exclusivement après :
```text
v0.2.11
+ 0.2.12-pre.001
```
Conformément à `VER-ID-008`, `workspace.package.version` reste :
```text
0.2.12-pre.1
```
Le correctif est strictement documentaire : il ne modifie aucun fichier de code, build, runtime, configuration exécutable ou migration.
## 2. Motif du correctif
Le forecast souple de `pre.001` avait été matérialisé sous forme de tableau. Cette représentation décrit correctement le découpage initial, mais elle est mal adaptée au suivi réel d'une release KSP : elle ne permet pas d'insérer naturellement les correctifs d'une tranche sous la prerelease concernée ni de faire évoluer son statut sans reconstruire le tableau.
Le précédent durable de `0.2.11` utilise déjà le format éditable attendu :
```text
### pre.NNN — responsabilité
**Statut : ...**
paragraphe de scope/gate
#### pre.NNN-fix.MMM — correction
**Statut : ...**
paragraphe de correction
```
Le présent fix applique cette convention à `0.2.12` sans modifier le contenu fonctionnel du forecast.
## 3. Correction du forecast
La section `Forecast souple recalibré` du plan `019` n'est plus un tableau.
Chaque tranche possède désormais :
- un titre `###` avec son identifiant et sa responsabilité ;
- un statut explicite ;
- un paragraphe décrivant scope, dépendances/gates et principaux hors-périmètre de la tranche ;
- la possibilité d'insérer ses correctifs directement en dessous sous forme de titres `####`.
`pre.001-fix.001` est lui-même matérialisé sous `pre.001` afin que le plan reflète dès maintenant la structure qui sera utilisée pendant toute la release.
## 4. Statuts initiaux
Le gate opérateur de `pre.001` fourni avant ce correctif est intégralement propre. Le plan marque donc :
```text
pre.001 réalisé ; gate opérateur complet PASS
pre.001-fix.001 réalisé
pre.002..015 planifié
rel.001 planifié
```
Ces statuts sont éditoriaux et pourront être révisés par les deltas ultérieurs sans rigidifier la prévision souple.
## 5. Forecast technique inchangé
Le correctif ne redécoupe pas la release. La trajectoire reste :
```text
pre.001 audit/sizing/maps/Config/security
pre.002 scaffold SOL Prices Desk
pre.003 Config composite + registry/resources atomiques + bootstrap
pre.004 runtime/registry rows/DTOs
pre.005 refresh row
pre.006 refresh many/all + in-flight/cooldown
pre.007 UX/polish/instrumentation
pre.008 hardening Price Desk
pre.009 Wallet composite/runtime Off-chain
pre.010 Wallet DTO/commands prix
pre.011 Wallet UI prix + non-régressions
pre.012 hardening cross-app
pre.013 gate technique/live + builds finaux
pre.014 réconciliation documentaire
pre.015 prompt suivant + CHANGELOG + ROADMAP
rel.001 publication stable
```
Les couloirs de fermeture restent donc distincts conformément à `VER-LIFECYCLE-006` à `VER-LIFECYCLE-012`.
## 6. Validation opérateur acquise avant le fix
Les résultats fournis par l'opérateur sur `0.2.12-pre.001` sont :
```text
cargo fmt --all PASS
python3 scripts/audit_rust_workspace_rules.py PASS / clean / 0 candidate export
python3 scripts/audit_markdown_tables.py ... deltas/0.2.12 PASS / 144 tables / 105 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test --workspace PASS
```
Les tests explicitement `ignored` dans la sortie restent les smokes/live/probes opt-in déjà prévus par leurs contrats ; aucun échec n'est signalé.
## 7. Fichiers modifiés
```text
docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md
```
Le header documentaire du plan passe de `version: 1` à `version: 2`.
## 8. Fichiers ajoutés
```text
deltas/0.2.12/pre.001-fix.001.md
```
## 9. Fichiers supprimés
```text
aucun
```
## 10. Fichiers volontairement inchangés
```text
Cargo.toml
deltas/0.2.12/pre.001.md
docs/validation/015-V0_2_12_SOL_PRICES_DESK.md
ROADMAP.md
CHANGELOG.md
prompts/017-V0_2_12_START_PROMPT.md
config/**
crates/**
```
Le delta `pre.001.md` publié n'est pas réécrit silencieusement. La validation durable conserve son rôle de matrice de gates ; le forecast détaillé et son historique de fixes restent propriétaires du plan de release.
## 11. Validations du correctif
Le correctif étant documentaire, le gate minimal attendu après application est :
```bash
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12
```
L'audit structurel Rust peut être rejoué sans risque mais aucune commande Cargo n'est requise par ce seul changement documentaire. Un opérateur peut néanmoins refaire le gate complet s'il souhaite conserver une séquence uniforme entre livraisons.
## 12. Scope inchangé
Le correctif n'ajoute aucun scaffold, crate, commande Tauri, DTO, Config runtime, provider, refresh, capability, ressource packagée ou intégration Wallet. La prochaine responsabilité reste exclusivement `0.2.12-pre.002` : scaffold strict de `ksp-app-solprices-desk`.

189
deltas/0.2.12/pre.001.md Normal file
View File

@@ -0,0 +1,189 @@
<!-- file: deltas/0.2.12/pre.001.md -->
<!-- version: 1 -->
# Delta `0.2.12-pre.001` — audit/sizing SOL Prices Desk + Wallet integration
## 1. Base requise
```text
v0.2.11
```
Archive opérateur utilisée : `khadhroony-solana-project-v0.2.11-full-from-gitea.zip`.
Vérifications d'identité :
```text
workspace.package.version = 0.2.11 PASS à l'entrée
deltas/0.2.11/rel.001.md présent
prompts/017-V0_2_12_START_PROMPT.md présent
.git absent de l'archive
```
Le tag stable n'est donc pas revérifiable localement par Git; l'archive opérateur et ses marqueurs internes sont l'autorité de reprise.
## 2. Objectif
Exécuter le gate obligatoire `pre.001` du prompt `0.2.12` sans commencer l'implémentation fonctionnelle : relire règles/architecture, auditer Off-chain/Config/Tauri/Wallet, fixer screen map, DTO/commands, Config/composite, état de présentation, threat model, intégration Wallet, tests/build/live et forecast.
## 3. Décisions prises
```text
nouvelle app ksp-app-solprices-desk
ports 1434/1435
rôle HID provider-agnostic uniquement
composite Price Desk oui
file_id cfg.composite.ksp-app-solprices-desk
components logging + offchain_transport
profil default public_keyless
profil opt-in all_free
profil tests public_keyless
schema composite existant, aucun nouveau format
état présentation backend Tauri en mémoire uniquement
prix IPC chaîne décimale exacte, jamais f64
refresh row + selected/many + all via MarketPriceService
auto-refresh OUT
prix canonique OUT
valorisation USD balance Wallet OUT
nouvelle crate UI/API partagée OUT
Price Desk dialog plugin OUT
Price Desk capabilities core:default + tracing:default
Wallet Off-chain extension additive du composite/runtime
```
L'ajout du nouveau descriptor Config doit être atomique avec les resources Tauri de Config Desk, Wallet Desk et SOL Prices Desk, parce que `prepare_packaged_runtime` parcourt le registry complet.
## 4. Fichiers ajoutés
```text
docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md
docs/validation/015-V0_2_12_SOL_PRICES_DESK.md
deltas/0.2.12/pre.001.md
```
## 5. Fichiers modifiés
```text
Cargo.toml
docs/000-README.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/validation/000-README.md
```
`workspace.package.version` passe à `0.2.12-pre.1` conformément à `VER-ID-009` pour toute prerelease non-fix.
## 6. Fichiers supprimés
```text
aucun
```
## 7. Fichiers volontairement inchangés
```text
ROADMAP.md
CHANGELOG.md
prompts/017-V0_2_12_START_PROMPT.md
docs/architecture/**
config/**
crates/**
.env.example
README/USAGE des crates
```
L'architecture durable contient déjà la frontière HID/Off-chain pertinente. Les Config/runtime/app sources sont réservées aux tranches d'implémentation suivantes. `ROADMAP.md` et `CHANGELOG.md` restent hors `pre.001` et sont réservés au couloir de publication prévu.
## 8. Audit interne exécuté
Points confirmés :
```text
MarketPriceService expose registry/refresh/refresh_many/refresh_all
MarketPriceDecimal exact suffit à l'IPC via projection String
registry/availability sont provider-neutral
ResolvedOffchainTransportConfig possède déjà MarketPriceService
schema composite V1 suffit au nouveau Desk
Wallet composite a logging/transport/wallet et sera étendu additivement
Config packaging copie tout le registry logique
Config Desk ports 1430/1431
Wallet Desk ports 1432/1433
nouvelle paire KSP réservée 1434/1435
capabilities actuelles = core:default + tracing:default
Price Desk n'a besoin ni dialog, ni fs, ni network plugin frontend
```
## 9. Audit externe exécuté
Sources officielles actuelles Tauri 2 et TS-RS réauditées. Tauri `2.11.5` est la release courante observée et la base utilise `tauri = ^2.11`; TS-RS `12.0.0` est courant et la base utilise `ts-rs = ^12.0`. Les patterns `beforeDevCommand`/`beforeBuildCommand`/`devUrl`/`frontendDist`, capabilities et `core:default` restent documentés.
Aucune mise à niveau de dépendance n'est justifiée par `pre.001`.
## 10. Validations exécutées
Sur l'archive extraite dans le sandbox :
```text
python3 scripts/audit_rust_workspace_rules.py PASS
python3 scripts/audit_markdown_tables.py README.md ... crates PASS avant modification
```
Preuve opérateur fournie sur la base stable avant ouverture :
```text
cargo fmt --all PASS
Rust rules audit PASS
Markdown table audit PASS
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test --workspace PASS
```
Après le présent changement documentaire :
```text
python3 scripts/audit_rust_workspace_rules.py PASS
python3 scripts/audit_markdown_tables.py ... deltas/0.2.12 PASS
```
## 11. Validations non exécutées dans ce sandbox
```text
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tauri dev
cargo tauri build
```
Cause : executable `cargo` absent du sandbox de préparation. Aucune de ces commandes n'est déclarée localement PASS. Le code Rust/config/runtime n'a pas été modifié par `pre.001`; le gate de base Cargo est couvert par la preuve opérateur fournie.
## 12. Forecast souple recalibré
```text
pre.001 audit/sizing/maps/Config/security
pre.002 scaffold SOL Prices Desk
pre.003 Config composite + registry/resources atomiques + bootstrap
pre.004 runtime/registry rows/DTOs
pre.005 refresh row
pre.006 refresh many/all + in-flight/cooldown
pre.007 UX/polish/instrumentation
pre.008 hardening Price Desk
pre.009 Wallet composite/runtime Off-chain
pre.010 Wallet DTO/commands prix
pre.011 Wallet UI prix + non-régressions
pre.012 hardening cross-app
pre.013 gate technique/live + builds finaux
pre.014 réconciliation documentaire
pre.015 prompt suivant + CHANGELOG + ROADMAP
rel.001 publication stable
```
Le scope reste clôturable; aucun redécoupage n'est requis avant `pre.002`.
## 13. Questions ouvertes
Aucune question bloquante pour `pre.002`.
Les détails visuels exacts (largeurs/labels/ordre final des colonnes) pourront être ajustés dans `pre.007` sans rouvrir les frontières provider-neutral, Config ou sécurité.

View File

@@ -0,0 +1,218 @@
<!-- file: deltas/0.2.12/pre.002-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.12-pre.002-fix.001` — alignement strict du gabarit desktop commun
## 1. Base requise
Ce correctif s'applique exclusivement après :
```text
v0.2.11
+ 0.2.12-pre.001
+ 0.2.12-pre.001-fix.001
+ 0.2.12-pre.002
```
La base opérateur fournie contient déjà plusieurs corrections locales du scaffold `pre.002`; elles sont conservées et servent de source réelle à ce fix.
## 2. Motif du correctif
Le scaffold initial était fonctionnel mais encore trop dérivé au lieu d'être une reprise stricte du gabarit desktop KSP. `KSP-APP-008/009/015/019/023/027/028` imposent que Config Desk reste la référence et que les Desks suivants réutilisent les conventions stabilisées sauf justification explicite.
L'opérateur avait déjà corrigé plusieurs écarts avant ce delta :
```text
@fltsci/tauri-plugin-tracing ajouté à package.json
resize-observer-polyfill ajouté à package.json
footer commun rétabli dans main.html
DOS_Amazigh.ttf rétabli pour le splash
splash.scss réaligné sur la police commune
```
Le présent fix poursuit cet alignement au lieu d'écraser ces corrections.
## 3. Version technique
Le fix modifie Rust, TypeScript, HTML, SCSS et configuration Tauri réellement consommés au runtime. Conformément à `VER-ID-007/010`, le signal Cargo devient :
```text
workspace.package.version = 0.2.12-pre.2.fix.1
```
Le nouveau Desk synchronise également son `package.json` et son `tauri.conf.json` sur `0.2.12-pre.2.fix.1`.
## 4. Commandes et conventions backend réalignées
Le wrapper Tauri temporaire :
```text
runtime_status
```
devient :
```text
get_runtime_status
```
Il reprend ainsi la convention déjà utilisée par Wallet Desk (`get_runtime_status`) et reste cohérent avec Config Desk (`get_logging_runtime_status`). La méthode interne `AppState::runtime_status` reste inchangée : seul le nom de commande IPC exposé suit le gabarit `get_*`.
`configure_commands()` adopte la même présentation progressive que les Desks existants. `configure_setup()` émet désormais l'événement shell via `TRACING_DOMAIN_SHELL`, ce qui supprime les deux warnings `unused import` / `dead_code` observés par l'opérateur sans supprimer une convention déjà utilisée dans Wallet Desk.
## 5. Frontend commun réellement repris
`frontend/ts/main.ts` réutilise désormais les invariants du template Config/Wallet :
```text
ResizeObserver importé depuis resize-observer-polyfill
window.ResizeObserver installé avant le shell SimpleBar
getCurrentWindow().label lu au bootstrap
navigation installée via helper dédié
loadRuntimeStatus() dédié
commande get_runtime_status
startup data load protégé par try/catch + warning sûr
```
Le package `resize-observer-polyfill` n'est donc plus seulement déclaré : il est réellement consommé par le bundle.
Le badge de version propre au scaffold est retiré du header. Comme dans Config/Wallet Desk, le header garde l'identité fonctionnelle et le titre de vue ; la version reste visible dans Diagnostics via le DTO runtime.
## 6. HTML/SASS et splash
Le shell réutilise le vocabulaire de classes déjà stabilisé dans Wallet Desk :
```text
app-runtime-list
app-placeholder
app-placeholder i
```
`_app.scss` reprend le socle partagé dans le même ordre et ne conserve que les différences réellement applicatives.
Les partials communs suivants restent byte-equivalents hors headers `file/version` avec Config Desk et Wallet Desk :
```text
_bootswatch.scss
_fontawesome.scss
_simplebar.scss
_variables.scss
main.scss
splash.scss
```
Le splash est réaligné sur la forme de titre commune :
```text
KSP SOL Prices Desk — Chargement
```
Le fichier `frontend/fonts/README.md` est ajouté pour tracer la même police `DOS_Amazigh.ttf` et la même empreinte SHA-256 que les deux Desks existants.
## 7. Canari anti-dérive du gabarit
`tests/desktop_contract.rs` ajoute un canari durable qui vérifie :
- dépendances frontend communes présentes ;
- consommation réelle de `resize-observer-polyfill` ;
- bootstrap via `getCurrentWindow()` ;
- commande `get_runtime_status` ;
- footer et classes shell communes ;
- présence du manifest de font ;
- bytes `DOS_Amazigh.ttf` identiques dans les trois Desks ;
- corps des six partials SCSS communs identiques entre Config, Wallet et SOL Prices Desk.
Le canari ne demande pas que les styles métier `_app.scss` soient identiques : seuls le socle et le vocabulaire du template doivent rester communs, les sélecteurs spécifiques à chaque application restant légitimes.
## 8. Scope fonctionnel inchangé
Le fix n'ajoute aucun :
```text
MarketPriceService
ResolvedOffchainTransportConfig
composite cfg.composite.ksp-app-solprices-desk
provider DTO
refresh row/many/all
table prix
DataTables
plugin dialog/fs/http/shell frontend
```
`pre.003` reste propriétaire du composite Config et du bootstrap Off-chain.
## 9. Retour opérateur acquis sur la base avant fix
L'opérateur a exécuté sur `0.2.12-pre.2` :
```text
npm i -D PASS / 0 vulnerability
cargo fmt --all PASS
cargo test -p ksp-app-solprices-desk PASS / 12 unit + 7 integration/public tests
cargo tauri dev PASS lancement Vite 1434 + binaire Tauri
```
Les seuls diagnostics Rust visibles étaient :
```text
unused import TRACING_DOMAIN_SHELL
constant TRACING_DOMAIN_SHELL is never used
```
Ce fix corrige précisément ces warnings en conservant le domaine shell et en l'utilisant comme Wallet Desk.
## 10. Fichiers modifiés
```text
Cargo.toml
crates/ksp-app-solprices-desk/package.json
crates/ksp-app-solprices-desk/tauri.conf.json
crates/ksp-app-solprices-desk/frontend/main.html
crates/ksp-app-solprices-desk/frontend/splash.html
crates/ksp-app-solprices-desk/frontend/sass/_app.scss
crates/ksp-app-solprices-desk/frontend/ts/main.ts
crates/ksp-app-solprices-desk/src/tauri.rs
crates/ksp-app-solprices-desk/tests/desktop_contract.rs
docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md
docs/validation/015-V0_2_12_SOL_PRICES_DESK.md
```
## 11. Fichier ajouté
```text
crates/ksp-app-solprices-desk/frontend/fonts/README.md
deltas/0.2.12/pre.002-fix.001.md
```
## 12. Fichiers volontairement inchangés
```text
crates/ksp-app-config-desk/**
crates/ksp-app-wallet-desk/**
deltas/0.2.12/pre.002.md
ROADMAP.md
CHANGELOG.md
prompts/017-V0_2_12_START_PROMPT.md
config/**
```
Les deux Desks stables restent la référence ; le correctif réaligne le nouveau Desk sur leur socle commun au lieu de modifier rétroactivement leurs surfaces métier.
## 13. Gate opérateur attendu
Après application :
```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
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
```
Le `cargo tauri dev` doit confirmer le splash/font/footer/styles, Vite `1434/1435`, l'absence de warnings `TRACING_DOMAIN_SHELL` et le chargement de Diagnostics via `get_runtime_status`.
Aucun `cargo tauri build` final n'est exigé par ce fix intermédiaire ; les builds de production restent dans le couloir final prévu par le plan.

248
deltas/0.2.12/pre.002.md Normal file
View File

@@ -0,0 +1,248 @@
<!-- file: deltas/0.2.12/pre.002.md -->
<!-- version: 1 -->
# 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 — <vue>`. 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`.

View File

@@ -0,0 +1,90 @@
<!-- file: deltas/0.2.12/pre.003-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.12-pre.003-fix.001` — correction du canari `ConfigEnvironment`
## 1. Base requise
Ce correctif s'applique exclusivement après `0.2.12-pre.003`. Le gate opérateur du `27 août 2026` a validé `cargo fmt --all`, les audits Rust/Markdown et `cargo check --workspace`, puis a échoué pendant `cargo clippy --workspace --all-targets` et `cargo test -p ksp-app-solprices-desk` sur le même défaut de compilation du test `config_composition`.
La version Cargo devient :
```text
0.2.12-pre.3.fix.1
```
Cette synchronisation est requise par `VER-ID-007` et `VER-ID-010` parce que le correctif modifie un fichier Rust compilé par le workspace.
## 2. Objectif
Corriger uniquement le canari de composition Config de `pre.003` sans modifier le contrat public ou le comportement runtime de `ConfigEnvironment`.
Le test contenait :
```text
assert!(environment.is_ok(), "... {environment:?}");
```
Le formatage de `Result<ConfigEnvironment, Error>` exigeait `Debug` sur `ConfigEnvironment`, alors que ce type n'expose pas ce contrat. Le correctif supprime l'interpolation du résultat dans le message d'assertion et conserve le contrôle `is_ok()` suivi du `match` explicite existant.
## 3. Décisions
- ne pas dériver ni implémenter `Debug` pour `ConfigEnvironment` uniquement pour un diagnostic de test ;
- ne pas introduire `unwrap`, `expect` ou `panic` ;
- ne modifier ni le composite SOL Prices Desk, ni le registry Config, ni le bootstrap Off-chain, ni le frontend ;
- rester strictement dans la responsabilité de `pre.003`, conformément à `VER-LIFECYCLE-009`.
## 4. Fichiers ajoutés
```text
deltas/0.2.12/pre.003-fix.001.md
```
## 5. Fichiers modifiés
```text
Cargo.toml
crates/ksp-app-solprices-desk/tests/config_composition.rs
docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md
docs/validation/015-V0_2_12_SOL_PRICES_DESK.md
```
## 6. Fichiers supprimés
```text
aucun
```
## 7. Validations opérateur ayant déclenché le fix
```text
cargo fmt --all PASS
python3 scripts/audit_rust_workspace_rules.py PASS
python3 scripts/audit_markdown_tables.py ... deltas/0.2.12 PASS
cargo check --workspace PASS
cargo clippy --workspace --all-targets FAIL — E0277 dans config_composition.rs
cargo test -p ksp-app-solprices-desk FAIL — même E0277
```
## 8. Validations exécutées après correction dans le sandbox
```text
python3 scripts/audit_rust_workspace_rules.py PASS
python3 scripts/audit_markdown_tables.py README.md ... deltas PASS
```
## 9. Validations non exécutées dans le sandbox
```text
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-app-solprices-desk
cargo test --workspace
```
`cargo` n'est pas disponible dans le sandbox. Ces gates doivent être rejoués par l'opérateur après application du correctif.
## 10. Questions ouvertes
Aucune. Si le gate opérateur devient propre, `pre.003` est clôturable et la release peut avancer vers `pre.004`.

250
deltas/0.2.12/pre.003.md Normal file
View File

@@ -0,0 +1,250 @@
<!-- file: deltas/0.2.12/pre.003.md -->
<!-- version: 1 -->
# Delta `0.2.12-pre.003` — composite Config et bootstrap Off-chain
## 1. Base requise
Cette tranche s'applique exclusivement après :
```text
v0.2.11
+ 0.2.12-pre.001
+ 0.2.12-pre.001-fix.001
+ 0.2.12-pre.002
+ 0.2.12-pre.002-fix.001
```
Le retour opérateur du `27 août 2026` clôt proprement `pre.002-fix.001` : `cargo fmt`, installation npm, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace, `cargo test -p ksp-app-solprices-desk` et `cargo tauri dev` passent sans warning Rust. `cargo test --workspace` n'a pas été rejoué dans ce retour précis et n'est pas revendiqué comme tel.
La version workspace passe à :
```text
0.2.12-pre.3
```
## 2. Responsabilité de la tranche
`pre.003` matérialise uniquement la composition Config du nouveau Desk et son bootstrap Off-chain provider-neutral :
```text
nouveau composite SOL Prices Desk
nouveau descriptor Config
registry Config 10 -> 11 descriptors
resources Config packagées 10 -> 11 dans les trois Desks
Logging du SOL Prices Desk sélectionné par composite
ResolvedOffchainTransportConfig construit au bootstrap
MarketPriceService détenu indirectement par Config
provenance Composite conservée
Diagnostics bootstrap sûrs
instrumentation frontend durable pour les contrôles existants
```
La tranche n'introduit encore aucun `MarketPriceRuntime`, row provider, observation affichée ni commande de refresh.
## 3. Composite SOL Prices Desk
Le document ajouté est :
```text
config/composite.ksp-app-solprices-desk.json
file_id = cfg.composite.ksp-app-solprices-desk
schema = schema.composite
```
Il contient exactement les profils prévus :
```text
public_keyless (default)
logging -> cfg.std.logging / console_info
offchain_transport -> cfg.std.offchain_transport / public_keyless
all_free
logging -> cfg.std.logging / console_info
offchain_transport -> cfg.std.offchain_transport / all_free
tests
logging -> cfg.std.logging / console_info
offchain_transport -> cfg.std.offchain_transport / public_keyless
```
Les composants sont résolus par `file_id`; aucun filename physique n'est connu du code applicatif.
## 4. Registry et packaging atomiques
`ksp-config-lib` enregistre le nouveau descriptor avec le schema composite existant. L'inventaire passe à **11 descriptors** : 6 documents Config et 5 schemas.
Comme `prepare_packaged_runtime` travaille sur le registry logique complet, la même tranche ajoute le nouveau resource mapping aux trois applications packagées :
```text
ksp-app-config-desk 11 resources
ksp-app-wallet-desk 11 resources
ksp-app-solprices-desk 11 resources
```
Les canaris historiques de Config Desk et Wallet Desk sont mis à jour pour rendre cette atomicité durable.
## 5. Bootstrap Config -> Off-chain
SOL Prices Desk charge son composite via `FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK` puis vérifie explicitement les composants obligatoires :
```text
logging -> FILE_ID_STD_LOGGING
offchain_transport -> FILE_ID_STD_OFFCHAIN_TRANSPORT
```
Le profil Logging du composite alimente le runtime Logging existant. Le profil Off-chain sélectionné est ensuite transmis à :
```text
ConfigDocumentEngine::resolve_offchain_transport_config_profile
```
Le résultat `ResolvedOffchainTransportConfig` reste détenu par l'état backend. Pour le profil default `public_keyless`, le service construit par Config expose les huit entrées provider-neutral déjà définies par `0.2.11`.
Aucune dépendance provider, URL, API key, rate limit ou logique HTTP n'est recréée dans la crate applicative.
## 6. État runtime exposé
`get_runtime_status` reste une commande de diagnostic et expose uniquement des projections sûres supplémentaires :
```text
active_composite_profile
active_logging_profile
active_offchain_profile
config_document_count
application_version
fallback_logging_active
shell_phase
startup_diagnostic sûr
```
Aucun provider individuel, prix, credential, endpoint ou payload Off-chain n'est projeté dans cette tranche.
## 7. Logging frontend durable
Conformément à `KSP-APP-027`, les contrôles frontend existants sont instrumentés via le bridge commun :
```text
clic / action utilisateur debug
transition de vue debug
demande IPC debug
fin technique IPC trace
échec contrôlé error/warn selon le chemin existant
```
Les logs ne sérialisent pas de valeurs métier ou secrètes. Le canari ajouté impose ce comportement avant l'arrivée des boutons prix : les futurs refresh row/many/all devront journaliser l'action, l'ID opaque/count/code sûr lorsque nécessaire, jamais prix brut, API key, header ou payload provider.
## 8. Tests et canaris ajoutés/étendus
`ksp-config-lib` couvre désormais :
```text
inventaire exact des 11 descriptors
public constants du nouveau composite
provenance Composite du profil Off-chain
construction du service public_keyless depuis le composite
interdiction de hardcoder le nouveau filename hors Config
```
SOL Prices Desk couvre :
```text
profils composite public_keyless / all_free / tests
mapping logging + offchain_transport exact
provenance Composite
service provider-neutral à huit entrées pour public_keyless
11 resources packagées
absence de MarketPriceRuntime avant pre.004
absence de refresh avant pre.005
logging debug des contrôles/navigation
logging debug/trace du wrapper IPC
```
## 9. Fichiers ajoutés
```text
config/composite.ksp-app-solprices-desk.json
crates/ksp-app-solprices-desk/src/offchain_runtime.rs
crates/ksp-app-solprices-desk/tests/config_composition.rs
deltas/0.2.12/pre.003.md
```
## 10. Fichiers modifiés
```text
Cargo.toml
crates/ksp-app-config-desk/tauri.conf.json
crates/ksp-app-config-desk/tests/desktop_contract.rs
crates/ksp-app-solprices-desk/package.json
crates/ksp-app-solprices-desk/tauri.conf.json
crates/ksp-app-solprices-desk/frontend/main.html
crates/ksp-app-solprices-desk/frontend/ts/main.ts
crates/ksp-app-solprices-desk/src/app_state.rs
crates/ksp-app-solprices-desk/src/bootstrap.rs
crates/ksp-app-solprices-desk/src/constants.rs
crates/ksp-app-solprices-desk/src/dto_common.rs
crates/ksp-app-solprices-desk/src/errors.rs
crates/ksp-app-solprices-desk/src/lib.rs
crates/ksp-app-solprices-desk/tests/desktop_contract.rs
crates/ksp-app-solprices-desk/tests/desktop_security.rs
crates/ksp-app-wallet-desk/tauri.conf.json
crates/ksp-app-wallet-desk/tests/desktop_contract.rs
crates/ksp-app-wallet-desk/tests/release_compliance.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/src/registry.rs
crates/ksp-config-lib/tests/ownership.rs
crates/ksp-config-lib/tests/public_api.rs
crates/ksp-config-lib/unit_tests/offchain_transport.rs
crates/ksp-config-lib/unit_tests/registry.rs
docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md
docs/validation/015-V0_2_12_SOL_PRICES_DESK.md
```
## 11. Fichiers volontairement inchangés
```text
crates/ksp-offchain-transport-lib/**
crates/ksp-app-wallet-desk/src/**
crates/ksp-app-wallet-desk/frontend/**
crates/ksp-app-config-desk/src/**
crates/ksp-app-config-desk/frontend/**
README.md
ROADMAP.md
CHANGELOG.md
prompts/017-V0_2_12_START_PROMPT.md
```
L'intégration Off-chain de Wallet Desk reste propriétaire des tranches ultérieures. Le présent changement Wallet/Config Desk est strictement limité à la cohérence des resources packagées et de leurs canaris.
## 12. Validations exécutées dans le sandbox
Après matérialisation de la tranche et du delta :
```text
python3 scripts/audit_rust_workspace_rules.py PASS
General Rust rule audit clean
Rust export completeness audit 0 candidate
KSP workspace Rust rule audit clean
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12
PASS
JSON parse composite/package/tauri.conf modifiés PASS
```
Le sandbox ne possède pas `cargo`; les commandes Cargo ne sont donc pas déclarées PASS localement.
## 13. Gate opérateur attendu
```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-config-lib
cargo test -p ksp-app-solprices-desk
cargo test --workspace
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
```
Le `cargo tauri dev` doit confirmer que le Desk démarre avec le composite default `public_keyless`, que Diagnostics affiche les profils composite/Off-chain et `11` documents Config, et qu'aucune table prix ni requête de refresh n'est encore présente.

View File

@@ -0,0 +1,151 @@
<!-- file: deltas/0.2.12/pre.004-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.12-pre.004-fix.001` — fixture composite SOL Prices pour Config Desk
## 1. Base requise
```text
0.2.12-pre.004 appliquée
workspace.package.version = 0.2.12-pre.4
```
Le gate opérateur du `27 août 2026` confirme `cargo fmt`, les audits Rust/Markdown, `cargo check --workspace`, Clippy workspace et `cargo test -p ksp-app-solprices-desk`. Le lancement `cargo tauri dev` du SOL Prices Desk fonctionne également et affiche les bootstraps Logging/Off-chain attendus.
`cargo test --workspace` échoue toutefois dans `ksp-app-config-desk` sur `profile_inventory_exposes_registered_profile_documents` avec `config/json_file_read_failed` : le registry contient `cfg.composite.ksp-app-solprices-desk`, mais le répertoire de fixtures Config Desk ne contient pas encore le fichier physique correspondant.
## 2. Type de livraison et version
```text
ksp-general-0.2.12-pre.004-fix.001.zip
```
Le correctif modifie un test Rust compilé. Conformément à `VER-ID-007` et `VER-ID-010`, la version technique devient :
```text
workspace.package.version = 0.2.12-pre.4.fix.1
```
Les versions de `crates/ksp-app-solprices-desk/package.json` et `tauri.conf.json` sont synchronisées sur `0.2.12-pre.4.fix.1`.
## 3. Cause
`ksp-app-config-desk` construit son management de fixture depuis :
```text
crates/ksp-config-lib/unit_tests/fixtures
```
L'inventory parcourt tous les descriptors du registry par défaut. Depuis l'ajout du composite SOL Prices Desk, il tente donc de charger :
```text
cfg.composite.ksp-app-solprices-desk
-> composite.ksp-app-solprices-desk.json
```
Le descriptor est valide et le runtime Config réel est correct ; seule la fixture de test n'avait pas suivi l'extension du registry.
## 4. Correction
Le fix ajoute :
```text
crates/ksp-config-lib/unit_tests/fixtures/composite.ksp-app-solprices-desk.json
```
La fixture conserve la structure fonctionnelle du composite avec les profils :
```text
public_keyless
all_free
tests
```
Elle référence uniquement des profils présents dans les autres fixtures :
```text
logging -> local_dev
offchain_transport -> public_keyless / all_free
```
Le test `profile_inventory_exposes_registered_profile_documents` est renforcé pour exiger explicitement :
```text
FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK
```
Ainsi, une future extension du registry sans synchronisation du jeu de fixtures reste détectable au gate workspace.
## 5. Périmètre strict
Aucun changement n'est apporté à :
```text
MarketPriceRuntime
MarketPriceService
DTOs prix
commandes Tauri prix
frontend SOL Prices Desk
providers / adapters / wire REST
Config registry ou schéma
composite runtime config/composite.ksp-app-solprices-desk.json
```
Le fix ne change donc ni le comportement fonctionnel de `pre.004`, ni le forecast de `pre.005`.
## 6. Fichiers ajoutés
```text
crates/ksp-config-lib/unit_tests/fixtures/composite.ksp-app-solprices-desk.json
deltas/0.2.12/pre.004-fix.001.md
```
## 7. Fichiers modifiés
```text
Cargo.toml
crates/ksp-app-config-desk/unit_tests/profiles.rs
crates/ksp-app-solprices-desk/package.json
crates/ksp-app-solprices-desk/tauri.conf.json
docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md
docs/validation/015-V0_2_12_SOL_PRICES_DESK.md
```
## 8. Fichiers supprimés
Aucun.
## 9. Validations exécutées dans le sandbox
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12
Markdown table audit: clean
```
Le sandbox ne possède pas `cargo`; aucune validation Cargo n'est revendiquée localement.
## 10. Gate opérateur attendu
```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-config-desk
cargo test -p ksp-app-solprices-desk
cargo test --workspace
```
Le résultat attendu est la fermeture du seul défaut observé : l'inventory Config Desk doit charger les onze documents enregistrés, dont le composite SOL Prices Desk, sans `json_file_read_failed`.
## 11. Décisions et questions ouvertes
Décision : corriger la fixture et renforcer le canari plutôt que modifier le registry ou masquer le descriptor dans Config Desk.
Question ouverte : aucune. Après un gate workspace propre, `pre.005` peut commencer le refresh individuel prévu.

View File

@@ -0,0 +1,118 @@
<!-- file: deltas/0.2.12/pre.004-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.12-pre.004-fix.002` — canari de version desktop version-neutral
## 1. Base requise
```text
0.2.12-pre.004-fix.001 appliquée
workspace.package.version = 0.2.12-pre.4.fix.1
```
Le gate opérateur du `27 août 2026` confirme `cargo fmt`, les audits Rust/Markdown, `cargo check --workspace` et Clippy workspace. `cargo test -p ksp-app-solprices-desk` échoue toutefois dans `pre_002_shell_uses_reserved_ports_windows_and_owned_backend_paths` : le canari compare encore les métadonnées desktop à la chaîne historique `0.2.12-pre.4`, alors que le fix précédent a légitimement avancé la version technique à `0.2.12-pre.4.fix.1`.
## 2. Type de livraison et version
```text
ksp-general-0.2.12-pre.004-fix.002.zip
```
Le correctif modifie un test Rust compilé. Conformément à `VER-ID-007` et `VER-ID-010`, la version technique devient :
```text
workspace.package.version = 0.2.12-pre.4.fix.2
```
Les versions de `crates/ksp-app-solprices-desk/package.json` et `tauri.conf.json` sont synchronisées sur `0.2.12-pre.4.fix.2`.
## 3. Cause
Le canari `pre_002_shell_uses_reserved_ports_windows_and_owned_backend_paths` contenait deux assertions exactes :
```text
tauri.conf.json version == 0.2.12-pre.4
package.json version == 0.2.12-pre.4
```
Cette forme n'est pas durable : tout suffixe `fix` ou toute prerelease ultérieure rend le test rouge alors que les métadonnées restent correctement synchronisées. Config Desk et Wallet Desk utilisent déjà un contrat version-neutral pour cette responsabilité.
## 4. Correction
SOL Prices Desk reprend la même convention :
```text
parse_semver_core(...)
assert_packaged_version_floor(...)
version SemVer-like
version core >= 0.2.6
tauri.conf.json == package.json
```
La chaîne exacte `0.2.12-pre.4` disparaît donc du canari de scaffold. Le test continue de protéger l'existence et la cohérence des versions desktop sans être couplé au numéro de la tranche courante.
## 5. Périmètre strict
Aucun changement n'est apporté à :
```text
MarketPriceRuntime
MarketPriceService
DTOs prix
commandes Tauri prix
frontend SOL Prices Desk
providers / adapters / wire REST
Config registry / composite / fixtures
```
Le fix ne change donc aucun comportement fonctionnel de `pre.004` et ne commence pas `pre.005`.
## 6. Fichiers ajoutés
```text
deltas/0.2.12/pre.004-fix.002.md
```
## 7. Fichiers modifiés
```text
Cargo.toml
crates/ksp-app-solprices-desk/package.json
crates/ksp-app-solprices-desk/tauri.conf.json
crates/ksp-app-solprices-desk/tests/desktop_contract.rs
docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md
docs/validation/015-V0_2_12_SOL_PRICES_DESK.md
```
## 8. Fichiers supprimés
Aucun.
## 9. Validations exécutées dans le sandbox
```text
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
```
Le sandbox ne possède pas `cargo`; aucune validation Cargo n'est revendiquée localement.
## 10. Gate opérateur attendu
```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
```
Le résultat attendu est que le canari desktop reste vert avec `0.2.12-pre.4.fix.2` et ne contienne plus de dépendance à une chaîne de version de prerelease figée.
## 11. Décisions et questions ouvertes
Décision : utiliser le même contrat version-neutral que Config Desk et Wallet Desk plutôt que remplacer une chaîne figée par une nouvelle chaîne figée.
Question ouverte : aucune. Après gate propre, `pre.005` peut commencer le refresh individuel.

285
deltas/0.2.12/pre.004.md Normal file
View File

@@ -0,0 +1,285 @@
<!-- file: deltas/0.2.12/pre.004.md -->
<!-- version: 1 -->
# Delta `0.2.12-pre.004` — runtime prix, registry rows et DTOs
## 1. Base requise
Cette tranche s'applique exclusivement après :
```text
v0.2.11
+ 0.2.12-pre.001
+ 0.2.12-pre.001-fix.001
+ 0.2.12-pre.002
+ 0.2.12-pre.002-fix.001
+ 0.2.12-pre.003
+ 0.2.12-pre.003-fix.001
```
Le retour opérateur du `27 août 2026` clôt `pre.003-fix.001` pour la progression de release : `cargo fmt`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace, `cargo test -p ksp-app-solprices-desk` et `cargo tauri dev` passent. Le runtime Tauri journalise le bootstrap Logging, le profil composite `public_keyless`, le profil Off-chain `public_keyless` et `provider_count=8`. `cargo test --workspace` n'est pas présent dans ce retour précis et n'est donc pas revendiqué comme exécuté.
La version workspace passe à :
```text
0.2.12-pre.4
```
Les versions `package.json` et `tauri.conf.json` de SOL Prices Desk sont synchronisées sur la même prerelease.
## 2. Responsabilité de la tranche
`pre.004` matérialise uniquement la couche applicative provider-neutral entre le `MarketPriceService` déjà construit par Config et la future UX de refresh :
```text
MarketPriceRuntime backend
état de présentation courant en mémoire uniquement
projection du registry en lignes stables
MarketPriceRuntimeStatusDto
MarketPriceProviderRowDto
commande list_market_prices
états frontend loading / empty / table
projection initiale sans observation ni prix
```
La tranche n'appelle ni `MarketPriceService::refresh`, ni `refresh_many`, ni `refresh_all`. Aucun bouton de refresh ou scheduler n'est introduit avant les tranches prévues.
## 3. Ownership du runtime prix
`AppState` possède désormais un `MarketPriceRuntime`. Celui-ci retient :
```text
OffchainTransportStartup
-> ResolvedOffchainTransportConfig
-> MarketPriceService
presentation: Mutex<...>
-> une entrée par provider_id opaque
-> loading bool
-> dernière observation optionnelle
```
La présentation n'est persistée ni dans Config, ni dans Store, ni sur le filesystem frontend, ni dans `localStorage`/`sessionStorage`.
L'inventaire initial est dérivé du `MarketPriceService::registry()` détaché. Chaque ligne commence avec :
```text
loading = false
observation = None
price = None
refreshed = false
```
L'availability initiale reste celle possédée par Off-chain Transport (`Ready`, `Disabled`, etc.).
## 4. DTOs provider-neutral
`MarketPriceRuntimeStatusDto` remplace le nom bootstrap générique précédent et expose uniquement :
```text
application/config/composite/logging/offchain metadata sûrs
provider_count
ready_provider_count
unavailable_provider_count
startup_diagnostic sûr
```
`MarketPriceProviderRowDto` expose uniquement la projection générique décidée par le plan :
```text
provider_id opaque
display_name
pair = SOL/USD
semantics générique
auth_mode générique
availability générique
loading
refreshed
price optionnel
provider_timestamp optionnel
received_at optionnel
retry_at optionnel
```
Le prix exact traverse l'IPC sous forme de `Option<String>` issue de `MarketPriceDecimal::to_canonical_string()`. Les timestamps traversent eux aussi sous forme de chaînes de millisecondes Unix afin de ne pas introduire de perte d'entier JavaScript. Aucun `f64` n'est une vérité IPC.
Les enums Off-chain `#[non_exhaustive]` sont projetés en codes génériques bornés avec fallback `unknown`; aucune enum provider-specific n'est exposée au frontend.
## 5. Commande d'inventaire
La nouvelle commande Tauri centralisée est :
```text
list_market_prices
```
Elle n'accepte aucun argument et retourne :
```text
Vec<MarketPriceProviderRowDto>
```
Elle ne déclenche aucun réseau. L'ordre des lignes suit l'ordre stable du registry Off-chain.
La surface command reste donc à cette tranche :
```text
emit_frontend_log
get_runtime_status
list_market_prices
splash_frontend_ready
```
Les commandes `refresh_market_price`, `refresh_market_prices` et `refresh_all_market_prices` restent absentes par contrat.
## 6. Frontend initial
La vue `Prices` remplace le placeholder de `pre.003` par trois états explicites :
```text
loading
empty
table provider-neutral
```
La table initiale affiche les capacités et l'availability du registry. Comme aucun refresh n'a encore eu lieu, les prix et timestamps d'observation s'affichent comme absents.
La tranche n'ajoute aucun bouton Refresh. La finalisation responsive/interactions/DataTable reste propriétaire de `pre.007`.
Diagnostics ajoute les comptes total/ready/unavailable sans exposer endpoint, credential, header ou payload provider.
## 7. Logging frontend/backend
`KSP-APP-027` reste appliqué :
```text
navigation/clic utilisateur debug
requête IPC debug via invokeKsp
fin IPC trace
chargement registry start/end trace
rendu des lignes trace avec row_count uniquement
erreur de projection/chargement warn/error sûr
```
Aucun log ne sérialise le prix, la row DTO complète, une API key, un header ou une réponse provider.
## 8. Dépendances et boundaries
SOL Prices Desk ajoute une dépendance directe à `ksp-offchain-transport-lib` parce que la couche application consomme désormais ses types publics génériques de registry/observation.
Cette dépendance ne donne lieu à aucune connaissance provider-specific :
```text
aucun SDK provider
aucun reqwest direct app
aucun fetch frontend
aucune URL/header/API key dans IPC
aucune branche CoinGecko/Kraken/etc.
aucun limiter/retry/sleep provider dans app
```
Config reste propriétaire de la construction du service et des secrets.
## 9. Tests et canaris
Les tests ajoutés/étendus couvrent :
```text
codes génériques auth/availability/semantics
projection initiale sans observation
projection exacte d'un decimal 123.4500 -> "123.45"
provider_timestamp / received_at / retry_at distincts
MarketPriceRuntime présent à partir de pre.004
commande list_market_prices centralisée dans tauri.rs
absence des trois commandes de refresh
états frontend loading/empty/table
aucune conversion parseFloat/Number du prix
aucun reqwest direct dans la crate app
logging registry sans sérialisation métier
```
Les bindings TS-RS restent générés par les tests et ne sont pas versionnés.
## 10. Fichiers ajoutés
```text
crates/ksp-app-solprices-desk/src/market_price_runtime.rs
crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs
deltas/0.2.12/pre.004.md
```
## 11. Fichiers modifiés
```text
Cargo.toml
crates/ksp-app-solprices-desk/Cargo.toml
crates/ksp-app-solprices-desk/package.json
crates/ksp-app-solprices-desk/tauri.conf.json
crates/ksp-app-solprices-desk/frontend/main.html
crates/ksp-app-solprices-desk/frontend/ts/main.ts
crates/ksp-app-solprices-desk/src/app_state.rs
crates/ksp-app-solprices-desk/src/dto_common.rs
crates/ksp-app-solprices-desk/src/lib.rs
crates/ksp-app-solprices-desk/src/tauri.rs
crates/ksp-app-solprices-desk/tests/desktop_contract.rs
crates/ksp-app-solprices-desk/tests/desktop_security.rs
docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md
docs/validation/015-V0_2_12_SOL_PRICES_DESK.md
```
## 12. Fichiers volontairement inchangés
```text
crates/ksp-offchain-transport-lib/**
crates/ksp-config-lib/**
crates/ksp-app-wallet-desk/**
crates/ksp-app-config-desk/**
README.md
ROADMAP.md
CHANGELOG.md
prompts/017-V0_2_12_START_PROMPT.md
```
Aucun provider, wire REST, rate limit ou Config document n'est modifié.
## 13. Validations exécutées dans le sandbox
Après matérialisation du code avant écriture du delta :
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
```
Après écriture du présent delta :
```text
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12
Markdown table audit: clean (143 table(s), 113 file(s))
validation JSON package.json / tauri.conf.json
JSON validation: clean
```
Le sandbox ne possède pas `cargo`; aucune commande Cargo n'est déclarée PASS localement.
## 14. Gate opérateur attendu
```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
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
```
Le parcours Tauri attendu doit afficher huit lignes provider-neutral pour le profil `public_keyless`, avec availability initiale mais sans prix ni timestamp d'observation, et sans effectuer de refresh réseau. Diagnostics doit afficher `provider_count=8` avec les comptes ready/unavailable cohérents.
## 15. Questions ouvertes
Aucune. Si le gate devient propre, `pre.005` peut brancher exclusivement le refresh individuel sur ce runtime sans modifier le format Config ni les adapters providers.

View File

@@ -0,0 +1,33 @@
<!-- file: deltas/0.2.12/pre.005-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.12-pre.005-fix.001` — Canari de centralisation Tauri rendu structurel
## Contexte
Le gate opérateur de `pre.005` confirme que le refresh individuel compile, fonctionne manuellement et que le Desk démarre. Le seul échec est le canari `pre_005_tauri_commands_remain_centralized_and_only_single_refresh_is_present`, qui traite à tort `AppState::refresh_market_price` comme du plumbing de commande simplement parce que son nom contient `refresh_market_price`.
## Correction
- `workspace.package.version` devient `0.2.12-pre.5.fix.1` ;
- `package.json` et `tauri.conf.json` sont synchronisés ;
- le test continue d'interdire tout `#[tauri::command]` hors `src/tauri.rs` ;
- il interdit aussi `tauri::generate_handler!` hors `src/tauri.rs` ;
- il n'interdit plus les méthodes applicatives portant le même nom métier que la commande Tauri ;
- les gardes `refresh_market_prices` et `refresh_all_market_prices` restent actives jusqu'à `pre.006`.
## Hors scope
Aucun changement n'est apporté au runtime prix, à `MarketPriceService`, au comportement réseau, aux DTOs, aux providers, au frontend, au logging ou au contrat du refresh individuel.
## Gate attendu
```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
```

85
deltas/0.2.12/pre.005.md Normal file
View File

@@ -0,0 +1,85 @@
# Delta `0.2.12-pre.005` — Refresh individuel SOL/USD
## 1. Identité
```text
release : 0.2.12
tranche : pre.005
version Cargo : 0.2.12-pre.5
base : 0.2.12-pre.4.fix.2
```
Le gate opérateur de la base est intégralement propre : audits Rust/Markdown, `cargo check --workspace`, Clippy, `cargo test -p ksp-app-solprices-desk`, `cargo test --workspace` et `cargo tauri dev` passent.
## 2. Scope réalisé
`pre.005` branche le premier chemin réseau explicite de SOL Prices Desk sans élargir la surface Off-chain Transport :
```text
refresh individuel par provider_id opaque oui
MarketPriceService::refresh réutilisé directement
nouvel adapter/provider non
mutex présentation tenu pendant I/O non
état loading mémoire backend par ligne
succès nouvelle observation exacte mémorisée
outcome sans observation ancienne observation réussie conservée
availability / retry projection du registry Off-chain
commande Tauri refresh_market_price oui
refresh many / all non, réservé à pre.006
polling / auto-refresh non
```
Le DTO de ligne existant reste lossless : prix et timestamps sont toujours des chaînes exactes. Aucune valeur `f64` n'est introduite.
## 3. Frontend
La table reçoit une colonne `Action` et un bouton `Refresh` par provider. Pendant l'appel, la ligne est localement affichée `loading` et le contrôle est désactivé ; la réponse backend remplace ensuite uniquement la ligne ciblée.
Le logging suit le contrat commun des Desks :
```text
clic Refresh debug
requête IPC debug
fin IPC trace
application de la row trace
échec warn
```
Seul le `provider_id` opaque peut accompagner ces événements. Aucun prix, payload provider, credential, header ou URL n'est journalisé.
## 4. Tests/canaris
Les canaris de la crate évoluent pour vérifier :
- présence du seul `refresh_market_price` et absence des refresh many/all ;
- centralisation Tauri dans `tauri.rs` ;
- absence de `reqwest`/`fetch`/persistance navigateur ;
- conservation de la dernière observation lorsqu'un outcome ne produit aucun nouveau prix ;
- remise à `loading = false` pour un provider non éligible sans dispatch réseau ;
- logging frontend du clic et de l'IPC sans valeurs métier.
## 5. Hors scope confirmé
```text
refresh selected/many/all
scheduling/polling
consensus/fallback provider
prix canonique KSP
valorisation USD Wallet
persistance Config/Store/browser
```
## 6. Gate
À exécuter par l'opérateur :
```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
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
```

103
deltas/0.2.12/pre.006.md Normal file
View File

@@ -0,0 +1,103 @@
# Delta `0.2.12-pre.006` — Refresh selected/many/all SOL/USD
## 1. Identité
```text
release : 0.2.12
tranche : pre.006
version Cargo : 0.2.12-pre.6
base : 0.2.12-pre.5.fix.1
```
Le gate opérateur de la base est propre : audits Rust/Markdown, `cargo check --workspace`, Clippy, `cargo test -p ksp-app-solprices-desk` et `cargo test --workspace` passent.
## 2. Scope réalisé
`pre.006` complète les actions manuelles du SOL Prices Desk sans ajouter de scheduler consommateur :
```text
refresh individuel conservé
refresh selected/many MarketPriceService::refresh_many
refresh all MarketPriceService::refresh_all
ordre selected ordre courant des rows / requête
ordre all ordre stable provider-id Off-chain
DTO batch input IDs opaques uniquement
DTO batch result rows + requested_count/refreshed_count
cooldown/retry registry Off-chain, aucune réinterprétation app
polling/auto-refresh non
fallback/consensus/moyenne non
```
## 3. In-flight et atomicité
Avant chaque opération, le backend valide la sélection complète sous le mutex de présentation :
- doublons interdits ;
- IDs inconnus interdits ;
- provider déjà `loading` interdit ;
- aucune ligne n'est marquée `loading` avant la fin de cette validation globale.
Après validation, toutes les lignes concernées passent `loading = true`, puis le mutex est relâché avant l'appel async au service. En cas d'erreur structurelle du service, les flags `loading` sont nettoyés. Les outcomes réussis ou non éligibles sont appliqués dans l'ordre retourné par Off-chain Transport et conservent la dernière observation réussie lorsqu'aucune nouvelle observation n'est produite.
## 4. Frontend et Logging
La vue Prices ajoute :
```text
checkbox par ligne
compteur selected
Refresh selected
Refresh all
```
Les IDs sélectionnés sont reconstruits dans l'ordre courant des rows avant l'IPC. Les contrôles batch sont désactivés lorsqu'une opération visible est déjà `loading`.
Instrumentation :
```text
changement de sélection debug
clic Refresh selected debug + selectedCount
clic Refresh all debug + providerCount
IPC request/completion debug / trace via invokeKsp
application résultat batch trace + requested/refreshed counts
échec warn
```
Aucun prix, payload provider, URL, header, credential ou DTO complet n'est sérialisé dans les logs.
## 5. Canaris
La tranche rend les canaris historiques évolutifs puis ajoute des preuves dédiées `pre.006` :
- le refresh individuel reste présent sans interdire les surfaces futures de sa propre release ;
- `#[tauri::command]` et `generate_handler!` restent centralisés dans `tauri.rs` ;
- exactement sept commandes Tauri sont attendues dans le Desk à cette tranche ;
- `refresh_many` et `refresh_all` sont appelés directement sur le service générique ;
- doublons et conflits `in_flight` ne provoquent aucune mutation partielle ;
- aucun `setInterval`, `setTimeout`, `requestAnimationFrame` ou `tokio::time::sleep` n'est ajouté au chemin prix.
## 6. Hors scope confirmé
```text
DataTable/polish final pre.007
hardening/package final pre.008
Wallet Desk prices pre.009+
auto-refresh OUT 0.2.12
prix canonique/fallback/consensus OUT 0.2.12
valorisation USD Wallet OUT 0.2.12
```
## 7. Gate
À exécuter par l'opérateur :
```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
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
```

112
deltas/0.2.12/pre.007.md Normal file
View File

@@ -0,0 +1,112 @@
# Delta `0.2.12-pre.007` — UX et instrumentation SOL Prices Desk
## 1. Identité
```text
release : 0.2.12
tranche : pre.007
version Cargo : 0.2.12-pre.7
base : 0.2.12-pre.6
```
Le gate opérateur de `pre.006` est intégralement propre : audits Rust/Markdown, `cargo check --workspace`, Clippy, `cargo test -p ksp-app-solprices-desk`, `cargo test --workspace` et lancement `cargo tauri dev` passent. Le parcours manuel confirme également le fonctionnement du multi-refresh.
## 2. Scope réalisé
`pre.007` finalise l'ergonomie du SOL Prices Desk sans modifier les contrats de dispatch Off-chain :
```text
résumé états providers Providers / Ready / Observed / Attention / Refreshing
table responsive + header sticky + scroll horizontal borné
colonnes secondaires Semantics/Auth adaptatives
availability badges génériques provider-neutral
prix String exacte inchangée
timestamps rendu humain + millisecondes exactes
provider timestamp absent Not supplied, jamais remplacé par received_at
sélection row + select all + clear selection
refresh row / selected / all inchangés
nouvelle commande IPC aucune
```
## 3. Lisibilité et fraîcheur
La table ne transforme pas la vérité métier :
- le prix exact reste la `String` canonique fournie par `MarketPriceDecimal` ;
- aucun `parseFloat` ni `Number(provider.price)` n'est introduit ;
- les millisecondes exactes restent visibles pour les timestamps ;
- une présentation locale/ISO est calculée uniquement pour l'affichage temporel, après validation d'une valeur entière compatible `Date` ;
- `provider_timestamp = None` est rendu comme `Not supplied` ;
- `received_at` reste une ligne KSP distincte ;
- `retry_at` est affiché séparément lorsqu'il existe ;
- `loading` ne remplace pas l'availability persistée : il devient uniquement le badge visuel temporaire `Refreshing`.
## 4. Interactions frontend
La sélection gagne deux contrôles purement locaux :
```text
select all providers
clear selection
```
Ils ne déclenchent aucun appel réseau et ne créent aucun état persistant navigateur. Le master checkbox expose les états `checked`/`indeterminate` à partir de la sélection courante. Les boutons batch restent désactivés pendant une opération visible `loading`.
## 5. Instrumentation
Le bridge Logging couvre désormais l'ensemble des contrôles :
```text
navigation/view activation debug
sélection row debug + provider_id opaque + bool
select all debug + selectedCount
clear selection debug + selectedCount
Refresh row debug + provider_id opaque
Refresh selected debug + selectedCount
Refresh all debug + providerCount
IPC request/completion debug / trace
rendu table trace + row/observed/attention counts
échec frontend warn sans payload métier
échec command backend warn + command/error_domain/error_code
```
Aucun prix, timestamp, body provider, URL, header, API key, credential ou DTO complet n'est journalisé.
## 6. Canaris
Les canaris `pre.007` vérifient :
- présence du résumé, du master select, de `Clear selection`, du header sticky et des styles responsive ;
- projection des états génériques actuels sans branche provider spécifique ;
- absence de coercition flottante du prix ;
- distinction explicite entre provider timestamp et KSP receipt timestamp ;
- instrumentation de tous les contrôles utilisateur ;
- projection backend des erreurs limitée à `command`, `error_domain` et `error_code` ;
- absence de `fetch`, `XMLHttpRequest`, `localStorage`, `sessionStorage`, `setInterval`, `setTimeout` et `requestAnimationFrame` dans le frontend prix.
## 7. Hors scope confirmé
```text
hardening hostile batch/package final pre.008
Wallet Desk Off-chain runtime pre.009
Wallet Desk price DTO/commands pre.010
Wallet Desk price UI pre.011
auto-refresh / polling OUT 0.2.12
prix canonique / fallback / moyenne OUT 0.2.12
valorisation USD Wallet OUT 0.2.12
```
## 8. Gate
À exécuter par l'opérateur :
```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
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
```

Some files were not shown because too many files have changed in this diff Show More