Compare commits

...

62 Commits

Author SHA1 Message Date
jyviko a8b4c36b9a UI kit: the egui-faces test sets up through install_weight_families (#13327)
It inlined the entry point's body; it now calls the call a host makes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:46:25 -05:00
jyviko 5f1167f2ce UI kit: the bare-context test also checks a context without the binding (#13327)
The control half: a bare context that never called install_weight_families
has no semibold family and no face that draws the name, which is why the
call is the host contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:46:25 -05:00
jyviko 93638960e2 UI kit: install_weight_families says it replaces the font definitions (#13327)
It sets egui's default definitions. It cannot merge into the context's own:
egui keeps the definitions set this frame private until the next one, and
reading the current fonts before the first frame panics. The doc and
docs/ui-kit.md say so; a host with faces passes its definitions through
with_weight_families instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:46:25 -05:00
jyviko 769e57654e UI kit: type_face::install is install_weight_families (#13327)
The kit's entry point had the same name as the host's private
fonts::install, which installs SolidCraft's faces. The new name says what
the kit call does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:46:25 -05:00
jyviko fda573a6a4 Fonts: the wasm test build imports the Thai, Hebrew and Arabic names (#13327)
The font tests use them on every target, so the import is gated on
test or a desktop target, not on the desktop alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:46:25 -05:00
jyviko dc24446027 Fonts: the Thai, Hebrew and Arabic names are imported only off the web (#13327)
Only the desktop's system font search uses them, so the wasm build warned
that the imports were unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:46:25 -05:00
jyviko 5153f78557 UI kit: a host with no faces of its own calls type_face::install (#13327)
The kit's weights draw only once the context binds the weight families. The
host contract is now stated on the module and in docs/ui-kit.md: a host with
faces binds them in its definitions (with_weight_families binds any it leaves
out), a host with none calls type_face::install once. A test draws the
semibold name of a kit status header on a bare context after only that call;
without it egui has no face for the family and draws no glyphs. Also puts
`pub mod type_face` in rustfmt's order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:46:25 -05:00
jyviko e39f269aa5 UI kit: the CJK font loader tests install the catalogs they read (#13327)
CjkFontLoader resolves the language through the installed catalogs, so its
tests passed only when another test in the process had installed them first
(they failed under `-- cjk` alone). Each now installs them itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:46:25 -05:00
jyviko be66e894d9 Fonts: which faces the web shell fetches is the host's choice (#13327)
web_script_font_kind and needs_chinese_font decide which on-demand faces
SolidCraft's web shell downloads, so they move from the kit's type API back
to fonts.rs, with their assertions in a host test. The Arabic fallback's doc
link points at solidcraft_ui_text::bidi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:46:25 -05:00
jyviko 2e43cce857 UI kit: the type API moves to ui_kit::type_face (#13327)
The weights (medium, semibold, bold, RichTextExt), the family and fallback
names, the script checks and the CJK font loader state move out of fonts.rs
into ui_kit::type_face, which holds no font data and no engine reference.
fonts.rs keeps the host part: the embedded Plex faces, ui_font over the
engine's font lookup, the system font search and the install functions.
with_weight_families lets a host that installs no faces draw the kit's
weights with egui's default faces. Call sites use the new path; no
re-export at the old one. The font-family guard names the new file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:46:25 -05:00
jyviko 71f5b6989d Start page: a menu entry's icon is a kit name until MenuItem takes an Icon (#12941)
Recent rows' context-menu entries are drawn by ui_kit::MenuItem, whose
icon is a kit icon name; #13379 gives it a host Icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:28:45 -05:00
jyviko 789d628e43 Home: start-page entries name a recovery entry or recent file by index (#12941)
FileAction::Recovery, OpenRecent, ForgetRecent and ShowInFolder carry
an index into the app's lists instead of a cloned entry or path, and
the page borrows titles and file names from the app, so drawing the
start page no longer clones an entry or a path per frame. dispatch looks
the index up once, when the entry is chosen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:28:45 -05:00
jyviko f292f6bfaf Start page: tiles and samples take a host Icon (#12941)
ui_kit::Icon is the picture a host gives a kit surface: a kit icon
name, a texture it made, or its own painter. FileView's action tiles and
sample cards draw it, so the start page shows another app's icons.
start_page is a public module like view_compass; Body::Start is boxed
(it is far larger than the other bodies). Tests: a tile draws the host's
texture icon, a recent row's context menu returns the entry picked, a
disabled rail entry returns nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:28:45 -05:00
jyviko be6158f82f Start page: the page is a component that returns the entry chosen (#12941)
start_page::show draws the rail, the title and one body (action tiles,
recent files and samples; a tile grid; a review list) from plain data:
labels, icon names, host thumbnails, and what each entry stands for.
It returns the entry clicked; it holds no app, design or engine type.
home.rs is SolidCraft's use of it: it builds the page from the app's
state and runs the chosen FileAction. Recent, menu and sample clicks now
go through dispatch after the frame instead of running mid-draw.
Every published home rect and handle is unchanged (dumped before/after).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:28:45 -05:00
jyviko 0397be0f7c Navigation bar: SolidCraft's View slot is nav:view on the bar and in More (#12944)
A harness test: with the View slot on the bar, `nav:view` is its handle; folded
into More in a narrow view it is not shown until More opens, then `nav:view` is
the folded row's handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:28:45 -05:00
jyviko 4384c52448 View compass: show() returns only a tip or Home; a drag is an OrbitStep (#12944)
ViewAction is now Snap or Home, so a drag cannot be represented as something the
compass returns; orbit() returns its motion as an OrbitStep. signed_axis_view's
match is exhaustive over what the compass can return, with no catch-all arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:28:44 -05:00
jyviko b2d5044fbd View compass: one function places a tip, for the compass and its figure (#12944)
tip_offset maps a posed screen direction to where its tip is drawn, applying the
reach once at f64; tips() and the Preferences navigator figure both call it, so
TIP_REACH goes. The figure's tips move by about one f32 ulp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:28:44 -05:00
jyviko c2436a98f1 View compass: the module list in rustfmt's order (#12944)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:16 -05:00
jyviko bfe3da9c3d View compass: SolidCraft runs only the actions the compass can return (#12944)
show() returns a tip or Home, never Orbit (a drag comes from orbit()); the docs
say so and signed_axis_view no longer carries a dead Orbit arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:16 -05:00
jyviko ec50f99608 View compass: the tip reach is defined once, by the compass (#12944)
The Preferences navigator figure placed its tips with its own 38.0 * 0.84; it now
scales view_compass::TIP_REACH, so the figure and the live compass cannot drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:16 -05:00
jyviko 95176d0326 View compass: plain [f64; 3] axes in and out, no geometry type (#12944)
ViewAxes, Tip and ViewAction::Snap carry plain arrays, with the dot product a
local function, so the component needs neither solidcore_record::Vec3 nor the
engine's geometry trait. The compass tip painter takes the axis as an array too
(it only reads which axis it is, for the colour); SolidCraft converts at its edge
(view_axes, the Snap arm), and the Preferences navigator figure is posed by the
same ViewAxes::pose and AXES list as the live compass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:16 -05:00
jyviko f0e2c95dd0 Navigation bar: the component's returned actions are tested from plain data (#12944)
Driven with no app: a slot and a row of its menu are returned and a closing menu
closes; More returns a folded slot and closes, opens a folded menu in its place,
and a menu that does not close on a pick stays open; a submenu row is returned by
its own id; the bar's context menu returns Customise, Hide and Reset, and a slot's
(on the bar or in More) returns Remove with the slot's stored index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:16 -05:00
jyviko 5dbfa589bf Navigation bar: repaint after running the chosen action (#12944)
A menu that stays open after a pick (View) shows its rows' new state on the next
frame instead of waiting for the next input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:16 -05:00
jyviko d3ba5d414b Navigation bar: a slot's second registry name is published where it is drawn (#12944)
SlotView gains an alias the bar publishes at the slot's handle, on the bar and in
More alike. SolidCraft gives the View menu's slot its older name `nav:view` there,
instead of looking the slot's handle up after the bar has drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:16 -05:00
jyviko 72254ce90d View compass: the signed-axis compass is a component that returns the view chosen (#12944)
view_compass draws the six signed axes from the camera's view axes (plain right,
up and back vectors), the Home chip and the hover tip, and returns a ViewAction:
Snap to an axis, Home, or Orbit by this frame's drag delta. Its drag tracker
(edge warp included) is shared by the cube and the custom body, which keep
their own drawing. SolidCraft turns the camera with the same calls as before
(animate_turn with the reverse-on-repeat rule, animate_view, orbit_drag).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:26:16 -05:00
jyviko c2eeb2c5ed Navigation bar: the bar is a component that returns the slot or menu row chosen (#12944)
nav_bar draws the bar, More, its context menu and the open menu from plain data
(SlotView, Menu, BarLabels) and returns a NavAction; it keeps which menu is open
and reads nothing of the app. navigation_bar_view now describes SolidCraft's
slots and its Visual style and View menus as that data and runs the action it
gets back with the same calls as before. The Preferences editor keeps using
paint_row, which now paints through the component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:26:16 -05:00
jyviko 2724151d04 Native menu: the actions with a fixed id are a slice, not a sized array (#12940)
The list grows with the menu, so it follows the repo's rule for growing lists (cargo xtask lists).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:03 -05:00
jyviko d83cc2e48b Window chrome: a menu of the bar that is off stays off; replace is for submenus (#12940)
The bar's own menus take their item's enabled flag. replace() refuses a menu of the bar
(it keeps the system entries the layout gave it), now documented and tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:13 -05:00
jyviko 4b6efddb3c Native menu: its control ids do the same over the control channel (#12940)
Agent Chat sends ui.agent_chat {toggle}, which the control channel now takes (show or hide as
the panel's button does). ui.help runs every item the Help menu sends (docs, messages and
install_cli too) and answers an error for an unknown item instead of a silent ok. A test
runs the menu's control ids through the control channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:13 -05:00
jyviko de146e349b Native menu: entries use the control channel's ids, tested on every system (#12940)
Quit is app.quit, Settings and Keyboard Shortcuts are ui.prefs {open}, Agent Chat is
ui.agent_chat and Help's items are ui.help {item}; the menus are menu.file, menu.file.recent,
menu.edit, menu.window and menu.help. The id round trip and the shortcut table are tested
without a window on every system; the module builds there for its tests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:13 -05:00
jyviko d83d6b3c1e Window chrome: the menu bar's layout is pure and tested on every system (#12940)
MenuTable lays out the whole bar (the app menu's system entries, the Window menu's extras,
submenus) and replaces a submenu's entries itself; NativeMenu only turns rows into AppKit
items. Nothing is dead code off macOS, and the layout and replace tests run everywhere.
The Window menu's id is menu.window; menu ids are never dispatched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:13 -05:00
jyviko 50c78b30dd Window chrome: the application bar is laid out by TitleBar (#12940)
app_bar drew its own drag area, traffic-light inset and caption buttons beside the shared
TitleBar; it now runs TitleBar::show and answers the returned WindowAction through
titlebar::run, so the title bar tests pin what SolidCraft draws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:13 -05:00
jyviko 26db4c7777 Window chrome: the macOS menu bar from the kit's menu items (#12940)
window_chrome::native_menu builds the menu bar from ui_kit::Item trees (the app menu's system
layout, the system's Window entries) and returns the chosen entry as (id, params); the app-menu
title comes from the app. SolidCraft's native_menu.rs builds its Items from FileAction with
command ids or ui.* ids and maps the ids back, with the same entries, shortcuts and labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:10:13 -05:00
jyviko 363ec793dc Window chrome: the title bar and window settings from plain inputs (#12940)
window_chrome holds what any Craft app's window needs without SolveApp: viewport(title, app_id,
custom_titlebar) with SolidCraft's window settings, TitleBar (traffic-light inset, drag area,
caption buttons) that returns a WindowAction for the app to run, and the resize zones and hit
test. titlebar.rs is SolidCraft's thin use of it: Close asks about unsaved designs first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:10:13 -05:00
jyviko bd4f6e857d Control: tests for the screenshot gate, a hidden window and frames nobody waits for (#12946)
Shots::issue takes a frame on the third frame and 150 ms after the request,
or 500 ms later for a view that keeps moving; a hidden window refuses a
request and answers one waiting; with nothing pending the frames that came
are dropped. Each fails on a one-line mutation of screenshot.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:03 -05:00
jyviko 31c6f39e98 Control: the app gives the screenshot reply for no frame (#12946)
The shared text named SolidCraft's ui.render. Shots::new takes the app's
words; SolidCraft passes its reply unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:51 -05:00
jyviko 731749224d Control: ui.screenshot and ui.resize are their own shared crate, solidcraft-control-egui (#12946)
d8d35d454 made egui and image dependencies of solidcraft-control, so the
headless engine, the CLI and the MCP bridge compiled egui. The screenshot
and window modules move unchanged to solidcraft-control-egui; ui-egui
uses them from there. cargo tree finds no egui under engine, mcp or control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:51 -05:00
jyviko a54ff93c59 Control: ui.screenshot and ui.resize are shared over egui (#12946)
The window's screenshots in flight (the frame waits, the AO-settled
shot, the hidden-window and chat-turn refusals, the 8 s deadline) move
from SolveApp to solidcraft_control::screenshot::Shots, with the PNG
encoding; ui.resize and the hidden-window test go to window. SolidCraft
passes its AO settle, its issue report's frames and its vfs write, so a
second app can serve ui.screenshot with the shared crate alone. The issue
report and tests reach the queue as app.shots; replies are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:50 -05:00
jyviko d2b9daeba8 Control: the shared server speaks the app's words and listens on loopback only (#12946)
The change event, the revision method, the docs path in the unauthorized
reply and the document noun come from the app's Protocol; SolidCraft's
replies are byte-identical. The design watch is the DocumentWatch.
Hub::listen refuses a listener off loopback, so no app can put the
channel on the network; SolidCraft binds 127.0.0.1 as before.
control_pending.rs is formatted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:09:50 -05:00
jyviko 223ccd0004 Control: the loopback control server is shared; SolidCraft gives it its Protocol (#12946)
The token-admitted JSON-lines server (framing and line limit, connection
cap, control.running/pending/capabilities, busy and timeout replies,
control.subscribe events), ControlRequest and the design watch move to
solidcraft-control as `server::Hub`. What a request means (its name in
messages, its effect, command id, step count, the batch wait, the
capabilities) is the app's `server::Protocol`; SolidCraft's is in
apps/solidcraft/src/control_server.rs with its listeners and token files.
The wire format is unchanged and the server's tests move with it.

Also from review: Effect::when_done and the timeout text take the app's
word for its document ("design" for SolidCraft), the Listen docs name no
SolidCraft preference, and bench_assistant.rs is formatted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:09:50 -05:00
jyviko 331218f23b Control: the token files and Effect are a shared crate, solidcraft-control (#12946)
The per-launch token, its files, the discovery file and the client's token
lookup move from the engine to crates/control, with no engine dependency.
Which app they belong to (name, data folder, environment, UTC format,
default port) is an `App` value; SolidCraft's is engine::control_auth::APP.
`Effect` moves with them; which requests do what stays in the engine's
control_pending. Callers switch paths; files and messages are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:09:50 -05:00
jyviko c361491d57 Docs: the architecture table lists the shared crates and the MCP split (#12947)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:50 -05:00
jyviko 24ba6f29ca MCP: the shared server tests what it owns, on its own (#12947)
Batches (mixed, notifications only, the 1000 limit), an unasked response,
the protocol version fallback, null arguments, enum mismatches, the empty
default lists and the list-then-call order Tools::call relies on are tested
in solidcraft-mcp-server with a fake Tools; the protocol-only lines move out
of SolidCraft's wrapper tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:50 -05:00
jyviko 3edfed7219 MCP: the server machinery is a shared crate, solidcraft-mcp-server (#12947)
JSON-RPC over stdio, the MCP lifecycle, tool and resource methods, tool
definitions with their annotations, the argument check and the text and
image results move to crates/mcp-server, which has no engine or GPU
dependency and is classed Shared. SolidCraft's tools, instructions,
resources and the agent's folders stay in crates/mcp and register into it
through the Tools trait; its public API, tools list and replies are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:09:50 -05:00
jyviko 0383f87e57 Theme: format the field contrast test (#13304)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:06:19 -05:00
jyviko bb91986df3 Theme: the icon lint and the docs read palettes.rs at crates/theme (#13304)
lint_icons.py compared the icon review page with assets/styles/ui/palettes.rs, which moved into
solidcraft-theme; it reads crates/theme/src/palettes.rs now (cargo xtask icons passes again), and
the READMEs, docs and test comments name the new path. set_custom and scoped_tokens say they set
tokens only, so a host with an icon renderer sets its palette too; the field contrast test applies
each built-in theme with set_file_palette, as the app does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:06:19 -05:00
jyviko af73d7bd90 Shared UI crates: the token contract as a shared crate, solidcraft-theme (#13304)
Tokens with SolidCraft's light and dark values in code (palettes.rs moves in from
assets/styles/ui, with the eight primary strokes it shared with the icon artwork written as
icon_strokes), colour vision, TabAccent, contrast checks, apply/visuals and the per-thread and
per-context theme state move into crates/theme, class Shared. It holds no data files and does not
depend on theme files or icon roles; with nothing set, components draw with the built-in tokens.

The theme system stays in ui-egui's theme module: the custom-theme glue (prepared theme, tab
targets and their fade, blur intent), publish_tab (was TabAccent::publish, which now stores its
result through TabAccent::set_current and set_published), set_file_palette, ViewportTheme.
ui-egui's build script no longer generates the primary-stroke constants; an icon test holds the
crate's literals equal to palette.toml's. Call sites use solidcraft_theme paths with no
re-export (one rename pass). Tokens, theme files and rendered pixels are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:06:19 -05:00
jyviko 7d86e00ca7 Theme: the icon palette leaves Tokens; the icon renderer owns it (#13304)
A theme file's icon palette was a field of Tokens, so the token set depended on the icon roles
ui-egui's build script generates. It is now the icon renderer's per-thread state
(icon_svg::set_theme_palette), set alongside the theme's palette by theme::set_file_palette, and
theme_files resolves it with resolve_icon_palette (the same overlay merge as the tokens) and
exports it as an argument. Rendered pixels, theme files and exports are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:04:35 -05:00
jyviko e894f4a12f i18n tests: the painted-English sweeps install the catalogs themselves (#13290)
no_english_is_painted_where_the_language_has_a_translation and
the_sweep_reports_english_only_where_a_translation_exists read translations without installing the
app's catalogs, so they passed only when another test in the same process had installed them first
(run alone, every id read as itself).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:56:31 -05:00
jyviko 7b9e66929b Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290)
bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's
i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its
catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the
build script that merges them, and the adapters that read engine types (diagnostic,
tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait)
stay in the app's i18n. The app installs its catalogs when it starts, when an app is built
and in the widget test context. Call sites use the crate's path, with no re-export at the
old one; messages, ids and fallbacks are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:56:31 -05:00
jyviko bd3a94e946 Material editor: the Remove buttons are translated (material.editor.remove)
The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read
"Remove" in every language. They use a new id, material.editor.remove, whose text in each
locale is the one that locale already gives the plain Remove button
prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now).
The label-literal guard found it once the file's import named tr directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:43 -05:00
jyviko b0d7219115 Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291)
geom's srgb module (the one sRGB transfer function every encode and decode goes through)
moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis
palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use
the crate; there is no re-export at the old paths. The theme's unused material_color wrapper
is removed, so no non-test theme code names solidcraft_engine. Values are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:43 -05:00
jyviko a7598a92c6 UI kit: length fields take unit scaling and length parsing from the host (#13270)
The kit's length field converted millimetres to and from the design's units through three
prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale,
length_number and parse_length_mm, and length_drag reads them from the context.
DocExpressions implements them with the same prefs helpers, so the app is unchanged. With
nothing installed the field edits plain millimetres. No kit file calls prefs outside
tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:43 -05:00
jyviko 5c4932ba42 UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261)
continuity_row and its explained and tip variants are CAD controls over the kernel's
Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's
choice_row. The Preferences navigator figure takes its axes (screen direction and depth)
as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is
painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine,
the record or the viewport outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:43 -05:00
jyviko ee72a98948 UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260)
LayoutMode (with its per-context publish and current), the card gap and the panic-free
clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes
the workspace width instead of reading the ribbon's constant. The flag that says a report
covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput
guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved
preferences load as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:21 -05:00
jyviko 2fba25f564 UI kit: navigation-bar values and the horizontal wheel in the kit (#13259)
The bar's edge, size, idle and button-kind values move beside the bar's drawing in
ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the
kit already has a container Edge). Serde names are unchanged, so saved preferences load as
before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's
slot table stays in navigation_bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko 7b90cdf3fb UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258)
parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's
fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker.
The kit no longer knows the expression language: a host installs an Expressions (is this
a literal, is this a unit, complete or step this text) per egui context, and fields read
it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr
and params_dialog::complete, when the app first draws into a context. Behaviour in the app
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko 8b7586ba87 UI kit: the kit owns the menu entry and the list width (#13257)
Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch
and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so
the control channel's menu report is byte-identical. context_menu keeps building
SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file
refers to context_menu any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko b23d1ada5d UI kit: Preferences search is a kit module, settings_search (#13256)
prefs_search moves whole into ui_kit::settings_search: it filters any settings page the
kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles
into the widget registry and reads the same wall clock as before (web_time), so no kit
file calls the app for search any more. Every caller uses the new path; state ids, handle
names, matches and scores are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko 9a0d9ce243 Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)
TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko be226abee7 UI kit: the widget registry is a shared crate, ui-registry (#12936)
The handle, rect and count registry that UI tests, scenarios and the control channel read
moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes
into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are
unchanged and every handle keeps its name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko 414b8bb139 Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)
A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
434 changed files with 16278 additions and 10901 deletions
-1
View File
@@ -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
View File
@@ -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",
+7
View File
@@ -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;
+2 -1
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+2
View File
@@ -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 }
+1 -1
View File
@@ -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;
+4 -4
View File
@@ -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);
}
});
+12 -11
View File
@@ -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());
+3 -1
View File
@@ -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
+6 -15
View File
@@ -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
View File
@@ -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}");
}
}
-1
View File
@@ -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
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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)}
+2 -2
View File
@@ -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)]);
+2 -2
View File
@@ -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));
+6 -6
View File
@@ -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);
+3 -3
View File
@@ -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": "删除"
}
}
+9
View File
@@ -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
+20
View File
@@ -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 |
+32
View File
@@ -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
}
}
+8
View File
@@ -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;
+17
View File
@@ -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 }
+14
View File
@@ -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 |
+8
View File
@@ -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;
+288
View File
@@ -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());
}
}
+19
View File
@@ -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)
}
+18
View File
@@ -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"
+23
View File
@@ -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 |
+665
View File
@@ -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(&current, &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(&current, "27878").unwrap();
}
// The first closes: the second's files stay.
remove_token_file(&file, &first);
remove_discovery(&current, &file, 27878, &first);
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
assert!(pid_path(&file).exists() && current.exists() && pid_path(&current).exists());
// Another port named in the discovery file is another app's too.
remove_discovery(&current, &file, 27879, &second);
assert!(current.exists());
// The second closes: nothing of either is left.
remove_token_file(&file, &format!(" {second}\n"));
remove_discovery(&current, &file, 27878, &second);
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(&current).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(&current, "27878").unwrap();
remove_token_file(&file, &second);
remove_discovery(&current, &file, 27878, &second);
assert!(!current.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+27
View File
@@ -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(),
}
}
}
+17
View File
@@ -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};
+118
View File
@@ -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
+1
View File
@@ -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 }
+1 -1
View File
@@ -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),
}
}
+2
View File
@@ -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 }
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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 {
+12 -622
View File
@@ -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(&current, &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(&current, "27878").unwrap();
}
// The first closes: the second's files stay.
remove_token_file(&file, &first);
remove_discovery(&current, &file, 27878, &first);
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
assert!(pid_path(&file).exists() && current.exists() && pid_path(&current).exists());
// Another port named in the discovery file is another app's too.
remove_discovery(&current, &file, 27879, &second);
assert!(current.exists());
// The second closes: nothing of either is left.
remove_token_file(&file, &format!(" {second}\n"));
remove_discovery(&current, &file, 27878, &second);
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(&current).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(&current, "27878").unwrap();
remove_token_file(&file, &second);
remove_discovery(&current, &file, 27878, &second);
assert!(!current.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+9 -30
View File
@@ -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"));
}
}
+2 -1
View File
@@ -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};
+2 -2
View File
@@ -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]
-1
View File
@@ -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;
+1
View File
@@ -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 }
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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");
}
+1
View File
@@ -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 }
+1 -1
View File
@@ -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.
+12
View File
@@ -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 }
+31
View File
@@ -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) |
+40
View File
@@ -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
}
+20
View File
@@ -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;
+101
View File
@@ -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(())
}
+217
View File
@@ -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, &params) {
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"))),
}
}
}
+217
View File
@@ -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"]);
}
+2
View File
@@ -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]
+8 -5
View File
@@ -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 {
+5 -3
View File
@@ -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
View File
@@ -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, &params) {
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
View File
@@ -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
View File
@@ -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
}
+1
View File
@@ -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 -31
View File
@@ -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)]
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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 };
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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),
+2 -2
View File
@@ -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"));
+15
View File
@@ -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 }
+25
View File
@@ -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) };
+13 -2
View File
@@ -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.
-16
View File
@@ -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 {
+4 -4
View File
@@ -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);
+4 -4
View File
@@ -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)
}
+19 -14
View File
@@ -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| {
+7 -3
View File
@@ -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();
+1 -1
View File
@@ -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,
}
}
+2 -1
View File
@@ -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 })
+8 -5
View File
@@ -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());
+18 -18
View File
@@ -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, &current_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);
+36 -36
View File
@@ -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},
+1 -1
View File
@@ -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;
+16 -16
View File
@@ -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);
}
+3 -3
View File
@@ -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));
});
});
}
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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 {
+7 -6
View File
@@ -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