mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 21:11:56 +00:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8b4c36b9a | |||
| 5f1167f2ce | |||
| 93638960e2 | |||
| 769e57654e | |||
| fda573a6a4 | |||
| dc24446027 | |||
| 5153f78557 | |||
| e39f269aa5 | |||
| be66e894d9 | |||
| 2e43cce857 | |||
| 71f5b6989d | |||
| 789d628e43 | |||
| f292f6bfaf | |||
| be6158f82f | |||
| 0397be0f7c | |||
| 4384c52448 | |||
| b2d5044fbd | |||
| c2436a98f1 | |||
| bfe3da9c3d | |||
| ec50f99608 | |||
| 95176d0326 | |||
| f0e2c95dd0 | |||
| 5dbfa589bf | |||
| d3ba5d414b | |||
| 72254ce90d | |||
| c2eeb2c5ed | |||
| 2724151d04 | |||
| d83cc2e48b | |||
| 4b6efddb3c | |||
| de146e349b | |||
| d83d6b3c1e | |||
| 50c78b30dd | |||
| 26db4c7777 | |||
| 363ec793dc | |||
| bd4f6e857d | |||
| 31c6f39e98 | |||
| 731749224d | |||
| a54ff93c59 | |||
| d2b9daeba8 | |||
| 223ccd0004 | |||
| 331218f23b | |||
| c361491d57 | |||
| 24ba6f29ca | |||
| 3edfed7219 | |||
| 0383f87e57 | |||
| bb91986df3 | |||
| af73d7bd90 | |||
| 7d86e00ca7 | |||
| e894f4a12f | |||
| 7b9e66929b | |||
| bd3a94e946 | |||
| b0d7219115 | |||
| a7598a92c6 | |||
| 5c4932ba42 | |||
| ee72a98948 | |||
| 2fba25f564 | |||
| 7b90cdf3fb | |||
| 8b7586ba87 | |||
| b23d1ada5d | |||
| 9a0d9ce243 | |||
| be226abee7 | |||
| 414b8bb139 |
@@ -201,7 +201,6 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
|
||||
Generated
+77
-3
@@ -4075,7 +4075,6 @@ dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"getrandom 0.3.4",
|
||||
"hidapi",
|
||||
"keyring",
|
||||
"libc",
|
||||
@@ -4093,10 +4092,13 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-3dx-client",
|
||||
"solidcraft-control",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-space-mouse",
|
||||
"solidcraft-theme",
|
||||
"solidcraft-ui-egui",
|
||||
"solidcraft-ui-text",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
@@ -4123,6 +4125,29 @@ dependencies = [
|
||||
"solidcraft-mcp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-color"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-control"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-control-egui"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"image",
|
||||
"serde_json",
|
||||
"solidcraft-control",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.40.0-rc.3"
|
||||
@@ -4141,6 +4166,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-derive",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -4173,6 +4199,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-control",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
@@ -4207,6 +4235,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
@@ -4234,6 +4263,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -4268,8 +4298,17 @@ version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
"solidcraft-control",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-mcp-server",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp-server"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4293,6 +4332,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-geom",
|
||||
"web-time",
|
||||
"weezl",
|
||||
@@ -4345,6 +4385,16 @@ dependencies = [
|
||||
name = "solidcraft-testkit"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-theme"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-color",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.40.0-rc.3"
|
||||
@@ -4355,6 +4405,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"muda",
|
||||
"nix 0.31.3",
|
||||
"pollster 0.4.0",
|
||||
"proc-macro2",
|
||||
@@ -4363,20 +4414,41 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-control",
|
||||
"solidcraft-control-egui",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-space-mouse",
|
||||
"solidcraft-theme",
|
||||
"solidcraft-ui-registry",
|
||||
"solidcraft-ui-text",
|
||||
"syn 2.0.119",
|
||||
"sys-locale",
|
||||
"tiny-skia",
|
||||
"toml",
|
||||
"ttf-parser",
|
||||
"unicode-bidi",
|
||||
"web-time",
|
||||
"winreg",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-registry"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-text"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.40.0-rc.3"
|
||||
@@ -4387,8 +4459,10 @@ dependencies = [
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
"solidcraft-control",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
"solidcraft-ui-text",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
||||
@@ -28,8 +28,15 @@ solidcraft-doc = { path = "crates/doc" }
|
||||
solidcraft-io = { path = "crates/io" }
|
||||
solidcraft-engine = { path = "crates/engine" }
|
||||
solidcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solidcraft-ui-registry = { path = "crates/ui-registry" }
|
||||
solidcraft-color = { path = "crates/color" }
|
||||
solidcraft-ui-text = { path = "crates/ui-text" }
|
||||
solidcraft-theme = { path = "crates/theme" }
|
||||
solidcraft-space-mouse = { path = "crates/space-mouse" }
|
||||
solidcraft-mcp = { path = "crates/mcp" }
|
||||
solidcraft-mcp-server = { path = "crates/mcp-server" }
|
||||
solidcraft-control = { path = "crates/control" }
|
||||
solidcraft-control-egui = { path = "crates/control-egui" }
|
||||
solidcraft-derive = { path = "crates/derive" }
|
||||
solidcraft-3dx-client = { path = "crates/threedx-client" }
|
||||
# The Rust port of NVIDIA's SMLib (Apache-2.0), the B-rep kernel behind crates/smlib. Pinned;
|
||||
|
||||
@@ -68,7 +68,8 @@ impl Control {
|
||||
if !addr.ip().is_loopback() || addr.port() == 0 {
|
||||
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
|
||||
}
|
||||
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
|
||||
let token = solidcraft_engine::control_auth::APP
|
||||
.client_token(addr.port(), token)
|
||||
.map(|t| t.token)
|
||||
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
|
||||
if token.len() > 4096 {
|
||||
|
||||
@@ -916,7 +916,7 @@ fn serve_mcp(
|
||||
/// The port of the running desktop app: the one its discovery file names, if something listens
|
||||
/// there (a file left by an app that crashed names a closed port).
|
||||
fn discover_app() -> Option<u16> {
|
||||
let port = solidcraft_engine::control_auth::read_current_port()?;
|
||||
let port = solidcraft_engine::control_auth::APP.read_current_port()?;
|
||||
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
|
||||
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).ok().map(|_| port)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ profile = ["solidcraft-engine/profile"]
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
solidcraft-ui-text = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
use solidcraft_control::ControlRequest;
|
||||
use wasm_bindgen::{JsValue, closure::Closure};
|
||||
|
||||
const MAX_PENDING: usize = 256;
|
||||
|
||||
@@ -48,15 +48,15 @@ impl Download {
|
||||
}
|
||||
if solidcraft_ui_egui::documents::any_dirty(app) {
|
||||
let mut answer = None;
|
||||
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
|
||||
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_text::tr("web.open_link"))
|
||||
.required()
|
||||
.show_body(ctx, |ui| {
|
||||
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
|
||||
ui.label(solidcraft_ui_text::tr("web.open_link_confirm"));
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.open"), ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -360,7 +360,7 @@ mod web {
|
||||
/// The start page's recent designs were filled from the stored ones.
|
||||
recents_synced: bool,
|
||||
/// On-demand script fonts: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font: solidcraft_ui_egui::ui_kit::type_face::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
/// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks.
|
||||
cjk_bytes: Option<Vec<u8>>,
|
||||
@@ -810,7 +810,7 @@ mod web {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
|
||||
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
|
||||
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
@@ -864,7 +864,7 @@ mod web {
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design
|
||||
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
|
||||
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
|
||||
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_stored_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("insert", e.name.clone()));
|
||||
@@ -1016,7 +1016,7 @@ mod web {
|
||||
|
||||
/// Whether the shell already has the on-demand face the live language needs.
|
||||
fn script_font_ready(&self) -> bool {
|
||||
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
|
||||
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_text::effective_language(&self.app.preferences.language)) {
|
||||
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
|
||||
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
|
||||
Some("ja") => self.cjk_bytes.is_some(),
|
||||
@@ -1058,8 +1058,8 @@ mod web {
|
||||
false
|
||||
}
|
||||
};
|
||||
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
|
||||
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if !installed && solidcraft_ui_egui::ui_kit::type_face::needs_cjk_font(effective) {
|
||||
log::warn!("script font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
@@ -1067,13 +1067,13 @@ mod web {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let ready = self.script_font_ready();
|
||||
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if self.font.tick(&mut self.app.preferences.language, ready) {
|
||||
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja"));
|
||||
}
|
||||
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
|
||||
// to Ukrainian (or English) must put the Latin face back in front.
|
||||
if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language {
|
||||
if solidcraft_ui_text::effective_language(&self.app.preferences.language) != self.font_language {
|
||||
self.apply_fonts(ctx);
|
||||
}
|
||||
}
|
||||
@@ -1081,7 +1081,7 @@ mod web {
|
||||
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
|
||||
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
|
||||
fn apply_fonts(&mut self, ctx: &egui::Context) {
|
||||
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
|
||||
solidcraft_ui_egui::fonts::setup_fonts(ctx);
|
||||
} else {
|
||||
@@ -1093,7 +1093,7 @@ mod web {
|
||||
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
|
||||
/// fetch the Japanese font, so the text does not stay tofu (#720).
|
||||
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
|
||||
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
|
||||
if self.font.watching_text() && solidcraft_ui_egui::ui_kit::type_face::painted_cjk(ctx) && self.font.text_needs_font() {
|
||||
self.fetch_font(ctx, "ja");
|
||||
}
|
||||
}
|
||||
@@ -1149,7 +1149,7 @@ mod web {
|
||||
UNSAVED.with(|u| u.set(self.asks_before_unload()));
|
||||
if self.launch.embed {
|
||||
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("web.open_full"), ui_kit::Button::Normal).clicked()
|
||||
&& let (Some(window), Some(url)) = (web_sys::window(), &self.full_url)
|
||||
{
|
||||
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener");
|
||||
@@ -1225,6 +1225,7 @@ mod web {
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
solidcraft_ui_egui::i18n::install();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let launch = crate::Launch::parse(&query());
|
||||
|
||||
@@ -31,9 +31,12 @@ neg_cmp_op_on_partial_ord = "allow"
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
# The D3D12 shader compiler the window starts with (#6756).
|
||||
solidcraft-gpu = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
solidcraft-ui-text = { workspace = true }
|
||||
solidcraft-theme = { workspace = true }
|
||||
solidcraft-space-mouse = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
@@ -45,7 +48,6 @@ rfd = { workspace = true }
|
||||
ron = "0.12"
|
||||
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
|
||||
pollster = { workspace = true }
|
||||
getrandom = "0.3"
|
||||
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ mod native_crash;
|
||||
mod native_crash_macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
mod native_menu;
|
||||
mod nav_bridge;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -468,7 +468,7 @@ fn case_variants(exts: &[&str]) -> Vec<String> {
|
||||
fn content_ui(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let content = ui.ctx().content_rect();
|
||||
let above = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), content.top()));
|
||||
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::theme::Tokens::get().app_bar);
|
||||
ui.painter().rect_filled(above, 0.0, solidcraft_theme::Tokens::get().app_bar);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(content), |ui| app.ui(ui));
|
||||
}
|
||||
|
||||
@@ -518,7 +518,7 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
|
||||
// The groups come from the format registry, so a new format joins this dialog by itself.
|
||||
let mut everything = io::formats::open_extensions(true);
|
||||
everything.extend(["dxf", "svg"]);
|
||||
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
|
||||
dialog = dialog.add_filter(solidcraft_ui_text::tr("file.open.drawing_filter"), &case_variants(&everything));
|
||||
for (label, exts) in io::formats::import_filters(true) {
|
||||
dialog = dialog.add_filter(label, &case_variants(&exts));
|
||||
}
|
||||
@@ -913,17 +913,7 @@ fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let icon =
|
||||
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
|
||||
Ok(egui::ViewportBuilder::default()
|
||||
.with_title("SolidCraft")
|
||||
.with_icon(icon)
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!custom_titlebar)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID))
|
||||
Ok(solidcraft_ui_egui::window_chrome::viewport("SolidCraft", APP_ID, custom_titlebar).with_icon(icon))
|
||||
}
|
||||
|
||||
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
|
||||
@@ -945,6 +935,7 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
|
||||
mark("main");
|
||||
solidcraft_ui_egui::i18n::install();
|
||||
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
|
||||
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
|
||||
// macOS version than the GPU's Metal compiler supports. Re-launching with
|
||||
@@ -1221,7 +1212,7 @@ fn main() -> eframe::Result {
|
||||
mark("viewport pipelines");
|
||||
// `--control`/the environment name a port; otherwise the preference (on by default)
|
||||
// opens the default one (docs/control-protocol.md).
|
||||
if let Some(listen_on) = solidcraft_engine::control_auth::plan(control_port, app.preferences.agent_control) {
|
||||
if let Some(listen_on) = solidcraft_control::auth::plan(control_port, app.preferences.agent_control) {
|
||||
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
|
||||
+347
-162
@@ -1,137 +1,221 @@
|
||||
//! macOS system menus, owned and updated on eframe's main thread.
|
||||
//! SolidCraft's macOS menu bar: its entries as kit menu items
|
||||
//! (`solidcraft_ui_egui::window_chrome::native_menu`), each with a command id or a `ui.*` id, run
|
||||
//! through the same actions as the application bar's File menu (`toolbar::FileAction`).
|
||||
//!
|
||||
//! Entries that are control actions use their ids and do the same there: `app.quit`,
|
||||
//! `ui.prefs {open}` (Settings and Keyboard Shortcuts), `ui.agent_chat {toggle}`, `ui.help {item}`. Undo and Redo are the commands
|
||||
//! `edit.undo` and `edit.redo`. The other entries' ids are defined here: `ui.file.new`,
|
||||
//! `ui.file.open`, `ui.file.open_recent {path}`, `ui.file.clear_recent`, `ui.file.save`,
|
||||
//! `ui.file.save_as`, `ui.file.save_json`, `ui.file.export {format}`,
|
||||
//! `ui.file.export_mesh {format}`. Menus are `menu.file`, `menu.file.recent`, `menu.edit`,
|
||||
//! `menu.window` and `menu.help`.
|
||||
//!
|
||||
//! The menu bar itself is macOS's; the entries and their ids build everywhere, so their tests run
|
||||
//! on every system.
|
||||
|
||||
use std::sync::mpsc::{Receiver, sync_channel};
|
||||
|
||||
use muda::{
|
||||
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
|
||||
accelerator::{Accelerator, Code, Modifiers},
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::{
|
||||
SolveApp,
|
||||
toolbar::FileAction,
|
||||
ui_kit::Item,
|
||||
window_chrome::native_menu::{self, MenuBar},
|
||||
};
|
||||
use solidcraft_ui_egui::{SolveApp, toolbar::FileAction};
|
||||
|
||||
/// The Open Recent submenu, refilled when the recent designs change.
|
||||
const RECENT: &str = "menu.file.recent";
|
||||
const OPEN_RECENT: &str = "ui.file.open_recent";
|
||||
/// Recent designs listed.
|
||||
#[cfg(target_os = "macos")]
|
||||
const RECENT_COUNT: usize = 32;
|
||||
|
||||
/// File > Insert entries.
|
||||
const INSERTS: [(&str, FileAction); 3] = [
|
||||
("Insert STEP\u{2026}", FileAction::Command("file.insert_step")),
|
||||
("Insert Mesh\u{2026}", FileAction::Command("file.insert_mesh")),
|
||||
("Insert Design", FileAction::Command("component.insert_design")),
|
||||
];
|
||||
|
||||
/// File > Export entries.
|
||||
const EXPORTS: [(&str, FileAction); 7] = [
|
||||
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
|
||||
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
|
||||
("Export STEP\u{2026}", FileAction::Export("step")),
|
||||
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
|
||||
("Export OBJ\u{2026}", FileAction::Export("obj")),
|
||||
("Export URDF\u{2026}", FileAction::Export("urdf")),
|
||||
("Export Design as JSON\u{2026}", FileAction::Export("json")),
|
||||
];
|
||||
|
||||
const DESIGN_PROPERTIES: FileAction = FileAction::Command("document.metadata");
|
||||
|
||||
/// The other actions with a fixed id (all but Open Recent), to map an id back to its action.
|
||||
const FIXED: &[FileAction] = &[
|
||||
FileAction::Settings,
|
||||
FileAction::Shortcuts,
|
||||
FileAction::Quit,
|
||||
FileAction::New,
|
||||
FileAction::Open,
|
||||
FileAction::ClearRecent,
|
||||
FileAction::Save,
|
||||
FileAction::SaveAs,
|
||||
FileAction::SaveJson,
|
||||
DESIGN_PROPERTIES,
|
||||
FileAction::Undo,
|
||||
FileAction::Redo,
|
||||
FileAction::AgentChat,
|
||||
];
|
||||
|
||||
/// The Help menu's entries: the control channel's `ui.help {item}`.
|
||||
const HELP_ID: &str = "ui.help";
|
||||
/// The Help menu's items (`help::run`), top to bottom.
|
||||
const HELP: [&str; 7] = ["docs", "updates", "shortcuts", "messages", "report", "crashes", "install_cli"];
|
||||
|
||||
fn help_label<'a>(app: &'a SolveApp, item: &str) -> &'a str {
|
||||
match item {
|
||||
"docs" => app.tr_label("Documentation"),
|
||||
"updates" => app.tr("update.check_for_updates"),
|
||||
"shortcuts" => app.tr_label("Keyboard Shortcuts"),
|
||||
"messages" => app.tr_label("Messages\u{2026}"),
|
||||
"report" => app.tr_label("Report an Issue\u{2026}"),
|
||||
"crashes" => app.tr_label("Crash Reports\u{2026}"),
|
||||
_ => app.tr_label("Install solidcraft-cli command"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a menu entry runs.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Entry {
|
||||
File(FileAction),
|
||||
Help(&'static str),
|
||||
}
|
||||
|
||||
/// The entry's id and parameters for `action`.
|
||||
fn id_of(action: &FileAction) -> (String, Option<Value>) {
|
||||
let fixed = |id: &str| (id.to_string(), None);
|
||||
match action {
|
||||
FileAction::Settings => ("ui.prefs".into(), Some(json!({ "open": "General" }))),
|
||||
FileAction::Shortcuts => ("ui.prefs".into(), Some(json!({ "open": "Shortcuts" }))),
|
||||
FileAction::Quit => fixed("app.quit"),
|
||||
FileAction::New => fixed("ui.file.new"),
|
||||
FileAction::Open => fixed("ui.file.open"),
|
||||
FileAction::OpenRecent(path) => (OPEN_RECENT.into(), Some(json!({ "path": path }))),
|
||||
FileAction::ClearRecent => fixed("ui.file.clear_recent"),
|
||||
FileAction::Save => fixed("ui.file.save"),
|
||||
FileAction::SaveAs => fixed("ui.file.save_as"),
|
||||
FileAction::SaveJson => fixed("ui.file.save_json"),
|
||||
FileAction::Export(format) => ("ui.file.export".into(), Some(json!({ "format": format }))),
|
||||
FileAction::ExportMesh(format) => ("ui.file.export_mesh".into(), Some(json!({ "format": format }))),
|
||||
FileAction::Command(command) => fixed(command),
|
||||
FileAction::Undo => fixed("edit.undo"),
|
||||
FileAction::Redo => fixed("edit.redo"),
|
||||
FileAction::AgentChat => ("ui.agent_chat".into(), Some(json!({ "toggle": true }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry with `id` and `params`, `None` for one this menu doesn't have.
|
||||
fn entry_of(id: &str, params: Option<&Value>) -> Option<Entry> {
|
||||
if id == OPEN_RECENT {
|
||||
return params.and_then(|p| p.get("path")).and_then(Value::as_str).map(|path| Entry::File(FileAction::OpenRecent(path.to_string())));
|
||||
}
|
||||
if id == HELP_ID {
|
||||
let item = params.and_then(|p| p.get("item")).and_then(Value::as_str);
|
||||
return HELP.into_iter().find(|h| Some(*h) == item).map(Entry::Help);
|
||||
}
|
||||
let wanted = (id.to_string(), params.cloned());
|
||||
FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).find(|a| id_of(a) == wanted).map(Entry::File)
|
||||
}
|
||||
|
||||
/// The entries with a key, as menu accelerators.
|
||||
const SHORTCUTS: [(FileAction, &str); 8] = [
|
||||
(FileAction::Settings, "Cmd+,"),
|
||||
(FileAction::Quit, "Cmd+Q"),
|
||||
(FileAction::New, "Cmd+N"),
|
||||
(FileAction::Open, "Cmd+O"),
|
||||
(FileAction::Save, "Cmd+S"),
|
||||
(FileAction::SaveAs, "Cmd+Shift+S"),
|
||||
(FileAction::Undo, "Cmd+Z"),
|
||||
(FileAction::Redo, "Cmd+Shift+Z"),
|
||||
];
|
||||
|
||||
fn item(label: &str, action: &FileAction) -> Item {
|
||||
let (id, params) = id_of(action);
|
||||
let shortcut = SHORTCUTS.iter().find(|(a, _)| a == action).map_or("", |(_, k)| *k);
|
||||
let mut item = Item::action(&id, label, "").key(shortcut);
|
||||
item.params = params;
|
||||
item
|
||||
}
|
||||
|
||||
fn submenu(id: &str, label: &str, children: Vec<Item>) -> Item {
|
||||
let mut menu = Item::action(id, label, "");
|
||||
menu.children = children;
|
||||
menu
|
||||
}
|
||||
|
||||
fn recent_items(app: &SolveApp, paths: &[String]) -> Vec<Item> {
|
||||
let mut items: Vec<Item> = paths.iter().map(|path| item(path, &FileAction::OpenRecent(path.clone()))).collect();
|
||||
items.push(Item::sep());
|
||||
items.push(item(app.tr("ui.clear_recent"), &FileAction::ClearRecent));
|
||||
items
|
||||
}
|
||||
|
||||
fn menu_bar(app: &SolveApp, recent: &[String]) -> MenuBar {
|
||||
let more = |key: &str| format!("{}\u{2026}", app.tr(key));
|
||||
let mut file = vec![
|
||||
item(app.tr("ui.new_design"), &FileAction::New),
|
||||
item(&more("ui.open"), &FileAction::Open),
|
||||
submenu(RECENT, app.tr("ui.open_recent"), recent_items(app, recent)),
|
||||
Item::sep(),
|
||||
];
|
||||
file.extend(INSERTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
|
||||
file.extend([
|
||||
Item::sep(),
|
||||
item(app.tr("ui.save"), &FileAction::Save),
|
||||
item(&more("ui.save_as"), &FileAction::SaveAs),
|
||||
item(app.tr_label("Save As JSON\u{2026}"), &FileAction::SaveJson),
|
||||
Item::sep(),
|
||||
]);
|
||||
file.extend(EXPORTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
|
||||
file.extend([Item::sep(), item(app.tr_label("Design Properties"), &DESIGN_PROPERTIES)]);
|
||||
let edit = vec![item(app.tr("ui.undo"), &FileAction::Undo), item(app.tr("ui.redo"), &FileAction::Redo)];
|
||||
let window = vec![item(app.tr_label("Agent Chat"), &FileAction::AgentChat)];
|
||||
let help = HELP.iter().map(|h| Item::action(HELP_ID, help_label(app, h), "").with(json!({ "item": h }))).collect();
|
||||
MenuBar {
|
||||
title: "SolidCraft".into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
app: vec![item(&more("ui.preferences"), &FileAction::Settings), item(&more("ui.keyboard_shortcuts"), &FileAction::Shortcuts)],
|
||||
quit: item(&format!("{} SolidCraft", app.tr("ui.quit")), &FileAction::Quit),
|
||||
menus: vec![
|
||||
submenu("menu.file", app.tr("ui.file"), file),
|
||||
submenu("menu.edit", app.tr_label("Edit"), edit),
|
||||
submenu(native_menu::WINDOW, "Window", window),
|
||||
submenu("menu.help", app.tr("ui.help"), help),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu bar, owned and updated on eframe's main thread.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub struct NativeMenu {
|
||||
menu: Menu,
|
||||
actions: Vec<(MenuItem, FileAction)>,
|
||||
help_actions: Vec<(MenuItem, &'static str)>,
|
||||
events: Receiver<MenuEvent>,
|
||||
recent: Submenu,
|
||||
menu: native_menu::NativeMenu,
|
||||
recent_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl NativeMenu {
|
||||
pub fn new(app: &SolveApp, ctx: &egui::Context) -> muda::Result<Self> {
|
||||
let menu = Menu::new();
|
||||
let application = Submenu::new("SolidCraft", true);
|
||||
let file = Submenu::new(app.tr("ui.file"), true);
|
||||
let edit = Submenu::new(app.tr_label("Edit"), true);
|
||||
let recent = Submenu::new(app.tr("ui.open_recent"), true);
|
||||
let window = Submenu::new("Window", true);
|
||||
let help = Submenu::new(app.tr("ui.help"), true);
|
||||
let mut help_actions = Vec::new();
|
||||
let mut actions = Vec::new();
|
||||
let mut add = |parent: &Submenu, label: &str, action: FileAction, shortcut: Option<Accelerator>| -> muda::Result<()> {
|
||||
let item = MenuItem::new(label, Self::action_enabled(&action, app, ctx), shortcut);
|
||||
parent.append(&item)?;
|
||||
actions.push((item, action));
|
||||
Ok(())
|
||||
};
|
||||
application.append(&PredefinedMenuItem::about(
|
||||
Some("About SolidCraft"),
|
||||
Some(AboutMetadata { name: Some("SolidCraft".into()), version: Some(env!("CARGO_PKG_VERSION").into()), ..Default::default() }),
|
||||
))?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
add(
|
||||
&application,
|
||||
&format!("{}\u{2026}", app.tr("ui.preferences")),
|
||||
FileAction::Settings,
|
||||
Some(Accelerator::new(Modifiers::META, Code::Comma)),
|
||||
)?;
|
||||
add(&application, &format!("{}\u{2026}", app.tr("ui.keyboard_shortcuts")), FileAction::Shortcuts, None)?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
application.append(&PredefinedMenuItem::services(None))?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
application.append_items(&[
|
||||
&PredefinedMenuItem::hide(Some("Hide SolidCraft")),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
])?;
|
||||
add(&application, &format!("{} SolidCraft", app.tr("ui.quit")), FileAction::Quit, Some(Accelerator::new(Modifiers::META, Code::KeyQ)))?;
|
||||
add(&file, app.tr("ui.new_design"), FileAction::New, Some(Accelerator::new(Modifiers::META, Code::KeyN)))?;
|
||||
add(&file, &format!("{}\u{2026}", app.tr("ui.open")), FileAction::Open, Some(Accelerator::new(Modifiers::META, Code::KeyO)))?;
|
||||
file.append(&recent)?;
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
for (label, command) in
|
||||
[("Insert STEP\u{2026}", "file.insert_step"), ("Insert Mesh\u{2026}", "file.insert_mesh"), ("Insert Design", "component.insert_design")]
|
||||
{
|
||||
add(&file, app.tr_label(label), FileAction::Command(command), None)?;
|
||||
}
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
add(&file, app.tr("ui.save"), FileAction::Save, Some(Accelerator::new(Modifiers::META, Code::KeyS)))?;
|
||||
add(
|
||||
&file,
|
||||
&format!("{}\u{2026}", app.tr("ui.save_as")),
|
||||
FileAction::SaveAs,
|
||||
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyS)),
|
||||
)?;
|
||||
add(&file, app.tr_label("Save As JSON\u{2026}"), FileAction::SaveJson, None)?;
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
for (label, action) in [
|
||||
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
|
||||
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
|
||||
("Export STEP\u{2026}", FileAction::Export("step")),
|
||||
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
|
||||
("Export OBJ\u{2026}", FileAction::Export("obj")),
|
||||
("Export URDF\u{2026}", FileAction::Export("urdf")),
|
||||
("Export Design as JSON\u{2026}", FileAction::Export("json")),
|
||||
] {
|
||||
add(&file, app.tr_label(label), action, None)?;
|
||||
}
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
add(&file, app.tr_label("Design Properties"), FileAction::Command("document.metadata"), None)?;
|
||||
add(&edit, app.tr("ui.undo"), FileAction::Undo, Some(Accelerator::new(Modifiers::META, Code::KeyZ)))?;
|
||||
add(&edit, app.tr("ui.redo"), FileAction::Redo, Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)))?;
|
||||
add(&window, app.tr_label("Agent Chat"), FileAction::AgentChat, None)?;
|
||||
window.append(&PredefinedMenuItem::separator())?;
|
||||
window.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(None),
|
||||
&PredefinedMenuItem::fullscreen(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(None),
|
||||
])?;
|
||||
for (action, label) in [
|
||||
("docs", "Documentation"),
|
||||
("updates", app.tr("update.check_for_updates")),
|
||||
("shortcuts", "Keyboard Shortcuts"),
|
||||
("messages", "Messages\u{2026}"),
|
||||
("report", "Report an Issue\u{2026}"),
|
||||
("crashes", "Crash Reports\u{2026}"),
|
||||
("install_cli", "Install solidcraft-cli command"),
|
||||
] {
|
||||
let item = MenuItem::new(app.tr_label(label), true, None);
|
||||
help.append(&item)?;
|
||||
help_actions.push((item, action));
|
||||
}
|
||||
menu.append_items(&[&application, &file, &edit, &window, &help])?;
|
||||
let (sender, events) = sync_channel(64);
|
||||
let ctx = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event| {
|
||||
let _ = sender.try_send(event);
|
||||
ctx.request_repaint();
|
||||
}));
|
||||
menu.init_for_nsapp();
|
||||
let mut native = Self { menu, actions, help_actions, events, recent, recent_paths: Vec::new() };
|
||||
native.update_recent(app)?;
|
||||
pub fn new(app: &SolveApp, ctx: &egui::Context) -> Result<Self, String> {
|
||||
let recent_paths: Vec<String> = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
|
||||
let menu = native_menu::NativeMenu::new(&menu_bar(app, &recent_paths), ctx)?;
|
||||
let native = Self { menu, recent_paths };
|
||||
native.update(app, ctx);
|
||||
Ok(native)
|
||||
}
|
||||
|
||||
pub fn raw_input_hook(&mut self, app: &mut SolveApp, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
for event in self.events.try_iter().take(64) {
|
||||
if let Some((_, action)) = self.help_actions.iter().find(|(item, _)| item.id() == &event.id) {
|
||||
solidcraft_ui_egui::help::run(app, ctx, action);
|
||||
}
|
||||
if let Some((_, action)) = self.actions.iter().find(|(item, _)| item.id() == &event.id) {
|
||||
if matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() {
|
||||
for (id, params) in self.menu.events() {
|
||||
match entry_of(&id, params.as_ref()) {
|
||||
Some(Entry::Help(item)) => solidcraft_ui_egui::help::run(app, ctx, item),
|
||||
// A focused text field takes its own Undo and Redo.
|
||||
Some(Entry::File(action @ (FileAction::Undo | FileAction::Redo))) if ctx.egui_wants_keyboard_input() => {
|
||||
raw.events.push(egui::Event::Key {
|
||||
key: egui::Key::Z,
|
||||
physical_key: None,
|
||||
@@ -139,58 +223,159 @@ impl NativeMenu {
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: matches!(action, FileAction::Redo), ..Default::default() },
|
||||
});
|
||||
} else {
|
||||
action.run(app);
|
||||
}
|
||||
Some(Entry::File(action)) => action.run(app),
|
||||
None => log::warn!("native menu: no entry `{id}`"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_enabled(action: &FileAction, app: &SolveApp, ctx: &egui::Context) -> bool {
|
||||
action.menu_enabled(app, ctx.egui_wants_keyboard_input())
|
||||
fn update(&self, app: &SolveApp, ctx: &egui::Context) {
|
||||
let focus = ctx.egui_wants_keyboard_input();
|
||||
self.menu.update(|id, params| match entry_of(id, params) {
|
||||
Some(Entry::File(action)) => action.menu_enabled(app, focus),
|
||||
Some(Entry::Help(item)) => solidcraft_ui_egui::help::available(app, item),
|
||||
None => false,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
for (item, action) in &self.actions {
|
||||
let enabled = Self::action_enabled(action, app, ctx);
|
||||
if item.is_enabled() != enabled {
|
||||
item.set_enabled(enabled);
|
||||
if !self.recent_paths.iter().eq(app.home.recent.iter().take(RECENT_COUNT)) {
|
||||
self.recent_paths = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
|
||||
if let Err(error) = self.menu.replace(RECENT, &recent_items(app, &self.recent_paths)) {
|
||||
app.set_status(format!("Recent designs menu: {error}"), true);
|
||||
}
|
||||
}
|
||||
for (item, action) in &self.help_actions {
|
||||
let enabled = solidcraft_ui_egui::help::available(app, action);
|
||||
if item.is_enabled() != enabled {
|
||||
item.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
if !self.recent_paths.iter().eq(app.home.recent.iter().take(32))
|
||||
&& let Err(error) = self.update_recent(app)
|
||||
{
|
||||
app.set_status(format!("Recent designs menu: {error}"), true);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_recent(&mut self, app: &SolveApp) -> muda::Result<()> {
|
||||
while self.recent.remove_at(0).is_some() {}
|
||||
self.actions.retain(|(_, action)| !matches!(action, FileAction::OpenRecent(_) | FileAction::ClearRecent));
|
||||
self.recent_paths = app.home.recent.iter().take(32).cloned().collect();
|
||||
for path in &self.recent_paths {
|
||||
let action = FileAction::OpenRecent(path.clone());
|
||||
let item = MenuItem::new(path, action.enabled(app), None);
|
||||
self.recent.append(&item)?;
|
||||
self.actions.push((item, action));
|
||||
}
|
||||
self.recent.append(&PredefinedMenuItem::separator())?;
|
||||
let clear = MenuItem::new(app.tr("ui.clear_recent"), !self.recent_paths.is_empty(), None);
|
||||
self.recent.append(&clear)?;
|
||||
self.actions.push((clear, FileAction::ClearRecent));
|
||||
Ok(())
|
||||
self.update(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeMenu {
|
||||
fn drop(&mut self) {
|
||||
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
|
||||
self.menu.remove_for_nsapp();
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every action the menu offers, Open Recent with a path.
|
||||
fn actions() -> Vec<FileAction> {
|
||||
let mut all: Vec<FileAction> = FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).collect();
|
||||
all.push(FileAction::OpenRecent("/work/a.solidcraft".into()));
|
||||
all
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_action_and_help_item_round_trips_through_its_id() {
|
||||
let mut seen = vec![];
|
||||
for action in actions() {
|
||||
let (id, params) = id_of(&action);
|
||||
assert!(!seen.contains(&(id.clone(), params.clone())), "two actions share `{id}` {params:?}");
|
||||
assert_eq!(entry_of(&id, params.as_ref()), Some(Entry::File(action)), "`{id}` {params:?}");
|
||||
seen.push((id, params));
|
||||
}
|
||||
for h in HELP {
|
||||
assert_eq!(entry_of(HELP_ID, Some(&json!({ "item": h }))), Some(Entry::Help(h)));
|
||||
}
|
||||
// The control channel's ids where it has one.
|
||||
assert_eq!(id_of(&FileAction::Quit), ("app.quit".to_string(), None));
|
||||
assert_eq!(id_of(&FileAction::Settings), ("ui.prefs".to_string(), Some(json!({"open": "General"}))));
|
||||
assert_eq!(id_of(&FileAction::Shortcuts), ("ui.prefs".to_string(), Some(json!({"open": "Shortcuts"}))));
|
||||
assert_eq!(id_of(&FileAction::AgentChat), ("ui.agent_chat".to_string(), Some(json!({"toggle": true}))));
|
||||
assert_eq!(entry_of("ui.file.export", Some(&json!({"format": "step"}))), Some(Entry::File(FileAction::Export("step"))));
|
||||
for (id, params) in [
|
||||
("ui.file.export", None),
|
||||
("ui.file.export", Some(json!({"format": "dwg"}))),
|
||||
(HELP_ID, Some(json!({"item": "nothing"}))),
|
||||
(HELP_ID, None),
|
||||
("ui.prefs", None),
|
||||
("solid.nothing", None),
|
||||
(OPEN_RECENT, None),
|
||||
(RECENT, None),
|
||||
(native_menu::WINDOW, None),
|
||||
] {
|
||||
assert_eq!(entry_of(id, params.as_ref()), None, "{id} {params:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shortcut_is_one_actions_and_the_menu_shows_it() {
|
||||
for (i, (action, key)) in SHORTCUTS.iter().enumerate() {
|
||||
assert!(SHORTCUTS.iter().skip(i + 1).all(|(a, k)| a != action && k != key), "{action:?} {key}");
|
||||
assert_eq!(item("label", action).shortcut, *key);
|
||||
}
|
||||
assert_eq!(item("label", &FileAction::SaveJson).shortcut, "");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn menu_shortcuts_parse_as_the_keys_they_were() {
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
let (cmd, shift) = (Modifiers::META, Modifiers::META | Modifiers::SHIFT);
|
||||
let want = [
|
||||
Accelerator::new(cmd, Code::Comma),
|
||||
Accelerator::new(cmd, Code::KeyQ),
|
||||
Accelerator::new(cmd, Code::KeyN),
|
||||
Accelerator::new(cmd, Code::KeyO),
|
||||
Accelerator::new(cmd, Code::KeyS),
|
||||
Accelerator::new(shift, Code::KeyS),
|
||||
Accelerator::new(cmd, Code::KeyZ),
|
||||
Accelerator::new(shift, Code::KeyZ),
|
||||
];
|
||||
for ((action, key), want) in SHORTCUTS.iter().zip(want) {
|
||||
assert_eq!(key.parse::<Accelerator>().ok(), Some(want), "{action:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_entry_of_the_menu_bar_runs_its_action() {
|
||||
fn leaves(items: &[Item], out: &mut Vec<Item>) {
|
||||
for item in items {
|
||||
if item.children.is_empty() {
|
||||
out.push(item.clone());
|
||||
} else {
|
||||
leaves(&item.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
let bar = menu_bar(&app, &["/work/a.solidcraft".to_string()]);
|
||||
let mut entries = vec![];
|
||||
leaves(&bar.app, &mut entries);
|
||||
leaves(std::slice::from_ref(&bar.quit), &mut entries);
|
||||
leaves(&bar.menus, &mut entries);
|
||||
entries.retain(|i| i.id != "-");
|
||||
// App menu 2 + Quit; File: New, Open, 1 recent, Clear, 3 inserts, Save, Save As, JSON,
|
||||
// 7 exports, Properties; Edit 2; Window 1; Help 7.
|
||||
assert_eq!(entries.len(), 3 + 4 + 3 + 3 + 7 + 1 + 2 + 1 + 7, "{:?}", entries.iter().map(|i| &i.id).collect::<Vec<_>>());
|
||||
for item in &entries {
|
||||
assert!(entry_of(&item.id, item.params.as_ref()).is_some(), "`{}` {:?} runs nothing", item.id, item.params);
|
||||
}
|
||||
assert!(bar.menus.iter().all(|m| m.id.starts_with("menu.")), "menu ids are never actions");
|
||||
}
|
||||
|
||||
/// The entries whose ids are control actions do over the control channel what they do in
|
||||
/// the menu.
|
||||
#[test]
|
||||
fn shared_ids_mean_the_same_over_the_control_channel() {
|
||||
use solidcraft_ui_egui::scenario::Harness;
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.ai_features = true;
|
||||
h.app.agent_chat.open = false;
|
||||
let (id, params) = id_of(&FileAction::AgentChat);
|
||||
for open in [true, false] {
|
||||
let out = h.call(&id, params.clone().unwrap_or_default());
|
||||
assert_eq!(out["ok"], true, "{out}");
|
||||
assert_eq!(h.app.agent_chat.open, open, "Agent Chat toggles as in the menu");
|
||||
}
|
||||
for action in [FileAction::Settings, FileAction::Shortcuts] {
|
||||
let (id, params) = id_of(&action);
|
||||
let out = h.call(&id, params.unwrap_or_default());
|
||||
assert_eq!(out["ok"], true, "{action:?}: {out}");
|
||||
}
|
||||
// install_cli links into the system; its menu and control paths are the same `help::run`.
|
||||
for item in HELP.into_iter().filter(|h| *h != "install_cli") {
|
||||
let out = h.call(HELP_ID, json!({ "item": item }));
|
||||
assert_eq!(out["ok"], true, "{item}: {out}");
|
||||
}
|
||||
assert_eq!(h.call(HELP_ID, json!({"item": "nothing"}))["ok"], false, "an unknown help item is an error");
|
||||
let out = h.call(&id_of(&FileAction::Quit).0, json!({}));
|
||||
assert_eq!((out["ok"].clone(), h.app.quit_requested), (json!(true), true), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ assets/
|
||||
ui/source/ # Rust viewport glyphs, markers and navigation
|
||||
branding/web/source/og.png # shared social preview card
|
||||
branding/logo/ # print/web logo files; guide in branding/README.md
|
||||
styles/ui/palettes.rs # light/dark defaults
|
||||
styles/web/loading.css # web loading/canvas styles
|
||||
appearances/library.rs # built-in visual presets
|
||||
materials/library.rs # physical defaults and colours
|
||||
|
||||
@@ -38,7 +38,7 @@ Other colours, all already used in the project:
|
||||
| Colour | Hex | Where it comes from |
|
||||
|---|---|---|
|
||||
| Wordmark blue | `#15499e` | loading logo `--word` |
|
||||
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
|
||||
| Interface accent | `#2678da` | light theme `accent` token (`crates/theme/src/palettes.rs`) |
|
||||
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
|
||||
| Dark background | `#1f232b` | dark theme `file_background` token |
|
||||
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
|
||||
|
||||
@@ -81,7 +81,7 @@ for message in icon_palette.validation_errors(pal, names | set(al)):
|
||||
err(d + '/palette.toml', message)
|
||||
# the review page lies its icons on the real app surfaces: its colours must be the app's tokens
|
||||
def app_tokens():
|
||||
src = open(os.path.join(d, '..', '..', '..', 'styles', 'ui', 'palettes.rs'), encoding='utf-8').read()
|
||||
src = open(os.path.join(d, '..', '..', '..', '..', 'crates', 'theme', 'src', 'palettes.rs'), encoding='utf-8').read()
|
||||
lo, hi = src.index('pub const fn light'), src.index('pub const fn dark')
|
||||
light_src, dark_src = src[lo:hi], src[hi:]
|
||||
def pick(block, name, dark):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* App surfaces (keep in sync with assets/styles/ui/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
|
||||
/* App surfaces (keep in sync with crates/theme/src/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
|
||||
:root{--bg:#fafbfc;--fg:#262a32;--card:#f6f7f9;--line:#d0d4dc;--mut:#6e7480;--sz:48px}
|
||||
body.dark{--bg:#2e3440;--fg:#dee2e8;--card:#272b34;--line:#3f4552;--mut:#949ba8}
|
||||
body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)}
|
||||
|
||||
@@ -110,7 +110,7 @@ pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
|
||||
}
|
||||
|
||||
pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
|
||||
brand_mark_styled(p, c, 20.0, crate::theme::Tokens::get().logo, true);
|
||||
brand_mark_styled(p, c, 20.0, solidcraft_theme::Tokens::get().logo, true);
|
||||
}
|
||||
|
||||
/// The same original mark as a single quiet silhouette, without face seams or outlines.
|
||||
@@ -127,7 +127,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
|
||||
};
|
||||
// The faces are separated by gaps that show the application bar, not by white lines.
|
||||
let [light, mid, dark] = colours;
|
||||
let edge_col = crate::theme::Tokens::get().app_bar;
|
||||
let edge_col = solidcraft_theme::Tokens::get().app_bar;
|
||||
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
|
||||
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
|
||||
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Constraint, assembly snap, hole placement and dimension marker artwork.
|
||||
use crate::dialogs_assembly::SnapKind;
|
||||
use crate::theme::Tokens;
|
||||
use egui::{Color32, Pos2, Stroke, vec2};
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
|
||||
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
|
||||
@@ -49,7 +49,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
|
||||
// What it snaps to (hovered), or which component it is (picked): a small label.
|
||||
if let Some(tag) = tag {
|
||||
let t = Tokens::get();
|
||||
let font = egui::FontId::proportional(crate::theme::Tokens::get().font_caption);
|
||||
let font = egui::FontId::proportional(solidcraft_theme::Tokens::get().font_caption);
|
||||
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
|
||||
let at = c + vec2(12.0, 10.0);
|
||||
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Navigation drawing definitions. Camera projection and picking remain in the UI widgets.
|
||||
use crate::theme::Tokens;
|
||||
use egui::{Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_engine::geom::Ring;
|
||||
use solidcraft_theme::Tokens;
|
||||
pub(crate) struct Facet {
|
||||
pub(crate) direction: Vec3,
|
||||
pub(crate) points: Ring<Pos2>,
|
||||
@@ -150,16 +150,16 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
|
||||
}
|
||||
/// The pointer target around a compass end: larger than the drawn disc, so it is easy to hit.
|
||||
pub(crate) const AXIS_RADIUS: f32 = 15.0;
|
||||
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
|
||||
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens) {
|
||||
axis_tip_scaled(painter, c, name, d, at, depth, hovered, t, 1.0);
|
||||
}
|
||||
|
||||
/// The same compass artwork at a uniform scale, including its lettering and strokes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
|
||||
let col = if d.x != 0.0 {
|
||||
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
|
||||
let col = if d[0] != 0.0 {
|
||||
t.axis_x
|
||||
} else if d.y != 0.0 {
|
||||
} else if d[1] != 0.0 {
|
||||
t.axis_y
|
||||
} else {
|
||||
t.axis_z
|
||||
@@ -196,7 +196,7 @@ pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &
|
||||
// On a hovered (white) disc the letter takes the axis colour.
|
||||
let letter = if hovered { col } else { t.on_accent };
|
||||
let label = name.to_uppercase();
|
||||
let galley = painter.layout_no_wrap(label, crate::fonts::bold(crate::theme::Tokens::get().font_compass * scale), letter);
|
||||
let galley = painter.layout_no_wrap(label, crate::ui_kit::type_face::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
|
||||
let origin = at - galley.size() / 2.0;
|
||||
let ppp = painter.ctx().pixels_per_point().max(0.5);
|
||||
let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp);
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
|
||||
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
|
||||
pub(crate) fn agent() -> Color32 {
|
||||
crate::theme::Tokens::get().icons.agent
|
||||
solidcraft_theme::Tokens::get().icons.agent
|
||||
}
|
||||
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
|
||||
let ic = crate::theme::Tokens::get().icons;
|
||||
let ic = solidcraft_theme::Tokens::get().icons;
|
||||
// An arrow pointer, tip at `p`.
|
||||
let pts: Vec<Pos2> =
|
||||
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
|
||||
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(crate::theme::Tokens::get().font_small), ic.highlight);
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(solidcraft_theme::Tokens::get().font_small), ic.highlight);
|
||||
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
|
||||
painter.rect_filled(r, 4.0, ic.agent);
|
||||
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"ar": {
|
||||
"material.editor.remove": "إزالة"
|
||||
},
|
||||
"bg": {
|
||||
"material.editor.remove": "Remove"
|
||||
},
|
||||
"bs": {
|
||||
"material.editor.remove": "Ukloni"
|
||||
},
|
||||
"ca": {
|
||||
"material.editor.remove": "Eliminar"
|
||||
},
|
||||
"cs": {
|
||||
"material.editor.remove": "Odebrat"
|
||||
},
|
||||
"da": {
|
||||
"material.editor.remove": "Fjern"
|
||||
},
|
||||
"de": {
|
||||
"material.editor.remove": "Entfernen"
|
||||
},
|
||||
"en": {
|
||||
"material.editor.remove": "Remove"
|
||||
},
|
||||
"es": {
|
||||
"material.editor.remove": "Eliminar"
|
||||
},
|
||||
"fi": {
|
||||
"material.editor.remove": "Poista"
|
||||
},
|
||||
"fr": {
|
||||
"material.editor.remove": "Retirer"
|
||||
},
|
||||
"gl": {
|
||||
"material.editor.remove": "Quitar"
|
||||
},
|
||||
"he": {
|
||||
"material.editor.remove": "הסרה"
|
||||
},
|
||||
"hr": {
|
||||
"material.editor.remove": "Ukloni"
|
||||
},
|
||||
"hu": {
|
||||
"material.editor.remove": "Eltávolítás"
|
||||
},
|
||||
"id": {
|
||||
"material.editor.remove": "Hapus"
|
||||
},
|
||||
"it": {
|
||||
"material.editor.remove": "Rimuovi"
|
||||
},
|
||||
"ja": {
|
||||
"material.editor.remove": "削除"
|
||||
},
|
||||
"ka": {
|
||||
"material.editor.remove": "წაშლა"
|
||||
},
|
||||
"ms": {
|
||||
"material.editor.remove": "Buang"
|
||||
},
|
||||
"nb": {
|
||||
"material.editor.remove": "Fjern"
|
||||
},
|
||||
"nl": {
|
||||
"material.editor.remove": "Verwijderen"
|
||||
},
|
||||
"pl": {
|
||||
"material.editor.remove": "Usuń"
|
||||
},
|
||||
"pt-BR": {
|
||||
"material.editor.remove": "Remover"
|
||||
},
|
||||
"ro": {
|
||||
"material.editor.remove": "Elimină"
|
||||
},
|
||||
"ru": {
|
||||
"material.editor.remove": "Убрать"
|
||||
},
|
||||
"sr": {
|
||||
"material.editor.remove": "Уклони"
|
||||
},
|
||||
"sv": {
|
||||
"material.editor.remove": "Ta bort"
|
||||
},
|
||||
"tr": {
|
||||
"material.editor.remove": "Kaldır"
|
||||
},
|
||||
"uk": {
|
||||
"material.editor.remove": "Вилучити"
|
||||
},
|
||||
"vi": {
|
||||
"material.editor.remove": "Xóa"
|
||||
},
|
||||
"zh-TW": {
|
||||
"material.editor.remove": "刪除"
|
||||
},
|
||||
"zh": {
|
||||
"material.editor.remove": "删除"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "solidcraft-color"
|
||||
description = "Colour rules and data any crate may share: the sRGB transfer function and the surface analysis palette"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,20 @@
|
||||
# solidcraft-color
|
||||
|
||||
Colour rules and data that any crate may share. It has no dependencies and holds no app types,
|
||||
so the renderer, the document, the importers and the UI theme all use the same rule and the same
|
||||
colours instead of keeping copies.
|
||||
|
||||
The sRGB transfer function (IEC 61966-2-1) lives here once: every encode and decode in Rust goes
|
||||
through `srgb`, so a change to the rule is one change. Shaders can't call Rust and keep their own
|
||||
copies.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it is |
|
||||
|---|---|
|
||||
| `srgb::encode(linear)` | Linear light (0..1) to an sRGB value (0..1) |
|
||||
| `srgb::decode(encoded)` | An sRGB value (0..1) to linear light |
|
||||
| `srgb::encode8(linear)` | Linear light to the nearest 8-bit sRGB code (out of range clamps, NaN reads as 0) |
|
||||
| `srgb::decode8(code)` | An 8-bit sRGB code to linear light |
|
||||
| `AnalysisColors` | The surface analysis palette (draft, accessibility, curvature, hatch), straight sRGB 0..1 as the shader takes it; the theme fills it and the renderer reads it |
|
||||
| `AnalysisColors::DEFAULT` | The light theme's colours with no colour vision mode, as headless pictures use |
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The colours a surface analysis is shaded in.
|
||||
|
||||
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnalysisColors {
|
||||
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
|
||||
pub draft: [[f32; 3]; 3],
|
||||
/// Accessibility Analysis: reachable, blocked.
|
||||
pub access: [[f32; 3]; 2],
|
||||
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
|
||||
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
|
||||
/// reads with hue lost too (#975).
|
||||
pub curvature: [[f32; 3]; 3],
|
||||
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
|
||||
pub hatch: bool,
|
||||
}
|
||||
|
||||
impl AnalysisColors {
|
||||
/// The light theme's colours (no colour vision mode): what headless pictures use.
|
||||
pub const DEFAULT: AnalysisColors = AnalysisColors {
|
||||
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
|
||||
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
|
||||
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
|
||||
hatch: false,
|
||||
};
|
||||
}
|
||||
|
||||
impl Default for AnalysisColors {
|
||||
fn default() -> Self {
|
||||
AnalysisColors::DEFAULT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Colour rules and data shared by every crate: the sRGB transfer function ([`srgb`]) and the surface analysis palette
|
||||
//! ([`AnalysisColors`]). No app types and no dependencies, so the renderer, the document and the theme all read the same
|
||||
//! rule and the same colours.
|
||||
|
||||
mod analysis;
|
||||
pub mod srgb;
|
||||
|
||||
pub use analysis::AnalysisColors;
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "solidcraft-control-egui"
|
||||
description = "The window's own control requests any egui app answers alike: ui.screenshot and ui.resize"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcraft-control = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The window's own requests (`ui.screenshot`, `ui.resize`) go through egui.
|
||||
egui = { workspace = true }
|
||||
# Screenshots are saved in the format their path names.
|
||||
image = { workspace = true }
|
||||
@@ -0,0 +1,14 @@
|
||||
# solidcraft-control-egui
|
||||
|
||||
The window's own control requests (docs/control-protocol.md), which any egui app answers the same
|
||||
way: `ui.screenshot` and `ui.resize`. The token files, the loopback server and the request types
|
||||
are `solidcraft-control`, which has no UI toolkit, so a headless engine, CLI or MCP bridge uses the
|
||||
control channel without compiling egui. No app types and no engine.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it does |
|
||||
|---|---|
|
||||
| `screenshot::Shots` | `ui.screenshot`: frames asked for, taken when the view settles, saved and answered, or refused when hidden, cancelled or late; the app gives the reply for no frame |
|
||||
| `screenshot::encode` | A frame in the format its path names |
|
||||
| `window::resize`, `window::hidden` | `ui.resize` over `egui::Context`; whether the window presents frames |
|
||||
@@ -0,0 +1,8 @@
|
||||
//! The window's own control requests (docs/control-protocol.md) any app answers alike over egui:
|
||||
//! `ui.screenshot` ([`screenshot::Shots`]) and `ui.resize` ([`window::resize`]). The token, the
|
||||
//! server and the requests themselves are `solidcraft-control`, which needs no UI toolkit.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod screenshot;
|
||||
pub mod window;
|
||||
@@ -0,0 +1,288 @@
|
||||
//! `ui.screenshot`: a picture of the window as it is presented, for a control request. The
|
||||
//! window asks egui for a frame a few frames after the request (a view that just moved settles
|
||||
//! first), saves it where the request says and answers; a hidden window, a request whose agent
|
||||
//! chat turn ended, or no frame within 8 s is answered at once with why.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use egui::ColorImage;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_control::{cancelled_chat_response, chat_turn_cancelled};
|
||||
|
||||
use crate::window::hidden;
|
||||
|
||||
/// A frame asked for: its token, the time (ms) from which it may be taken, and the frames drawn
|
||||
/// since it was asked for.
|
||||
pub type Queued = (u64, f64, u32);
|
||||
|
||||
/// A request waiting for its frame: its token, where to save it, where to answer, and the time
|
||||
/// (ms) after which no frame is coming.
|
||||
pub type Pending = (u64, Option<String>, Sender<Value>, f64);
|
||||
|
||||
/// A frame egui handed back, by its token.
|
||||
pub type Shot = (u64, Arc<ColorImage>);
|
||||
|
||||
/// The window's screenshots in flight. The app may queue frames of its own (an issue report's)
|
||||
/// with tokens from [`Shots::token`]; [`Shots::collect`] hands those back.
|
||||
pub struct Shots {
|
||||
/// The error a request gets when the window presents no frame: the app's words, which may
|
||||
/// name its other ways to a picture.
|
||||
no_frame: String,
|
||||
/// The last token given out.
|
||||
pub token: u64,
|
||||
/// Frames asked for and not yet requested from egui.
|
||||
pub queued: Vec<Queued>,
|
||||
/// Control requests waiting for their frame.
|
||||
pub pending: Vec<Pending>,
|
||||
/// The agent chat turn of each pending request that belongs to one.
|
||||
turns: HashMap<u64, Arc<AtomicBool>>,
|
||||
/// Frames egui hands back; egui calls back, [`Shots::collect`] reads them.
|
||||
tx: Sender<Shot>,
|
||||
rx: Receiver<Shot>,
|
||||
}
|
||||
|
||||
impl Shots {
|
||||
/// No screenshots in flight; a request the window presents no frame for is answered with the
|
||||
/// error `no_frame`.
|
||||
pub fn new(no_frame: impl Into<String>) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
Shots { no_frame: no_frame.into(), token: 0, queued: Vec::new(), pending: Vec::new(), turns: HashMap::new(), tx, rx }
|
||||
}
|
||||
|
||||
/// Take the window's picture for a request that answers on `reply`, saved at `path`; `turn`:
|
||||
/// the agent chat turn it belongs to. `now` is in ms. A window that is minimised or covered
|
||||
/// (another macOS Space) presents no frame: that is said at once rather than after the
|
||||
/// deadline (#1677).
|
||||
pub fn request(&mut self, ctx: &egui::Context, path: Option<String>, reply: Sender<Value>, turn: Option<Arc<AtomicBool>>, now: f64) {
|
||||
if hidden(ctx) {
|
||||
let _ = reply.send(json!({"ok": false, "hidden": true, "error": self.no_frame}));
|
||||
return;
|
||||
}
|
||||
self.token += 1;
|
||||
let token = self.token;
|
||||
if let Some(turn) = turn {
|
||||
self.turns.insert(token, turn);
|
||||
}
|
||||
self.queued.push((token, now + 150.0, 0));
|
||||
self.pending.push((token, path, reply, now + 8000.0));
|
||||
}
|
||||
|
||||
/// Each frame: ask egui for the frames that are due, three frames after their request at the
|
||||
/// earliest. `settled`: the view is drawn at full quality; a view that keeps moving is taken
|
||||
/// as it is half a second later (#7165).
|
||||
pub fn issue(&mut self, ctx: &egui::Context, settled: bool, now: f64) {
|
||||
let shot_tx = &self.tx;
|
||||
self.queued.retain_mut(|(token, at, frames)| {
|
||||
*frames += 1;
|
||||
if now >= *at && *frames >= 3 && (settled || now >= *at + 500.0) {
|
||||
let (token, tx, ctx2) = (*token, shot_tx.clone(), ctx.clone());
|
||||
ctx.request_screenshot(move |image| {
|
||||
let _ = tx.send((token, image));
|
||||
ctx2.request_repaint();
|
||||
});
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
if !self.queued.is_empty() || !self.pending.is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// Each frame: answer the requests whose frame came, saving it with `save` (the reply), and
|
||||
/// those whose turn ended or that wait in vain. `others`: the app waits for frames of its own;
|
||||
/// then every frame that came this frame is handed back, for the app to take its own from.
|
||||
pub fn collect(&mut self, ctx: &egui::Context, now: f64, others: bool, save: impl Fn(&ColorImage, Option<&str>) -> Value) -> Vec<Shot> {
|
||||
// A screenshot requested in one tab must never return or save a frame of another.
|
||||
let turns = &mut self.turns;
|
||||
let queued = &mut self.queued;
|
||||
self.pending.retain(|(token, _, reply, _)| {
|
||||
if chat_turn_cancelled(turns.get(token).map(Arc::as_ref)) {
|
||||
turns.remove(token);
|
||||
queued.retain(|(waiting, ..)| waiting != token);
|
||||
let _ = reply.send(cancelled_chat_response());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
// Drained every frame: a shot that comes after its request was cancelled or timed out
|
||||
// is dropped here instead of waiting in the channel.
|
||||
let shots: Vec<Shot> = self.rx.try_iter().collect();
|
||||
if self.pending.is_empty() && !others {
|
||||
return Vec::new();
|
||||
}
|
||||
for (token, image) in &shots {
|
||||
if let Some(i) = self.pending.iter().position(|(t, ..)| t == token) {
|
||||
let (_, path, reply, _) = self.pending.remove(i);
|
||||
self.turns.remove(token);
|
||||
let _ = reply.send(save(image, path.as_deref()));
|
||||
}
|
||||
}
|
||||
// Hidden while waiting: no frame is coming.
|
||||
let hidden = hidden(ctx);
|
||||
let (turns, no_frame) = (&mut self.turns, &self.no_frame);
|
||||
self.pending.retain(|(token, _, reply, deadline)| {
|
||||
if now < *deadline && !hidden {
|
||||
return true;
|
||||
}
|
||||
let _ = reply.send(json!({"ok": false, "error": no_frame}));
|
||||
turns.remove(token);
|
||||
false
|
||||
});
|
||||
shots
|
||||
}
|
||||
}
|
||||
|
||||
/// `image` in the format `path`'s extension names (a PNG for `.png`).
|
||||
pub fn encode(image: &ColorImage, path: &str) -> Result<Vec<u8>, String> {
|
||||
let [w, h] = image.size;
|
||||
let mut buf = Vec::with_capacity(w * h * 4);
|
||||
for c in &image.pixels {
|
||||
buf.extend_from_slice(&c.to_array());
|
||||
}
|
||||
let img = image::RgbaImage::from_raw(w as u32, h as u32, buf).ok_or("bad image")?;
|
||||
let format = image::ImageFormat::from_path(path).map_err(|e| e.to_string())?;
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut bytes, format).map_err(|e| e.to_string())?;
|
||||
Ok(bytes.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The app's reply for no frame.
|
||||
const NO_FRAME: &str = "no frame here";
|
||||
|
||||
fn pixel_image() -> Arc<ColorImage> {
|
||||
Arc::new(ColorImage::new([4, 3], vec![egui::Color32::from_rgb(10, 20, 30); 12]))
|
||||
}
|
||||
|
||||
/// A request is answered with its frame once egui hands it back, saved as the app saves it;
|
||||
/// a frame of the app's own is handed back to it; a request whose frame never comes is
|
||||
/// answered after its deadline.
|
||||
#[test]
|
||||
fn a_request_is_answered_with_its_frame_or_its_deadline() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, answer) = channel();
|
||||
shots.request(&ctx, Some("a.png".into()), reply, None, 0.0);
|
||||
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (1, 1, 1));
|
||||
// The frame egui hands back, and one the app asked for itself.
|
||||
shots.queued.clear();
|
||||
let _ = shots.tx.send((1, pixel_image()));
|
||||
let _ = shots.tx.send((2, pixel_image()));
|
||||
let save = |image: &ColorImage, path: Option<&str>| json!({"ok": true, "result": {"path": path, "width": image.size[0]}});
|
||||
let back = shots.collect(&ctx, 1.0, true, save);
|
||||
assert_eq!(back.iter().map(|(t, _)| *t).collect::<Vec<_>>(), [1, 2]);
|
||||
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": true, "result": {"path": "a.png", "width": 4}})));
|
||||
assert!(shots.pending.is_empty());
|
||||
|
||||
let (reply, late) = channel();
|
||||
shots.request(&ctx, None, reply, None, 0.0);
|
||||
assert!(shots.collect(&ctx, 7999.0, false, save).is_empty());
|
||||
assert!(late.try_recv().is_err(), "still waiting");
|
||||
shots.collect(&ctx, 8000.0, false, save);
|
||||
assert_eq!(late.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
|
||||
}
|
||||
|
||||
/// A request whose agent chat turn ended is answered so, and its frame is never taken.
|
||||
#[test]
|
||||
fn a_request_whose_turn_ended_is_refused() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, answer) = channel();
|
||||
let turn = Arc::new(AtomicBool::new(true));
|
||||
shots.request(&ctx, None, reply, Some(Arc::clone(&turn)), 0.0);
|
||||
turn.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
shots.collect(&ctx, 1.0, false, |_, _| Value::Null);
|
||||
assert_eq!(answer.try_recv().ok(), Some(cancelled_chat_response()));
|
||||
assert!(shots.queued.is_empty() && shots.pending.is_empty());
|
||||
}
|
||||
|
||||
/// One frame of a window that is minimised (`hidden`) or shown, running `f`.
|
||||
fn frame(ctx: &egui::Context, hidden: bool, f: impl FnMut(&egui::Context)) {
|
||||
let mut raw = egui::RawInput::default();
|
||||
let info = egui::ViewportInfo { minimized: Some(hidden), occluded: Some(false), ..Default::default() };
|
||||
raw.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
// No painter takes the frame's textures here.
|
||||
ctx.run_pass(raw, f).textures_delta.clear();
|
||||
}
|
||||
|
||||
/// A frame is asked of egui 150 ms after its request at the earliest and on its third frame;
|
||||
/// a view that keeps moving is taken as it is 500 ms after that.
|
||||
#[test]
|
||||
fn a_frame_is_taken_once_the_view_settles() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, _answer) = channel();
|
||||
shots.request(&ctx, None, reply.clone(), None, 0.0);
|
||||
shots.issue(&ctx, true, 200.0);
|
||||
shots.issue(&ctx, true, 200.0);
|
||||
assert_eq!(shots.queued.len(), 1, "two frames drawn");
|
||||
shots.issue(&ctx, true, 200.0);
|
||||
assert!(shots.queued.is_empty(), "the third frame is taken");
|
||||
|
||||
shots.request(&ctx, None, reply.clone(), None, 0.0);
|
||||
for _ in 0..3 {
|
||||
shots.issue(&ctx, true, 149.0);
|
||||
}
|
||||
assert_eq!(shots.queued.len(), 1, "too soon");
|
||||
shots.issue(&ctx, true, 150.0);
|
||||
assert!(shots.queued.is_empty());
|
||||
|
||||
shots.request(&ctx, None, reply, None, 0.0);
|
||||
for _ in 0..3 {
|
||||
shots.issue(&ctx, false, 649.0);
|
||||
}
|
||||
assert_eq!(shots.queued.len(), 1, "still moving");
|
||||
shots.issue(&ctx, false, 650.0);
|
||||
assert!(shots.queued.is_empty(), "taken as it is");
|
||||
assert_eq!(shots.pending.len(), 3, "every request still waits for its frame");
|
||||
}
|
||||
|
||||
/// A hidden window refuses a request at once, and answers one that waits when it hides.
|
||||
#[test]
|
||||
fn a_hidden_window_answers_at_once() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, answer) = channel();
|
||||
frame(&ctx, true, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
|
||||
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "hidden": true, "error": NO_FRAME})));
|
||||
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (0, 0, 0));
|
||||
|
||||
frame(&ctx, false, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
|
||||
frame(&ctx, false, |ctx| {
|
||||
shots.collect(ctx, 1.0, false, |_, _| Value::Null);
|
||||
});
|
||||
assert!(answer.try_recv().is_err(), "shown: still waiting");
|
||||
frame(&ctx, true, |ctx| {
|
||||
shots.collect(ctx, 2.0, false, |_, _| Value::Null);
|
||||
});
|
||||
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
|
||||
assert!(shots.pending.is_empty());
|
||||
}
|
||||
|
||||
/// With nothing waiting, the frames that came are dropped, not kept for a later request.
|
||||
#[test]
|
||||
fn frames_nobody_waits_for_are_dropped() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let _ = shots.tx.send((5, pixel_image()));
|
||||
assert!(shots.collect(&ctx, 0.0, false, |_, _| Value::Null).is_empty());
|
||||
assert!(shots.collect(&ctx, 1.0, true, |_, _| Value::Null).is_empty(), "the frame was dropped");
|
||||
}
|
||||
|
||||
/// The saved bytes are the format the path names.
|
||||
#[test]
|
||||
fn a_png_path_gets_a_png() {
|
||||
let bytes = encode(&pixel_image(), "shot.png").unwrap();
|
||||
assert!(bytes.starts_with(b"\x89PNG"), "{:?}", bytes.get(..8));
|
||||
assert!(encode(&pixel_image(), "shot.nope").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! The window's own control requests over `egui::Context`, which any app answers the same way.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// The window is minimised or fully covered (another macOS Space), so it presents no frames
|
||||
/// (#1677). Unknown counts as shown: the screenshot deadline still ends a wait for a frame that
|
||||
/// never comes.
|
||||
pub fn hidden(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().visible()) == Some(false)
|
||||
}
|
||||
|
||||
/// `ui.resize {width, height}`: the window's inner size in points, kept within 320×240 and
|
||||
/// 8192×8192.
|
||||
pub fn resize(ctx: &egui::Context, params: &Value) -> Result<Value, String> {
|
||||
let f = |k: &str| params.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
|
||||
let (Some(w), Some(h)) = (f("width"), f("height")) else { return Err("missing width/height".into()) };
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "solidcraft-control"
|
||||
description = "The control channel any app may share: token files, the loopback server, and its requests; no UI toolkit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# The loopback server is for desktop apps; the web app is driven through the page instead.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# The per-launch control token: 32 random bytes from the operating system (MIT OR Apache-2.0).
|
||||
getrandom = "0.3"
|
||||
@@ -0,0 +1,23 @@
|
||||
# solidcraft-control
|
||||
|
||||
The control channel any app may share (docs/control-protocol.md). An app that listens for
|
||||
control makes a random token per launch, writes it to a file only the user can read and refuses
|
||||
every connection whose first request doesn't carry it; a client finds the token the same way.
|
||||
Which app the files belong to, its environment, its name in messages and its default port come
|
||||
from an `App` value the app defines. No app types and no engine.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it does |
|
||||
|---|---|
|
||||
| `auth::App` | An app's identity: `name`, `data_dir`, `env` (its prefixed variables), `utc`, `default_port` |
|
||||
| `App::token_path(port)`, `App::current_port_path()` | `<data folder>/control/<port>.token` and the discovery file (`CONTROL_DIR` replaces the folder) |
|
||||
| `App::write_token_file(path, token)` | Write a token readable only by the user, with its `<port>.pid` file beside it |
|
||||
| `App::write_current_port`, `App::read_current_port`, `App::remove_current_port` | The discovery file of the most recently started app |
|
||||
| `App::env_token()`, `App::client_token(port, explicit)` | The token a client sends: explicit, `CONTROL_TOKEN`, else the token file |
|
||||
| `auth::ClientToken::explain_error(error)` | A refusal with the token file named and whether it is stale; never the token |
|
||||
| `auth::plan`, `auth::Listen`, `auth::bind_with_fallback`, `auth::PORT_TRIES` | Where a launch listens; a loopback listener on the first free port |
|
||||
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
|
||||
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
|
||||
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
|
||||
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
|
||||
@@ -0,0 +1,665 @@
|
||||
//! The control channel's per-launch token (docs/control-protocol.md). An app listening for
|
||||
//! control makes a random token, writes it to a file only the user can read
|
||||
//! ([`App::token_path`]) and refuses every connection whose first request doesn't carry it, so
|
||||
//! other users on the same machine (loopback is shared, on Windows across remote-desktop sessions
|
||||
//! too) can't drive the app. Clients find it with [`App::client_token`]. Next to it, `<port>.pid`
|
||||
//! names the process that wrote it, so a client refused with a leftover token can say the file is
|
||||
//! stale.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Error, ErrorKind, Read, Result, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Shortest token accepted from the app's `CONTROL_TOKEN` variable (hex characters).
|
||||
pub const MIN_TOKEN_LEN: usize = 32;
|
||||
|
||||
/// Ports tried from the app's default port upward when it is taken (a second window, another
|
||||
/// program).
|
||||
pub const PORT_TRIES: u16 = 16;
|
||||
|
||||
/// Which app a control channel belongs to: where its token files live, its environment and its
|
||||
/// name in messages.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct App {
|
||||
/// The app's name in messages ("restart SolidCraft").
|
||||
pub name: &'static str,
|
||||
/// The per-user data folder; the token files go in its `control` folder.
|
||||
pub data_dir: fn() -> Option<PathBuf>,
|
||||
/// The app's environment variable `name` (`CONTROL_DIR`, `CONTROL_TOKEN`), with the app's prefix.
|
||||
pub env: fn(&str) -> Option<OsString>,
|
||||
/// A time in seconds since the Unix epoch, as the app writes UTC times in messages.
|
||||
pub utc: fn(u64) -> String,
|
||||
/// The port the control channel tries first when the launch gives none. Below the ephemeral
|
||||
/// ranges of Linux (32768+) and Windows/macOS (49152+), so the system never hands it to an
|
||||
/// outgoing connection.
|
||||
pub default_port: u16,
|
||||
}
|
||||
|
||||
/// Where the control channel listens, from the launch's `--control`/environment port and the app's
|
||||
/// allow-agents setting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Listen {
|
||||
/// Exactly this port (`--control PORT`, the app's `CONTROL_PORT`); no fallback.
|
||||
Exact(u16),
|
||||
/// The app's [`App::default_port`], or the next free one of [`PORT_TRIES`].
|
||||
Default,
|
||||
}
|
||||
|
||||
/// What a launch listens on. An explicit port wins over the app's allow-agents setting
|
||||
/// (`enabled`), so a scripted launch works with the setting off; with the setting off and no port,
|
||||
/// nothing listens.
|
||||
pub fn plan(explicit: Option<u16>, enabled: bool) -> Option<Listen> {
|
||||
match explicit {
|
||||
Some(port) => Some(Listen::Exact(port)),
|
||||
None if enabled => Some(Listen::Default),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a loopback-only listener on `first`, or on the next free port when it is taken, up to
|
||||
/// `tries` ports in all.
|
||||
pub fn bind_with_fallback(first: u16, tries: u16) -> Result<std::net::TcpListener> {
|
||||
let mut last = Error::new(ErrorKind::AddrInUse, "no port to try");
|
||||
for port in (first..=u16::MAX).take(usize::from(tries.max(1))) {
|
||||
match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => return Ok(l),
|
||||
// In use; Windows reports a port another program holds exclusively as denied.
|
||||
Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::PermissionDenied) => last = e,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The folder of the token files: `<data folder>/control` (the app's `CONTROL_DIR` replaces it).
|
||||
fn control_dir(&self) -> Option<PathBuf> {
|
||||
match self.custom_control_dir() {
|
||||
Some(d) => Some(PathBuf::from(d)),
|
||||
None => Some((self.data_dir)()?.join("control")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder the app's `CONTROL_DIR` names in place of the default one.
|
||||
fn custom_control_dir(&self) -> Option<OsString> {
|
||||
(self.env)("CONTROL_DIR").filter(|d| !d.is_empty())
|
||||
}
|
||||
|
||||
/// The token file of the app listening on `port`: `<data folder>/control/<port>.token`
|
||||
/// (the app's `CONTROL_DIR` replaces the folder).
|
||||
pub fn token_path(&self, port: u16) -> Option<PathBuf> {
|
||||
Some(self.control_dir()?.join(format!("{port}.token")))
|
||||
}
|
||||
|
||||
/// The discovery file: the port of the most recently started app, so a client with no port
|
||||
/// finds it. It holds only the port; the token stays in its own file.
|
||||
pub fn current_port_path(&self) -> Option<PathBuf> {
|
||||
Some(self.control_dir()?.join("current.port"))
|
||||
}
|
||||
|
||||
/// Record `port` as the running app's, readable only by the user like the token files.
|
||||
pub fn write_current_port(&self, port: u16) -> Result<PathBuf> {
|
||||
let path = self.current_port_path().ok_or_else(|| Error::new(ErrorKind::NotFound, "no data folder"))?;
|
||||
self.write_token_file(&path, &port.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The port the discovery file names, if there is one.
|
||||
pub fn read_current_port(&self) -> Option<u16> {
|
||||
parse_port(&std::fs::read_to_string(self.current_port_path()?).ok().filter(|t| t.len() <= 64)?)
|
||||
}
|
||||
|
||||
/// Remove the discovery file (and its process file) at exit, unless a later app has taken it
|
||||
/// over: it names another port, or another launch now holds this port's token file. Under
|
||||
/// Flatpak every instance has its own loopback, so two windows can listen on the same port
|
||||
/// number and write the same file names; the one closing first must leave the other's (#8794).
|
||||
/// Run it after [`remove_token_file`], which takes this launch's own token file away first.
|
||||
pub fn remove_current_port(&self, port: u16, token: &str) {
|
||||
if let (Some(current), Some(file)) = (self.current_port_path(), self.token_path(port)) {
|
||||
remove_discovery(¤t, &file, port, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// A token set on purpose with the app's `CONTROL_TOKEN` (test harnesses, sandboxed packages
|
||||
/// whose data folder the client can't see), if it is long enough.
|
||||
pub fn env_token(&self) -> Option<String> {
|
||||
(self.env)("CONTROL_TOKEN").and_then(|v| v.into_string().ok()).map(|t| t.trim().to_string()).filter(|t| t.len() >= MIN_TOKEN_LEN)
|
||||
}
|
||||
|
||||
/// The token a client sends to the app on `port`: `explicit` (`--token`), else the app's
|
||||
/// `CONTROL_TOKEN`, else the app's token file.
|
||||
pub fn client_token(&self, port: u16, explicit: Option<&str>) -> Option<ClientToken> {
|
||||
if let Some(t) = explicit.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
return Some(ClientToken { token: t.to_string(), file: None, app: *self });
|
||||
}
|
||||
if let Some(t) = self.env_token() {
|
||||
return Some(ClientToken { token: t, file: None, app: *self });
|
||||
}
|
||||
// Read directly, not through any agent's file scope: the app's own file.
|
||||
read_token_file(&self.token_path(port)?, *self)
|
||||
}
|
||||
|
||||
/// Write `token` to `path`, readable and writable only by the user: the folder is made `0700`
|
||||
/// and the file `0600` on Unix; on Windows the file's inherited permissions are replaced by one
|
||||
/// entry for the user (`icacls`) before the token goes in; when that fails, the still empty file
|
||||
/// is removed and no token is written. A file left by an earlier launch is replaced. The process
|
||||
/// file beside it ([`pid_path`]) records this process, best effort.
|
||||
pub fn write_token_file(&self, path: &Path, token: &str) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
let restrict = |p: &Path| restrict_to_user(p, self.custom_control_dir().is_some());
|
||||
#[cfg(not(windows))]
|
||||
let restrict = |_: &Path| Ok(());
|
||||
write_token_file_with(path, token, restrict)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_port(text: &str) -> Option<u16> {
|
||||
text.trim().parse::<u16>().ok().filter(|p| *p != 0)
|
||||
}
|
||||
|
||||
/// [`App::remove_current_port`] on given files: `current` is the discovery file, `file` the token
|
||||
/// file of `port`.
|
||||
fn remove_discovery(current: &Path, file: &Path, port: u16, token: &str) {
|
||||
let named = std::fs::read_to_string(current).ok().filter(|t| t.len() <= 64).and_then(|t| parse_port(&t));
|
||||
if named != Some(port) || held_by_another(file, token) {
|
||||
return;
|
||||
}
|
||||
let _ = std::fs::remove_file(current);
|
||||
let _ = std::fs::remove_file(pid_path(current));
|
||||
}
|
||||
|
||||
/// A client token and, when it came from a token file, where that file is.
|
||||
pub struct ClientToken {
|
||||
pub token: String,
|
||||
pub file: Option<TokenSource>,
|
||||
/// The app it is for, named in an explanation.
|
||||
pub app: App,
|
||||
}
|
||||
|
||||
/// The token file a client read: its path, when it was written and the process that wrote it.
|
||||
pub struct TokenSource {
|
||||
pub path: PathBuf,
|
||||
pub modified: Option<SystemTime>,
|
||||
/// From `<port>.pid`: the writer's process id and, on Linux, its start time.
|
||||
pub writer: Option<(u32, Option<u64>)>,
|
||||
}
|
||||
|
||||
impl ClientToken {
|
||||
/// The app's `error`, and for a refused token read from a file, which file it was, when it
|
||||
/// was written and whether the instance that wrote it has exited. Never the token itself.
|
||||
pub fn explain_error(&self, error: &str) -> String {
|
||||
let Some(src) = &self.file else { return error.to_string() };
|
||||
if !error.starts_with("unauthorized") {
|
||||
return error.to_string();
|
||||
}
|
||||
let written = src
|
||||
.modified
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|t| (self.app.utc)(t.as_secs()))
|
||||
.unwrap_or_else(|| "at an unknown time".into());
|
||||
let age = src.modified.and_then(|t| t.elapsed().ok()).map(|d| format!(" ({} s ago)", d.as_secs())).unwrap_or_default();
|
||||
let path = src.path.display();
|
||||
let name = self.app.name;
|
||||
match src.writer.filter(|(pid, start)| writer_exited(*pid, *start)) {
|
||||
Some((pid, _)) => format!(
|
||||
"{error}; stale token file from an instance that exited (process {pid}): {path}, written {written}{age}; delete that file or restart {name}"
|
||||
),
|
||||
None => format!("{error}; token from {path}, written {written}{age}; if {name} was force-quit, restart it or delete that file"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files bigger than this are not token files.
|
||||
const MAX_TOKEN_FILE: u64 = 4096;
|
||||
|
||||
fn read_token_file(path: &Path, app: App) -> Option<ClientToken> {
|
||||
let f = std::fs::File::open(path).ok()?;
|
||||
let modified = f.metadata().ok().and_then(|m| m.modified().ok());
|
||||
let mut text = String::new();
|
||||
f.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
|
||||
if text.len() as u64 > MAX_TOKEN_FILE {
|
||||
return None;
|
||||
}
|
||||
let token = text.trim();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let writer = read_pid_file(&pid_path(&path));
|
||||
Some(ClientToken { token: token.to_string(), file: Some(TokenSource { path, modified, writer }), app })
|
||||
}
|
||||
|
||||
/// The process file beside the token file `path`: `7878.token` → `7878.pid`.
|
||||
pub fn pid_path(path: &Path) -> PathBuf {
|
||||
path.with_extension("pid")
|
||||
}
|
||||
|
||||
/// `<pid> [<start time>]`; anything else (or no file) is no information.
|
||||
fn read_pid_file(path: &Path) -> Option<(u32, Option<u64>)> {
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(256).read_to_string(&mut text).ok()?;
|
||||
let mut fields = text.split_whitespace();
|
||||
let pid = fields.next()?.parse::<u32>().ok().filter(|&pid| pid > 0)?;
|
||||
Some((pid, fields.next().and_then(|s| s.parse().ok())))
|
||||
}
|
||||
|
||||
/// Whether the process that wrote a token file is known to be gone: not running, or its id now
|
||||
/// belongs to a process that started at another time. Unknown counts as running.
|
||||
fn writer_exited(pid: u32, start: Option<u64>) -> bool {
|
||||
process_running(pid) == Some(false) || start.zip(process_start(pid)).is_some_and(|(saved, now)| saved != now)
|
||||
}
|
||||
|
||||
/// Whether process `pid` runs; `None` when that can't be found out.
|
||||
fn process_running(pid: u32) -> Option<bool> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if !Path::new("/proc/self").exists() {
|
||||
return None;
|
||||
}
|
||||
match std::fs::metadata(format!("/proc/{pid}")) {
|
||||
Ok(_) => Some(true),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Some(false),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
{
|
||||
let output = std::process::Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output().ok()?;
|
||||
if output.status.success() {
|
||||
return Some(true);
|
||||
}
|
||||
(output.status.code() == Some(1) && output.stdout.iter().all(u8::is_ascii_whitespace)).then_some(false)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
let output = std::process::Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
|
||||
.creation_flags(0x0800_0000)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() || output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// `"image","pid",…` per process; with no match, a message line without a pid.
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
Some(text.lines().any(|line| line.split("\",\"").nth(1).and_then(|s| s.trim_matches('"').parse::<u32>().ok()) == Some(pid)))
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = pid;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux: the process's start time (clock ticks after boot), which tells a reused id apart.
|
||||
fn process_start(pid: u32) -> Option<u64> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut stat = String::new();
|
||||
std::fs::File::open(format!("/proc/{pid}/stat")).ok()?.take(4096).read_to_string(&mut stat).ok()?;
|
||||
stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = pid;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `a == b`, taking the same time wherever they differ.
|
||||
pub fn tokens_match(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// [`App::write_token_file`], with `restrict` limiting the new file to the user (Windows).
|
||||
fn write_token_file_with(path: &Path, token: &str, restrict: impl FnOnce(&Path) -> Result<()>) -> Result<()> {
|
||||
let dir = path.parent().ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no folder"))?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let opened = opts.open(path)?;
|
||||
// On a refusal the file goes with the closure, so it is closed before it is removed.
|
||||
let mut f = match restrict(path).map(|()| opened) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(path);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
f.write_all(token.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
f.sync_all()?;
|
||||
let pid = std::process::id();
|
||||
let line = process_start(pid).map_or_else(|| format!("{pid}\n"), |start| format!("{pid} {start}\n"));
|
||||
let _ = std::fs::write(pid_path(path), line);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a token file this launch wrote, and its process file, while it still holds `token`.
|
||||
/// Another launch may have written the same file since (a second window on the same port,
|
||||
/// #8794); its file stays. The process id can't tell the two apart, as every Flatpak instance
|
||||
/// has the same one, but each launch's token is its own.
|
||||
pub fn remove_token_file(path: &Path, token: &str) {
|
||||
if read_token(path).is_some_and(|held| tokens_match(&held, token.trim())) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(pid_path(path));
|
||||
}
|
||||
}
|
||||
|
||||
/// The token a token file holds, trimmed; `None` when there is no readable token file.
|
||||
fn read_token(path: &Path) -> Option<String> {
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
|
||||
let token = text.trim();
|
||||
(text.len() as u64 <= MAX_TOKEN_FILE && !token.is_empty()).then(|| token.to_string())
|
||||
}
|
||||
|
||||
/// Whether the token file at `path` holds a token other than `token`: another launch's.
|
||||
fn held_by_another(path: &Path, token: &str) -> bool {
|
||||
read_token(path).is_some_and(|held| !tokens_match(&held, token.trim()))
|
||||
}
|
||||
|
||||
/// How long `icacls` may take (#6145). It finishes at once; on some computers the new process
|
||||
/// is left suspended (security software holding it), and waiting on it without a limit froze
|
||||
/// the app.
|
||||
#[cfg(windows)]
|
||||
const ICACLS_LIMIT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// How a run of `icacls` ended.
|
||||
#[cfg(any(windows, test))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum IcaclsEnd {
|
||||
Succeeded,
|
||||
Failed,
|
||||
/// It didn't finish within [`ICACLS_LIMIT`] and was ended.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Whether a token may go into a file after `icacls` ended this way. `custom_folder`: the file is
|
||||
/// in the folder the app's `CONTROL_DIR` names. A timeout is accepted only in the default folder,
|
||||
/// the user's own local app data, which other users can't read by default; nothing is known about
|
||||
/// the permissions of any other folder.
|
||||
#[cfg(any(windows, test))]
|
||||
fn restriction_result(end: IcaclsEnd, custom_folder: bool, path: &Path) -> Result<()> {
|
||||
match end {
|
||||
IcaclsEnd::Succeeded => Ok(()),
|
||||
IcaclsEnd::TimedOut if !custom_folder => Ok(()),
|
||||
IcaclsEnd::TimedOut => Err(Error::new(ErrorKind::TimedOut, format!("icacls did not finish in time to restrict {}", path.display()))),
|
||||
IcaclsEnd::Failed => Err(Error::other(format!("icacls failed to restrict {}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: drop inherited entries and grant only the current user full access. An `icacls`
|
||||
/// that doesn't finish in time is ended, and [`restriction_result`] decides what that means.
|
||||
#[cfg(windows)]
|
||||
fn restrict_to_user(path: &Path, custom_folder: bool) -> Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let user = std::env::var("USERNAME").map_err(|_| Error::other("USERNAME is not set"))?;
|
||||
let domain = std::env::var("USERDOMAIN").ok().filter(|d| !d.is_empty());
|
||||
let who = domain.map_or(user.clone(), |d| format!("{d}\\{user}"));
|
||||
let child = std::process::Command::new("icacls")
|
||||
.arg(path)
|
||||
.args(["/inheritance:r", "/grant:r", &format!("{who}:F")])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.creation_flags(0x0800_0000)
|
||||
.spawn()?;
|
||||
let end = match wait_within(child, ICACLS_LIMIT)? {
|
||||
Some(status) if status.success() => IcaclsEnd::Succeeded,
|
||||
Some(_) => IcaclsEnd::Failed,
|
||||
None => IcaclsEnd::TimedOut,
|
||||
};
|
||||
restriction_result(end, custom_folder, path)
|
||||
}
|
||||
|
||||
/// Wait for `child` at most `limit`: its exit status, or `None` (and the child ended) when it
|
||||
/// hasn't finished by then.
|
||||
#[cfg(any(windows, test))]
|
||||
fn wait_within(mut child: std::process::Child, limit: std::time::Duration) -> Result<Option<std::process::ExitStatus>> {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(Some(status));
|
||||
}
|
||||
if start.elapsed() >= limit {
|
||||
// Not waited for afterwards: a suspended process may not end at once.
|
||||
let _ = child.kill();
|
||||
return Ok(None);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the token file (path, token) when the app ends, while it still holds that token.
|
||||
pub struct TokenFile(pub PathBuf, pub String);
|
||||
|
||||
impl Drop for TokenFile {
|
||||
fn drop(&mut self) {
|
||||
remove_token_file(&self.0, &self.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An app with no data folder and no environment, whose times read `t<seconds>`.
|
||||
const TEST: App = App { name: "TestCraft", data_dir: || None, env: |_| None, utc: |secs| format!("t{secs}"), default_port: 27878 };
|
||||
|
||||
/// #6145: a child that doesn't finish in time is given up on, not waited for forever.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_slow_child_is_waited_for_only_so_long() {
|
||||
use std::time::{Duration, Instant};
|
||||
let start = Instant::now();
|
||||
let slow = std::process::Command::new("sleep").arg("30").spawn().unwrap();
|
||||
assert!(wait_within(slow, Duration::from_millis(200)).unwrap().is_none());
|
||||
assert!(start.elapsed() < Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
let quick = std::process::Command::new("true").spawn().unwrap();
|
||||
assert!(wait_within(quick, Duration::from_secs(5)).unwrap().is_some_and(|s| s.success()));
|
||||
let failing = std::process::Command::new("false").spawn().unwrap();
|
||||
assert!(wait_within(failing, Duration::from_secs(5)).unwrap().is_some_and(|s| !s.success()));
|
||||
}
|
||||
|
||||
/// #6145's freeze fix stays in the default folder, the user's own; a folder picked with
|
||||
/// the app's `CONTROL_DIR` gets no token unless the file was really restricted.
|
||||
#[test]
|
||||
fn only_the_default_folder_accepts_an_icacls_that_timed_out() {
|
||||
let path = Path::new("control/7878.token");
|
||||
for custom in [false, true] {
|
||||
assert!(restriction_result(IcaclsEnd::Succeeded, custom, path).is_ok());
|
||||
assert!(restriction_result(IcaclsEnd::Failed, custom, path).is_err());
|
||||
}
|
||||
assert!(restriction_result(IcaclsEnd::TimedOut, false, path).is_ok());
|
||||
let e = restriction_result(IcaclsEnd::TimedOut, true, path).unwrap_err();
|
||||
assert_eq!(e.kind(), ErrorKind::TimedOut);
|
||||
assert!(e.to_string().contains("7878.token"), "{e}");
|
||||
}
|
||||
|
||||
/// A file that couldn't be restricted is removed, empty and with no process file beside it, and
|
||||
/// the error goes to the caller. A restricted one gets its token.
|
||||
#[test]
|
||||
fn a_token_file_that_could_not_be_restricted_is_removed() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-restrict-{}", std::process::id()));
|
||||
let path = dir.join("7879.token");
|
||||
let e = write_token_file_with(&path, "secret-token", |p| {
|
||||
assert_eq!(std::fs::read(p).unwrap(), b"", "restricted before the token goes in");
|
||||
restriction_result(IcaclsEnd::TimedOut, true, p)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(e.kind(), ErrorKind::TimedOut);
|
||||
assert!(!path.exists() && !pid_path(&path).exists());
|
||||
write_token_file_with(&path, "secret-token", |p| restriction_result(IcaclsEnd::TimedOut, false, p)).unwrap();
|
||||
assert_eq!(read_token(&path).as_deref(), Some("secret-token"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_compare_whole() {
|
||||
assert!(tokens_match("abc123", "abc123"));
|
||||
assert!(!tokens_match("abc123", "abc124"));
|
||||
assert!(!tokens_match("abc123", "abc12"));
|
||||
assert!(!tokens_match("", "a"));
|
||||
}
|
||||
|
||||
/// The preference starts the listener by default, off it doesn't, and a port given on the
|
||||
/// command line or in the environment wins over it either way.
|
||||
#[test]
|
||||
fn the_preference_decides_unless_a_port_is_given() {
|
||||
assert_eq!(plan(None, true), Some(Listen::Default));
|
||||
assert_eq!(plan(None, false), None);
|
||||
assert_eq!(plan(Some(7878), false), Some(Listen::Exact(7878)));
|
||||
assert_eq!(plan(Some(7878), true), Some(Listen::Exact(7878)));
|
||||
}
|
||||
|
||||
/// A taken port falls through to the next free one, on loopback only; a run of taken ports
|
||||
/// is an error, not a hang.
|
||||
#[test]
|
||||
fn a_taken_port_falls_back_to_the_next_free_one() {
|
||||
let first = bind_with_fallback(TEST.default_port, PORT_TRIES).unwrap();
|
||||
let a = first.local_addr().unwrap();
|
||||
assert!(a.ip().is_loopback() && (TEST.default_port..TEST.default_port + PORT_TRIES).contains(&a.port()), "{a}");
|
||||
let second = bind_with_fallback(a.port(), PORT_TRIES).unwrap();
|
||||
assert!(second.local_addr().unwrap().port() > a.port());
|
||||
assert!(bind_with_fallback(a.port(), 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_discovery_file_holds_a_port() {
|
||||
assert_eq!(parse_port("27879\n"), Some(27879));
|
||||
assert_eq!(parse_port("0"), None);
|
||||
assert_eq!(parse_port("70000"), None);
|
||||
assert_eq!(parse_port("nope"), None);
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-current-port-{}", std::process::id()));
|
||||
let path = dir.join("current.port");
|
||||
TEST.write_token_file(&path, "27881").unwrap();
|
||||
assert_eq!(parse_port(&std::fs::read_to_string(&path).unwrap()), Some(27881));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A one-line token file (and one with no process file) still reads.
|
||||
#[test]
|
||||
fn a_token_file_reads_with_and_without_its_process_file() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-read-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("7879.token");
|
||||
std::fs::write(&path, "legacy-token\n").unwrap();
|
||||
let client = read_token_file(&path, TEST).unwrap();
|
||||
assert_eq!(client.token, "legacy-token");
|
||||
let src = client.file.as_ref().unwrap();
|
||||
assert_eq!(src.path, std::fs::canonicalize(&path).unwrap());
|
||||
assert!(src.modified.is_some() && src.writer.is_none());
|
||||
std::fs::write(pid_path(&path), "not a pid\n").unwrap();
|
||||
assert!(read_token_file(&path, TEST).unwrap().file.unwrap().writer.is_none());
|
||||
std::fs::write(&path, "x".repeat(4097)).unwrap();
|
||||
assert!(read_token_file(&path, TEST).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A refused token's explanation names the app and writes the time as the app does.
|
||||
#[test]
|
||||
fn an_explanation_names_the_app() {
|
||||
let file = Some(TokenSource { path: PathBuf::from("7878.token"), modified: Some(UNIX_EPOCH), writer: None });
|
||||
let client = ClientToken { token: "secret-token".into(), file, app: TEST };
|
||||
let error = client.explain_error("unauthorized");
|
||||
assert!(error.contains("written t0 (") && error.contains("if TestCraft was force-quit"), "{error}");
|
||||
assert!(!error.contains("secret-token"), "{error}");
|
||||
let explicit = TEST.client_token(0, Some("explicit-token")).unwrap();
|
||||
assert!(explicit.token == "explicit-token" && explicit.file.is_none());
|
||||
assert_eq!(explicit.explain_error("unauthorized"), "unauthorized");
|
||||
}
|
||||
|
||||
/// A process file naming a process that has exited marks the token file stale.
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn a_token_file_from_an_exited_process_is_stale() {
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "auth::tests::tokens_compare_whole"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
assert!(child.wait().unwrap().success());
|
||||
let source = |writer| Some(TokenSource { path: PathBuf::from("7878.token"), modified: None, writer });
|
||||
let stale = ClientToken { token: "secret-token".into(), file: source(Some((pid, None))), app: TEST };
|
||||
let error = stale.explain_error("unauthorized");
|
||||
assert!(error.contains(&format!("stale token file from an instance that exited (process {pid}): 7878.token")), "{error}");
|
||||
assert!(!error.contains("secret-token"));
|
||||
let live = ClientToken { token: "t".into(), file: source(Some((std::process::id(), process_start(std::process::id())))), app: TEST };
|
||||
assert!(!live.explain_error("unauthorized").contains("stale"));
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let reused = process_start(std::process::id()).map(|s| s + 1);
|
||||
let reused = ClientToken { token: "t".into(), file: source(Some((std::process::id(), reused))), app: TEST };
|
||||
assert!(reused.explain_error("unauthorized").contains("stale token file"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The token file is the user's alone, and a client reads it back.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_token_file_is_private_to_the_user() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-auth-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("control/7878.token");
|
||||
std::fs::create_dir_all(dir.join("control")).unwrap();
|
||||
std::fs::write(&path, "stale").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
TEST.write_token_file(&path, "0123456789abcdef0123456789abcdef").unwrap();
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&path), 0o600);
|
||||
assert_eq!(mode(path.parent().unwrap()), 0o700);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
|
||||
let writer = read_token_file(&path, TEST).unwrap().file.unwrap().writer;
|
||||
assert_eq!(writer.map(|(pid, _)| pid), Some(std::process::id()));
|
||||
drop(TokenFile(path.clone(), "0123456789abcdef0123456789abcdef".into()));
|
||||
assert!(!path.exists() && !pid_path(&path).exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #8794: under Flatpak two windows can listen on the same port number (each has its own
|
||||
/// loopback) and write the same files. The one closing first leaves the other's token, process
|
||||
/// and discovery files; the last one takes them all away, `current.pid` too.
|
||||
#[test]
|
||||
fn a_closing_launch_leaves_another_launchs_files() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-two-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (file, current) = (dir.join("27878.token"), dir.join("current.port"));
|
||||
let (first, second) = ("a".repeat(64), "b".repeat(64));
|
||||
// The first window, then the second on the same port, each writing both files.
|
||||
for token in [&first, &second] {
|
||||
TEST.write_token_file(&file, token).unwrap();
|
||||
TEST.write_token_file(¤t, "27878").unwrap();
|
||||
}
|
||||
// The first closes: the second's files stay.
|
||||
remove_token_file(&file, &first);
|
||||
remove_discovery(¤t, &file, 27878, &first);
|
||||
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
|
||||
assert!(pid_path(&file).exists() && current.exists() && pid_path(¤t).exists());
|
||||
// Another port named in the discovery file is another app's too.
|
||||
remove_discovery(¤t, &file, 27879, &second);
|
||||
assert!(current.exists());
|
||||
// The second closes: nothing of either is left.
|
||||
remove_token_file(&file, &format!(" {second}\n"));
|
||||
remove_discovery(¤t, &file, 27878, &second);
|
||||
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(¤t).exists());
|
||||
// With no token file left (removed by hand, or never written), the discovery file of the
|
||||
// port still goes; a missing file is no error.
|
||||
TEST.write_token_file(¤t, "27878").unwrap();
|
||||
remove_token_file(&file, &second);
|
||||
remove_discovery(¤t, &file, 27878, &second);
|
||||
assert!(!current.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! What a client is told about a control request that outlasts its wait (docs/control-protocol.md,
|
||||
//! #1618): the app's control server and a bridged client both say what the request still does when
|
||||
//! it finishes. Which requests do what is the app's to say.
|
||||
|
||||
/// What a request still running does when it finishes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// It only reads (the document, the window or the command list): nothing changes.
|
||||
ReadsOnly,
|
||||
/// It writes a file (an export, a save, a render to a path); the document stays as it is.
|
||||
WritesFile,
|
||||
/// It changes the document (or may: anything not known to read or only write a file).
|
||||
Changes,
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
/// What happens when the request finishes, for a message: "it only reads: nothing changes",
|
||||
/// "it was not cancelled and will still write the file when it finishes; the design does not
|
||||
/// change", … `document` is what the app calls its document ("design").
|
||||
pub fn when_done(self, document: &str) -> String {
|
||||
match self {
|
||||
Effect::ReadsOnly => "it only reads: nothing changes".into(),
|
||||
Effect::WritesFile => format!("it was not cancelled and will still write the file when it finishes; the {document} does not change"),
|
||||
Effect::Changes => "it was not cancelled and will still apply when it finishes".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
|
||||
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
|
||||
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
|
||||
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
|
||||
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
|
||||
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod auth;
|
||||
mod effect;
|
||||
mod request;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod server;
|
||||
|
||||
pub use effect::Effect;
|
||||
pub use request::{ControlRequest, ControlResponse, DOCUMENT_WATCH, DocumentWatch, Subscriber, cancelled_chat_response, chat_turn_cancelled};
|
||||
@@ -0,0 +1,118 @@
|
||||
//! A control request as the window gets it, and the design watch subscribed connections wait on.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub type ControlResponse = Value;
|
||||
|
||||
pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: Sender<ControlResponse>,
|
||||
/// Set once the window starts on it; until then it waits its turn (`control.running`, #3654).
|
||||
pub started: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
/// Chat requests already admitted by the gate may outlive a tab switch. This turn's
|
||||
/// validity follows them through queues, computation and cursor animation.
|
||||
pub chat_turn: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None }, rx)
|
||||
}
|
||||
|
||||
/// Answer a request whose agent chat turn has ended, and say so: such a request is not run.
|
||||
pub fn refuse_cancelled_chat(&self) -> bool {
|
||||
if chat_turn_cancelled(self.chat_turn.as_deref()) {
|
||||
let _ = self.reply.send(cancelled_chat_response());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the agent chat turn a request belongs to has ended (`None`: it belongs to none).
|
||||
pub fn chat_turn_cancelled(turn: Option<&std::sync::atomic::AtomicBool>) -> bool {
|
||||
turn.is_some_and(|active| !active.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// The reply to a request whose agent chat turn has ended.
|
||||
pub fn cancelled_chat_response() -> Value {
|
||||
json!({"ok": false, "error": "not run: the agent chat turn ended or its document tab was left"})
|
||||
}
|
||||
|
||||
/// The active design's id and revision for control connections that sent `control.subscribe`
|
||||
/// (#6985): the window publishes them each frame while one is subscribed, and each such
|
||||
/// connection waits for them to move, then sends the app's change event (`Protocol::change_event`).
|
||||
#[derive(Default)]
|
||||
pub struct DocumentWatch {
|
||||
subscribers: AtomicUsize,
|
||||
now: Mutex<Option<(String, u64)>>,
|
||||
moved: Condvar,
|
||||
}
|
||||
|
||||
/// The app's [`DocumentWatch`].
|
||||
pub static DOCUMENT_WATCH: DocumentWatch = DocumentWatch::new();
|
||||
|
||||
impl DocumentWatch {
|
||||
pub const fn new() -> Self {
|
||||
DocumentWatch { subscribers: AtomicUsize::new(0), now: Mutex::new(None), moved: Condvar::new() }
|
||||
}
|
||||
|
||||
fn now(&self) -> MutexGuard<'_, Option<(String, u64)>> {
|
||||
self.now.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The design as last published (`None` before anything was).
|
||||
pub fn current(&self) -> Option<(String, u64)> {
|
||||
self.now().clone()
|
||||
}
|
||||
|
||||
/// The design as it is now; waiters wake only when it moved. Only the latest is kept, so
|
||||
/// changes made faster than a connection sends them go out as one.
|
||||
pub fn publish(&self, document_id: &str, revision: u64) {
|
||||
let mut now = self.now();
|
||||
if now.as_ref().is_some_and(|(id, r)| id == document_id && *r == revision) {
|
||||
return;
|
||||
}
|
||||
*now = Some((document_id.to_string(), revision));
|
||||
self.moved.notify_all();
|
||||
}
|
||||
|
||||
/// Whether a connection is subscribed: the window publishes only then.
|
||||
pub fn subscribed(&self) -> bool {
|
||||
self.subscribers.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
|
||||
/// Count a subscribed connection until the returned guard is dropped.
|
||||
pub fn join(&'static self) -> Subscriber {
|
||||
self.subscribers.fetch_add(1, Ordering::Relaxed);
|
||||
Subscriber(self)
|
||||
}
|
||||
|
||||
/// Wait up to `wait` for the design to differ from `seen`, or for `stop`; the design then
|
||||
/// (`None` before anything was published).
|
||||
pub fn wait(&self, seen: &(String, u64), stop: &AtomicBool, wait: Duration) -> Option<(String, u64)> {
|
||||
let unmoved = |now: &mut Option<(String, u64)>| now.as_ref() == Some(seen) && !stop.load(Ordering::Relaxed);
|
||||
let (now, _) = self.moved.wait_timeout_while(self.now(), wait, unmoved).unwrap_or_else(PoisonError::into_inner);
|
||||
now.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A subscribed connection, counted by its [`DocumentWatch`] while this lives.
|
||||
pub struct Subscriber(&'static DocumentWatch);
|
||||
|
||||
impl Drop for Subscriber {
|
||||
fn drop(&mut self) {
|
||||
self.0.subscribers.fetch_sub(1, Ordering::Relaxed);
|
||||
// Under the lock, so a connection about to wait sees its `stop` set before it does.
|
||||
let _now = self.0.now();
|
||||
self.0.moved.notify_all();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-color = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
|
||||
@@ -154,7 +154,7 @@ impl VisualPreset {
|
||||
match self.recipe {
|
||||
Recipe::Opaque(r) => r.color,
|
||||
// A UI swatch of the transmission values, not rendered glass or absorption.
|
||||
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_geom::srgb::encode8),
|
||||
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_color::srgb::encode8),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ required-features = ["solver-profiling"]
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-color = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
|
||||
@@ -1721,7 +1721,7 @@ fn cpu_saved_thin_sheet_uses_its_authored_ior_beer_thickness_and_roundtrips_offl
|
||||
let a = (-sigma * 2.0).exp();
|
||||
let denominator = 1.0 - f * f * a * a;
|
||||
let scene = f + (1.0 - f).powi(2) * (f * a * a + a) / denominator;
|
||||
solidcraft_geom::srgb::encode8(scene / (1.0 + scene))
|
||||
solidcraft_color::srgb::encode8(scene / (1.0 + scene))
|
||||
});
|
||||
assert_eq!(decoded.get_pixel(0, 0).0, [rgb[0], rgb[1], rgb[2], 255]);
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
|
||||
@@ -401,7 +401,7 @@ pub(super) fn apply_appearance(s: &mut Session, params: &Value) -> Result<Value>
|
||||
let value = item("surfaces", &input.id, command)?;
|
||||
let lo = triple(&value["base_linear_low"], command)?;
|
||||
let hi = triple(&value["base_linear_high"], command)?;
|
||||
let color = std::array::from_fn::<_, 3, _>(|i| crate::geom::srgb::encode8((lo[i] + hi[i]) * 0.5));
|
||||
let color = std::array::from_fn::<_, 3, _>(|i| solidcraft_color::srgb::encode8((lo[i] + hi[i]) * 0.5));
|
||||
(
|
||||
value,
|
||||
LibraryLook {
|
||||
|
||||
@@ -1,642 +1,32 @@
|
||||
//! The control channel's per-launch token (docs/control-protocol.md). `solidcraft --control PORT`
|
||||
//! makes a random token, writes it to a file only the user can read
|
||||
//! ([`token_path`]) and refuses every connection whose first request doesn't carry it, so other
|
||||
//! users on the same machine (loopback is shared, on Windows across remote-desktop sessions too)
|
||||
//! can't drive the app. Clients find it with [`client_token`]. Next to it, `<port>.pid` names
|
||||
//! the process that wrote it, so a client refused with a leftover token can say the file is stale.
|
||||
//! SolidCraft's control channel identity for the shared token files (`solidcraft_control::auth`):
|
||||
//! its name, data folder, `SOLIDCRAFT_` environment and default port (docs/control-protocol.md).
|
||||
|
||||
use std::io::{Error, ErrorKind, Read, Result, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use solidcraft_control::auth::App;
|
||||
|
||||
/// Shortest token accepted from `SOLIDCRAFT_CONTROL_TOKEN` (hex characters).
|
||||
pub const MIN_TOKEN_LEN: usize = 32;
|
||||
|
||||
/// The port the control channel tries first when `--control` and `SOLIDCRAFT_CONTROL_PORT` give none.
|
||||
/// Below the ephemeral ranges of Linux (32768+) and Windows/macOS (49152+), so the system never
|
||||
/// hands it to an outgoing connection.
|
||||
pub const DEFAULT_PORT: u16 = 27878;
|
||||
|
||||
/// Ports tried from [`DEFAULT_PORT`] upward when it is taken (a second window, another program).
|
||||
pub const PORT_TRIES: u16 = 16;
|
||||
|
||||
/// Where the control channel listens, from the launch's `--control`/environment port and the
|
||||
/// "Let AI agents connect" preference.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Listen {
|
||||
/// Exactly this port (`--control PORT`, `SOLIDCRAFT_CONTROL_PORT`); no fallback.
|
||||
Exact(u16),
|
||||
/// [`DEFAULT_PORT`], or the next free one of [`PORT_TRIES`].
|
||||
Default,
|
||||
}
|
||||
|
||||
/// What a launch listens on. An explicit port wins over the preference, so a scripted launch
|
||||
/// works with the setting off; with the setting off and no port, nothing listens.
|
||||
pub fn plan(explicit: Option<u16>, enabled: bool) -> Option<Listen> {
|
||||
match explicit {
|
||||
Some(port) => Some(Listen::Exact(port)),
|
||||
None if enabled => Some(Listen::Default),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a loopback-only listener on `first`, or on the next free port when it is taken, up to
|
||||
/// `tries` ports in all.
|
||||
pub fn bind_with_fallback(first: u16, tries: u16) -> Result<std::net::TcpListener> {
|
||||
let mut last = Error::new(ErrorKind::AddrInUse, "no port to try");
|
||||
for port in (first..=u16::MAX).take(usize::from(tries.max(1))) {
|
||||
match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => return Ok(l),
|
||||
// In use; Windows reports a port another program holds exclusively as denied.
|
||||
Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::PermissionDenied) => last = e,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// The folder of the token files: `<data folder>/control` (`SOLIDCRAFT_CONTROL_DIR` replaces it).
|
||||
fn control_dir() -> Option<PathBuf> {
|
||||
match custom_control_dir() {
|
||||
Some(d) => Some(PathBuf::from(d)),
|
||||
None => Some(crate::recovery::data_dir()?.join("control")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder `SOLIDCRAFT_CONTROL_DIR` names in place of the default one.
|
||||
fn custom_control_dir() -> Option<std::ffi::OsString> {
|
||||
solidcraft_geom::env::var_os("CONTROL_DIR").filter(|d| !d.is_empty())
|
||||
}
|
||||
|
||||
/// The token file of the app listening on `port`: `<data folder>/control/<port>.token`
|
||||
/// (`SOLIDCRAFT_CONTROL_DIR` replaces the folder).
|
||||
pub fn token_path(port: u16) -> Option<PathBuf> {
|
||||
Some(control_dir()?.join(format!("{port}.token")))
|
||||
}
|
||||
|
||||
/// The discovery file: the port of the most recently started app, so a client with no port
|
||||
/// (`solidcraft-cli mcp`) finds it. It holds only the port; the token stays in its own file.
|
||||
pub fn current_port_path() -> Option<PathBuf> {
|
||||
Some(control_dir()?.join("current.port"))
|
||||
}
|
||||
|
||||
/// Record `port` as the running app's, readable only by the user like the token files.
|
||||
pub fn write_current_port(port: u16) -> Result<PathBuf> {
|
||||
let path = current_port_path().ok_or_else(|| Error::new(ErrorKind::NotFound, "no data folder"))?;
|
||||
write_token_file(&path, &port.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The port the discovery file names, if there is one.
|
||||
pub fn read_current_port() -> Option<u16> {
|
||||
parse_port(&std::fs::read_to_string(current_port_path()?).ok().filter(|t| t.len() <= 64)?)
|
||||
}
|
||||
|
||||
fn parse_port(text: &str) -> Option<u16> {
|
||||
text.trim().parse::<u16>().ok().filter(|p| *p != 0)
|
||||
}
|
||||
|
||||
/// Remove the discovery file (and its process file) at exit, unless a later app has taken it
|
||||
/// over: it names another port, or another launch now holds this port's token file. Under
|
||||
/// Flatpak every instance has its own loopback, so two windows can listen on the same port
|
||||
/// number and write the same file names; the one closing first must leave the other's (#8794).
|
||||
/// Run it after [`remove_token_file`], which takes this launch's own token file away first.
|
||||
pub fn remove_current_port(port: u16, token: &str) {
|
||||
if let (Some(current), Some(file)) = (current_port_path(), token_path(port)) {
|
||||
remove_discovery(¤t, &file, port, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`remove_current_port`] on given files: `current` is the discovery file, `file` the token file
|
||||
/// of `port`.
|
||||
fn remove_discovery(current: &Path, file: &Path, port: u16, token: &str) {
|
||||
let named = std::fs::read_to_string(current).ok().filter(|t| t.len() <= 64).and_then(|t| parse_port(&t));
|
||||
if named != Some(port) || held_by_another(file, token) {
|
||||
return;
|
||||
}
|
||||
let _ = std::fs::remove_file(current);
|
||||
let _ = std::fs::remove_file(pid_path(current));
|
||||
}
|
||||
|
||||
/// A token set on purpose with `SOLIDCRAFT_CONTROL_TOKEN` (test harnesses, sandboxed packages
|
||||
/// whose data folder the client can't see), if it is long enough.
|
||||
pub fn env_token() -> Option<String> {
|
||||
solidcraft_geom::env::var("CONTROL_TOKEN").map(|t| t.trim().to_string()).filter(|t| t.len() >= MIN_TOKEN_LEN)
|
||||
}
|
||||
|
||||
/// The token a client sends to the app on `port`: `explicit` (`--token`), else
|
||||
/// `SOLIDCRAFT_CONTROL_TOKEN`, else the app's token file.
|
||||
pub fn client_token(port: u16, explicit: Option<&str>) -> Option<ClientToken> {
|
||||
if let Some(t) = explicit.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
return Some(ClientToken { token: t.to_string(), file: None });
|
||||
}
|
||||
if let Some(t) = env_token() {
|
||||
return Some(ClientToken { token: t, file: None });
|
||||
}
|
||||
// Read directly, not through `vfs`: the app's own file, outside any agent's folders.
|
||||
read_token_file(&token_path(port)?)
|
||||
}
|
||||
|
||||
/// A client token and, when it came from a token file, where that file is.
|
||||
pub struct ClientToken {
|
||||
pub token: String,
|
||||
pub file: Option<TokenSource>,
|
||||
}
|
||||
|
||||
/// The token file a client read: its path, when it was written and the process that wrote it.
|
||||
pub struct TokenSource {
|
||||
pub path: PathBuf,
|
||||
pub modified: Option<SystemTime>,
|
||||
/// From `<port>.pid`: the writer's process id and, on Linux, its start time.
|
||||
pub writer: Option<(u32, Option<u64>)>,
|
||||
}
|
||||
|
||||
impl ClientToken {
|
||||
/// The app's `error`, and for a refused token read from a file, which file it was, when it
|
||||
/// was written and whether the instance that wrote it has exited. Never the token itself.
|
||||
pub fn explain_error(&self, error: &str) -> String {
|
||||
let Some(src) = &self.file else { return error.to_string() };
|
||||
if !error.starts_with("unauthorized") {
|
||||
return error.to_string();
|
||||
}
|
||||
let written = src
|
||||
.modified
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|t| crate::crash::utc(t.as_secs()))
|
||||
.unwrap_or_else(|| "at an unknown time".into());
|
||||
let age = src.modified.and_then(|t| t.elapsed().ok()).map(|d| format!(" ({} s ago)", d.as_secs())).unwrap_or_default();
|
||||
let path = src.path.display();
|
||||
match src.writer.filter(|(pid, start)| writer_exited(*pid, *start)) {
|
||||
Some((pid, _)) => format!(
|
||||
"{error}; stale token file from an instance that exited (process {pid}): {path}, written {written}{age}; delete that file or restart SolidCraft"
|
||||
),
|
||||
None => format!("{error}; token from {path}, written {written}{age}; if SolidCraft was force-quit, restart it or delete that file"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files bigger than this are not token files.
|
||||
const MAX_TOKEN_FILE: u64 = 4096;
|
||||
|
||||
fn read_token_file(path: &Path) -> Option<ClientToken> {
|
||||
let f = std::fs::File::open(path).ok()?;
|
||||
let modified = f.metadata().ok().and_then(|m| m.modified().ok());
|
||||
let mut text = String::new();
|
||||
f.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
|
||||
if text.len() as u64 > MAX_TOKEN_FILE {
|
||||
return None;
|
||||
}
|
||||
let token = text.trim();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let writer = read_pid_file(&pid_path(&path));
|
||||
Some(ClientToken { token: token.to_string(), file: Some(TokenSource { path, modified, writer }) })
|
||||
}
|
||||
|
||||
/// The process file beside the token file `path`: `7878.token` → `7878.pid`.
|
||||
pub fn pid_path(path: &Path) -> PathBuf {
|
||||
path.with_extension("pid")
|
||||
}
|
||||
|
||||
/// `<pid> [<start time>]`; anything else (or no file) is no information.
|
||||
fn read_pid_file(path: &Path) -> Option<(u32, Option<u64>)> {
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(256).read_to_string(&mut text).ok()?;
|
||||
let mut fields = text.split_whitespace();
|
||||
let pid = fields.next()?.parse::<u32>().ok().filter(|&pid| pid > 0)?;
|
||||
Some((pid, fields.next().and_then(|s| s.parse().ok())))
|
||||
}
|
||||
|
||||
/// Whether the process that wrote a token file is known to be gone: not running, or its id now
|
||||
/// belongs to a process that started at another time. Unknown counts as running.
|
||||
fn writer_exited(pid: u32, start: Option<u64>) -> bool {
|
||||
process_running(pid) == Some(false) || start.zip(process_start(pid)).is_some_and(|(saved, now)| saved != now)
|
||||
}
|
||||
|
||||
/// Whether process `pid` runs; `None` when that can't be found out.
|
||||
fn process_running(pid: u32) -> Option<bool> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if !Path::new("/proc/self").exists() {
|
||||
return None;
|
||||
}
|
||||
match std::fs::metadata(format!("/proc/{pid}")) {
|
||||
Ok(_) => Some(true),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Some(false),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
{
|
||||
let output = std::process::Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output().ok()?;
|
||||
if output.status.success() {
|
||||
return Some(true);
|
||||
}
|
||||
(output.status.code() == Some(1) && output.stdout.iter().all(u8::is_ascii_whitespace)).then_some(false)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
let output = std::process::Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
|
||||
.creation_flags(0x0800_0000)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() || output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// `"image","pid",…` per process; with no match, a message line without a pid.
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
Some(text.lines().any(|line| line.split("\",\"").nth(1).and_then(|s| s.trim_matches('"').parse::<u32>().ok()) == Some(pid)))
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = pid;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux: the process's start time (clock ticks after boot), which tells a reused id apart.
|
||||
fn process_start(pid: u32) -> Option<u64> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut stat = String::new();
|
||||
std::fs::File::open(format!("/proc/{pid}/stat")).ok()?.take(4096).read_to_string(&mut stat).ok()?;
|
||||
stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = pid;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `a == b`, taking the same time wherever they differ.
|
||||
pub fn tokens_match(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// Write `token` to `path`, readable and writable only by the user: the folder is made `0700`
|
||||
/// and the file `0600` on Unix; on Windows the file's inherited permissions are replaced by one
|
||||
/// entry for the user (`icacls`) before the token goes in; when that fails, the still empty file
|
||||
/// is removed and no token is written. A file left by an earlier launch is replaced. The process
|
||||
/// file beside it ([`pid_path`]) records this process, best effort.
|
||||
pub fn write_token_file(path: &Path, token: &str) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
let restrict = restrict_to_user;
|
||||
#[cfg(not(windows))]
|
||||
let restrict = |_: &Path| Ok(());
|
||||
write_token_file_with(path, token, restrict)
|
||||
}
|
||||
|
||||
/// [`write_token_file`], with `restrict` limiting the new file to the user (Windows).
|
||||
fn write_token_file_with(path: &Path, token: &str, restrict: impl FnOnce(&Path) -> Result<()>) -> Result<()> {
|
||||
let dir = path.parent().ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no folder"))?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let opened = opts.open(path)?;
|
||||
// On a refusal the file goes with the closure, so it is closed before it is removed.
|
||||
let mut f = match restrict(path).map(|()| opened) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(path);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
f.write_all(token.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
f.sync_all()?;
|
||||
let pid = std::process::id();
|
||||
let line = process_start(pid).map_or_else(|| format!("{pid}\n"), |start| format!("{pid} {start}\n"));
|
||||
let _ = std::fs::write(pid_path(path), line);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a token file this launch wrote, and its process file, while it still holds `token`.
|
||||
/// Another launch may have written the same file since (a second window on the same port,
|
||||
/// #8794); its file stays. The process id can't tell the two apart, as every Flatpak instance
|
||||
/// has the same one, but each launch's token is its own.
|
||||
pub fn remove_token_file(path: &Path, token: &str) {
|
||||
if read_token(path).is_some_and(|held| tokens_match(&held, token.trim())) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(pid_path(path));
|
||||
}
|
||||
}
|
||||
|
||||
/// The token a token file holds, trimmed; `None` when there is no readable token file.
|
||||
fn read_token(path: &Path) -> Option<String> {
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
|
||||
let token = text.trim();
|
||||
(text.len() as u64 <= MAX_TOKEN_FILE && !token.is_empty()).then(|| token.to_string())
|
||||
}
|
||||
|
||||
/// Whether the token file at `path` holds a token other than `token`: another launch's.
|
||||
fn held_by_another(path: &Path, token: &str) -> bool {
|
||||
read_token(path).is_some_and(|held| !tokens_match(&held, token.trim()))
|
||||
}
|
||||
|
||||
/// How long `icacls` may take (#6145). It finishes at once; on some computers the new process
|
||||
/// is left suspended (security software holding it), and waiting on it without a limit froze
|
||||
/// the app.
|
||||
#[cfg(windows)]
|
||||
const ICACLS_LIMIT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// How a run of `icacls` ended.
|
||||
#[cfg(any(windows, test))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum IcaclsEnd {
|
||||
Succeeded,
|
||||
Failed,
|
||||
/// It didn't finish within [`ICACLS_LIMIT`] and was ended.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Whether a token may go into a file after `icacls` ended this way. `custom_folder`: the file is
|
||||
/// in the folder `SOLIDCRAFT_CONTROL_DIR` names. A timeout is accepted only in the default folder,
|
||||
/// the user's own local app data, which other users can't read by default; nothing is known about
|
||||
/// the permissions of any other folder.
|
||||
#[cfg(any(windows, test))]
|
||||
fn restriction_result(end: IcaclsEnd, custom_folder: bool, path: &Path) -> Result<()> {
|
||||
match end {
|
||||
IcaclsEnd::Succeeded => Ok(()),
|
||||
IcaclsEnd::TimedOut if !custom_folder => Ok(()),
|
||||
IcaclsEnd::TimedOut => Err(Error::new(ErrorKind::TimedOut, format!("icacls did not finish in time to restrict {}", path.display()))),
|
||||
IcaclsEnd::Failed => Err(Error::other(format!("icacls failed to restrict {}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: drop inherited entries and grant only the current user full access. An `icacls`
|
||||
/// that doesn't finish in time is ended, and [`restriction_result`] decides what that means.
|
||||
#[cfg(windows)]
|
||||
fn restrict_to_user(path: &Path) -> Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let user = std::env::var("USERNAME").map_err(|_| Error::other("USERNAME is not set"))?;
|
||||
let domain = std::env::var("USERDOMAIN").ok().filter(|d| !d.is_empty());
|
||||
let who = domain.map_or(user.clone(), |d| format!("{d}\\{user}"));
|
||||
let child = std::process::Command::new("icacls")
|
||||
.arg(path)
|
||||
.args(["/inheritance:r", "/grant:r", &format!("{who}:F")])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.creation_flags(0x0800_0000)
|
||||
.spawn()?;
|
||||
let end = match wait_within(child, ICACLS_LIMIT)? {
|
||||
Some(status) if status.success() => IcaclsEnd::Succeeded,
|
||||
Some(_) => IcaclsEnd::Failed,
|
||||
None => IcaclsEnd::TimedOut,
|
||||
};
|
||||
restriction_result(end, custom_control_dir().is_some(), path)
|
||||
}
|
||||
|
||||
/// Wait for `child` at most `limit`: its exit status, or `None` (and the child ended) when it
|
||||
/// hasn't finished by then.
|
||||
#[cfg(any(windows, test))]
|
||||
fn wait_within(mut child: std::process::Child, limit: std::time::Duration) -> Result<Option<std::process::ExitStatus>> {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(Some(status));
|
||||
}
|
||||
if start.elapsed() >= limit {
|
||||
// Not waited for afterwards: a suspended process may not end at once.
|
||||
let _ = child.kill();
|
||||
return Ok(None);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the token file (path, token) when the app ends, while it still holds that token.
|
||||
pub struct TokenFile(pub PathBuf, pub String);
|
||||
|
||||
impl Drop for TokenFile {
|
||||
fn drop(&mut self) {
|
||||
remove_token_file(&self.0, &self.1);
|
||||
}
|
||||
}
|
||||
/// SolidCraft's control channel: token files in `<data folder>/control` (`SOLIDCRAFT_CONTROL_DIR`
|
||||
/// replaces it), `SOLIDCRAFT_CONTROL_TOKEN`, and 27878 first when the launch names no port.
|
||||
pub const APP: App =
|
||||
App { name: "SolidCraft", data_dir: crate::recovery::data_dir, env: solidcraft_geom::env::var_os, utc: crate::crash::utc, default_port: 27878 };
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #6145: a child that doesn't finish in time is given up on, not waited for forever.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_slow_child_is_waited_for_only_so_long() {
|
||||
use std::time::{Duration, Instant};
|
||||
let start = Instant::now();
|
||||
let slow = std::process::Command::new("sleep").arg("30").spawn().unwrap();
|
||||
assert!(wait_within(slow, Duration::from_millis(200)).unwrap().is_none());
|
||||
assert!(start.elapsed() < Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
let quick = std::process::Command::new("true").spawn().unwrap();
|
||||
assert!(wait_within(quick, Duration::from_secs(5)).unwrap().is_some_and(|s| s.success()));
|
||||
let failing = std::process::Command::new("false").spawn().unwrap();
|
||||
assert!(wait_within(failing, Duration::from_secs(5)).unwrap().is_some_and(|s| !s.success()));
|
||||
}
|
||||
|
||||
/// #6145's freeze fix stays in the default folder, the user's own; a folder picked with
|
||||
/// `SOLIDCRAFT_CONTROL_DIR` gets no token unless the file was really restricted.
|
||||
#[test]
|
||||
fn only_the_default_folder_accepts_an_icacls_that_timed_out() {
|
||||
let path = Path::new("control/7878.token");
|
||||
for custom in [false, true] {
|
||||
assert!(restriction_result(IcaclsEnd::Succeeded, custom, path).is_ok());
|
||||
assert!(restriction_result(IcaclsEnd::Failed, custom, path).is_err());
|
||||
}
|
||||
assert!(restriction_result(IcaclsEnd::TimedOut, false, path).is_ok());
|
||||
let e = restriction_result(IcaclsEnd::TimedOut, true, path).unwrap_err();
|
||||
assert_eq!(e.kind(), ErrorKind::TimedOut);
|
||||
assert!(e.to_string().contains("7878.token"), "{e}");
|
||||
}
|
||||
|
||||
/// A file that couldn't be restricted is removed, empty and with no process file beside it, and
|
||||
/// the error goes to the caller. A restricted one gets its token.
|
||||
#[test]
|
||||
fn a_token_file_that_could_not_be_restricted_is_removed() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-restrict-{}", std::process::id()));
|
||||
let path = dir.join("7879.token");
|
||||
let e = write_token_file_with(&path, "secret-token", |p| {
|
||||
assert_eq!(std::fs::read(p).unwrap(), b"", "restricted before the token goes in");
|
||||
restriction_result(IcaclsEnd::TimedOut, true, p)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(e.kind(), ErrorKind::TimedOut);
|
||||
assert!(!path.exists() && !pid_path(&path).exists());
|
||||
write_token_file_with(&path, "secret-token", |p| restriction_result(IcaclsEnd::TimedOut, false, p)).unwrap();
|
||||
assert_eq!(read_token(&path).as_deref(), Some("secret-token"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_compare_whole() {
|
||||
assert!(tokens_match("abc123", "abc123"));
|
||||
assert!(!tokens_match("abc123", "abc124"));
|
||||
assert!(!tokens_match("abc123", "abc12"));
|
||||
assert!(!tokens_match("", "a"));
|
||||
}
|
||||
|
||||
/// The preference starts the listener by default, off it doesn't, and a port given on the
|
||||
/// command line or in the environment wins over it either way.
|
||||
#[test]
|
||||
fn the_preference_decides_unless_a_port_is_given() {
|
||||
assert_eq!(plan(None, true), Some(Listen::Default));
|
||||
assert_eq!(plan(None, false), None);
|
||||
assert_eq!(plan(Some(7878), false), Some(Listen::Exact(7878)));
|
||||
assert_eq!(plan(Some(7878), true), Some(Listen::Exact(7878)));
|
||||
}
|
||||
|
||||
/// A taken port falls through to the next free one, on loopback only; a run of taken ports
|
||||
/// is an error, not a hang.
|
||||
#[test]
|
||||
fn a_taken_port_falls_back_to_the_next_free_one() {
|
||||
let first = bind_with_fallback(DEFAULT_PORT, PORT_TRIES).unwrap();
|
||||
let a = first.local_addr().unwrap();
|
||||
assert!(a.ip().is_loopback() && (DEFAULT_PORT..DEFAULT_PORT + PORT_TRIES).contains(&a.port()), "{a}");
|
||||
let second = bind_with_fallback(a.port(), PORT_TRIES).unwrap();
|
||||
assert!(second.local_addr().unwrap().port() > a.port());
|
||||
assert!(bind_with_fallback(a.port(), 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_discovery_file_holds_a_port() {
|
||||
assert_eq!(parse_port("27879\n"), Some(27879));
|
||||
assert_eq!(parse_port("0"), None);
|
||||
assert_eq!(parse_port("70000"), None);
|
||||
assert_eq!(parse_port("nope"), None);
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-current-port-{}", std::process::id()));
|
||||
let path = dir.join("current.port");
|
||||
write_token_file(&path, "27881").unwrap();
|
||||
assert_eq!(parse_port(&std::fs::read_to_string(&path).unwrap()), Some(27881));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A one-line token file (and one with no process file) still reads.
|
||||
#[test]
|
||||
fn a_token_file_reads_with_and_without_its_process_file() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-read-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("7879.token");
|
||||
std::fs::write(&path, "legacy-token\n").unwrap();
|
||||
let client = read_token_file(&path).unwrap();
|
||||
assert_eq!(client.token, "legacy-token");
|
||||
let src = client.file.as_ref().unwrap();
|
||||
assert_eq!(src.path, std::fs::canonicalize(&path).unwrap());
|
||||
assert!(src.modified.is_some() && src.writer.is_none());
|
||||
std::fs::write(pid_path(&path), "not a pid\n").unwrap();
|
||||
assert!(read_token_file(&path).unwrap().file.unwrap().writer.is_none());
|
||||
std::fs::write(&path, "x".repeat(4097)).unwrap();
|
||||
assert!(read_token_file(&path).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
use solidcraft_control::auth::{ClientToken, TokenSource};
|
||||
use std::path::PathBuf;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
/// A refused token names its file and when it was written, never the token.
|
||||
#[test]
|
||||
fn unauthorized_error_names_the_token_file() {
|
||||
let path = PathBuf::from("control/7878.token");
|
||||
let modified = Some(UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000));
|
||||
let client = ClientToken { token: "secret-token".into(), file: Some(TokenSource { path: path.clone(), modified, writer: None }) };
|
||||
let client = ClientToken { token: "secret-token".into(), file: Some(TokenSource { path: path.clone(), modified, writer: None }), app: APP };
|
||||
let error = client.explain_error("unauthorized: send the control token");
|
||||
assert!(error.contains(&format!("token from {}, written 2023-11-14T22:13:20Z (", path.display())), "{error}");
|
||||
assert!(error.contains("restart it or delete that file") && !error.contains("secret-token"), "{error}");
|
||||
assert_eq!(client.explain_error("unknown method"), "unknown method");
|
||||
let explicit = client_token(0, Some("explicit-token")).unwrap();
|
||||
let explicit = APP.client_token(0, Some("explicit-token")).unwrap();
|
||||
assert!(explicit.token == "explicit-token" && explicit.file.is_none());
|
||||
assert_eq!(explicit.explain_error("unauthorized"), "unauthorized");
|
||||
}
|
||||
|
||||
/// A process file naming a process that has exited marks the token file stale.
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn a_token_file_from_an_exited_process_is_stale() {
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "control_auth::tests::tokens_compare_whole"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
assert!(child.wait().unwrap().success());
|
||||
let source = |writer| Some(TokenSource { path: PathBuf::from("7878.token"), modified: None, writer });
|
||||
let stale = ClientToken { token: "secret-token".into(), file: source(Some((pid, None))) };
|
||||
let error = stale.explain_error("unauthorized");
|
||||
assert!(error.contains(&format!("stale token file from an instance that exited (process {pid}): 7878.token")), "{error}");
|
||||
assert!(!error.contains("secret-token"));
|
||||
let live = ClientToken { token: "t".into(), file: source(Some((std::process::id(), process_start(std::process::id())))) };
|
||||
assert!(!live.explain_error("unauthorized").contains("stale"));
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let reused = process_start(std::process::id()).map(|s| s + 1);
|
||||
let reused = ClientToken { token: "t".into(), file: source(Some((std::process::id(), reused))) };
|
||||
assert!(reused.explain_error("unauthorized").contains("stale token file"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The token file is the user's alone, and a client reads it back.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_token_file_is_private_to_the_user() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-auth-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("control/7878.token");
|
||||
std::fs::create_dir_all(dir.join("control")).unwrap();
|
||||
std::fs::write(&path, "stale").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
write_token_file(&path, "0123456789abcdef0123456789abcdef").unwrap();
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&path), 0o600);
|
||||
assert_eq!(mode(path.parent().unwrap()), 0o700);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
|
||||
let writer = read_token_file(&path).unwrap().file.unwrap().writer;
|
||||
assert_eq!(writer.map(|(pid, _)| pid), Some(std::process::id()));
|
||||
drop(TokenFile(path.clone(), "0123456789abcdef0123456789abcdef".into()));
|
||||
assert!(!path.exists() && !pid_path(&path).exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #8794: under Flatpak two windows can listen on the same port number (each has its own
|
||||
/// loopback) and write the same files. The one closing first leaves the other's token, process
|
||||
/// and discovery files; the last one takes them all away, `current.pid` too.
|
||||
#[test]
|
||||
fn a_closing_launch_leaves_another_launchs_files() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-two-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (file, current) = (dir.join("27878.token"), dir.join("current.port"));
|
||||
let (first, second) = ("a".repeat(64), "b".repeat(64));
|
||||
// The first window, then the second on the same port, each writing both files.
|
||||
for token in [&first, &second] {
|
||||
write_token_file(&file, token).unwrap();
|
||||
write_token_file(¤t, "27878").unwrap();
|
||||
}
|
||||
// The first closes: the second's files stay.
|
||||
remove_token_file(&file, &first);
|
||||
remove_discovery(¤t, &file, 27878, &first);
|
||||
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
|
||||
assert!(pid_path(&file).exists() && current.exists() && pid_path(¤t).exists());
|
||||
// Another port named in the discovery file is another app's too.
|
||||
remove_discovery(¤t, &file, 27879, &second);
|
||||
assert!(current.exists());
|
||||
// The second closes: nothing of either is left.
|
||||
remove_token_file(&file, &format!(" {second}\n"));
|
||||
remove_discovery(¤t, &file, 27878, &second);
|
||||
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(¤t).exists());
|
||||
// With no token file left (removed by hand, or never written), the discovery file of the
|
||||
// port still goes; a missing file is no error.
|
||||
write_token_file(¤t, "27878").unwrap();
|
||||
remove_token_file(&file, &second);
|
||||
remove_discovery(¤t, &file, 27878, &second);
|
||||
assert!(!current.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
//! What a client is told about a control request that outlasts its wait (docs/control-protocol.md,
|
||||
//! #1618): the app's control server and the bridged MCP server both say what the request still
|
||||
//! does when it finishes: nothing (it only reads), write a file (an export or a save), or change
|
||||
//! the design.
|
||||
//! What SolidCraft's requests do when they outlast their wait (docs/control-protocol.md, #1618), as
|
||||
//! a [`solidcraft_control::Effect`]: nothing (it only reads), write a file (an export or a save),
|
||||
//! or change the design. The app's control server and the bridged MCP server both say it.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// What a request still running does when it finishes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// It only reads (the design, the window or the command list): nothing changes.
|
||||
ReadsOnly,
|
||||
/// It writes a file (an export, a save, a render to a path); the design stays as it is.
|
||||
WritesFile,
|
||||
/// It changes the design (or may: anything not known to read or only write a file).
|
||||
Changes,
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
/// What happens when the request finishes, for a message: "it only reads: nothing changes",
|
||||
/// "it was not cancelled and will still write the file when it finishes", …
|
||||
pub fn when_done(self) -> &'static str {
|
||||
match self {
|
||||
Effect::ReadsOnly => "it only reads: nothing changes",
|
||||
Effect::WritesFile => "it was not cancelled and will still write the file when it finishes; the design does not change",
|
||||
Effect::Changes => "it was not cancelled and will still apply when it finishes",
|
||||
}
|
||||
}
|
||||
}
|
||||
use solidcraft_control::Effect;
|
||||
|
||||
/// Control methods that only read: a timed-out one changes nothing when it finishes.
|
||||
/// (`ui.screenshot` with a `path`, and `ui.render`, write a file: see [`effect`].)
|
||||
@@ -163,8 +140,10 @@ mod tests {
|
||||
assert_eq!(effect(m, &p), Effect::WritesFile, "{m} {p}");
|
||||
assert!(!reads_only(m, &p), "{m} {p}");
|
||||
}
|
||||
assert!(Effect::WritesFile.when_done().contains("will still write the file") && !Effect::WritesFile.when_done().contains("apply"));
|
||||
assert!(Effect::Changes.when_done().contains("will still apply"));
|
||||
assert!(Effect::ReadsOnly.when_done().contains("nothing changes"));
|
||||
assert!(
|
||||
Effect::WritesFile.when_done("design").contains("will still write the file") && !Effect::WritesFile.when_done("design").contains("apply")
|
||||
);
|
||||
assert!(Effect::Changes.when_done("design").contains("will still apply"));
|
||||
assert!(Effect::ReadsOnly.when_done("design").contains("nothing changes"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ use solidcraft_geom::prelude::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use solidcore_record::{Plane, Vec3};
|
||||
use solidcraft_color::AnalysisColors;
|
||||
use solidcraft_geom::Aabb3;
|
||||
use solidcraft_render::{AnalysisColors, Camera, Rgb, Scene, SceneAnalysis, SceneLine, SceneMesh};
|
||||
use solidcraft_render::{Camera, Rgb, Scene, SceneAnalysis, SceneLine, SceneMesh};
|
||||
use solidcraft_sketch::Sketch;
|
||||
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
@@ -222,7 +222,7 @@ const MARK: Palette = Palette {
|
||||
cap: Rgb(55, 56, 57),
|
||||
hatch: [Rgb(61, 62, 63), Rgb(64, 65, 66)],
|
||||
isocurve: Rgb(58, 59, 60),
|
||||
analysis: solidcraft_render::AnalysisColors {
|
||||
analysis: solidcraft_color::AnalysisColors {
|
||||
draft: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]],
|
||||
access: [[0.11, 0.12, 0.13], [0.14, 0.15, 0.16]],
|
||||
curvature: [[0.17, 0.18, 0.19], [0.21, 0.22, 0.23], [0.24, 0.25, 0.26]],
|
||||
@@ -253,7 +253,7 @@ fn the_default_palette_is_the_colour_table() {
|
||||
(colors::SKETCH_PROJECTED, colors::SKETCH_LOST, colors::SKETCH_CONSTRUCTION, colors::SKETCH_CENTERLINE)
|
||||
);
|
||||
assert_eq!((p.axes, p.cap, p.isocurve), ([colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], colors::SECTION_CAP, colors::ISOCURVE));
|
||||
assert_eq!(p.analysis, solidcraft_render::AnalysisColors::DEFAULT);
|
||||
assert_eq!(p.analysis, solidcraft_color::AnalysisColors::DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -40,7 +40,6 @@ mod profile;
|
||||
pub mod section;
|
||||
pub mod simplify;
|
||||
pub mod spline_space;
|
||||
pub mod srgb;
|
||||
pub mod stderr;
|
||||
pub mod tess;
|
||||
pub mod title_block;
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
robust = "1.2"
|
||||
solidcraft-color = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-render = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
|
||||
@@ -4528,7 +4528,7 @@ fn uniform_bytes_placing(cb: &Frame, w: f32, h: f32, linear: bool, reversed: boo
|
||||
/// The shader's analysis colours (`ana_c`: draft positive, between, negative; reachable,
|
||||
/// blocked; curvature flat, middle, tight; alpha 1) and flags (`ana_f.x`: 1 to stripe the bad
|
||||
/// faces).
|
||||
pub fn analysis_colors(c: &solidcraft_render::AnalysisColors) -> [f32; 36] {
|
||||
pub fn analysis_colors(c: &solidcraft_color::AnalysisColors) -> [f32; 36] {
|
||||
let mut v = [0.0; 36];
|
||||
let colors = c.draft.iter().chain(&c.access).chain(&c.curvature).flat_map(|rgb| [rgb[0], rgb[1], rgb[2], 1.0]);
|
||||
for (slot, x) in v.iter_mut().zip(colors) {
|
||||
|
||||
@@ -301,7 +301,7 @@ fn pictures_show_the_surface_analysis() {
|
||||
s.execute("inspect.curvature_map", &json!({})).unwrap();
|
||||
let curvature = picture(&s);
|
||||
let near = |p: [u8; 4], c: [u8; 3]| (0..3).all(|k| (i32::from(p[k]) - i32::from(c[k])).abs() < 60);
|
||||
let flat = solidcraft_engine::render::AnalysisColors::DEFAULT.curvature[0].map(|v| (v * 255.0).round() as u8);
|
||||
let flat = solidcraft_color::AnalysisColors::DEFAULT.curvature[0].map(|v| (v * 255.0).round() as u8);
|
||||
let flats = |img: &[u8]| pixels(img).filter(|p| near(*p, flat)).count();
|
||||
// The flat colour is dark, so the plain picture's edge lines come near it in places.
|
||||
assert!(
|
||||
@@ -317,7 +317,7 @@ fn pictures_show_the_surface_analysis() {
|
||||
assert!(black > 500 && white > 500, "zebra: {black} black, {white} white pixels");
|
||||
s.execute("inspect.accessibility", &json!({})).unwrap();
|
||||
let access = picture(&s);
|
||||
let [ok, blocked] = colors_of(solidcraft_engine::render::AnalysisColors::DEFAULT.access);
|
||||
let [ok, blocked] = colors_of(solidcraft_color::AnalysisColors::DEFAULT.access);
|
||||
let (reachable, under) = (pixels(&access).filter(|p| near(*p, ok)).count(), pixels(&access).filter(|p| near(*p, blocked)).count());
|
||||
assert!(reachable > 1000 && under > 1000, "accessibility: {reachable} reachable, {under} blocked pixels");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ trust-unknown-source = []
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-color = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
solidcraft-doc = { workspace = true }
|
||||
|
||||
@@ -842,7 +842,7 @@ impl<'d> Reader<'d, '_, '_> {
|
||||
|
||||
/// A linear base colour channel as an 8-bit sRGB code; one that isn't a number reads as 0.8.
|
||||
fn srgb(c: f32) -> u8 {
|
||||
solidcraft_geom::srgb::encode8(if c.is_finite() { f64::from(c) } else { 0.8 })
|
||||
solidcraft_color::srgb::encode8(if c.is_finite() { f64::from(c) } else { 0.8 })
|
||||
}
|
||||
|
||||
/// A material's look: base colour, alpha when blended, metallic/roughness when opaque.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "solidcraft-mcp-server"
|
||||
description = "MCP server machinery any app may share: JSON-RPC 2.0 over stdio, the MCP lifecycle, tool and resource methods, tool schemas and argument checks, image results"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,31 @@
|
||||
# solidcraft-mcp-server
|
||||
|
||||
The machinery of a Model Context Protocol server: JSON-RPC 2.0 over stdio (one message per
|
||||
line), the MCP lifecycle (`initialize`, `ping`, `notifications/initialized`), the tools and
|
||||
resources methods, tool definitions with their annotations, the check of a call's arguments
|
||||
against the schema it was listed with, and the content blocks a tool replies with (text, images).
|
||||
It holds no app types and depends on no engine or renderer, so any app can serve its own tools
|
||||
through it.
|
||||
|
||||
An app implements `Tools`: its instructions, its tool definitions as they are listed now, how a
|
||||
tool runs, and its resources. `Server` speaks the protocol around them. A `tools/call` lists the
|
||||
tools, checks the arguments against that listing (an unknown tool, a missing or unknown argument,
|
||||
a type or enum mismatch is a JSON-RPC invalid-params error and the tool never runs), then calls
|
||||
the tool. Hostile input is answered, never fatal: a line longer than `Info::max_line` is refused
|
||||
in bounded space, bad UTF-8 and bad JSON are parse errors, and serving goes on.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it does |
|
||||
|---|---|
|
||||
| `Tools` | An app's tools: `instructions`, `list`, `call`, and optionally `resources`, `read_resource` |
|
||||
| `Server::new(info, tools)` | A server over `tools`; `serve(input, output)` until input closes, `handle_line`, `handle`, `tools()`, `is_initialized()` |
|
||||
| `Info` | `name`, `title`, `version` for `serverInfo`, and `max_line`, the longest request line read |
|
||||
| `PROTOCOL_VERSION`, `SUPPORTED_VERSIONS` | The protocol revisions spoken; the newest is offered for one not known |
|
||||
| `tool(name, title, desc, schema, effect)` | A tool definition with annotations from its `Effect` |
|
||||
| `Effect` | `ReadOnly`, `Edits`, `Replaces`, `Files`: the approval hints a client is given |
|
||||
| `object(props, required)`, `string(desc)` | Input-schema pieces: a closed object, a described string |
|
||||
| `check_arguments(defs, name, args)` | The top-level check `tools/call` makes |
|
||||
| `text_result(text, is_error)` | A tool result of one text block |
|
||||
| `image_result(data, mime, text)` | A tool result of an image (base64-encoded here) and its text |
|
||||
| `base64(data)` | Standard base64 with padding (RFC 4648) |
|
||||
@@ -0,0 +1,40 @@
|
||||
//! The content blocks a tool result is made of (MCP `CallToolResult`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A tool result of one text block, flagged as an error when `is_error` (the model sees the
|
||||
/// message; an error is still a result, not a JSON-RPC error).
|
||||
pub fn text_result(text: impl Into<String>, is_error: bool) -> Value {
|
||||
let mut r = json!({"content": [{"type": "text", "text": text.into()}]});
|
||||
if is_error {
|
||||
r["isError"] = json!(true);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A tool result of an image (`data` of type `mime`, base64-encoded here) followed by a text
|
||||
/// block saying what it shows.
|
||||
pub fn image_result(data: &[u8], mime: &str, text: impl Into<String>) -> Value {
|
||||
json!({"content": [
|
||||
{"type": "image", "data": base64(data), "mimeType": mime},
|
||||
{"type": "text", "text": text.into()},
|
||||
]})
|
||||
}
|
||||
|
||||
/// Standard base64 with padding (RFC 4648), as MCP image content carries it.
|
||||
pub fn base64(data: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
|
||||
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
||||
for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
|
||||
if i <= chunk.len() {
|
||||
out.push(char::from(T[((n >> shift) & 63) as usize]));
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! MCP server machinery any app may share: JSON-RPC 2.0 over stdio (newline-delimited), the MCP
|
||||
//! lifecycle, the tools and resources methods, tool schemas with the check of a call's arguments
|
||||
//! against them, and the content blocks tools reply with (text, images).
|
||||
//!
|
||||
//! An app implements [`Tools`]: what its tools are, how each one runs, its instructions and its
|
||||
//! resources. [`Server`] speaks the protocol around them. Nothing here knows any app's design,
|
||||
//! engine or renderer.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod content;
|
||||
mod schema;
|
||||
mod server;
|
||||
|
||||
pub use content::{base64, image_result, text_result};
|
||||
pub use schema::{Effect, check_arguments, object, string, tool};
|
||||
pub use server::{Info, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server, Tools};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Tool definitions (MCP `Tool`: name, title, description, input schema, annotations) and the
|
||||
//! check of a call's arguments against the definition it was listed with.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// What a tool may do, for the client's approval prompts (MCP tool annotations). Clients that
|
||||
/// auto-approve on these hints must not be told less than the truth.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// Only looks.
|
||||
ReadOnly,
|
||||
/// Changes the app's document in memory, undoably.
|
||||
Edits,
|
||||
/// Replaces the app's document in memory (unsaved changes are lost).
|
||||
Replaces,
|
||||
/// May read, write, overwrite or delete files on disk.
|
||||
Files,
|
||||
}
|
||||
|
||||
/// A tool's definition, its annotations from `effect`.
|
||||
pub fn tool(name: &str, title: &str, desc: &str, schema: Value, effect: Effect) -> Value {
|
||||
let (read_only, destructive, open_world) = match effect {
|
||||
Effect::ReadOnly => (true, false, false),
|
||||
Effect::Edits => (false, false, false),
|
||||
Effect::Replaces => (false, true, false),
|
||||
Effect::Files => (false, true, true),
|
||||
};
|
||||
json!({
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": desc,
|
||||
"inputSchema": schema,
|
||||
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive, "idempotentHint": read_only, "openWorldHint": open_world},
|
||||
})
|
||||
}
|
||||
|
||||
/// An object schema with these `props`, the `required` ones, and no other keys.
|
||||
pub fn object(props: Value, required: &[&str]) -> Value {
|
||||
let mut o = json!({"type": "object", "properties": props, "additionalProperties": false});
|
||||
if !required.is_empty() {
|
||||
o["required"] = json!(required);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// A string property described by `desc`.
|
||||
pub fn string(desc: &str) -> Value {
|
||||
json!({"type": "string", "description": desc})
|
||||
}
|
||||
|
||||
fn type_matches(t: &str, v: &Value) -> bool {
|
||||
match t {
|
||||
"string" => v.is_string(),
|
||||
"number" => v.is_number(),
|
||||
"integer" => v.is_i64() || v.is_u64(),
|
||||
"boolean" => v.is_boolean(),
|
||||
"array" => v.is_array(),
|
||||
"object" => v.is_object(),
|
||||
"null" => v.is_null(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check `args` against tool `name`'s input schema as `defs` list it (top level): unknown tool,
|
||||
/// missing required arguments, unknown arguments, type and enum mismatches.
|
||||
pub fn check_arguments(defs: &[Value], name: &str, args: &Value) -> Result<(), String> {
|
||||
let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?;
|
||||
let schema = &def["inputSchema"];
|
||||
let Some(a) = args.as_object() else { return Err(format!("{name}: arguments must be an object")) };
|
||||
for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
if a.get(r).is_none_or(Value::is_null) {
|
||||
return Err(format!("{name}: missing required argument `{r}`"));
|
||||
}
|
||||
}
|
||||
let props = schema["properties"].as_object();
|
||||
for (k, v) in a {
|
||||
let Some(p) = props.and_then(|p| p.get(k)) else {
|
||||
let valid: Vec<&str> = props.map(|p| p.keys().map(String::as_str).collect()).unwrap_or_default();
|
||||
let valid = if valid.is_empty() { "none".to_string() } else { valid.join(", ") };
|
||||
let k: String = k.chars().take(64).collect();
|
||||
return Err(format!("{name}: unknown argument `{k}`; its arguments are: {valid}"));
|
||||
};
|
||||
if v.is_null() {
|
||||
continue;
|
||||
}
|
||||
let ok = match &p["type"] {
|
||||
Value::String(t) => type_matches(t, v),
|
||||
Value::Array(ts) => ts.iter().filter_map(Value::as_str).any(|t| type_matches(t, v)),
|
||||
_ => true,
|
||||
};
|
||||
if !ok {
|
||||
return Err(format!("{name}: argument `{k}` must be of type {}", p["type"]));
|
||||
}
|
||||
if let Some(allowed) = p["enum"].as_array()
|
||||
&& !allowed.contains(v)
|
||||
{
|
||||
return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone())));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::schema::check_arguments;
|
||||
|
||||
/// The newest protocol revision we speak; offered when the client asks for one we don't know.
|
||||
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
|
||||
|
||||
const PARSE_ERROR: i64 = -32700;
|
||||
const INVALID_REQUEST: i64 = -32600;
|
||||
const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
|
||||
/// Most messages in one JSON-RPC batch.
|
||||
const MAX_BATCH: usize = 1000;
|
||||
|
||||
/// An app's tools, served by [`Server`].
|
||||
pub trait Tools {
|
||||
/// What every client is told at `initialize`.
|
||||
fn instructions(&self) -> String;
|
||||
/// Every tool's definition ([`crate::tool`]), as `tools/list` serves it now.
|
||||
fn list(&mut self) -> Vec<Value>;
|
||||
/// Run tool `name`. The server calls it right after the [`Tools::list`] its arguments were
|
||||
/// checked against ([`crate::check_arguments`]), so it runs the tool as that listing defined
|
||||
/// it. Failures are tool results with `isError: true` ([`crate::text_result`]); it never
|
||||
/// panics on hostile arguments.
|
||||
fn call(&mut self, name: &str, args: &Value) -> Value;
|
||||
/// The resources `resources/list` serves (MCP `Resource`: uri, name, description, mimeType).
|
||||
fn resources(&self) -> Vec<Value> {
|
||||
Vec::new()
|
||||
}
|
||||
/// The JSON of resource `uri`: `None` when there is no such resource, `Err` when it could not
|
||||
/// be read now.
|
||||
fn read_resource(&mut self, uri: &str) -> Option<Result<Value, String>> {
|
||||
let _ = uri;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Who the server says it is at `initialize`, and the longest request line it reads.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Info {
|
||||
pub name: &'static str,
|
||||
pub title: &'static str,
|
||||
pub version: &'static str,
|
||||
/// Longest accepted request line, in bytes; a longer one is refused in bounded space.
|
||||
pub max_line: usize,
|
||||
}
|
||||
|
||||
/// An MCP server over the app's [`Tools`].
|
||||
pub struct Server<T: Tools> {
|
||||
info: Info,
|
||||
tools: T,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
fn response(id: Value, result: Value) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
||||
}
|
||||
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
|
||||
}
|
||||
|
||||
impl<T: Tools> Server<T> {
|
||||
pub fn new(info: Info, tools: T) -> Self {
|
||||
Self { info, tools, initialized: false }
|
||||
}
|
||||
pub fn tools(&mut self) -> &mut T {
|
||||
&mut self.tools
|
||||
}
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.initialized
|
||||
}
|
||||
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes. Only protocol messages go to
|
||||
/// `output`; diagnostics belong on stderr. Consume oversized lines in bounded space so a
|
||||
/// client cannot exhaust memory by sending an arbitrarily long request without a newline.
|
||||
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
loop {
|
||||
let mut line = Vec::new();
|
||||
let mut oversized = false;
|
||||
let mut received = false;
|
||||
loop {
|
||||
let chunk = input.fill_buf()?;
|
||||
if chunk.is_empty() {
|
||||
break;
|
||||
}
|
||||
let count = chunk.iter().position(|&byte| byte == b'\n').map_or(chunk.len(), |i| i + 1);
|
||||
let newline = chunk[count - 1] == b'\n';
|
||||
let content_len = count - usize::from(newline);
|
||||
if !oversized {
|
||||
if line.len().saturating_add(content_len) > self.info.max_line {
|
||||
oversized = true;
|
||||
line.clear();
|
||||
} else {
|
||||
line.extend_from_slice(&chunk[..content_len]);
|
||||
}
|
||||
}
|
||||
input.consume(count);
|
||||
received = true;
|
||||
if newline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !received {
|
||||
break;
|
||||
}
|
||||
let reply = if oversized {
|
||||
Some(error(Value::Null, INVALID_REQUEST, "request too large").to_string())
|
||||
} else {
|
||||
match String::from_utf8(line) {
|
||||
Ok(line) => self.handle_line(&line),
|
||||
// Not UTF-8: answer with a parse error and keep serving.
|
||||
Err(_) => Some(error(Value::Null, PARSE_ERROR, "parse error: input is not UTF-8").to_string()),
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
output.write_all(reply.as_bytes())?;
|
||||
output.write_all(b"\n")?;
|
||||
output.flush()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One line in, at most one line out (notifications get no reply).
|
||||
pub fn handle_line(&mut self, line: &str) -> Option<String> {
|
||||
// Check the raw input before trimming: a huge whitespace prefix is still a huge line.
|
||||
if line.len() > self.info.max_line {
|
||||
return Some(error(Value::Null, INVALID_REQUEST, "request too large").to_string());
|
||||
}
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(Value::Array(batch)) if batch.is_empty() => Some(error(Value::Null, INVALID_REQUEST, "empty batch")),
|
||||
Ok(Value::Array(batch)) if batch.len() > MAX_BATCH => Some(error(Value::Null, INVALID_REQUEST, "batch too large")),
|
||||
Ok(Value::Array(batch)) => {
|
||||
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||
}
|
||||
Ok(msg) => self.handle(msg),
|
||||
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
|
||||
};
|
||||
reply.map(|r| r.to_string())
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, msg: Value) -> Option<Value> {
|
||||
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
|
||||
let id = o.get("id").cloned();
|
||||
let Some(method) = o.get("method").and_then(Value::as_str) else {
|
||||
// A response to a request of ours (we send none) or junk.
|
||||
if o.contains_key("result") || o.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
|
||||
};
|
||||
let params = o.get("params").cloned().unwrap_or(Value::Null);
|
||||
let Some(id) = id else {
|
||||
if method == "notifications/initialized" {
|
||||
self.initialized = true;
|
||||
}
|
||||
return None;
|
||||
};
|
||||
Some(match self.request(method, ¶ms) {
|
||||
Ok(r) => response(id, r),
|
||||
Err((code, m)) => error(id, code, m),
|
||||
})
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => {
|
||||
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
||||
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
||||
let Info { name, title, version: server_version, .. } = self.info;
|
||||
Ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false}},
|
||||
"serverInfo": {"name": name, "title": title, "version": server_version},
|
||||
"instructions": self.tools.instructions(),
|
||||
}))
|
||||
}
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({"tools": self.tools.list()})),
|
||||
"tools/call" => {
|
||||
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
|
||||
let args = match params.get("arguments") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(a) => a.clone(),
|
||||
};
|
||||
check_arguments(&self.tools.list(), name, &args).map_err(|e| (INVALID_PARAMS, e))?;
|
||||
Ok(self.tools.call(name, &args))
|
||||
}
|
||||
"resources/list" => Ok(json!({"resources": self.tools.resources()})),
|
||||
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
|
||||
"prompts/list" => Ok(json!({"prompts": []})),
|
||||
"resources/read" => {
|
||||
let uri = params.get("uri").and_then(Value::as_str).unwrap_or("");
|
||||
let v = self
|
||||
.tools
|
||||
.read_resource(uri)
|
||||
.ok_or_else(|| (RESOURCE_NOT_FOUND, format!("unknown resource `{uri}`")))?
|
||||
.map_err(|e| (INTERNAL_ERROR, e))?;
|
||||
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]}))
|
||||
}
|
||||
_ => Err((METHOD_NOT_FOUND, format!("method `{method}` not found"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Effect, Info, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server, Tools, check_arguments, object, string, text_result, tool};
|
||||
|
||||
/// One app's tools: `echo` and `level`, nothing behind them (no engine, no design). `log` records
|
||||
/// every listing and call in order.
|
||||
#[derive(Default)]
|
||||
struct Echo {
|
||||
calls: Vec<Value>,
|
||||
log: Vec<String>,
|
||||
}
|
||||
|
||||
impl Tools for Echo {
|
||||
fn instructions(&self) -> String {
|
||||
"Echo test tools.".into()
|
||||
}
|
||||
fn list(&mut self) -> Vec<Value> {
|
||||
self.log.push("list".into());
|
||||
vec![
|
||||
tool("echo", "Echo", "Say `text` back.", object(json!({"text": string("What to say")}), &["text"]), Effect::ReadOnly),
|
||||
tool("level", "Level", "Set the level.", object(json!({"level": {"type": "string", "enum": ["low", "high"]}}), &[]), Effect::Edits),
|
||||
]
|
||||
}
|
||||
fn call(&mut self, name: &str, args: &Value) -> Value {
|
||||
self.log.push(format!("call {name}"));
|
||||
self.calls.push(json!({"name": name, "args": args}));
|
||||
text_result(args["text"].as_str().unwrap_or_default(), false)
|
||||
}
|
||||
}
|
||||
|
||||
fn info() -> Info {
|
||||
Info { name: "echo", title: "Echo", version: "1.0.0", max_line: 1 << 16 }
|
||||
}
|
||||
|
||||
fn serve(lines: &[Value]) -> (Server<Echo>, Vec<Value>) {
|
||||
let input: String = lines.iter().map(|l| format!("{l}\n")).collect();
|
||||
let mut output = Vec::new();
|
||||
let mut s = Server::new(info(), Echo::default());
|
||||
s.serve(std::io::Cursor::new(input), &mut output).unwrap();
|
||||
let replies = String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
(s, replies)
|
||||
}
|
||||
|
||||
/// A registered tool is listed and called over the stdio transport, with no engine behind it.
|
||||
#[test]
|
||||
fn a_registered_tool_is_served_over_stdio() {
|
||||
let (mut s, replies) = serve(&[
|
||||
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26"}}),
|
||||
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
|
||||
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
|
||||
json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": "hi"}}}),
|
||||
]);
|
||||
assert_eq!(replies.len(), 3, "the notification gets no reply: {replies:?}");
|
||||
let init = &replies[0]["result"];
|
||||
assert_eq!(init["protocolVersion"], "2025-03-26");
|
||||
assert_eq!(init["serverInfo"], json!({"name": "echo", "title": "Echo", "version": "1.0.0"}));
|
||||
assert_eq!(init["instructions"], "Echo test tools.");
|
||||
assert!(s.is_initialized());
|
||||
assert_eq!(replies[1]["result"]["tools"][0]["name"], "echo");
|
||||
assert_eq!(replies[1]["result"]["tools"][0]["annotations"]["readOnlyHint"], true);
|
||||
assert_eq!(replies[2], json!({"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "hi"}]}}));
|
||||
assert_eq!(s.tools().calls, vec![json!({"name": "echo", "args": {"text": "hi"}})]);
|
||||
}
|
||||
|
||||
/// Arguments are checked against the tool's schema before the tool runs; unknown tools and
|
||||
/// methods are JSON-RPC errors.
|
||||
#[test]
|
||||
fn arguments_break_the_schema_before_the_tool_runs() {
|
||||
let (mut s, replies) = serve(&[
|
||||
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "echo", "arguments": {}}}),
|
||||
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": 3}}}),
|
||||
json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": "a", "loud": true}}}),
|
||||
json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "shout", "arguments": {}}}),
|
||||
json!({"jsonrpc": "2.0", "id": 5, "method": "nope"}),
|
||||
json!({"jsonrpc": "2.0", "id": 6, "method": "resources/read", "params": {"uri": "x://y"}}),
|
||||
]);
|
||||
let codes: Vec<&Value> = replies.iter().map(|r| &r["error"]["code"]).collect();
|
||||
assert_eq!(codes, [&json!(-32602), &json!(-32602), &json!(-32602), &json!(-32602), &json!(-32601), &json!(-32002)], "{replies:?}");
|
||||
assert_eq!(replies[0]["error"]["message"], "echo: missing required argument `text`");
|
||||
assert_eq!(replies[2]["error"]["message"], "echo: unknown argument `loud`; its arguments are: text");
|
||||
assert!(s.tools().calls.is_empty());
|
||||
let defs = s.tools().list();
|
||||
assert!(check_arguments(&defs, "echo", &json!({"text": "a"})).is_ok());
|
||||
}
|
||||
|
||||
/// Hostile framing: oversized lines, bad UTF-8 and bad JSON are answered and the server goes on.
|
||||
#[test]
|
||||
fn hostile_lines_are_answered_and_serving_goes_on() {
|
||||
let mut input = vec![b'x'; (1 << 16) + 10];
|
||||
input.push(b'\n');
|
||||
input.extend_from_slice(b"\xff\xfe\n{not json\n[]\n");
|
||||
input.extend_from_slice(json!({"jsonrpc": "2.0", "id": 9, "method": "ping"}).to_string().as_bytes());
|
||||
let mut output = Vec::new();
|
||||
Server::new(info(), Echo::default()).serve(std::io::Cursor::new(input), &mut output).unwrap();
|
||||
let replies: Vec<Value> = String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let codes: Vec<&Value> = replies.iter().map(|r| r.get("error").map_or(&r["result"], |e| &e["code"])).collect();
|
||||
assert_eq!(codes, [&json!(-32600), &json!(-32700), &json!(-32700), &json!(-32600), &json!({})], "{replies:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc4648() {
|
||||
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
|
||||
assert_eq!(crate::base64(i.as_bytes()), o);
|
||||
}
|
||||
let r = crate::image_result(b"foo", "image/png", "info");
|
||||
assert_eq!(r, json!({"content": [{"type": "image", "data": "Zm9v", "mimeType": "image/png"}, {"type": "text", "text": "info"}]}));
|
||||
}
|
||||
|
||||
fn ping(id: usize) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "method": "ping"})
|
||||
}
|
||||
|
||||
fn notification() -> Value {
|
||||
json!({"jsonrpc": "2.0", "method": "notifications/progress"})
|
||||
}
|
||||
|
||||
fn reply(s: &mut Server<Echo>, msg: &Value) -> Option<Value> {
|
||||
s.handle_line(&msg.to_string()).map(|r| serde_json::from_str(&r).unwrap())
|
||||
}
|
||||
|
||||
/// A batch answers each request in it and none of its notifications; a batch of notifications
|
||||
/// only gets no reply at all; a batch holds at most 1000 messages.
|
||||
#[test]
|
||||
fn batches_answer_their_requests_up_to_the_limit() {
|
||||
let mut s = Server::new(info(), Echo::default());
|
||||
let mixed =
|
||||
json!([ping(1), notification(), {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": "b"}}}]);
|
||||
let replies = reply(&mut s, &mixed).unwrap();
|
||||
assert_eq!(
|
||||
replies,
|
||||
json!([{"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "b"}]}}])
|
||||
);
|
||||
assert_eq!(reply(&mut s, &json!([notification(), notification()])), None);
|
||||
let full: Vec<Value> = (0..1000).map(ping).collect();
|
||||
assert_eq!(reply(&mut s, &Value::Array(full)).unwrap().as_array().map(Vec::len), Some(1000));
|
||||
let over: Vec<Value> = (0..1001).map(ping).collect();
|
||||
let r = reply(&mut s, &Value::Array(over)).unwrap();
|
||||
assert_eq!(r, json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "batch too large"}}));
|
||||
}
|
||||
|
||||
/// We send no requests, so a response is never answered, alone or in a batch; a message with
|
||||
/// neither a method nor a result, or one that is not an object, is refused; a blank line is skipped.
|
||||
#[test]
|
||||
fn a_response_we_never_asked_for_is_ignored() {
|
||||
let mut s = Server::new(info(), Echo::default());
|
||||
assert_eq!(reply(&mut s, &json!({"jsonrpc": "2.0", "id": 1, "result": {}})), None);
|
||||
assert_eq!(reply(&mut s, &json!({"jsonrpc": "2.0", "id": 2, "error": {"code": -1, "message": "no"}})), None);
|
||||
assert_eq!(
|
||||
reply(&mut s, &json!([{"jsonrpc": "2.0", "id": 3, "result": {}}, ping(4)])).unwrap(),
|
||||
json!([{"jsonrpc": "2.0", "id": 4, "result": {}}])
|
||||
);
|
||||
let r = reply(&mut s, &json!({"jsonrpc": "2.0", "id": 5})).unwrap();
|
||||
assert_eq!(r, json!({"jsonrpc": "2.0", "id": 5, "error": {"code": -32600, "message": "missing `method`"}}));
|
||||
let r = reply(&mut s, &json!(42)).unwrap();
|
||||
assert_eq!(r, json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "message must be an object"}}));
|
||||
assert_eq!(s.handle_line(" "), None);
|
||||
assert!(s.tools().log.is_empty());
|
||||
}
|
||||
|
||||
/// A version we speak is echoed; one we don't, or none, gets ours.
|
||||
#[test]
|
||||
fn an_unknown_protocol_version_gets_ours() {
|
||||
let mut s = Server::new(info(), Echo::default());
|
||||
let version = |s: &mut Server<Echo>, params: Value| {
|
||||
reply(s, &json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params})).unwrap()["result"]["protocolVersion"].clone()
|
||||
};
|
||||
for v in SUPPORTED_VERSIONS {
|
||||
assert_eq!(version(&mut s, json!({"protocolVersion": v})), json!(v));
|
||||
}
|
||||
assert_eq!(version(&mut s, json!({"protocolVersion": "1999-01-01"})), json!(PROTOCOL_VERSION));
|
||||
assert_eq!(version(&mut s, json!({"protocolVersion": 7})), json!(PROTOCOL_VERSION));
|
||||
assert_eq!(version(&mut s, json!({})), json!(PROTOCOL_VERSION));
|
||||
assert!(!s.is_initialized(), "initialized only by the client's notification");
|
||||
}
|
||||
|
||||
/// `arguments: null` is no arguments; a value outside a property's `enum` is refused before the
|
||||
/// tool runs.
|
||||
#[test]
|
||||
fn null_arguments_are_none_and_enums_are_checked() {
|
||||
let mut s = Server::new(info(), Echo::default());
|
||||
let call = |s: &mut Server<Echo>, id: u64, args: Value| {
|
||||
reply(s, &json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": "level", "arguments": args}})).unwrap()
|
||||
};
|
||||
assert!(call(&mut s, 1, Value::Null).get("result").is_some());
|
||||
let none = reply(&mut s, &json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "level"}})).unwrap();
|
||||
assert!(none.get("result").is_some(), "{none}");
|
||||
assert!(call(&mut s, 3, json!({"level": "high"})).get("result").is_some());
|
||||
let r = call(&mut s, 4, json!({"level": "loud"}));
|
||||
assert_eq!(r["error"], json!({"code": -32602, "message": "level: `level` must be one of [\"low\",\"high\"]"}));
|
||||
let r = call(&mut s, 5, json!([]));
|
||||
assert_eq!(r["error"], json!({"code": -32602, "message": "level: arguments must be an object"}));
|
||||
let args: Vec<&Value> = s.tools().calls.iter().map(|c| &c["args"]).collect();
|
||||
assert_eq!(args, [&json!({}), &json!({}), &json!({"level": "high"})]);
|
||||
}
|
||||
|
||||
/// An app that serves no resources, templates or prompts lists none of each.
|
||||
#[test]
|
||||
fn the_default_lists_are_empty() {
|
||||
let mut s = Server::new(info(), Echo::default());
|
||||
let result = |s: &mut Server<Echo>, method: &str| reply(s, &json!({"jsonrpc": "2.0", "id": 1, "method": method})).unwrap()["result"].clone();
|
||||
assert_eq!(result(&mut s, "resources/list"), json!({"resources": []}));
|
||||
assert_eq!(result(&mut s, "resources/templates/list"), json!({"resourceTemplates": []}));
|
||||
assert_eq!(result(&mut s, "prompts/list"), json!({"prompts": []}));
|
||||
}
|
||||
|
||||
/// `Tools::call` runs right after the listing its arguments were checked against, every time,
|
||||
/// whatever was listed before it; a refused call is listed and never run.
|
||||
#[test]
|
||||
fn every_call_runs_right_after_its_listing() {
|
||||
let mut s = Server::new(info(), Echo::default());
|
||||
let call = |name: &str, args: Value| json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}});
|
||||
reply(&mut s, &json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}));
|
||||
reply(&mut s, &call("echo", json!({"text": "a"})));
|
||||
reply(&mut s, &call("echo", json!({})));
|
||||
reply(&mut s, &json!([call("level", json!({})), call("echo", json!({"text": "b"}))]));
|
||||
assert_eq!(s.tools().log, ["list", "list", "call echo", "list", "list", "call level", "list", "call echo"]);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
solidcraft-mcp-server = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -88,7 +88,7 @@ pub(crate) const STILL_RUNNING: &str = "still running:";
|
||||
struct Outstanding {
|
||||
what: String,
|
||||
/// What it does when it finishes; `None`: not known (a request this bridge did not send).
|
||||
effect: Option<control_pending::Effect>,
|
||||
effect: Option<solidcraft_control::Effect>,
|
||||
since: Instant,
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ impl Remote {
|
||||
}
|
||||
let mut msg = json!({"id": id, "method": method, "params": params});
|
||||
// The token goes with every request (an app restarted mid-session has a new one).
|
||||
let token = solidcraft_engine::control_auth::client_token(self.port, self.explicit_token.as_deref());
|
||||
let token = solidcraft_engine::control_auth::APP.client_token(self.port, self.explicit_token.as_deref());
|
||||
if let Some(t) = &token {
|
||||
msg["token"] = json!(t.token);
|
||||
}
|
||||
@@ -384,7 +384,8 @@ impl Remote {
|
||||
/// The tool error for the outstanding request still running after its wait.
|
||||
fn still_running(&self, earlier: bool) -> String {
|
||||
let Some(o) = &self.outstanding else { return format!("{STILL_RUNNING} a request is still running in the app") };
|
||||
let effect = o.effect.map_or("it was not cancelled and may still change the design when it finishes", control_pending::Effect::when_done);
|
||||
let effect =
|
||||
o.effect.map_or_else(|| "it was not cancelled and may still change the design when it finishes".into(), |e| e.when_done("design"));
|
||||
let settle = self.waits.settle.as_secs();
|
||||
if earlier {
|
||||
format!(
|
||||
@@ -495,7 +496,8 @@ impl Remote {
|
||||
}
|
||||
// A refused token: say which token file it came from (and whether it is stale).
|
||||
outcome(&v).map_err(|error| {
|
||||
solidcraft_engine::control_auth::client_token(self.port, self.explicit_token.as_deref())
|
||||
solidcraft_engine::control_auth::APP
|
||||
.client_token(self.port, self.explicit_token.as_deref())
|
||||
.map_or_else(|| error.clone(), |t| t.explain_error(&error))
|
||||
})
|
||||
}
|
||||
@@ -661,7 +663,8 @@ impl Backend for Remote {
|
||||
let reply = reply.map_err(|e| self.explain(&e))?;
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
|
||||
let r = outcome(&v).map_err(|error| {
|
||||
solidcraft_engine::control_auth::client_token(self.port, self.explicit_token.as_deref())
|
||||
solidcraft_engine::control_auth::APP
|
||||
.client_token(self.port, self.explicit_token.as_deref())
|
||||
.map_or_else(|| error.clone(), |t| t.explain_error(&error))
|
||||
});
|
||||
match r {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! SolidCraft MCP server.
|
||||
//!
|
||||
//! JSON-RPC 2.0 over stdio (newline-delimited), implementing the MCP lifecycle, tools and
|
||||
//! resources. Tools map onto the control-channel methods (`engine.execute`, `document.inspect`,
|
||||
//! SolidCraft's tools, instructions and resources, served over JSON-RPC 2.0 on stdio by the
|
||||
//! shared MCP server (`solidcraft-mcp-server`: the lifecycle, tool schemas and argument checks).
|
||||
//! Tools map onto the control-channel methods (`engine.execute`, `document.inspect`,
|
||||
//! `ui.render`…, see `docs/control-protocol.md`), served either by an in-process headless
|
||||
//! [`Headless`] session or by a running app through its loopback control port ([`Remote`]), so a
|
||||
//! person can watch the agent model. Every modelling step is an engine command; tools never open
|
||||
@@ -17,7 +18,8 @@ mod tools;
|
||||
|
||||
pub use backend::{Auto, Backend, Headless, Remote};
|
||||
pub use options::{COMPACT_ENV, COMPACT_FLAG, Options};
|
||||
pub use server::{PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server};
|
||||
pub use server::Server;
|
||||
pub use solidcraft_mcp_server::{PROTOCOL_VERSION, SUPPORTED_VERSIONS};
|
||||
pub use tools::{call_tool, call_tool_with, tool_definitions, tool_definitions_with};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
+107
-212
@@ -1,30 +1,13 @@
|
||||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
//! SolidCraft's MCP server: its tools, instructions and resources over the shared server
|
||||
//! (`solidcraft-mcp-server`), with the agent's folders and the output options around each call.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
/// Longest accepted request line, shared with the control server.
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
|
||||
use solidcraft_engine::io::vfs::{self, PathScope};
|
||||
use solidcraft_mcp_server::{Info, Tools};
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::options::Options;
|
||||
use crate::tools::{call_tool_with, check_arguments_with, tool_definitions_with};
|
||||
|
||||
/// The newest protocol revision we speak; offered when the client asks for one we don't know.
|
||||
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
|
||||
|
||||
const PARSE_ERROR: i64 = -32700;
|
||||
const INVALID_REQUEST: i64 = -32600;
|
||||
const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
|
||||
/// Most messages in one JSON-RPC batch.
|
||||
const MAX_BATCH: usize = 1000;
|
||||
use crate::tools::{call_tool_with, tool_definitions_with};
|
||||
|
||||
/// What every MCP client is told at `initialize`: how to plan, build and verify a part. It stays
|
||||
/// version-neutral (no workaround for a quirk of the current kernel or solver: those belong in
|
||||
@@ -58,16 +41,6 @@ Fix, don't rebuild: a wrong number in a feature is changed with `timeline.edit`
|
||||
(`parameters.list {\"filter\":\"feature\"}` names them); `edit.checkpoint` and `edit.restore_checkpoint` go back exactly.\n\n\
|
||||
A call whose error starts `still running:` was not cancelled: don't send it again; `pending` waits for its result.";
|
||||
|
||||
pub struct Server {
|
||||
backend: Box<dyn Backend>,
|
||||
initialized: bool,
|
||||
scope: Option<PathScope>,
|
||||
/// The options from the command line and environment.
|
||||
options: Options,
|
||||
/// The options the app's Preferences ask for, once it has answered (`None`: not yet).
|
||||
app_options: Option<Options>,
|
||||
}
|
||||
|
||||
/// The design files a call opens or saves (#7181): `open` and `save`, and the commands they run
|
||||
/// (`doc.open`, `file.save`, `file.save_as`) through `execute` or as `batch` steps. Each reads or
|
||||
/// writes the file it names through the agent's scope.
|
||||
@@ -91,34 +64,25 @@ fn design_files(name: &str, args: &Value) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn response(id: Value, result: Value) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
||||
}
|
||||
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
|
||||
/// SolidCraft's tools as the shared server serves them: the backend they run on, the files they
|
||||
/// may reach and the options that shape what they return.
|
||||
struct Agent {
|
||||
backend: Box<dyn Backend>,
|
||||
scope: Option<PathScope>,
|
||||
/// The options from the command line and environment.
|
||||
options: Options,
|
||||
/// The options the app's Preferences ask for, once it has answered (`None`: not yet).
|
||||
app_options: Option<Options>,
|
||||
/// The options of the last listing: a call runs with the tools as the listing it was checked
|
||||
/// against defined them.
|
||||
listed: Options,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self { backend, initialized: false, scope: None, options: Options::default(), app_options: None }
|
||||
}
|
||||
/// Confine the files tool calls may reach to `scope` (`None`: anywhere). `solidcraft-cli mcp`
|
||||
/// always sets one unless started with `--allow-any-path` (docs/mcp.md).
|
||||
pub fn with_path_scope(mut self, scope: Option<PathScope>) -> Self {
|
||||
self.scope = scope;
|
||||
self
|
||||
}
|
||||
/// Shape tool output with `options` (default: as always; `solidcraft-cli mcp --compact` or
|
||||
/// `SOLIDCRAFT_MCP_COMPACT=1` turn on compact output, docs/mcp.md). The app's Preferences can
|
||||
/// turn an option on too ([`Server::options`]).
|
||||
pub fn with_options(mut self, options: Options) -> Self {
|
||||
self.options = options;
|
||||
self
|
||||
}
|
||||
impl Agent {
|
||||
/// The options tools use now: the server's own, with what the app's Preferences turn on. The
|
||||
/// app is asked until it answers once (it may start after the server, or be busy), and its
|
||||
/// answer then holds for this session, so the tools an agent listed keep their shape.
|
||||
pub fn options(&mut self) -> Options {
|
||||
fn options(&mut self) -> Options {
|
||||
if self.app_options.is_none()
|
||||
&& let Ok(reply) = self.backend.app_options()
|
||||
{
|
||||
@@ -126,173 +90,104 @@ impl Server {
|
||||
}
|
||||
self.options.or(self.app_options.unwrap_or_default())
|
||||
}
|
||||
pub fn backend(&mut self) -> &mut dyn Backend {
|
||||
self.backend.as_mut()
|
||||
}
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.initialized
|
||||
}
|
||||
|
||||
impl Tools for Agent {
|
||||
fn instructions(&self) -> String {
|
||||
format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe())
|
||||
}
|
||||
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes. Only protocol messages go to
|
||||
/// `output`; diagnostics belong on stderr. Consume oversized lines in bounded space so an
|
||||
/// agent cannot exhaust memory by sending an arbitrarily long request without a newline.
|
||||
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
loop {
|
||||
let mut line = Vec::new();
|
||||
let mut oversized = false;
|
||||
let mut received = false;
|
||||
loop {
|
||||
let chunk = input.fill_buf()?;
|
||||
if chunk.is_empty() {
|
||||
break;
|
||||
}
|
||||
let count = chunk.iter().position(|&byte| byte == b'\n').map_or(chunk.len(), |i| i + 1);
|
||||
let newline = chunk[count - 1] == b'\n';
|
||||
let content_len = count - usize::from(newline);
|
||||
if !oversized {
|
||||
if line.len().saturating_add(content_len) > MAX_LINE {
|
||||
oversized = true;
|
||||
line.clear();
|
||||
} else {
|
||||
line.extend_from_slice(&chunk[..content_len]);
|
||||
}
|
||||
}
|
||||
input.consume(count);
|
||||
received = true;
|
||||
if newline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !received {
|
||||
break;
|
||||
}
|
||||
let reply = if oversized {
|
||||
Some(error(Value::Null, INVALID_REQUEST, "request too large").to_string())
|
||||
} else {
|
||||
match String::from_utf8(line) {
|
||||
Ok(line) => self.handle_line(&line),
|
||||
// Not UTF-8: answer with a parse error and keep serving.
|
||||
Err(_) => Some(error(Value::Null, PARSE_ERROR, "parse error: input is not UTF-8").to_string()),
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
output.write_all(reply.as_bytes())?;
|
||||
output.write_all(b"\n")?;
|
||||
output.flush()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
fn list(&mut self) -> Vec<Value> {
|
||||
self.listed = self.options();
|
||||
tool_definitions_with(self.listed)
|
||||
}
|
||||
|
||||
/// One line in, at most one line out (notifications get no reply).
|
||||
pub fn handle_line(&mut self, line: &str) -> Option<String> {
|
||||
// Check the raw input before trimming: a huge whitespace prefix is still a huge line.
|
||||
if line.len() > MAX_LINE {
|
||||
return Some(error(Value::Null, INVALID_REQUEST, "request too large").to_string());
|
||||
}
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(Value::Array(batch)) if batch.is_empty() => Some(error(Value::Null, INVALID_REQUEST, "empty batch")),
|
||||
Ok(Value::Array(batch)) if batch.len() > MAX_BATCH => Some(error(Value::Null, INVALID_REQUEST, "batch too large")),
|
||||
Ok(Value::Array(batch)) => {
|
||||
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||
fn call(&mut self, name: &str, args: &Value) -> Value {
|
||||
let options = self.listed;
|
||||
// The open design's folder counts, and goes on counting for the rest of this run when a
|
||||
// save-as or open moves the design elsewhere (#7181).
|
||||
let open = self.backend.design_path();
|
||||
self.scope = self.scope.take().map(|s| s.with_design(open.as_deref()));
|
||||
let scope = self.scope.clone();
|
||||
let backend = self.backend.as_mut();
|
||||
let mut result = vfs::scoped(scope, || call_tool_with(backend, name, args, options));
|
||||
// So does the folder of each design file the call opened or saved: the call reached it,
|
||||
// so it was one of the agent's folders already. Only paths given in full: a running app
|
||||
// resolves a relative one against its own working folder.
|
||||
if result["isError"] != true {
|
||||
for file in design_files(name, args).iter().filter(|f| std::path::Path::new(f).is_absolute()) {
|
||||
self.scope = self.scope.take().map(|s| s.with_design_file(file));
|
||||
}
|
||||
Ok(msg) => self.handle(msg),
|
||||
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
|
||||
};
|
||||
reply.map(|r| r.to_string())
|
||||
}
|
||||
if let Some(note) = self.backend.notice()
|
||||
&& let Some(content) = result.get_mut("content").and_then(Value::as_array_mut)
|
||||
{
|
||||
content.push(json!({"type": "text", "text": note}));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, msg: Value) -> Option<Value> {
|
||||
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
|
||||
let id = o.get("id").cloned();
|
||||
let Some(method) = o.get("method").and_then(Value::as_str) else {
|
||||
// A response to a request of ours (we send none) or junk.
|
||||
if o.contains_key("result") || o.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
|
||||
};
|
||||
let params = o.get("params").cloned().unwrap_or(Value::Null);
|
||||
let Some(id) = id else {
|
||||
if method == "notifications/initialized" {
|
||||
self.initialized = true;
|
||||
}
|
||||
return None;
|
||||
};
|
||||
Some(match self.request(method, ¶ms) {
|
||||
Ok(r) => response(id, r),
|
||||
Err((code, m)) => error(id, code, m),
|
||||
fn resources(&self) -> Vec<Value> {
|
||||
vec![
|
||||
json!({"uri": "solidcraft://design", "name": "Active design", "description": "document.inspect with measurements", "mimeType": "application/json"}),
|
||||
json!({"uri": "solidcraft://commands", "name": "Command catalog", "description": "every command with its parameter docs", "mimeType": "application/json"}),
|
||||
]
|
||||
}
|
||||
|
||||
fn read_resource(&mut self, uri: &str) -> Option<Result<Value, String>> {
|
||||
Some(match uri {
|
||||
// The solvecraft:// URIs are what earlier releases listed (compatibility shim,
|
||||
// docs/compatibility.md); clients that saved them still read the resource.
|
||||
"solidcraft://design" | "solvecraft://design" => self.backend.call("document.inspect", json!({"measure": true})),
|
||||
"solidcraft://commands" | "solvecraft://commands" => self.backend.call("engine.commands", json!({})),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => {
|
||||
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
||||
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
||||
Ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false}},
|
||||
"serverInfo": {"name": "solidcraft", "title": "SolidCraft", "version": env!("CARGO_PKG_VERSION")},
|
||||
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
|
||||
}))
|
||||
}
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({"tools": tool_definitions_with(self.options())})),
|
||||
"tools/call" => {
|
||||
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
|
||||
let args = match params.get("arguments") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(a) => a.clone(),
|
||||
};
|
||||
let options = self.options();
|
||||
check_arguments_with(name, &args, options).map_err(|e| (INVALID_PARAMS, e))?;
|
||||
// The open design's folder counts, and goes on counting for the rest of this run
|
||||
// when a save-as or open moves the design elsewhere (#7181).
|
||||
let open = self.backend.design_path();
|
||||
self.scope = self.scope.take().map(|s| s.with_design(open.as_deref()));
|
||||
let scope = self.scope.clone();
|
||||
let backend = self.backend.as_mut();
|
||||
let mut result = vfs::scoped(scope, || call_tool_with(backend, name, &args, options));
|
||||
// So does the folder of each design file the call opened or saved: the call reached
|
||||
// it, so it was one of the agent's folders already. Only paths given in full: a
|
||||
// running app resolves a relative one against its own working folder.
|
||||
if result["isError"] != true {
|
||||
for file in design_files(name, &args).iter().filter(|f| std::path::Path::new(f).is_absolute()) {
|
||||
self.scope = self.scope.take().map(|s| s.with_design_file(file));
|
||||
}
|
||||
}
|
||||
if let Some(note) = backend.notice()
|
||||
&& let Some(content) = result.get_mut("content").and_then(Value::as_array_mut)
|
||||
{
|
||||
content.push(json!({"type": "text", "text": note}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
"resources/list" => Ok(json!({"resources": [
|
||||
{"uri": "solidcraft://design", "name": "Active design", "description": "document.inspect with measurements", "mimeType": "application/json"},
|
||||
{"uri": "solidcraft://commands", "name": "Command catalog", "description": "every command with its parameter docs", "mimeType": "application/json"},
|
||||
]})),
|
||||
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
|
||||
"prompts/list" => Ok(json!({"prompts": []})),
|
||||
"resources/read" => {
|
||||
let uri = params.get("uri").and_then(Value::as_str).unwrap_or("");
|
||||
let v = match uri {
|
||||
// The solvecraft:// URIs are what earlier releases listed (compatibility shim,
|
||||
// docs/compatibility.md); clients that saved them still read the resource.
|
||||
"solidcraft://design" | "solvecraft://design" => self.backend.call("document.inspect", json!({"measure": true})),
|
||||
"solidcraft://commands" | "solvecraft://commands" => self.backend.call("engine.commands", json!({})),
|
||||
_ => return Err((RESOURCE_NOT_FOUND, format!("unknown resource `{uri}`"))),
|
||||
}
|
||||
.map_err(|e| (INTERNAL_ERROR, e))?;
|
||||
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]}))
|
||||
}
|
||||
_ => Err((METHOD_NOT_FOUND, format!("method `{method}` not found"))),
|
||||
}
|
||||
/// SolidCraft's MCP server over a [`Backend`].
|
||||
pub struct Server {
|
||||
inner: solidcraft_mcp_server::Server<Agent>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
let info = Info { name: "solidcraft", title: "SolidCraft", version: env!("CARGO_PKG_VERSION"), max_line: solidcraft_engine::MAX_LINE };
|
||||
let agent = Agent { backend, scope: None, options: Options::default(), app_options: None, listed: Options::default() };
|
||||
Self { inner: solidcraft_mcp_server::Server::new(info, agent) }
|
||||
}
|
||||
/// Confine the files tool calls may reach to `scope` (`None`: anywhere). `solidcraft-cli mcp`
|
||||
/// always sets one unless started with `--allow-any-path` (docs/mcp.md).
|
||||
pub fn with_path_scope(mut self, scope: Option<PathScope>) -> Self {
|
||||
self.inner.tools().scope = scope;
|
||||
self
|
||||
}
|
||||
/// Shape tool output with `options` (default: as always; `solidcraft-cli mcp --compact` or
|
||||
/// `SOLIDCRAFT_MCP_COMPACT=1` turn on compact output, docs/mcp.md). The app's Preferences can
|
||||
/// turn an option on too ([`Server::options`]).
|
||||
pub fn with_options(mut self, options: Options) -> Self {
|
||||
self.inner.tools().options = options;
|
||||
self
|
||||
}
|
||||
/// The options tools use now: the server's own, with what the app's Preferences turn on.
|
||||
pub fn options(&mut self) -> Options {
|
||||
self.inner.tools().options()
|
||||
}
|
||||
pub fn backend(&mut self) -> &mut dyn Backend {
|
||||
self.inner.tools().backend.as_mut()
|
||||
}
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.inner.is_initialized()
|
||||
}
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes (see the shared server).
|
||||
pub fn serve(&mut self, input: impl std::io::BufRead, output: impl std::io::Write) -> std::io::Result<()> {
|
||||
self.inner.serve(input, output)
|
||||
}
|
||||
/// One line in, at most one line out (notifications get no reply).
|
||||
pub fn handle_line(&mut self, line: &str) -> Option<String> {
|
||||
self.inner.handle_line(line)
|
||||
}
|
||||
pub fn handle(&mut self, msg: Value) -> Option<Value> {
|
||||
self.inner.handle(msg)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-21
@@ -426,9 +426,6 @@ fn handshake_and_tools_list() {
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "solidcraft");
|
||||
assert!(init["result"]["capabilities"]["tools"].is_object());
|
||||
assert!(init["result"]["instructions"].as_str().unwrap().contains("headless"));
|
||||
// An unknown version gets ours.
|
||||
let init = call(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"}));
|
||||
assert_eq!(init["result"]["protocolVersion"], PROTOCOL_VERSION);
|
||||
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
|
||||
assert!(s.is_initialized());
|
||||
assert_eq!(call(&mut s, 3, "ping", json!({}))["result"], json!({}));
|
||||
@@ -1535,14 +1532,6 @@ fn unknown_tools_and_bad_arguments_are_clean_errors() {
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let r = raw_tool(&mut s, "execute", json!({"command": "NoSuchCommand"}));
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("unknown command"));
|
||||
// Framing errors.
|
||||
assert!(s.handle_line("{not json").unwrap().contains("-32700"));
|
||||
assert!(s.handle_line("[]").unwrap().contains("-32600"));
|
||||
assert!(s.handle_line("42").unwrap().contains("-32600"));
|
||||
assert!(s.handle_line(" ").is_none());
|
||||
assert!(s.handle_line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#).is_none());
|
||||
let both = s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/x"}]"#).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&both).unwrap().as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1723,14 +1712,6 @@ fn hostile_arguments_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc4648() {
|
||||
use crate::tools::base64;
|
||||
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
|
||||
assert_eq!(base64(i.as_bytes()), o);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtering by a command id returns that command, not every command whose docs mention it (#269).
|
||||
#[test]
|
||||
fn list_commands_filter_by_a_command_id_matches_ids() {
|
||||
@@ -3818,8 +3799,9 @@ fn compact_inspect_design_summarises_sketches_unless_asked() {
|
||||
assert_eq!(def, crate::tool_definitions_with(crate::Options { compact: true }).into_iter().find(|d| d["name"] == "inspect_design").unwrap());
|
||||
// Arguments are checked against the tools as listed: `bodies` only with compact output.
|
||||
let bodies = json!({"bodies": ["Body1"]});
|
||||
assert!(crate::tools::check_arguments_with("inspect_design", &bodies, crate::Options { compact: true }).is_ok());
|
||||
assert!(crate::tools::check_arguments("inspect_design", &bodies).is_err_and(|e| e.contains("bodies")));
|
||||
let check = |options| solidcraft_mcp_server::check_arguments(&crate::tool_definitions_with(options), "inspect_design", &bodies);
|
||||
assert!(check(crate::Options { compact: true }).is_ok());
|
||||
assert!(check(crate::Options::default()).is_err_and(|e| e.contains("bodies")));
|
||||
|
||||
let (_, summary) = inspect_text(&mut s, json!({"measure": false}));
|
||||
assert!(summary.len() < 4000, "{} bytes: {summary}", summary.len());
|
||||
|
||||
+30
-152
@@ -7,6 +7,8 @@ use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_engine::timing;
|
||||
|
||||
use solidcraft_mcp_server::{Effect, object, string, tool};
|
||||
|
||||
use crate::backend::{Backend, STILL_RUNNING};
|
||||
use crate::options::Options;
|
||||
|
||||
@@ -26,48 +28,6 @@ const VIEWS: &[&str] = &["iso", "home", "front", "back", "top", "bottom", "left"
|
||||
/// What `check_parameters` can report.
|
||||
const REPORTS: &[&str] = &["measure", "cylinders", "errors"];
|
||||
|
||||
fn obj(props: Value, required: &[&str]) -> Value {
|
||||
let mut o = json!({"type": "object", "properties": props, "additionalProperties": false});
|
||||
if !required.is_empty() {
|
||||
o["required"] = json!(required);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// What a tool may do, for the client's approval prompts (MCP tool annotations). Clients that
|
||||
/// auto-approve on these hints must not be told less than the truth (#205).
|
||||
#[derive(Clone, Copy)]
|
||||
enum Effect {
|
||||
/// Only looks.
|
||||
ReadOnly,
|
||||
/// Changes the design in memory, undoably.
|
||||
Edits,
|
||||
/// Replaces the design in memory (unsaved changes are lost).
|
||||
Replaces,
|
||||
/// May read, write, overwrite or delete files on disk (any command, a file path).
|
||||
Files,
|
||||
}
|
||||
|
||||
fn tool(name: &str, title: &str, desc: &str, schema: Value, effect: Effect) -> Value {
|
||||
let (read_only, destructive, open_world) = match effect {
|
||||
Effect::ReadOnly => (true, false, false),
|
||||
Effect::Edits => (false, false, false),
|
||||
Effect::Replaces => (false, true, false),
|
||||
Effect::Files => (false, true, true),
|
||||
};
|
||||
json!({
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": desc,
|
||||
"inputSchema": schema,
|
||||
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive, "idempotentHint": read_only, "openWorldHint": open_world},
|
||||
})
|
||||
}
|
||||
|
||||
fn string(desc: &str) -> Value {
|
||||
json!({"type": "string", "description": desc})
|
||||
}
|
||||
|
||||
/// The `expect` argument of `execute` and `batch`: what the design should measure once the
|
||||
/// call has gone through.
|
||||
fn expect_schema() -> Value {
|
||||
@@ -117,7 +77,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
(start of the id) and `panel` (a whole panel or tab name); shrink each entry with `compact` (id and label) \
|
||||
or `fields`; page it with `limit` and `offset`, which make the reply {commands, total, offset, next_offset}. \
|
||||
With none of these the reply is the whole catalog, about 350 KB.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\""),
|
||||
"prefix": string("Case-insensitive start of the command id, e.g. \"sketch.\""),
|
||||
@@ -140,7 +100,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
"Describe a command",
|
||||
"One command's parameter docs and, for commands with a typed contract (every new command, and more of the older ones as they are converted), the \
|
||||
JSON Schemas (2020-12) of its parameters (`input`) and of a successful result (`output`); null for the others.",
|
||||
obj(json!({"id": string("Command id, e.g. \"solid.extrude\"")}), &["id"]),
|
||||
object(json!({"id": string("Command id, e.g. \"solid.extrude\"")}), &["id"]),
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool(
|
||||
@@ -158,7 +118,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
and name, every body (bodies) and the bodies the feature created, changed and removed (created_bodies, changed_bodies, removed_bodies). \
|
||||
view.pick {pixels: [[x,y]…]} answers the body, face, exact world point, normal and depth under pixels of a screenshot (read-only; docs/control-protocol.md). \
|
||||
`expect` {volume_mm3, tol, bodies, bbox} checks the design after the command (as `batch` does) and flags a mismatch.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"command": string("Command id (see list_commands)"),
|
||||
"params": {"type": "object", "description": "The command's parameters"},
|
||||
@@ -186,7 +146,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
file and passed as `path` instead of `commands`. Give `expect` {volume_mm3, tol, bodies, bbox} with the numbers \
|
||||
you computed beforehand: after the last step the design is measured and compared, so no separate measure call is \
|
||||
needed; a mismatch is flagged as an error naming it, and the design is kept as built.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"commands": {
|
||||
"type": "array",
|
||||
@@ -218,7 +178,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
`min` and `max` distance and, where one is round, `center_distance` (centre to centre, or from its axis). \
|
||||
`summary: true` keeps one short object per body (name, volume_mm3, bbox, faces, closed) and the total volume: \
|
||||
enough to check a stage against the numbers you computed, at a fraction of the size.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"body": string("Body name (default: all bodies)"),
|
||||
"names_only": {"type": "boolean", "description": "Only each body's name (assembly occurrences included) and its definition's, without measuring (default false)"},
|
||||
@@ -238,7 +198,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
`faces: \"cylinders\"` lists only the cylindrical faces, as `cylinders`, with the halves the kernel splits one cylinder \
|
||||
into merged: {radius, origin, axis, extent, names}, `extent` the [min, max] distance along `axis` from `origin` that \
|
||||
the cylinder spans, and no edges. That is enough to check where every hole and boss is, at a fraction of the size.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"body": string("Body name, e.g. \"Body1\""),
|
||||
"faces": {"type": "string", "enum": ["all", "cylinders"], "description": "\"all\" (default): every edge and face. \"cylinders\": only the cylindrical faces, merged, with their extent along the axis, and no edges"},
|
||||
@@ -255,7 +215,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
uses. Give one of the two (both only when they agree). Returns the new value, the features recomputed (`recomputed`), the \
|
||||
features whose earlier result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any \
|
||||
features that now fail.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"name": string("Parameter name"),
|
||||
"value": {"type": ["number", "string"], "description": "Number or expression (or give it as `expression`)"},
|
||||
@@ -278,7 +238,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
`restored: true`; if the design could not be put back the call is an error with `restored: false` and the values \
|
||||
still applied. An unknown parameter name is an error and nothing changes. Replaces the loop set_parameter, \
|
||||
measure, body_topology, set_parameter back.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"set": {
|
||||
"type": "object",
|
||||
@@ -305,7 +265,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
`grid: false` and `axes: false` leave the ground grid (on Z 0) and the origin's axes out of a model render. \
|
||||
A body whose appearance is see-through (appearance.assign with an `opacity` below 1) is drawn see-through, \
|
||||
so what is inside it shows: that is how to look at the body a cut would remove inside the material it meets.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"view": string(
|
||||
"Standard view (iso, home, front, back, top, bottom, left, right, fit) or the name of a saved named view (default: keep the current one; headless starts at iso)"
|
||||
@@ -335,7 +295,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
\"both\" scales by width and reports `aspect_mismatch` when the shapes differ. Returns as text the overlap (0..1) and the model's top, \
|
||||
bottom, left and right edges against the picture's in mm at nine stations (positive where the model reaches past the picture), and as \
|
||||
an image the overlay PNG: model red, picture blue.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"reference": string("PNG or JPEG to match, inside the server's folders"),
|
||||
"view": string("Standard view (front, top, right, iso, ...) the picture is seen from (default front)"),
|
||||
@@ -357,7 +317,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
comes from the extension unless given. URDF (.urdf) exports the whole assembly for robot simulators: a link per \
|
||||
component placement with mass and inertia, joints from the assembly's joints, and STL meshes in metres written \
|
||||
next to it (docs/urdf.md); RViz needs mesh_prefix such as package://robot/meshes/.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"path": string("Output file"),
|
||||
"format": {"type": "string", "enum": ["step", "3mf", "3mf-tagged", "stl", "stla", "obj", "urdf"]},
|
||||
@@ -378,7 +338,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
message, location, backtrace, app version and commit, system, the command that crashed and the recent ones, \
|
||||
and design_path / replay_path when it can be replayed: `solidcraft-cli crashes replay <id>`, or \
|
||||
`solidcraft-cli run <replay_path> --in <design_path>`. Reports never leave this computer.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"id": string("A report id from the list"),
|
||||
"limit": {"type": "integer", "description": "Reports to list (default 20)"},
|
||||
@@ -394,7 +354,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
command id (a batch's step count), seconds since it arrived (running_s) and whether it still waits its turn \
|
||||
(queued); `self` marks a request sent on the connection that asks. Answers at once, even while the app is busy, \
|
||||
and never shows parameters. Ask it when a call timed out or the app seems slow, before sending a change again.",
|
||||
obj(json!({}), &[]),
|
||||
object(json!({}), &[]),
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool(
|
||||
@@ -404,16 +364,16 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
starting \"still running:\"). Waits for it up to 60 s: then its own result (or error), or done: false while \
|
||||
it still runs (call again). pending: null when nothing is running. A request still running was not \
|
||||
cancelled: don't send it again; other calls wait for it before they are sent.",
|
||||
obj(json!({}), &[]),
|
||||
object(json!({}), &[]),
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), Effect::Edits),
|
||||
tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), Effect::Edits),
|
||||
tool("undo", "Undo", "Undo the last change to the design.", object(json!({}), &[]), Effect::Edits),
|
||||
tool("redo", "Redo", "Redo the last undone change.", object(json!({}), &[]), Effect::Edits),
|
||||
tool(
|
||||
"new_design",
|
||||
"New design",
|
||||
"Start a new, empty design (unsaved changes to the current one are discarded).",
|
||||
obj(json!({"name": string("Design name (default Untitled)")}), &[]),
|
||||
object(json!({"name": string("Design name (default Untitled)")}), &[]),
|
||||
Effect::Replaces,
|
||||
),
|
||||
tool(
|
||||
@@ -426,7 +386,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
instead with the file.insert_step (STEP) or file.insert_mesh (mesh) commands. A STEP file from a system \
|
||||
that has Y up opens lying on its back: `up: \"y\"` turns its component a quarter turn about X (Y becomes Z). glTF is Y up, \
|
||||
so it is turned by default (`up: \"z\"` keeps its axes). `names` picks what the bodies are named after when the solids' own names say nothing useful.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"path": string("Path to a .sc (or .solidcraft), .step/.stp, .3mf, .stl, .obj, .glb or .gltf file"),
|
||||
"up": {"type": "string", "enum": ["z", "y"], "description": "STEP and glTF only. z keeps the file's axes; y: the file has Y up, so its bodies are turned to stand Z up. Default: z for STEP, y for glTF"},
|
||||
@@ -443,7 +403,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
By default the path must lie in one of the agent's folders: the folder of a design opened or saved in this session (not its hidden files), \
|
||||
SolidCraft's recovery folder, or a folder this server was started with `--allow-path DIR`. For a folder outside them, ask the user \
|
||||
to add `--allow-path` and that folder to this server's arguments in their MCP client configuration.",
|
||||
obj(
|
||||
object(
|
||||
json!({"path": string("Path to save to, e.g. part.sc (default: the current file)"), "format": {"type": "string", "enum": ["container", "json"], "description": "Design encoding (default: JSON for .json, container otherwise)"}}),
|
||||
&[],
|
||||
),
|
||||
@@ -455,7 +415,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
"Read a document of SolidCraft's bundled agent skills (solidcraft-modeling, solidcraft-mechanisms, \
|
||||
solidcraft-editing, solidcraft-checking) from the copy built into SolidCraft. `file` defaults to reference.md; \
|
||||
each skill also has SKILL.md. Unknown skills or files are refused with the available ones.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"skill": string("Skill name, such as solidcraft-modeling"),
|
||||
"file": {"type": "string", "description": "Document of that skill", "default": "reference.md"},
|
||||
@@ -480,7 +440,7 @@ fn inspect_design_tool(opts: Options) -> Value {
|
||||
status), assembly joints (what each origin is attached to: occurrence, the feature, face, edge or sketch \
|
||||
curve its face names refer to, whether it resolves and why not; `execute` `joint.inspect {joint}` gives one), \
|
||||
the active sketch, selection and undo depth.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
|
||||
"sketches": {"type": "boolean", "description": "Full sketch contents (default true; false = summary only)"},
|
||||
@@ -501,7 +461,7 @@ fn inspect_design_tool(opts: Options) -> Value {
|
||||
(curves with ids and coordinates, points, constraints, profiles) only for the sketches named in `sketches`, or \
|
||||
all with `sketches: true` (large: a 720-point sketch is some 200 KB). Assembly joints: what each origin is \
|
||||
attached to (`execute` `joint.inspect {joint}` gives one). Arguments: measure, sketches, bodies, joints.",
|
||||
obj(
|
||||
object(
|
||||
json!({
|
||||
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
|
||||
"sketches": {
|
||||
@@ -518,65 +478,6 @@ fn inspect_design_tool(opts: Options) -> Value {
|
||||
)
|
||||
}
|
||||
|
||||
fn type_matches(t: &str, v: &Value) -> bool {
|
||||
match t {
|
||||
"string" => v.is_string(),
|
||||
"number" => v.is_number(),
|
||||
"integer" => v.is_i64() || v.is_u64(),
|
||||
"boolean" => v.is_boolean(),
|
||||
"array" => v.is_array(),
|
||||
"object" => v.is_object(),
|
||||
"null" => v.is_null(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`check_arguments_with`] the default options.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
|
||||
check_arguments_with(name, args, Options::default())
|
||||
}
|
||||
|
||||
/// Validates `args` against the tool's schema as listed with these [`Options`] (top level):
|
||||
/// unknown tool, missing required arguments, unknown arguments, type and enum mismatches.
|
||||
pub(crate) fn check_arguments_with(name: &str, args: &Value, opts: Options) -> Result<(), String> {
|
||||
let defs = tool_definitions_with(opts);
|
||||
let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?;
|
||||
let schema = &def["inputSchema"];
|
||||
let Some(a) = args.as_object() else { return Err(format!("{name}: arguments must be an object")) };
|
||||
for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
if a.get(r).is_none_or(Value::is_null) {
|
||||
return Err(format!("{name}: missing required argument `{r}`"));
|
||||
}
|
||||
}
|
||||
let props = schema["properties"].as_object();
|
||||
for (k, v) in a {
|
||||
let Some(p) = props.and_then(|p| p.get(k)) else {
|
||||
let valid: Vec<&str> = props.map(|p| p.keys().map(String::as_str).collect()).unwrap_or_default();
|
||||
let valid = if valid.is_empty() { "none".to_string() } else { valid.join(", ") };
|
||||
let k: String = k.chars().take(64).collect();
|
||||
return Err(format!("{name}: unknown argument `{k}`; its arguments are: {valid}"));
|
||||
};
|
||||
if v.is_null() {
|
||||
continue;
|
||||
}
|
||||
let ok = match &p["type"] {
|
||||
Value::String(t) => type_matches(t, v),
|
||||
Value::Array(ts) => ts.iter().filter_map(Value::as_str).any(|t| type_matches(t, v)),
|
||||
_ => true,
|
||||
};
|
||||
if !ok {
|
||||
return Err(format!("{name}: argument `{k}` must be of type {}", p["type"]));
|
||||
}
|
||||
if let Some(allowed) = p["enum"].as_array()
|
||||
&& !allowed.contains(v)
|
||||
{
|
||||
return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone())));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A tool result: content blocks plus the error flag. JSON is pretty-printed, or compact with
|
||||
/// compact output.
|
||||
fn text_result(v: &Value, is_error: bool, opts: Options) -> Value {
|
||||
@@ -584,16 +485,14 @@ fn text_result(v: &Value, is_error: bool, opts: Options) -> Value {
|
||||
Value::String(s) => s.clone(),
|
||||
v => opts.json_text(v),
|
||||
};
|
||||
let mut r = json!({"content": [{"type": "text", "text": text}]});
|
||||
if is_error {
|
||||
r["isError"] = json!(true);
|
||||
if let Some(reason) = v
|
||||
let mut r = solidcraft_mcp_server::text_result(text, is_error);
|
||||
if is_error
|
||||
&& let Some(reason) = v
|
||||
.as_str()
|
||||
.and_then(solidcraft_engine::panels::commands::structured_error)
|
||||
.or_else(|| v.get("failed").and_then(|failed| failed.get("panel_error")).cloned())
|
||||
{
|
||||
r["structuredContent"] = json!({"panel_error":reason});
|
||||
}
|
||||
{
|
||||
r["structuredContent"] = json!({"panel_error":reason});
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -2167,10 +2066,7 @@ static SHOT: AtomicU64 = AtomicU64::new(0);
|
||||
/// `screenshot`: render or capture to a file, return it as an MCP image content block.
|
||||
fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||
match capture(b, a) {
|
||||
Ok((png, info)) => json!({"content": [
|
||||
{"type": "image", "data": base64(&png), "mimeType": "image/png"},
|
||||
{"type": "text", "text": info.to_string()},
|
||||
]}),
|
||||
Ok((png, info)) => solidcraft_mcp_server::image_result(&png, "image/png", info.to_string()),
|
||||
Err(e) => text_result(&Value::String(e), true, Options::default()),
|
||||
}
|
||||
}
|
||||
@@ -2282,21 +2178,3 @@ fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Valu
|
||||
}
|
||||
Ok((png, reply))
|
||||
}
|
||||
|
||||
/// Standard base64 with padding (RFC 4648).
|
||||
pub(crate) fn base64(data: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
|
||||
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
||||
for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
|
||||
if i <= chunk.len() {
|
||||
out.push(char::from(T[((n >> shift) & 63) as usize]));
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ raytrace-cpu = ["dep:robust"]
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-color = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,37 +1,7 @@
|
||||
//! Surface analysis as a view draws it: the shading mode and the colours it uses.
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnalysisColors {
|
||||
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
|
||||
pub draft: [[f32; 3]; 3],
|
||||
/// Accessibility Analysis: reachable, blocked.
|
||||
pub access: [[f32; 3]; 2],
|
||||
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
|
||||
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
|
||||
/// reads with hue lost too (#975).
|
||||
pub curvature: [[f32; 3]; 3],
|
||||
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
|
||||
pub hatch: bool,
|
||||
}
|
||||
|
||||
impl AnalysisColors {
|
||||
/// The light theme's colours (no colour vision mode): what headless pictures use.
|
||||
pub const DEFAULT: AnalysisColors = AnalysisColors {
|
||||
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
|
||||
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
|
||||
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
|
||||
hatch: false,
|
||||
};
|
||||
}
|
||||
|
||||
impl Default for AnalysisColors {
|
||||
fn default() -> Self {
|
||||
AnalysisColors::DEFAULT
|
||||
}
|
||||
}
|
||||
use solidcraft_color::AnalysisColors;
|
||||
|
||||
/// A way of shading the model's faces to judge their shape.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
||||
@@ -10,7 +10,7 @@ use super::{CompletePass, MAX_BYTES, invalid, limit};
|
||||
use crate::job::encoding::PngEncoding;
|
||||
use crate::job::{CancelToken, CpuHost, ErrorCode, JobError, Phase, Progress, Reservation, Result};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::srgb::encode8;
|
||||
use solidcraft_color::srgb::encode8;
|
||||
|
||||
/// Encoded bytes retain the actual existing host lease. No detached byte getter.
|
||||
pub struct CompletedPng {
|
||||
|
||||
@@ -2757,7 +2757,7 @@ mod retained_request_tests {
|
||||
assert!(frame.linear_exr().is_some());
|
||||
let color = [128, 64, 255].map(|v| {
|
||||
let x = crate::surface::srgb8_to_linear(v) as f32 as f64;
|
||||
solidcraft_geom::srgb::encode8(x / (1.0 + x))
|
||||
solidcraft_color::srgb::encode8(x / (1.0 + x))
|
||||
});
|
||||
assert_eq!(image::load_from_memory(frame.png()).unwrap().to_rgba8().get_pixel(0, 0).0, [color[0], color[1], color[2], 255]);
|
||||
let held = job.clone();
|
||||
|
||||
@@ -375,7 +375,7 @@ impl PhotographicState {
|
||||
let grain = (random(index as u32 ^ self.options.seed) - 0.5) * self.options.grain * (luminance + 0.03).sqrt();
|
||||
for channel in 0..3 {
|
||||
let linear = (rgb[channel] + flare[channel] + grain).max(0.0);
|
||||
self.output.push(solidcraft_geom::srgb::encode8(linear));
|
||||
self.output.push(solidcraft_color::srgb::encode8(linear));
|
||||
}
|
||||
self.output.push(255);
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ impl Display {
|
||||
let strength = self.finishing.bloom.map_or(0.0, |b| b.strength);
|
||||
value += strength * self.tone_map.map(f64::from(glow[channel]) * self.exposure * self.finishing.rgb_gain[channel]);
|
||||
}
|
||||
*out = solidcraft_geom::srgb::encode8(value);
|
||||
*out = solidcraft_color::srgb::encode8(value);
|
||||
}
|
||||
rgba[3] = if self.transparent { (alpha * 255.0).round() as u8 } else { 255 };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub mod texture_lod;
|
||||
pub mod texture_sampling;
|
||||
pub mod transmission;
|
||||
|
||||
pub use analysis::{AnalysisColors, AnalysisMode, SceneAnalysis};
|
||||
pub use analysis::{AnalysisMode, SceneAnalysis};
|
||||
pub use backend::{
|
||||
Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits,
|
||||
RenderMode, RenderOperation, RenderPolicy, RenderProfile, RenderRequest, ResolveError, ResolvedRequest, ScratchLayout, builtin_backend,
|
||||
|
||||
@@ -15,8 +15,8 @@ use crate::{
|
||||
surface::{OpaqueSurface, srgb8_to_linear},
|
||||
};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_color::srgb::encode8;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_geom::srgb::encode8;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "environment_image_tests.rs"]
|
||||
|
||||
@@ -111,7 +111,7 @@ impl StagePanel {
|
||||
mesh.tri_face.extend([0, 0]);
|
||||
// The surface carries exact linear reflectance. This display colour is not
|
||||
// used as an additional multiplier by PBR consumers.
|
||||
let color = self.reflectance_linear.map(solidcraft_geom::srgb::encode8);
|
||||
let color = self.reflectance_linear.map(solidcraft_color::srgb::encode8);
|
||||
Ok(ProductMesh {
|
||||
name: format!("__render_stage__/{}", self.id),
|
||||
mesh: Arc::new(mesh),
|
||||
|
||||
@@ -160,8 +160,8 @@ impl OpaqueSurface {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact piecewise sRGB decoding of an existing 8-bit color channel ([`solidcraft_geom::srgb::decode8`]).
|
||||
pub use solidcraft_geom::srgb::decode8 as srgb8_to_linear;
|
||||
/// Exact piecewise sRGB decoding of an existing 8-bit color channel ([`solidcraft_color::srgb::decode8`]).
|
||||
pub use solidcraft_color::srgb::decode8 as srgb8_to_linear;
|
||||
|
||||
/// Normalize finite directions without overflow or an arbitrary magnitude threshold.
|
||||
pub fn unit_direction(v: Vec3) -> Result<Vec3, SurfaceError> {
|
||||
|
||||
@@ -179,7 +179,7 @@ impl NormalizationSource {
|
||||
let linear = match conversion {
|
||||
ColorConversion::RawData => bounded,
|
||||
ColorConversion::PngGammaSrgbPrimaries { encoding_gamma } => bounded.powf(1.0 / encoding_gamma),
|
||||
_ => solidcraft_geom::srgb::decode(bounded),
|
||||
_ => solidcraft_color::srgb::decode(bounded),
|
||||
};
|
||||
if !linear.is_finite() || !(0.0..=1.0).contains(&linear) {
|
||||
return Err(invalid("normalization produced invalid linear/data channels"));
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "solidcraft-theme"
|
||||
description = "The token contract UI components draw with: SolidCraft's light and dark tokens, colour vision, tab accents and the theme state a host sets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
solidcraft-color = { workspace = true }
|
||||
@@ -0,0 +1,25 @@
|
||||
# solidcraft-theme
|
||||
|
||||
The token contract every UI component draws with. A component reads its colours and sizes from
|
||||
`Tokens::get()` and its tab colours from `TabAccent::current(ctx)`; with nothing set, it draws with
|
||||
SolidCraft's built-in dark tokens. The crate holds no data files and knows no theme-file format, so a
|
||||
component vendored into another app draws with that app's tokens and needs no theme files, built-in
|
||||
themes or icon artwork.
|
||||
|
||||
SolidCraft's theme system (theme files, their options, the built-in themes and the per-tab surface
|
||||
fades) stays in the app: it resolves `Tokens` and hands them to this crate.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it is |
|
||||
|---|---|
|
||||
| `Tokens` | Every colour and size the UI draws with; `Tokens::light()` and `Tokens::dark()` are SolidCraft's values, written in code |
|
||||
| `Tokens::get()`, `Tokens::view()` | The tokens to draw with now (the window's, and those drawn straight onto the 3D view) |
|
||||
| `set_dark`, `set_vision`, `set_viewport_dark`, `set_viewport_background`, `set_logo_accent` | The thread's theme state, which the host sets every frame |
|
||||
| `set_custom`, `custom`, `scoped_tokens`, `scoped_panel_tokens` | A host's own palette in place of the built-in one, for the thread or for a scope |
|
||||
| `ColorVision` | Colour vision modes: a second set of values for the colours that carry a meaning |
|
||||
| `TabAccent`, `TAB_NAMES` | A tab's selection colours, published per egui context |
|
||||
| `set_published`, `PublishedTab`, `Environment`, `Surfaces`, `Fade` | A frame's per-tab palettes, as a host's theme system resolves and fades them |
|
||||
| `apply(ctx)` | egui's visuals and spacing from the tokens |
|
||||
| `contrast_ratio`, `composite`, `panel_surface_fill`, ... | WCAG contrast checks on token pairs |
|
||||
| `icon_strokes` | The primary strokes the tokens share with SolidCraft's icon artwork |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,21 @@
|
||||
//! Original light and dark palette values. Compiled by the UI theme boundary.
|
||||
use crate::theme::{AnalysisColors, IconColors, Tokens};
|
||||
//! Original light and dark palette values.
|
||||
use crate::{IconColors, Tokens};
|
||||
use egui::Color32;
|
||||
use solidcraft_color::AnalysisColors;
|
||||
|
||||
/// The primary (`active-3`) strokes, light then dark, that the tokens share with SolidCraft's icon artwork
|
||||
/// (`assets/icons/ui/design/palette.toml`, including its colour vision variants). ui-egui's icon tests hold them equal.
|
||||
pub mod icon_strokes {
|
||||
pub const SKETCH_PRIMARY: [[u8; 3]; 2] = [[5, 127, 69], [18, 141, 69]];
|
||||
pub const CONSTRAINT_PRIMARY: [[u8; 3]; 2] = [[202, 71, 61], [193, 99, 88]];
|
||||
pub const CONSTRUCTION_PRIMARY: [[u8; 3]; 2] = [[156, 86, 0], [167, 93, 0]];
|
||||
pub const SKETCH_RED_GREEN: [[u8; 3]; 2] = [[29, 125, 82], [29, 127, 137]];
|
||||
pub const CONSTRUCTION_RED_GREEN: [[u8; 3]; 2] = [[185, 115, 0], [167, 93, 0]];
|
||||
pub const CONSTRAINT_RED_GREEN: [[u8; 3]; 2] = [[175, 64, 56], [193, 99, 88]];
|
||||
pub const CONSTRUCTION_BLUE_YELLOW: [[u8; 3]; 2] = [[185, 115, 0], [195, 125, 0]];
|
||||
pub const CONSTRAINT_BLUE_YELLOW: [[u8; 3]; 2] = [[175, 64, 56], [133, 85, 74]];
|
||||
}
|
||||
use icon_strokes as strokes;
|
||||
|
||||
const fn icon_primary(rgb: [u8; 3]) -> Color32 {
|
||||
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
|
||||
@@ -17,8 +32,7 @@ impl Tokens {
|
||||
nav_tip_key_border: Color32::from_rgba_unmultiplied_const(128, 140, 160, 153),
|
||||
nav_field: Color32::from_rgb(255, 255, 255),
|
||||
|
||||
borders: crate::theme::Borders::LEGACY,
|
||||
icon_palette: None,
|
||||
borders: crate::Borders::LEGACY,
|
||||
file_dim: Color32::from_rgb(58, 70, 86),
|
||||
file_recovery_well: Color32::from_rgb(247, 226, 184),
|
||||
file_action_height: 32.0,
|
||||
@@ -119,7 +133,7 @@ impl Tokens {
|
||||
text_dim: Color32::from_rgb(110, 116, 128),
|
||||
accent: Color32::from_rgb(38, 120, 218),
|
||||
accent_soft: Color32::from_rgb(214, 230, 250),
|
||||
sketch_accent: icon_primary(crate::icon_svg::SKETCH_PRIMARY[0]),
|
||||
sketch_accent: icon_primary(strokes::SKETCH_PRIMARY[0]),
|
||||
// Icon palette active-3 ramps; these accents have no compiled constants yet.
|
||||
surface_accent: Color32::from_rgb(164, 80, 0),
|
||||
mesh_accent: Color32::from_rgb(130, 103, 0),
|
||||
@@ -172,7 +186,7 @@ impl Tokens {
|
||||
axis_y: Color32::from_rgb(60, 170, 60),
|
||||
axis_z: Color32::from_rgb(60, 100, 230),
|
||||
construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70),
|
||||
construction_geom: icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[0]),
|
||||
construction_geom: icon_primary(strokes::CONSTRUCTION_PRIMARY[0]),
|
||||
body: Color32::from_rgb(176, 186, 198),
|
||||
body_edge: Color32::from_rgb(40, 44, 52),
|
||||
manipulator: Color32::from_rgb(40, 110, 220),
|
||||
@@ -215,7 +229,7 @@ impl Tokens {
|
||||
profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70),
|
||||
glyph_bg: Color32::WHITE,
|
||||
glyph_edge: Color32::from_rgb(190, 110, 100),
|
||||
glyph_text: icon_primary(crate::icon_svg::CONSTRAINT_PRIMARY[0]),
|
||||
glyph_text: icon_primary(strokes::CONSTRAINT_PRIMARY[0]),
|
||||
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
|
||||
rubber_band: Color32::from_rgb(30, 120, 230),
|
||||
app_bar_text: Color32::from_rgb(230, 234, 242),
|
||||
@@ -339,7 +353,7 @@ impl Tokens {
|
||||
t.text_dim = Color32::from_rgb(148, 155, 168);
|
||||
t.accent = Color32::from_rgb(58, 136, 238);
|
||||
t.accent_soft = Color32::from_rgb(42, 70, 110);
|
||||
t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_PRIMARY[1]);
|
||||
t.sketch_accent = icon_primary(strokes::SKETCH_PRIMARY[1]);
|
||||
t.surface_accent = Color32::from_rgb(149, 104, 45);
|
||||
t.mesh_accent = Color32::from_rgb(133, 113, 0);
|
||||
t.form_accent = Color32::from_rgb(123, 99, 166);
|
||||
@@ -399,7 +413,7 @@ impl Tokens {
|
||||
t.pref_ok = Color32::from_rgb(127, 209, 174);
|
||||
t.pref_ok_fill = Color32::from_rgb(31, 58, 49);
|
||||
t.pref_disabled = Color32::from_rgb(138, 148, 163);
|
||||
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]);
|
||||
t.construction_geom = icon_primary(strokes::CONSTRUCTION_PRIMARY[1]);
|
||||
t.error = Color32::from_rgb(240, 100, 96);
|
||||
t.sketch_conflict = t.error;
|
||||
t.warning = Color32::from_rgb(236, 170, 70);
|
||||
@@ -430,7 +444,7 @@ impl Tokens {
|
||||
t.chip = Color32::from_rgb(59, 101, 168);
|
||||
t.glyph_bg = Color32::from_rgb(44, 50, 60);
|
||||
t.glyph_edge = Color32::from_rgb(200, 130, 120);
|
||||
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_PRIMARY[1]);
|
||||
t.glyph_text = icon_primary(strokes::CONSTRAINT_PRIMARY[1]);
|
||||
t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240);
|
||||
t.rubber_band = Color32::from_rgb(92, 160, 255);
|
||||
t.timeline_item = Color32::from_rgb(52, 58, 70);
|
||||
@@ -473,8 +487,8 @@ impl Tokens {
|
||||
if !dark {
|
||||
t.form_success = Color32::from_rgb(39, 88, 78);
|
||||
}
|
||||
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_RED_GREEN[dark as usize]);
|
||||
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_RED_GREEN[dark as usize]);
|
||||
t.construction_geom = icon_primary(strokes::CONSTRUCTION_RED_GREEN[dark as usize]);
|
||||
t.glyph_text = icon_primary(strokes::CONSTRAINT_RED_GREEN[dark as usize]);
|
||||
// A deeper red keeps conflicting sketch geometry (#7244) apart from the orange
|
||||
// construction and centerline lines and the crimson lost links for a red-green loss.
|
||||
t.sketch_conflict = Color32::from_rgb(195, 35, 30);
|
||||
@@ -484,14 +498,14 @@ impl Tokens {
|
||||
if dark {
|
||||
t.bend_up = Color32::from_rgb(110, 165, 255);
|
||||
t.bend_down = Color32::from_rgb(240, 180, 70);
|
||||
t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_RED_GREEN[1]);
|
||||
t.sketch_accent = icon_primary(strokes::SKETCH_RED_GREEN[1]);
|
||||
} else {
|
||||
t.sketch_projected = Color32::from_rgb(205, 120, 215);
|
||||
t.sketch_locked = Color32::from_rgb(60, 150, 120);
|
||||
t.timeline_item_error = Color32::from_rgb(232, 196, 170);
|
||||
t.bend_up = Color32::from_rgb(30, 100, 210);
|
||||
t.bend_down = Color32::from_rgb(200, 120, 0);
|
||||
t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_RED_GREEN[0]);
|
||||
t.sketch_accent = icon_primary(strokes::SKETCH_RED_GREEN[0]);
|
||||
}
|
||||
t
|
||||
}
|
||||
@@ -506,8 +520,8 @@ impl Tokens {
|
||||
// Green and blue status lights merge for tritan vision; use rose for working.
|
||||
t.agent_busy = Color32::from_rgb(232, 150, 190);
|
||||
t.preview_sketch = Color32::from_rgb(125, 180, 105);
|
||||
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_BLUE_YELLOW[dark as usize]);
|
||||
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_BLUE_YELLOW[dark as usize]);
|
||||
t.glyph_text = icon_primary(strokes::CONSTRAINT_BLUE_YELLOW[dark as usize]);
|
||||
t.construction_geom = icon_primary(strokes::CONSTRUCTION_BLUE_YELLOW[dark as usize]);
|
||||
// Conflicting sketch geometry (#7244) apart from the lost links and the construction and
|
||||
// centerline lines for a blue-yellow loss: darker on the light view, lighter on the dark.
|
||||
t.sketch_conflict = if dark { Color32::from_rgb(255, 170, 160) } else { Color32::from_rgb(165, 15, 30) };
|
||||
@@ -11,6 +11,14 @@ workspace = true
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
solidcraft-control-egui = { workspace = true }
|
||||
# The widget registry (handles, rects, counts) the UI kit publishes into.
|
||||
solidcraft-ui-registry = { workspace = true }
|
||||
solidcraft-color = { workspace = true }
|
||||
solidcraft-ui-text = { workspace = true }
|
||||
# The token contract every component draws with; the theme system here resolves and sets it.
|
||||
solidcraft-theme = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui-wgpu = { workspace = true }
|
||||
solidcraft-gpu = { workspace = true }
|
||||
@@ -25,10 +33,8 @@ image = { workspace = true, features = ["jpeg", "ico", "gif", "webp", "bmp", "ti
|
||||
ttf-parser = { workspace = true }
|
||||
# Right-to-left interface text (Hebrew): the Unicode Bidirectional Algorithm puts translated
|
||||
# strings in display order, which egui does not do itself (MIT OR Apache-2.0).
|
||||
unicode-bidi = "0.3"
|
||||
# The system's preferred languages on a first start (MIT OR Apache-2.0, maintained by
|
||||
# 1Password): the OS locale on desktop and, with `js`, `navigator.languages` in the browser (#10138).
|
||||
sys-locale = { version = "0.3.2", features = ["js"] }
|
||||
semver = { workspace = true }
|
||||
# Updates: the downloaded package's SHA-256 (against the release's SHA256SUMS.txt), its optional
|
||||
# minisign signature (MIT), and the portable .zip's executables.
|
||||
@@ -47,6 +53,11 @@ nix = { workspace = true }
|
||||
# updated by its installer (MIT; already in the tree as a build dependency).
|
||||
winreg = "0.56"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The macOS menu bar (window_chrome::native_menu; MIT OR Apache-2.0, already in the tree through
|
||||
# the desktop app).
|
||||
muda = { version = "0.21.2", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
solidcraft-engine = { workspace = true, features = ["bundled-render-library", "profile"] }
|
||||
# Parse the source guard structurally, including code after inline test modules.
|
||||
|
||||
@@ -184,22 +184,6 @@ fn icon_data() -> Result<(), String> {
|
||||
}
|
||||
let _ = writeln!(out, "pub(crate) const ACCENT_NAMES: &[&str] = &{:?};", accent_names);
|
||||
let _ = writeln!(out, "pub(crate) const ACCENTS: &[&[(usize, [u8; 3], [u8; 3])]] = &[{}];", accents.join(", "));
|
||||
// UI tokens and SVGs share the same primary strokes, including accessibility overrides.
|
||||
for (constant, value) in [
|
||||
("SKETCH_PRIMARY", palette.get("accents").and_then(|v| v.get("sketch"))),
|
||||
("CONSTRAINT_PRIMARY", palette.get("accents").and_then(|v| v.get("constraint"))),
|
||||
("CONSTRUCTION_PRIMARY", palette.get("accents").and_then(|v| v.get("construction"))),
|
||||
("SKETCH_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("sketch"))),
|
||||
("CONSTRUCTION_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("construction"))),
|
||||
("CONSTRAINT_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("constraint"))),
|
||||
("CONSTRUCTION_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("construction"))),
|
||||
("CONSTRAINT_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("constraint"))),
|
||||
] {
|
||||
let role = value.and_then(|v| v.get("active-3"));
|
||||
let l = hex(role.and_then(|v| v.get("light")), constant)?;
|
||||
let d = hex(role.and_then(|v| v.get("dark")), constant)?;
|
||||
let _ = writeln!(out, "pub(crate) const {constant}: [[u8; 3]; 2] = [{}, {}];", rgb(&l), rgb(&d));
|
||||
}
|
||||
let mut variants = Vec::new();
|
||||
if let Some(groups) = palette.get("variants").and_then(toml::Value::as_table) {
|
||||
for (vision, group) in groups {
|
||||
|
||||
@@ -248,7 +248,7 @@ impl AcpBackend {
|
||||
// Drain replies first: an acknowledgement already waiting must keep the connection.
|
||||
if self.cancelling.is_some_and(|(_, started)| now.saturating_duration_since(started) >= CANCEL_GRACE) {
|
||||
self.proc.shutdown(false);
|
||||
out.push(self.finish_exit(vec![crate::i18n::tr("ui.agent_chat_cancel_timeout").to_string()]));
|
||||
out.push(self.finish_exit(vec![solidcraft_ui_text::tr("ui.agent_chat_cancel_timeout").to_string()]));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -359,9 +359,9 @@ pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result<String, Test
|
||||
}
|
||||
}
|
||||
Ok(FromAgent::Exited(tail)) => {
|
||||
return Err(TestError::Other(tail.last().cloned().unwrap_or_else(|| crate::i18n::tr("ui.agent_chat_exited").to_string())));
|
||||
return Err(TestError::Other(tail.last().cloned().unwrap_or_else(|| solidcraft_ui_text::tr("ui.agent_chat_exited").to_string())));
|
||||
}
|
||||
Err(_) => return Err(TestError::Other(crate::i18n::tr("ui.ai_no_answer").to_string())),
|
||||
Err(_) => return Err(TestError::Other(solidcraft_ui_text::tr("ui.ai_no_answer").to_string())),
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -372,7 +372,7 @@ pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result<String, Test
|
||||
p.send(&acp::request(2, "session/new", acp::session_new_params(&session_cwd, None, &v, acp::SYSTEM_NOTE)));
|
||||
wait_for(&p, 2).map(|_| {
|
||||
let who = acp::agent_info(&v);
|
||||
crate::i18n::format("ui.ai_test_acp_ok", &[("agent", if who.is_empty() { "?" } else { &who })])
|
||||
solidcraft_ui_text::format("ui.ai_test_acp_ok", &[("agent", if who.is_empty() { "?" } else { &who })])
|
||||
})
|
||||
});
|
||||
p.shutdown(true);
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Mode {
|
||||
Mode::ALL.into_iter().find(|m| m.id() == s)
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
crate::i18n::tr(match self {
|
||||
solidcraft_ui_text::tr(match self {
|
||||
Mode::Ask => "ui.ai_mode_ask",
|
||||
Mode::Edits => "ui.ai_mode_edits",
|
||||
Mode::All => "ui.ai_mode_all",
|
||||
@@ -217,7 +217,7 @@ impl Entry {
|
||||
match self.kind {
|
||||
Kind::Api | Kind::Anthropic => format!("{} · {}", self.model, self.base_url),
|
||||
Kind::Acp => match (&self.problem, &self.version) {
|
||||
(Some(p), _) => crate::i18n::format("ui.agent_chat_cannot_start", &[("error", p)]),
|
||||
(Some(p), _) => solidcraft_ui_text::format("ui.agent_chat_cannot_start", &[("error", p)]),
|
||||
(None, Some(v)) => v.clone(),
|
||||
(None, None) => super::panel::short_command(&self.argv.join(" ")),
|
||||
},
|
||||
@@ -228,7 +228,7 @@ impl Entry {
|
||||
pub fn problem_hint(&self) -> Option<String> {
|
||||
let problem = self.problem.as_deref()?;
|
||||
let program = self.argv.first().map(String::as_str).unwrap_or_default();
|
||||
Some(crate::i18n::format(
|
||||
Some(solidcraft_ui_text::format(
|
||||
"ui.agent_chat_reinstall",
|
||||
&[("agent", &self.name), ("program", program), ("error", problem), ("install", self.install)],
|
||||
))
|
||||
@@ -381,7 +381,7 @@ pub fn normalize_url(url: &str) -> Result<String, String> {
|
||||
let u = url.trim().trim_end_matches('/').to_string();
|
||||
let lower = u.to_ascii_lowercase();
|
||||
if !(lower.starts_with("http://") || lower.starts_with("https://")) || u.len() < 10 || u.contains(char::is_whitespace) {
|
||||
return Err(crate::i18n::tr("ui.ai_bad_url").to_string());
|
||||
return Err(solidcraft_ui_text::tr("ui.ai_bad_url").to_string());
|
||||
}
|
||||
Ok(u)
|
||||
}
|
||||
|
||||
@@ -672,7 +672,7 @@ pub struct ApiBackend {
|
||||
impl ApiBackend {
|
||||
pub fn start(cfg: ApiConfig, ctx: StartContext) -> Result<ApiBackend, String> {
|
||||
if cfg.key.is_some() && !super::agents::key_allowed_for(&cfg.base_url, cfg.protocol == Protocol::OpenAi && cfg.allow_insecure_http) {
|
||||
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
|
||||
return Err(solidcraft_ui_text::tr("ui.ai_key_needs_https").to_string());
|
||||
}
|
||||
// One stable session ID per chat for OpenCode Go's routing and prompt cache.
|
||||
let session_id = new_session_id();
|
||||
@@ -1295,7 +1295,7 @@ impl Worker {
|
||||
|
||||
/// What the chat says when a request ran out of time: the limit, and where to raise it.
|
||||
pub fn timed_out(limit_s: u64) -> String {
|
||||
crate::i18n::format("ui.ai_request_timed_out", &[("seconds", &limit_s.to_string())])
|
||||
solidcraft_ui_text::format("ui.ai_request_timed_out", &[("seconds", &limit_s.to_string())])
|
||||
}
|
||||
|
||||
/// A failed request as people read it: one that ran into the endpoint's request timeout (the
|
||||
@@ -1309,10 +1309,10 @@ pub fn http_error(status: u16, body: &Value) -> String {
|
||||
let detail =
|
||||
body.pointer("/error/message").or_else(|| body.get("error")).and_then(Value::as_str).unwrap_or("").chars().take(300).collect::<String>();
|
||||
let what = match status {
|
||||
401 | 403 => crate::i18n::tr("ui.ai_http_key"),
|
||||
404 => crate::i18n::tr("ui.ai_http_not_found"),
|
||||
429 => crate::i18n::tr("ui.ai_http_busy"),
|
||||
413 => crate::i18n::tr("ui.ai_http_too_large"),
|
||||
401 | 403 => solidcraft_ui_text::tr("ui.ai_http_key"),
|
||||
404 => solidcraft_ui_text::tr("ui.ai_http_not_found"),
|
||||
429 => solidcraft_ui_text::tr("ui.ai_http_busy"),
|
||||
413 => solidcraft_ui_text::tr("ui.ai_http_too_large"),
|
||||
_ => "",
|
||||
};
|
||||
format!(
|
||||
@@ -1643,7 +1643,7 @@ fn anthropic_headers(base_url: &str, key: Option<&str>) -> Result<Vec<(String, S
|
||||
let mut headers = vec![("anthropic-version".to_string(), ANTHROPIC_VERSION.to_string())];
|
||||
if let Some(k) = key {
|
||||
if !super::agents::key_safe_for(base_url) {
|
||||
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
|
||||
return Err(solidcraft_ui_text::tr("ui.ai_key_needs_https").to_string());
|
||||
}
|
||||
headers.push(("x-api-key".to_string(), k.to_string()));
|
||||
}
|
||||
@@ -1670,7 +1670,7 @@ pub fn anthropic_model_list(http: &HttpFn, base_url: &str, key: Option<&str>) ->
|
||||
pub fn test_anthropic_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &str) -> Result<String, String> {
|
||||
let model = model.trim();
|
||||
if model.is_empty() {
|
||||
return Err(crate::i18n::tr("ui.ai_need_model").to_string());
|
||||
return Err(solidcraft_ui_text::tr("ui.ai_need_model").to_string());
|
||||
}
|
||||
let mut headers = anthropic_headers(base_url, key)?;
|
||||
headers.push(("Content-Type".to_string(), "application/json".to_string()));
|
||||
@@ -1687,7 +1687,7 @@ pub fn test_anthropic_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>,
|
||||
if status != 200 {
|
||||
return Err(http_error(status, &v));
|
||||
}
|
||||
Ok(crate::i18n::format("ui.ai_test_answered", &[("model", model)]))
|
||||
Ok(solidcraft_ui_text::format("ui.ai_test_answered", &[("model", model)]))
|
||||
}
|
||||
|
||||
/// A tool call's result as text for the model, and whether it failed.
|
||||
@@ -1741,7 +1741,7 @@ fn tool_title(name: &str, args: &Value) -> String {
|
||||
/// endpoint should be, say): an error, so the picker says so and asks again later.
|
||||
fn listed_models(body: &Value, name_key: &str) -> Result<Vec<(String, Option<String>)>, String> {
|
||||
let Some(entries) = body.get("data").and_then(Value::as_array) else {
|
||||
return Err(crate::i18n::tr("ui.agent_chat_models_not_a_list").to_string());
|
||||
return Err(solidcraft_ui_text::tr("ui.agent_chat_models_not_a_list").to_string());
|
||||
};
|
||||
Ok(entries
|
||||
.iter()
|
||||
@@ -1771,7 +1771,7 @@ pub fn model_list(http: &HttpFn, base_url: &str, key: Option<&str>, allow_insecu
|
||||
let mut headers = Vec::new();
|
||||
if let Some(k) = key {
|
||||
if !super::agents::key_allowed_for(base_url, allow_insecure_http) {
|
||||
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
|
||||
return Err(solidcraft_ui_text::tr("ui.ai_key_needs_https").to_string());
|
||||
}
|
||||
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
|
||||
}
|
||||
@@ -1783,13 +1783,16 @@ pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &s
|
||||
let mut headers = Vec::new();
|
||||
if let Some(k) = key {
|
||||
if !super::agents::key_allowed_for(base_url, allow_insecure_http) {
|
||||
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
|
||||
return Err(solidcraft_ui_text::tr("ui.ai_key_needs_https").to_string());
|
||||
}
|
||||
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
|
||||
}
|
||||
let models = model_entries(http, base_url, headers)?;
|
||||
let found = model.is_empty() || models.iter().any(|m| m.id == model);
|
||||
Ok(crate::i18n::format(if found { "ui.ai_test_ok" } else { "ui.ai_test_no_model" }, &[("count", &models.len().to_string()), ("model", model)]))
|
||||
Ok(solidcraft_ui_text::format(
|
||||
if found { "ui.ai_test_ok" } else { "ui.ai_test_no_model" },
|
||||
&[("count", &models.len().to_string()), ("model", model)],
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2642,6 +2645,7 @@ mod tests {
|
||||
/// id that could not be sent back is left out, and the list stays within its caps.
|
||||
#[test]
|
||||
fn a_listing_keeps_the_names_and_stays_within_its_caps() {
|
||||
crate::i18n::install();
|
||||
let mut data = vec![
|
||||
json!({"id": "openai/gpt-x", "name": "OpenAI: GPT X", "description": "Long text", "supported_parameters": ["tools"]}),
|
||||
json!({"id": "same", "name": "same", "display_name": "Same One"}),
|
||||
@@ -2675,7 +2679,7 @@ mod tests {
|
||||
/// picker says why and asks again later; a list with nothing in it is a list.
|
||||
#[test]
|
||||
fn an_answer_that_is_no_model_list_is_an_error() {
|
||||
let expected = crate::i18n::tr("ui.agent_chat_models_not_a_list").to_string();
|
||||
let expected = solidcraft_ui_text::tr("ui.agent_chat_models_not_a_list").to_string();
|
||||
let page: HttpFn = Arc::new(|_: &HttpRequest| Ok((200, b"<!doctype html><html><body>Control UI</body></html>".to_vec())));
|
||||
assert_eq!(model_list(&page, "http://localhost:18789/v1", None, false).unwrap_err(), expected);
|
||||
assert_eq!(anthropic_model_list(&page, "http://localhost:18789/v1", None).unwrap_err(), expected);
|
||||
@@ -3110,6 +3114,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_anthropic_test_sends_one_small_message_and_checks_the_key_rule() {
|
||||
crate::i18n::install();
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::<HttpRequest>::new()));
|
||||
let log = seen.clone();
|
||||
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::backend::{self, PromptPart};
|
||||
use crate::i18n::format as trf;
|
||||
use solidcraft_ui_text::format as trf;
|
||||
|
||||
/// A refusal in the app's language: the file's name, then why.
|
||||
fn refused(key: &str, name: &str, args: &[(&str, &str)]) -> String {
|
||||
@@ -508,6 +508,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bad_files_are_refused_with_a_reason() {
|
||||
crate::i18n::install();
|
||||
let d = dir("bad");
|
||||
let fake = d.join("fake.png");
|
||||
std::fs::write(&fake, b"not a picture at all").unwrap();
|
||||
@@ -533,15 +534,16 @@ mod tests {
|
||||
/// Review of #1744: the refusals were English whatever the app's language.
|
||||
#[test]
|
||||
fn refusals_speak_the_apps_language() {
|
||||
crate::i18n::install();
|
||||
let d = dir("lang");
|
||||
let binary = d.join("bin.json");
|
||||
std::fs::write(&binary, [0xff, 0xfe, 0x00, 0x80]).unwrap();
|
||||
{
|
||||
let _ja = crate::i18n::scope("ja");
|
||||
let _ja = solidcraft_ui_text::scope("ja");
|
||||
assert!(read(&binary).unwrap_err().contains("UTF-8 のテキストではありません"));
|
||||
}
|
||||
{
|
||||
let _zh = crate::i18n::scope("zh");
|
||||
let _zh = solidcraft_ui_text::scope("zh");
|
||||
assert!(read(&binary).unwrap_err().contains("不是 UTF-8 文本"));
|
||||
}
|
||||
assert!(read(&binary).unwrap_err().contains("not UTF-8 text"), "English again outside the scope");
|
||||
@@ -552,6 +554,7 @@ mod tests {
|
||||
/// sent as PNG; SVG goes as its source text; HEIC says why not.
|
||||
#[test]
|
||||
fn each_picture_format_goes_the_way_the_agent_reads_it() {
|
||||
crate::i18n::install();
|
||||
let d = dir("formats");
|
||||
let img = image::RgbaImage::from_pixel(30, 20, image::Rgba([20, 120, 220, 255]));
|
||||
for (file, format, mime) in [
|
||||
@@ -617,6 +620,7 @@ mod tests {
|
||||
/// its size and its place) instead of refused; the agent reads it with its own tools.
|
||||
#[test]
|
||||
fn other_files_go_by_reference() {
|
||||
crate::i18n::install();
|
||||
let d = dir("reference");
|
||||
let zip = d.join("fixtures.zip");
|
||||
std::fs::write(&zip, vec![0u8; 3000]).unwrap();
|
||||
|
||||
@@ -21,7 +21,7 @@ pub enum TestError {
|
||||
impl TestError {
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::AuthRequired => crate::i18n::tr("ui.agent_chat_state_auth"),
|
||||
Self::AuthRequired => solidcraft_ui_text::tr("ui.agent_chat_state_auth"),
|
||||
Self::Other(message) => message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,7 +483,7 @@ fn detect_in(dirs: &[PathBuf], home: Option<&Path>, limit: Duration) -> Detectio
|
||||
argv.extend(k.args.iter().map(|a| a.to_string()));
|
||||
let mut result = if k.reports_version { probe(&path, limit) } else { Probe::default() };
|
||||
if let Some(version) = result.old_node.as_deref() {
|
||||
result.problem = Some(crate::i18n::format("ui.agent_chat_node_too_old", &[("version", version)]));
|
||||
result.problem = Some(solidcraft_ui_text::format("ui.agent_chat_node_too_old", &[("version", version)]));
|
||||
if uses_env_node(&path)
|
||||
&& let Some(node) = compatible_node(dirs, limit)
|
||||
{
|
||||
@@ -561,6 +561,7 @@ mod tests {
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn a_node_adapter_recovers_from_an_incompatible_desktop_runtime() {
|
||||
crate::i18n::install();
|
||||
use super::super::process::{AgentProcess, FromAgent};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! so the parsed blocks are kept per conversation item and parsed again only when its text
|
||||
//! changes.
|
||||
|
||||
use crate::fonts::RichTextExt as _;
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use crate::ui_kit::type_face::RichTextExt as _;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -14,8 +14,8 @@ use super::markdown::{self, Block, Span};
|
||||
use egui::text::{LayoutJob, TextFormat};
|
||||
use egui::{Color32, FontId, Margin, RichText, Stroke, vec2};
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::theme::Tokens;
|
||||
use solidcraft_theme::Tokens;
|
||||
use solidcraft_ui_text::tr;
|
||||
|
||||
/// The parsed blocks of `src`, kept in the context's temporary memory under `slot` (the item's
|
||||
/// index) and replaced when the text changes.
|
||||
@@ -43,7 +43,7 @@ fn job(ui: &egui::Ui, spans: &[Span], size: f32, color: Color32) -> LayoutJob {
|
||||
let plain = TextFormat { font_id: base.clone(), color, ..Default::default() };
|
||||
match s {
|
||||
Span::Text(x) => j.append(x, 0.0, plain),
|
||||
Span::Bold(x) => j.append(x, 0.0, TextFormat { font_id: crate::fonts::semibold(size), color: t.text, ..plain }),
|
||||
Span::Bold(x) => j.append(x, 0.0, TextFormat { font_id: crate::ui_kit::type_face::semibold(size), color: t.text, ..plain }),
|
||||
Span::Italic(x) => j.append(x, 0.0, TextFormat { italics: true, ..plain }),
|
||||
Span::Code(x) => {
|
||||
j.append(x, 0.0, TextFormat { font_id: FontId::monospace(size - (t.font_button - t.font_body)), background: t.field, ..plain })
|
||||
|
||||
@@ -45,9 +45,9 @@ use serde_json::{Value, json};
|
||||
|
||||
use context::Context;
|
||||
|
||||
use crate::i18n::{format as trf, tr};
|
||||
use crate::{ControlRequest, SolveApp};
|
||||
use backend::{ChatBackend, Event, PermissionOption, PromptPart, ToolStatus};
|
||||
use solidcraft_ui_text::{format as trf, tr};
|
||||
|
||||
pub(crate) use panel::{finish_shared, left_inset, owns_keyboard_input, prepare_shared, request_new_chat, shared_content, tool_space};
|
||||
pub use panel::{show, tip as panel_tip, toggle, toggle_shortcut};
|
||||
@@ -565,11 +565,11 @@ impl AgentChat {
|
||||
job: impl FnOnce() -> std::result::Result<attach::Attachment, String> + Send + 'static,
|
||||
) -> std::result::Result<String, String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let language = crate::i18n::language();
|
||||
let language = solidcraft_ui_text::language();
|
||||
std::thread::Builder::new()
|
||||
.name("chat-file".into())
|
||||
.spawn(move || {
|
||||
let _language = crate::i18n::scope(language);
|
||||
let _language = solidcraft_ui_text::scope(language);
|
||||
let _ = tx.send(job());
|
||||
})
|
||||
.map_err(|e| trf("ui.agent_chat_file_error", &[("name", &name), ("error", &e.to_string())]))?;
|
||||
@@ -2387,8 +2387,8 @@ fn set_config_option_checked(app: &mut SolveApp, id: &str, value: &str, listed_o
|
||||
|
||||
/// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`,
|
||||
/// `connect_at_start` and `open_at_start` (the launch preferences), `instructions` (the custom
|
||||
/// instructions, #8220), `open`,
|
||||
/// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `attach` (a file's path, for the
|
||||
/// instructions, #8220), `open`, `toggle` (show or hide the panel as its button does, unfolding a
|
||||
/// folded one), `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `attach` (a file's path, for the
|
||||
/// next message), `remove_file` (index), `send`, `attach_view`, `attach_selection`, `pick`,
|
||||
/// `permission` (an option id, or `"cancel"`), `approve` (`true`/`false`, the oldest held
|
||||
/// request), `cancel` (Stop), `send_now` (steer: stop, then send the queue), `remove_pending` (index), `undo` (the newest message's changes); returns the panel's state.
|
||||
@@ -2503,6 +2503,9 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
|
||||
if let Some(o) = p.get("open").and_then(Value::as_bool) {
|
||||
app.agent_chat.open = o;
|
||||
}
|
||||
if p.get("toggle").and_then(Value::as_bool) == Some(true) {
|
||||
toggle(app);
|
||||
}
|
||||
if let Some(s) = p.get("side").and_then(Value::as_str) {
|
||||
if !matches!(s, "left" | "right") {
|
||||
return Err("side must be left or right".into());
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
//! or in Preferences), resizable, folded to a thin strip with Minimise, closed with ×. Its side,
|
||||
//! width and fold are preferences, so they come back next time.
|
||||
|
||||
use crate::fonts::RichTextExt as _;
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use crate::ui_kit::type_face::RichTextExt as _;
|
||||
use crate::ui_kit::{Container, Edge, Surface};
|
||||
use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||||
|
||||
use super::*;
|
||||
use crate::theme::Tokens;
|
||||
use crate::ui_kit;
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
pub const MIN_WIDTH: f32 = 280.0;
|
||||
pub const MAX_WIDTH: f32 = 760.0;
|
||||
@@ -260,7 +260,7 @@ pub(crate) fn left_inset(app: &SolveApp, available: Rect) -> f32 {
|
||||
&& app.preferences.agent_chat_side == "left"
|
||||
{
|
||||
let width = if app.preferences.agent_chat_minimized { STRIP } else { app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH) };
|
||||
(width + crate::shell::SHELL_GAP).min(available.width().max(0.0))
|
||||
(width + crate::ui_kit::SHELL_GAP).min(available.width().max(0.0))
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
@@ -276,9 +276,9 @@ pub(crate) fn tool_space(app: &SolveApp, mut available: Rect) -> Rect {
|
||||
&& let Some(chat) = app.agent_chat.panel_rect.filter(|r| r.is_finite() && r.is_positive())
|
||||
{
|
||||
if app.preferences.agent_chat_side == "left" {
|
||||
available.min.x = crate::shell::within(chat.right() + crate::shell::SHELL_GAP, available.left(), available.right());
|
||||
available.min.x = crate::ui_kit::within(chat.right() + crate::ui_kit::SHELL_GAP, available.left(), available.right());
|
||||
} else {
|
||||
available.max.x = crate::shell::within(chat.left() - crate::shell::SHELL_GAP, available.left(), available.right());
|
||||
available.max.x = crate::ui_kit::within(chat.left() - crate::ui_kit::SHELL_GAP, available.left(), available.right());
|
||||
}
|
||||
}
|
||||
available
|
||||
@@ -287,7 +287,7 @@ pub(crate) fn tool_space(app: &SolveApp, mut available: Rect) -> Rect {
|
||||
/// The same outer rectangle is used for the floating sheet and its docking preview.
|
||||
fn floating_bounds(app: &SolveApp, available: Rect, left: bool) -> Rect {
|
||||
let t = Tokens::get();
|
||||
let gap = crate::shell::SHELL_GAP;
|
||||
let gap = crate::ui_kit::SHELL_GAP;
|
||||
let mut bounds = available.shrink(gap);
|
||||
// The ribbon already supplies the top gap shared by browser and history cards.
|
||||
bounds.min.y = available.top();
|
||||
@@ -890,7 +890,7 @@ fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: boo
|
||||
zone.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
tr(if to_left { "ui.agent_chat_dock_left" } else { "ui.agent_chat_dock_right" }),
|
||||
egui::FontId::proportional(crate::theme::Tokens::get().font_heading),
|
||||
egui::FontId::proportional(solidcraft_theme::Tokens::get().font_heading),
|
||||
t.text,
|
||||
);
|
||||
}
|
||||
@@ -1329,7 +1329,7 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
if let Some(a) = &app.agent_chat.auth_status {
|
||||
ui.label(RichText::new(a).color(t.text_dim).size(crate::theme::Tokens::get().font_small));
|
||||
ui.label(RichText::new(a).color(t.text_dim).size(solidcraft_theme::Tokens::get().font_small));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1707,7 +1707,7 @@ fn auth_box(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::text(ui, tr("ui.agent_chat_auth_none"), ui_kit::Text::Note("auth_none"));
|
||||
}
|
||||
for m in methods {
|
||||
ui.add_space(crate::theme::Tokens::get().space_4);
|
||||
ui.add_space(solidcraft_theme::Tokens::get().space_4);
|
||||
let desc = m.description.trim();
|
||||
ui_kit::text(
|
||||
ui,
|
||||
@@ -1737,7 +1737,7 @@ fn auth_box(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(crate::theme::Tokens::get().space_4);
|
||||
ui.add_space(solidcraft_theme::Tokens::get().space_4);
|
||||
if ui_kit::button(ui, tr("ui.agent_chat_auth_retry"), ui_kit::Button::Normal).clicked() {
|
||||
connect(app, ui.ctx());
|
||||
}
|
||||
@@ -1772,7 +1772,7 @@ fn copy_message_menu(ui: &egui::Ui, response: &egui::Response, n: usize, text: &
|
||||
.frame(ui_kit::Menu::frame())
|
||||
.open_memory(if opened { Some(egui::SetOpenCommand::Bool(true)) } else { None })
|
||||
.show(|ui| {
|
||||
ui_kit::Menu::show(ui, crate::context_menu::LIST_W, |ui| {
|
||||
ui_kit::Menu::show(ui, crate::ui_kit::LIST_W, |ui| {
|
||||
let item = ui_kit::MenuItem::new("copy_message", tr("ui.agent_chat_copy_message"), "copy");
|
||||
let row = item.show(ui);
|
||||
crate::scenario::publish_handle(&format!("agent_chat:copy_menu:{n}"), row.rect.center());
|
||||
@@ -1985,9 +1985,9 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
Item::Changes { steps, doc, undone } => {
|
||||
ui.horizontal(|ui| {
|
||||
let text = trf("ui.agent_chat_changes", &[("n", &steps.to_string())]);
|
||||
ui.label(RichText::new(text).color(t.text_dim).size(crate::theme::Tokens::get().font_caption));
|
||||
ui.label(RichText::new(text).color(t.text_dim).size(solidcraft_theme::Tokens::get().font_caption));
|
||||
if *undone {
|
||||
ui.label(RichText::new(tr("ui.agent_chat_undone")).color(t.text_dim).size(crate::theme::Tokens::get().font_caption));
|
||||
ui.label(RichText::new(tr("ui.agent_chat_undone")).color(t.text_dim).size(solidcraft_theme::Tokens::get().font_caption));
|
||||
} else if Arc::ptr_eq(doc, ¤t_doc) {
|
||||
let b =
|
||||
ui_kit::button(ui, tr("ui.agent_chat_undo_changes"), ui_kit::Button::Normal).backing_on_hover_text(tr("ui.ai_undo_tip"));
|
||||
@@ -2024,7 +2024,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let tag = if stopped && !busy { "ui.agent_chat_queued_stopped" } else { "ui.agent_chat_queued" };
|
||||
ui.label(RichText::new(tr(tag)).color(t.accent).semibold().size(crate::theme::Tokens::get().font_small));
|
||||
ui.label(RichText::new(tr(tag)).color(t.accent).semibold().size(solidcraft_theme::Tokens::get().font_small));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let x = ui_kit::button(ui, "×", ui_kit::Button::Small).backing_on_hover_text(tr("ui.agent_chat_remove"));
|
||||
crate::scenario::publish_handle(&format!("agent_chat:pending_remove:{n}"), x.rect.center());
|
||||
@@ -2059,7 +2059,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
super::markdown_view::selectable(ui, egui::Label::new(RichText::new(&p.text).color(t.text)).wrap());
|
||||
}
|
||||
if p.image {
|
||||
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(crate::theme::Tokens::get().font_small));
|
||||
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(solidcraft_theme::Tokens::get().font_small));
|
||||
}
|
||||
context_chips(ui, &p.context, false);
|
||||
files_line(ui, p.files.iter().map(|f| f.name.as_str()));
|
||||
@@ -2274,7 +2274,7 @@ fn chip(ui: &mut egui::Ui, thumbs: &mut std::collections::HashMap<u64, egui::Tex
|
||||
r.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
f.mark(),
|
||||
egui::FontId::proportional(crate::theme::Tokens::get().font_micro),
|
||||
egui::FontId::proportional(solidcraft_theme::Tokens::get().font_micro),
|
||||
color,
|
||||
);
|
||||
r.width()
|
||||
@@ -2533,10 +2533,10 @@ mod tests {
|
||||
#[test]
|
||||
fn assistant_header_and_composer_keep_actions_inside_each_panel_width() {
|
||||
for dark in [false, true] {
|
||||
crate::theme::set_dark(dark);
|
||||
solidcraft_theme::set_dark(dark);
|
||||
for width in [280.0, 440.0, 760.0] {
|
||||
let ctx = crate::fonts::test_context();
|
||||
crate::theme::apply(&ctx);
|
||||
solidcraft_theme::apply(&ctx);
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
app.agent_chat.detection = Some(discover::Detection::default());
|
||||
app.agent_chat.picking = Some(false);
|
||||
|
||||
@@ -208,10 +208,10 @@ fn start_listing(app: &mut SolveApp, ctx: &egui::Context, key: ListingKey, secre
|
||||
};
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (protocol, url, allow_insecure_http) = (key.0, key.1.clone(), key.3);
|
||||
let language = crate::i18n::language();
|
||||
let language = solidcraft_ui_text::language();
|
||||
let wake = ctx.clone();
|
||||
let spawned = std::thread::Builder::new().name("endpoint-models".into()).spawn(move || {
|
||||
let _language = crate::i18n::scope(language);
|
||||
let _language = solidcraft_ui_text::scope(language);
|
||||
let list = secret().and_then(|secret| match protocol {
|
||||
Protocol::OpenAi => super::api_client::model_list(&http, &url, secret.as_deref(), allow_insecure_http),
|
||||
Protocol::Anthropic => super::api_client::anthropic_model_list(&http, &url, secret.as_deref()),
|
||||
@@ -519,7 +519,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
permissions_section(app, ui);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let browsing = !crate::prefs_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_ASSISTANT;
|
||||
let browsing = !crate::ui_kit::settings_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_ASSISTANT;
|
||||
if app.preferences.ai_features && browsing && app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
refresh(app, &ui.ctx().clone());
|
||||
}
|
||||
@@ -561,7 +561,7 @@ fn instructions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let response = ui_kit::text_area(ui, tr("ai.prefs.instructions"), id, text, 6, acp::INSTRUCTIONS_LIMIT);
|
||||
if response.has_focus() {
|
||||
// Typed text belongs to the field, not to Preferences search.
|
||||
crate::prefs_search::note_key_sink(ui.ctx());
|
||||
crate::ui_kit::settings_search::note_key_sink(ui.ctx());
|
||||
}
|
||||
crate::scenario::publish_target("prefs:agent_instructions", response.rect);
|
||||
ui_kit::text(ui, tr("ai.prefs.instructions_note"), ui_kit::Text::Hint("instructions"));
|
||||
@@ -618,7 +618,7 @@ fn permissions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let names: std::collections::HashMap<_, _> = agents::entries(app).into_iter().map(|entry| (entry.id, entry.name)).collect();
|
||||
for (agent, tools) in remembered {
|
||||
let t = crate::theme::Tokens::get();
|
||||
let t = solidcraft_theme::Tokens::get();
|
||||
ui_kit::settings_block(ui, names.get(&agent).unwrap_or(&agent), t.space_24 * 6.0 + t.space_6, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let reset = ui_kit::button(ui, tr("ui.ai_reset"), ui_kit::Button::Small);
|
||||
@@ -635,7 +635,7 @@ fn permissions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
fn panel_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get();
|
||||
let t = solidcraft_theme::Tokens::get();
|
||||
ui_kit::settings_section(ui, "panel", tr("ui.ai_panel"), |ui| {
|
||||
let label_width = [tr("ui.agent_chat_side"), tr("ui.agent_chat_turn_limit")]
|
||||
.iter()
|
||||
@@ -658,20 +658,20 @@ fn panel_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let previous = usize::from(p.agent_chat_turn_limit != 0);
|
||||
let mut limit = previous;
|
||||
let options = [tr("ui.agent_chat_turn_unlimited"), tr("prefs.assistant_limited")];
|
||||
crate::prefs_search::note_current(ui, &options.join(" "));
|
||||
crate::ui_kit::settings_search::note_current(ui, &options.join(" "));
|
||||
let dropdown_width = ui_kit::ContentWidth::dropdown(ui, &options);
|
||||
let response =
|
||||
ui_kit::settings_control_width(ui, dropdown_width, |ui| ui_kit::dropdown(ui, "assistant_turn_limit", &mut limit, &options));
|
||||
let query = crate::prefs_search::query(ui);
|
||||
let query = crate::ui_kit::settings_search::query(ui);
|
||||
if let Some(option) = options
|
||||
.iter()
|
||||
.find(|option| option.to_lowercase() == query)
|
||||
.or_else(|| options.iter().find(|option| crate::prefs_search::texts_hit(ui, &[*option])))
|
||||
.or_else(|| options.iter().find(|option| crate::ui_kit::settings_search::texts_hit(ui, &[*option])))
|
||||
{
|
||||
crate::prefs_search::remember(ui, option, response.rect);
|
||||
crate::ui_kit::settings_search::remember(ui, option, response.rect);
|
||||
}
|
||||
for option in options {
|
||||
crate::prefs_search::follow(ui, option, response.rect);
|
||||
crate::ui_kit::settings_search::follow(ui, option, response.rect);
|
||||
}
|
||||
crate::scenario::publish_target("prefs:agent_chat_turn_limit:mode", response.rect);
|
||||
if limit != previous {
|
||||
@@ -699,14 +699,14 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui,
|
||||
|ui| {
|
||||
let response = ui_kit::checkbox(ui, tr("ai.prefs.show_pointer"), &mut app.ui.agent_cursor.show)
|
||||
.backing_on_hover_text(crate::i18n::tr_label("Show the pointer an AI agent moves (#35)"));
|
||||
.backing_on_hover_text(solidcraft_ui_text::tr_label("Show the pointer an AI agent moves (#35)"));
|
||||
crate::scenario::publish_target("prefs:agent_pointer_show", response.rect);
|
||||
ui.add_enabled_ui(app.ui.agent_cursor.show, |ui| {
|
||||
let speed_label_width = ui_kit::ContentWidth::label(ui, tr("ai.prefs.pointer_speed")) + crate::theme::Tokens::get().space_4;
|
||||
let speed_label_width = ui_kit::ContentWidth::label(ui, tr("ai.prefs.pointer_speed")) + solidcraft_theme::Tokens::get().space_4;
|
||||
ui_kit::settings_form(ui, speed_label_width, |ui| {
|
||||
ui_kit::row(ui, tr("ai.prefs.pointer_speed"), |ui| {
|
||||
let name = tr("prefs.assistant_pointer_speed");
|
||||
crate::prefs_search::note_current(ui, name);
|
||||
crate::ui_kit::settings_search::note_current(ui, name);
|
||||
let response =
|
||||
ui_kit::named_control(ui, name, |ui| crate::agent_cursor::speed_slider(ui, &mut app.ui.agent_cursor.speed));
|
||||
crate::scenario::publish_target("prefs:agent_pointer_speed", response.rect);
|
||||
@@ -717,11 +717,11 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), value, name));
|
||||
// The visible row owns Speed and its legacy Motion speed alias. The
|
||||
// native editor's name owns queries that only match Pointer speed.
|
||||
if !crate::prefs_search::texts_hit(ui, &[tr("ai.prefs.pointer_speed")]) {
|
||||
crate::prefs_search::remember(ui, name, response.rect);
|
||||
if !crate::ui_kit::settings_search::texts_hit(ui, &[tr("ai.prefs.pointer_speed")]) {
|
||||
crate::ui_kit::settings_search::remember(ui, name, response.rect);
|
||||
}
|
||||
for label in [tr("ai.prefs.pointer_speed"), name] {
|
||||
crate::prefs_search::follow(ui, label, response.rect);
|
||||
crate::ui_kit::settings_search::follow(ui, label, response.rect);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -734,7 +734,7 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
(app.ui.agent_cursor.show, app.ui.agent_cursor.speed.label(), app.ui.agent_cursor.follow_camera)
|
||||
},
|
||||
|ui, (visible, speed, follow)| ui_kit::settings_pointer_preview(ui, visible, crate::i18n::tr_label(&speed), follow),
|
||||
|ui, (visible, speed, follow)| ui_kit::settings_pointer_preview(ui, visible, solidcraft_ui_text::tr_label(&speed), follow),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -767,7 +767,7 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let mut retimed: Option<(String, u64)> = None;
|
||||
let mut reeffort: Option<(String, String)> = None;
|
||||
let mut picked_model: Option<(String, ui_kit::Picked)> = None;
|
||||
let t = crate::theme::Tokens::get();
|
||||
let t = solidcraft_theme::Tokens::get();
|
||||
ui_kit::settings_section_count(ui, "agents", tr("ui.ai_agents"), Some(&all.len().to_string()), |ui| {
|
||||
if app.agent_chat.detecting.is_some() {
|
||||
ui_kit::text(ui, tr("ui.agent_chat_detecting"), ui_kit::Text::Hint("detecting"));
|
||||
@@ -781,12 +781,12 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
for entry in &all {
|
||||
ui.push_id(&entry.id, |ui| {
|
||||
let detail = entry.detail();
|
||||
let gate = crate::prefs_search::block_gate(ui, &entry.name, Some(&format!("{} {detail}", entry.id)));
|
||||
let before = crate::prefs_search::hits(ui);
|
||||
let gate = crate::ui_kit::settings_search::block_gate(ui, &entry.name, Some(&format!("{} {detail}", entry.id)));
|
||||
let before = crate::ui_kit::settings_search::hits(ui);
|
||||
let mut paint = |ui: &mut egui::Ui| {
|
||||
let card = ui_kit::settings_card(ui, |ui| {
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
crate::prefs_search::visible(ui, &[&entry.name, &entry.id, &detail]);
|
||||
crate::ui_kit::settings_search::visible(ui, &[&entry.name, &entry.id, &detail]);
|
||||
ui.label(egui::RichText::new(&entry.name).strong().size(t.font_label).color(t.form_ink));
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(ui.available_width(), t.control_height),
|
||||
@@ -851,7 +851,7 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let shown = current.map_or_else(|| tr("ui.ai_mode_global").to_owned(), |mode| mode.label().to_owned());
|
||||
let mut pick: Option<Option<Mode>> = None;
|
||||
let options: Vec<_> = std::iter::once(tr("ui.ai_mode_global")).chain(Mode::ALL.map(Mode::label)).collect();
|
||||
crate::prefs_search::note_current(ui, &options.join(" "));
|
||||
crate::ui_kit::settings_search::note_current(ui, &options.join(" "));
|
||||
let response = ui_kit::settings_control_width(ui, t.space_24 * 9.0 + t.space_4, |ui| {
|
||||
ui_kit::dropdown_menu(ui, ("sc_ai_mode", &entry.id), &shown, |ui| {
|
||||
if ui_kit::list_picker(ui, "global", tr("ui.ai_mode_global"), current.is_none()).clicked() {
|
||||
@@ -864,16 +864,16 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
})
|
||||
});
|
||||
let query = crate::prefs_search::query(ui);
|
||||
let query = crate::ui_kit::settings_search::query(ui);
|
||||
if let Some(option) = options
|
||||
.iter()
|
||||
.find(|option| option.to_lowercase() == query)
|
||||
.or_else(|| options.iter().find(|option| crate::prefs_search::texts_hit(ui, &[*option])))
|
||||
.or_else(|| options.iter().find(|option| crate::ui_kit::settings_search::texts_hit(ui, &[*option])))
|
||||
{
|
||||
crate::prefs_search::remember(ui, option, response.rect);
|
||||
crate::ui_kit::settings_search::remember(ui, option, response.rect);
|
||||
}
|
||||
for option in options {
|
||||
crate::prefs_search::follow(ui, option, response.rect);
|
||||
crate::ui_kit::settings_search::follow(ui, option, response.rect);
|
||||
}
|
||||
match pick {
|
||||
Some(Some(mode)) => {
|
||||
@@ -965,23 +965,23 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
crate::scenario::publish_rect(&format!("prefs:ai_card:{}", entry.id), card.response.rect);
|
||||
let metadata = [&entry.name, &entry.id, &detail];
|
||||
if let Some(text) = metadata.iter().find(|text| crate::prefs_search::texts_hit(ui, &[*text])) {
|
||||
crate::prefs_search::remember(ui, text, card.response.rect);
|
||||
if let Some(text) = metadata.iter().find(|text| crate::ui_kit::settings_search::texts_hit(ui, &[*text])) {
|
||||
crate::ui_kit::settings_search::remember(ui, text, card.response.rect);
|
||||
}
|
||||
for text in metadata {
|
||||
crate::prefs_search::follow(ui, text, card.response.rect);
|
||||
crate::ui_kit::settings_search::follow(ui, text, card.response.rect);
|
||||
}
|
||||
};
|
||||
let show = gate.as_ref().is_none_or(|gate| gate.show);
|
||||
if show {
|
||||
paint(ui);
|
||||
} else {
|
||||
crate::prefs_search::run_hidden(ui, &entry.id, |ui| {
|
||||
crate::ui_kit::settings_search::run_hidden(ui, &entry.id, |ui| {
|
||||
paint(ui);
|
||||
});
|
||||
}
|
||||
if let Some(gate) = gate {
|
||||
crate::prefs_search::block_finish(ui, &gate, show, before);
|
||||
crate::ui_kit::settings_search::block_finish(ui, &gate, show, before);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1496,7 +1496,7 @@ mod card_tests {
|
||||
.job
|
||||
.sections
|
||||
.iter()
|
||||
.all(|section| section.format.font_id.size == crate::theme::Tokens::get().font_page_heading);
|
||||
.all(|section| section.format.font_id.size == solidcraft_theme::Tokens::get().font_page_heading);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1513,7 +1513,7 @@ mod card_tests {
|
||||
for key in ["assistant_availability", "permissions", "agents", "instructions", "panel", "assistant_connecting", "agent_pointer"] {
|
||||
let section = scenario::rect_of(&format!("prefs:section:{key}")).unwrap();
|
||||
assert!((section.left() - body.left()).abs() <= 1.0 && (section.right() - body.right()).abs() <= 1.0);
|
||||
assert!((section.height() - (crate::theme::Tokens::get().control_height + crate::theme::Tokens::get().space_2)).abs() <= 1.0);
|
||||
assert!((section.height() - (solidcraft_theme::Tokens::get().control_height + solidcraft_theme::Tokens::get().space_2)).abs() <= 1.0);
|
||||
if let Some(bottom) = previous {
|
||||
assert!((section.top() - bottom - 38.0).abs() <= 1.0, "{theme}: {key} retains the page section rhythm");
|
||||
}
|
||||
@@ -1524,7 +1524,7 @@ mod card_tests {
|
||||
|
||||
#[test]
|
||||
fn pointer_speed_search_reveals_one_real_slider_in_all_enablement_states() {
|
||||
use crate::prefs_search::{nav, set_cursor};
|
||||
use crate::ui_kit::settings_search::{nav, set_cursor};
|
||||
for theme in ["light", "dark"] {
|
||||
for (ai, show) in [(true, true), (true, false), (false, true), (false, false)] {
|
||||
let mut h = Harness::new();
|
||||
@@ -1569,7 +1569,7 @@ mod card_tests {
|
||||
|
||||
#[test]
|
||||
fn overlapping_agent_metadata_has_one_card_target_and_keeps_fields_indexed() {
|
||||
use crate::prefs_search::{nav, set_cursor};
|
||||
use crate::ui_kit::settings_search::{nav, set_cursor};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::i18n::{format as trf, tr, tr_label};
|
||||
use solidcraft_ui_text::{format as trf, tr, tr_label};
|
||||
|
||||
/// Steps of a batch named before "and N more".
|
||||
const MAX_STEPS: usize = 4;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
fn transcript_clipboard_harness(layout: crate::shell::LayoutMode) -> crate::scenario::Harness {
|
||||
fn transcript_clipboard_harness(layout: crate::ui_kit::LayoutMode) -> crate::scenario::Harness {
|
||||
let mut h = narrow_chat();
|
||||
h.app.ui.layout = layout;
|
||||
h.app.preferences.agent_chat_width = 500.0;
|
||||
@@ -36,7 +36,7 @@ fn transcript_click(h: &mut crate::scenario::Harness, pos: egui::Pos2) {
|
||||
|
||||
#[test]
|
||||
fn transcript_copy_and_cut_do_not_touch_the_cad_clipboard() {
|
||||
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
|
||||
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
|
||||
let mut h = transcript_clipboard_harness(layout);
|
||||
let at = transcript_text_position(&h);
|
||||
transcript_click(&mut h, at);
|
||||
@@ -57,7 +57,7 @@ fn transcript_copy_and_cut_do_not_touch_the_cad_clipboard() {
|
||||
|
||||
#[test]
|
||||
fn transcript_paste_does_not_insert_a_previously_copied_body() {
|
||||
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
|
||||
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
|
||||
let mut h = transcript_clipboard_harness(layout);
|
||||
h.frame_with(vec![egui::Event::Copy]);
|
||||
assert!(!h.app.copied_bodies.is_empty(), "prime the real CAD clipboard");
|
||||
@@ -77,7 +77,7 @@ fn transcript_paste_does_not_insert_a_previously_copied_body() {
|
||||
|
||||
#[test]
|
||||
fn transcript_key_events_do_not_run_cad_shortcuts() {
|
||||
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
|
||||
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
|
||||
let mut h = transcript_clipboard_harness(layout);
|
||||
let at = transcript_text_position(&h);
|
||||
transcript_click(&mut h, at);
|
||||
@@ -106,7 +106,7 @@ fn transcript_key_events_do_not_run_cad_shortcuts() {
|
||||
#[test]
|
||||
fn transcript_shortcut_ownership_ends_when_the_panel_is_hidden() {
|
||||
for hidden in ["closed", "minimized", "disabled"] {
|
||||
let mut h = transcript_clipboard_harness(crate::shell::LayoutMode::Floating);
|
||||
let mut h = transcript_clipboard_harness(crate::ui_kit::LayoutMode::Floating);
|
||||
h.frame_with(vec![egui::Event::Copy]);
|
||||
let at = transcript_text_position(&h);
|
||||
transcript_click(&mut h, at);
|
||||
@@ -1144,7 +1144,7 @@ fn leaving_a_tab_refuses_admitted_commands_animations_and_screenshots() {
|
||||
// Capture a request already waiting for a frame, as well as a queued command.
|
||||
let screenshot = app.control_held.pop_back().unwrap();
|
||||
app.run_control(&ctx, screenshot);
|
||||
assert_eq!(app.pending_shots.len(), 1);
|
||||
assert_eq!(app.shots.pending.len(), 1);
|
||||
if animate {
|
||||
app.drain_control(&ctx);
|
||||
assert!(app.agent.busy(), "the command is waiting on cursor animation");
|
||||
@@ -1160,8 +1160,8 @@ fn leaving_a_tab_refuses_admitted_commands_animations_and_screenshots() {
|
||||
assert!(response["error"].as_str().is_some_and(|e| e.contains("document tab was left")));
|
||||
}
|
||||
assert!(Arc::ptr_eq(&app.session.doc, &other));
|
||||
assert!(app.session.undo.is_empty() && app.pending_shots.is_empty());
|
||||
assert!(app.queued_shots.is_empty(), "a cancelled screenshot must not capture the incoming tab");
|
||||
assert!(app.session.undo.is_empty() && app.shots.pending.is_empty());
|
||||
assert!(app.shots.queued.is_empty(), "a cancelled screenshot must not capture the incoming tab");
|
||||
crate::documents::switch(&mut app, 0);
|
||||
assert!(app.session.doc.features.is_empty() && app.session.undo.is_empty());
|
||||
// Adopting into a blank tab replaces the document and clears its old conversation.
|
||||
@@ -2906,7 +2906,7 @@ fn pictures_are_read_off_the_window_thread() {
|
||||
send(&mut app, &ctx, words);
|
||||
assert_eq!(script.prompts().len(), 1);
|
||||
{
|
||||
let _ja = crate::i18n::scope("ja");
|
||||
let _ja = solidcraft_ui_text::scope("ja");
|
||||
control(&mut app, &ctx, &json!({"attach": at(&d, "fake.png")})).unwrap();
|
||||
}
|
||||
settle_files(&mut app, &ctx);
|
||||
@@ -3184,7 +3184,7 @@ fn a_press_at_the_edge_without_a_drag_towards_it_scrolls_nothing() {
|
||||
/// at the panel's edge or is cut off at the other one.
|
||||
#[test]
|
||||
fn the_agent_list_keeps_the_panels_side_margin() {
|
||||
use crate::shell::LayoutMode;
|
||||
use crate::ui_kit::LayoutMode;
|
||||
for layout in [LayoutMode::Docked, LayoutMode::Floating] {
|
||||
let mut h = narrow_chat();
|
||||
h.app.ui.layout = layout;
|
||||
@@ -3200,7 +3200,7 @@ fn the_agent_list_keeps_the_panels_side_margin() {
|
||||
});
|
||||
}
|
||||
let at = |label: &str| texts.iter().find(|(text, r)| text == label && panel.contains(r.center())).map(|(_, r)| *r);
|
||||
let margin = crate::theme::Tokens::get().space_12 - 0.5;
|
||||
let margin = solidcraft_theme::Tokens::get().space_12 - 0.5;
|
||||
let agent = at(tr("ui.agent_chat_pick")).expect("the Agent row");
|
||||
assert!(agent.left() >= panel.left() + margin, "{layout:?}: Agent at {agent:?} in {panel:?}");
|
||||
let refresh = at(tr("ui.agent_chat_refresh")).expect("Refresh");
|
||||
@@ -4614,12 +4614,12 @@ fn the_composer_hint_stays_on_one_row_at_the_narrowest_width() {
|
||||
continue;
|
||||
}
|
||||
let language = path.file_stem().unwrap().to_string_lossy().into_owned();
|
||||
let _scope = crate::i18n::scope(&language);
|
||||
let _scope = solidcraft_ui_text::scope(&language);
|
||||
for key in ["ui.agent_chat_placeholder", "ui.agent_chat_placeholder_busy"] {
|
||||
let hint = crate::i18n::tr(key).to_owned();
|
||||
let hint = solidcraft_ui_text::tr(key).to_owned();
|
||||
let mut rows = 0;
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
rows = crate::ui_kit::assistant_hint_galley(ui, &hint, width - 2.0 * crate::theme::Tokens::get().form_inset).rows.len();
|
||||
rows = crate::ui_kit::assistant_hint_galley(ui, &hint, width - 2.0 * solidcraft_theme::Tokens::get().form_inset).rows.len();
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(rows, 1, "{language} {key} at {width} wide: {hint}");
|
||||
@@ -4634,7 +4634,7 @@ fn the_composer_hint_stays_on_one_row_at_the_narrowest_width() {
|
||||
#[test]
|
||||
fn tool_room_beside_the_docked_assistant_survives_an_inside_out_view() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
app.ui.layout = crate::shell::LayoutMode::Floating;
|
||||
app.ui.layout = crate::ui_kit::LayoutMode::Floating;
|
||||
app.preferences.ai_features = true;
|
||||
app.agent_chat.open = true;
|
||||
app.preferences.agent_chat_popped = false;
|
||||
@@ -4699,7 +4699,7 @@ fn a_prompt_at_the_top_of_the_conversation_is_as_tall_as_its_content() {
|
||||
let button = crate::scenario::handle_at("agent_chat:copy:0").expect("the prompt's Copy message button");
|
||||
let prompt = crate::scenario::rect_of("agent_chat:msg:0").expect("the prompt's message area");
|
||||
let reply = crate::scenario::rect_of("agent_chat:msg:1").expect("the reply's message area");
|
||||
let row = crate::theme::Tokens::get().control_height;
|
||||
let row = solidcraft_theme::Tokens::get().control_height;
|
||||
assert!(prompt.bottom() - button.y < row, "the bubble ends {} below its button", prompt.bottom() - button.y);
|
||||
assert!(reply.top() - button.y < 2.0 * row, "the reply starts {} below the prompt's button", reply.top() - button.y);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn speed_slider(ui: &mut egui::Ui, speed: &mut Speed) -> egui::Response {
|
||||
1.0..=5.5,
|
||||
crate::ui_kit::Slider {
|
||||
step: Some(0.5),
|
||||
format: |v| if v > 5.25 { crate::i18n::tr_label("Instant").into() } else { format!("{v:.2}x") },
|
||||
format: |v| if v > 5.25 { solidcraft_ui_text::tr_label("Instant").into() } else { format!("{v:.2}x") },
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
@@ -493,7 +493,7 @@ pub fn paint(app: &SolveApp, ctx: &egui::Context) {
|
||||
crate::icons::pointer::paint(&painter, p);
|
||||
if let Some((_, label)) = app.agent.job.as_ref().and_then(|job| job.stops.front()).filter(|(_, label)| !label.is_empty()) {
|
||||
use crate::ui_kit::{Container, Surface};
|
||||
let t = crate::theme::Tokens::get();
|
||||
let t = solidcraft_theme::Tokens::get();
|
||||
Container::new(ctx, Surface::Popup)
|
||||
.area(egui::Id::unique("sc_agent_command"))
|
||||
.order(egui::Order::Debug)
|
||||
@@ -504,7 +504,7 @@ pub fn paint(app: &SolveApp, ctx: &egui::Context) {
|
||||
.show(ctx, |ui| {
|
||||
let frame = Container::popup_frame(ui.style()).fill(t.overlay);
|
||||
Container::show_backing(ui, frame, |ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).size(t.font_label).color(t.text));
|
||||
ui.label(egui::RichText::new(solidcraft_ui_text::tr_label(label)).size(t.font_label).color(t.text));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ pub fn tick(app: &mut SolveApp) {
|
||||
}
|
||||
let _ = std::fs::write(&file, serde_json::to_string(&all).unwrap_or_default());
|
||||
let names = fresh.iter().map(|c| client_name(c)).collect::<Vec<_>>().join(", ");
|
||||
app.set_status(crate::i18n::format("ai.connected_toast", &[("clients", &names)]), false);
|
||||
app.set_status(solidcraft_ui_text::format("ai.connected_toast", &[("clients", &names)]), false);
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => app.ai_connect.job = None,
|
||||
|
||||
@@ -213,7 +213,7 @@ impl State {
|
||||
self.retire();
|
||||
return Scope { previous, alpha };
|
||||
}
|
||||
let colors = crate::theme::Tokens::get();
|
||||
let colors = solidcraft_theme::Tokens::get();
|
||||
let background = [colors.viewport_top, colors.viewport_bottom, colors.app_bar];
|
||||
if self.last_background != Some(background) {
|
||||
let failed = self.failure.is_some() || self.active.as_ref().is_some_and(|s| s.inner.lock().is_ok_and(|i| i.failure.is_some()));
|
||||
@@ -433,7 +433,7 @@ pub fn viewport(painter: &Painter, rect: Rect, cb: ViewportCallback, background:
|
||||
if let Ok(mut inner) = shared.inner.lock() {
|
||||
inner.source_count = inner.source_count.saturating_add(1);
|
||||
}
|
||||
let outside = crate::theme::Tokens::get().app_bar;
|
||||
let outside = solidcraft_theme::Tokens::get().app_bar;
|
||||
painter.add(egui_wgpu::Callback::new_paint_callback(
|
||||
rect,
|
||||
Source {
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::doc::metadata::{self, BOM_KEYS};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
/// The window's state; the rows come from the engine.
|
||||
#[derive(Default)]
|
||||
@@ -115,7 +115,7 @@ fn cell<R>(ui: &mut egui::Ui, width: f32, add: impl FnOnce(&mut egui::Ui) -> R)
|
||||
|
||||
/// The label of one of [`BOM_KEYS`].
|
||||
fn field_label(key: &str) -> &str {
|
||||
metadata::key(key).map_or(key, |k| crate::i18n::tr(k.label))
|
||||
metadata::key(key).map_or(key, |k| solidcraft_ui_text::tr(k.label))
|
||||
}
|
||||
|
||||
/// Apply the picked row's fields to each of its definitions (one undo step each).
|
||||
@@ -146,8 +146,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let bom = d.rows(app);
|
||||
let rows: Vec<Value> = bom["rows"].as_array().cloned().unwrap_or_default();
|
||||
let (mut export, mut apply_row) = (None, None);
|
||||
let closed =
|
||||
crate::ui_kit::Window::new(egui::Id::unique("sc_bom"), "bom", crate::i18n::tr_label("Bill of Materials")).cards(3.0).show_body(ctx, |ui| {
|
||||
let closed = crate::ui_kit::Window::new(egui::Id::unique("sc_bom"), "bom", solidcraft_ui_text::tr_label("Bill of Materials"))
|
||||
.cards(3.0)
|
||||
.show_body(ctx, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let was = (d.top, d.by_definition, d.group_children);
|
||||
let mut levels = usize::from(d.top);
|
||||
@@ -166,7 +167,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
});
|
||||
d.by_definition = grouping == 1;
|
||||
ui.separator();
|
||||
crate::ui_kit::checkbox(ui, crate::i18n::tr("bom.group_children"), &mut d.group_children);
|
||||
crate::ui_kit::checkbox(ui, solidcraft_ui_text::tr("bom.group_children"), &mut d.group_children);
|
||||
if was != (d.top, d.by_definition, d.group_children) {
|
||||
d.picked = None;
|
||||
}
|
||||
@@ -408,7 +409,7 @@ mod tests {
|
||||
h.frames(2);
|
||||
let (Some(desc), Some(rev)) = (h.widget("Description"), h.widget("Rev")) else { panic!("the grid's headers are drawn") };
|
||||
// Before, every column but the last was as narrow as a button and the grid used a third of the window.
|
||||
let card = f64::from(crate::theme::Tokens::get().form_card_width);
|
||||
let card = f64::from(solidcraft_theme::Tokens::get().form_card_width);
|
||||
assert!(rev[0] - desc[0] > card, "Description spreads out: it starts at {}, Rev at {}", desc[0], rev[0]);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user