mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
f64ae3bf3c
A native, pure-Rust app that shows anyone how a piece of software works, with evidence-backed answers, then helps them rebuild it. It ships a desktop app, a CLI and an MCP server, all driven by one engine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1652 lines
70 KiB
Rust
1652 lines
70 KiB
Rust
//! The Craft Anything egui interface: Simple mode (make something from a prompt, or ask about a
|
|
//! target) and Advanced mode (the full workbench). Both drive the same engine.
|
|
//!
|
|
//! Threading: the UI thread never waits. Engine tools, AI detection, file dialogs and file writes
|
|
//! run on worker threads ([`jobs`]) and come back through a channel that is drained once per
|
|
//! frame. The session lock is only ever taken with `Engine::try_session` from here, so a long tool
|
|
//! holding it never freezes a frame; the last snapshot ([`SessionView`]) is shown meanwhile.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
pub mod builders;
|
|
pub mod chat;
|
|
pub mod demo;
|
|
pub mod engine_ui;
|
|
pub mod evidence_ui;
|
|
pub mod first_run;
|
|
pub mod folders;
|
|
pub mod game_files;
|
|
pub mod glossary;
|
|
pub mod home;
|
|
pub mod i18n;
|
|
mod icon_data;
|
|
pub mod icons;
|
|
pub mod jobs;
|
|
pub mod just_ask;
|
|
pub mod make;
|
|
pub mod manage;
|
|
pub mod palette;
|
|
pub mod pins;
|
|
pub mod project;
|
|
pub mod prompt_box;
|
|
pub mod report;
|
|
pub mod schema_form;
|
|
pub mod settings_ui;
|
|
pub mod sidebar;
|
|
pub mod slash;
|
|
pub mod state;
|
|
pub mod theme;
|
|
pub mod titlebar;
|
|
pub mod usage;
|
|
pub mod widgets;
|
|
pub mod workbench;
|
|
|
|
use craft_anything_ai::{AgentEvent, AiHub, BackendConfig, Conversation, Detected};
|
|
use craft_anything_engine::evidence::{ErrorCode, Evidence, ToolError, Unknown};
|
|
use craft_anything_engine::{CallOptions, Caller, Consent, Engine, Mode, Settings, TargetKind, ToolOutput, clean_room};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use state::{CenterTab, ChatItem, ConsentAnswer, Fill, RailKind, Screen, UiState};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use theme::ThemeKind;
|
|
|
|
pub use i18n::tr;
|
|
|
|
/// Every icon name the UI uses (checked against the embedded set by a test).
|
|
pub const USED_ICONS: &[&str] = &[
|
|
"activity",
|
|
"app-window",
|
|
"binary",
|
|
"book-open",
|
|
"bot",
|
|
"boxes",
|
|
"braces",
|
|
"check",
|
|
"chevron-down",
|
|
"chevron-right",
|
|
"circle-help",
|
|
"circle-stop",
|
|
"clipboard-list",
|
|
"code",
|
|
"copy",
|
|
"cpu",
|
|
"database",
|
|
"download",
|
|
"external-link",
|
|
"file",
|
|
"file-code",
|
|
"fingerprint",
|
|
"flask-conical",
|
|
"folder",
|
|
"folder-open",
|
|
"gauge",
|
|
"globe",
|
|
"graduation-cap",
|
|
"hammer",
|
|
"history",
|
|
"house",
|
|
"info",
|
|
"key-round",
|
|
"layers",
|
|
"link",
|
|
"list-tree",
|
|
"lock",
|
|
"message-square",
|
|
"microscope",
|
|
"network",
|
|
"package",
|
|
"play",
|
|
"plus",
|
|
"puzzle",
|
|
"radar",
|
|
"refresh-cw",
|
|
"rocket",
|
|
"scan-search",
|
|
"search",
|
|
"send",
|
|
"server",
|
|
"settings",
|
|
"shield",
|
|
"shield-check",
|
|
"smartphone",
|
|
"sparkles",
|
|
"terminal",
|
|
"trash-2",
|
|
"triangle-alert",
|
|
"upload",
|
|
"user",
|
|
"wand-sparkles",
|
|
"workflow",
|
|
"wrench",
|
|
"x",
|
|
"zap",
|
|
];
|
|
|
|
/// Where a tool result goes.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub enum Route {
|
|
/// Into the conversation as a proof card. `cta` adds an "add a brain" card after it.
|
|
Chat {
|
|
intro: String,
|
|
cta: bool,
|
|
},
|
|
Rail(RailKind),
|
|
View(CenterTab),
|
|
/// From the command palette: the Result tab.
|
|
Palette,
|
|
Silent,
|
|
}
|
|
|
|
/// What a worker hands back to the UI thread.
|
|
pub enum Done {
|
|
Tool {
|
|
tool: String,
|
|
params: Value,
|
|
route: Route,
|
|
result: Result<ToolOutput, ToolError>,
|
|
},
|
|
Opened {
|
|
path: PathBuf,
|
|
result: Result<Value, ToolError>,
|
|
},
|
|
Detected(Vec<Detected>),
|
|
/// The configured backends, and the ones chosen to build with and to extract with.
|
|
Backends(Vec<BackendConfig>, Option<String>, Option<String>),
|
|
Picked {
|
|
pick: Pick,
|
|
path: Option<PathBuf>,
|
|
},
|
|
/// Pictures of the original were chosen (empty when the dialog was cancelled).
|
|
Pictures(Vec<PathBuf>),
|
|
/// Pictures for the next message were chosen.
|
|
ToAttach(Vec<PathBuf>),
|
|
/// The game engines on this computer were listed.
|
|
GameEngines(craft_anything_engines::game::Inventory),
|
|
/// What engine made the game at `path`.
|
|
GameFound {
|
|
path: PathBuf,
|
|
found: Option<craft_anything_engines::game::Found>,
|
|
},
|
|
/// An engine download finished: ready, or its vendor installer was opened.
|
|
EngineGot {
|
|
builder: craft_anything_engines::game::Builder,
|
|
installer: bool,
|
|
},
|
|
/// Ghidra and its Java were downloaded and set up for Deep mode.
|
|
GhidraGot,
|
|
/// A tool started by a button finished (see `CraftApp::act`).
|
|
Acted {
|
|
tool: &'static str,
|
|
result: Result<Value, ToolError>,
|
|
},
|
|
/// Which asset providers have a key set up (provider id → stored).
|
|
AssetKeys(std::collections::BTreeMap<String, bool>),
|
|
Wrote {
|
|
what: String,
|
|
path: Option<PathBuf>,
|
|
},
|
|
BackendAdded {
|
|
name: String,
|
|
result: Result<(), ToolError>,
|
|
},
|
|
/// The projects folder was listed.
|
|
Projects(Vec<craft_anything_engine::workspace::Workspace>),
|
|
/// A new project's folder was made (see `CraftApp::start_make`).
|
|
Created {
|
|
ws: Box<craft_anything_engine::workspace::Workspace>,
|
|
request: String,
|
|
backend: String,
|
|
/// Why the pictures couldn't be copied, if they couldn't.
|
|
pictures: Option<String>,
|
|
},
|
|
/// A message's pictures were copied into its project (see `CraftApp::deliver`).
|
|
Attached {
|
|
root: PathBuf,
|
|
text: String,
|
|
attachments: Vec<PathBuf>,
|
|
files: Result<Vec<PathBuf>, String>,
|
|
},
|
|
/// Pictures of the original were copied into the open project (how many were).
|
|
PicturesAdded(Result<usize, String>),
|
|
/// The open project's files were read for the Project screen.
|
|
Facts(project::Facts),
|
|
/// A screenshot thumbnail was decoded (`None` when it couldn't be).
|
|
Thumb {
|
|
path: PathBuf,
|
|
image: Option<egui::TextureHandle>,
|
|
},
|
|
Nothing,
|
|
}
|
|
|
|
/// What a file dialog was for.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Pick {
|
|
OpenFile,
|
|
OpenFolder,
|
|
GhidraDir,
|
|
/// The app, game or file to rebuild.
|
|
TargetFile,
|
|
TargetFolder,
|
|
ProjectsDir,
|
|
/// The folder a new project starts in (it may already have the person's files).
|
|
ProjectFolder,
|
|
/// Another folder whose projects are listed (Settings).
|
|
ExtraProjectsDir,
|
|
/// Pictures of the original to match (several at once).
|
|
Pictures,
|
|
/// Pictures for the next message on a project (several at once).
|
|
Attach,
|
|
/// The game folder for Game files.
|
|
GameFolder,
|
|
/// Where to save a picture from a game.
|
|
SavePicture,
|
|
}
|
|
|
|
/// One open target, as the UI shows it.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TargetInfo {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub kind: TargetKind,
|
|
pub path: PathBuf,
|
|
pub sha256: String,
|
|
pub bytes: Arc<[u8]>,
|
|
}
|
|
|
|
/// The last snapshot of the session, refreshed whenever the lock is free and something changed.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SessionView {
|
|
pub targets: Vec<TargetInfo>,
|
|
pub active: Option<String>,
|
|
pub clean_room: bool,
|
|
/// Newest first, capped.
|
|
pub ledger: Vec<Evidence>,
|
|
pub ledger_len: usize,
|
|
pub unknowns: Vec<Unknown>,
|
|
pub project: Option<String>,
|
|
pub project_allows: Vec<String>,
|
|
}
|
|
|
|
impl SessionView {
|
|
pub fn active_target(&self) -> Option<&TargetInfo> {
|
|
self.active.as_ref().and_then(|id| self.targets.iter().find(|t| &t.id == id))
|
|
}
|
|
}
|
|
|
|
/// One line in the Log panel.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LogLine {
|
|
pub at: Instant,
|
|
pub error: bool,
|
|
pub text: String,
|
|
}
|
|
|
|
/// UI preferences remembered between launches (through eframe's storage).
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Prefs {
|
|
pub purpose: craft_anything_engine::project::Purpose,
|
|
pub backend: Option<String>,
|
|
pub explain_new: Option<bool>,
|
|
pub rail: Option<RailKind>,
|
|
pub center: Option<CenterTab>,
|
|
pub bottom_open: Option<bool>,
|
|
/// The sidebar was folded to icons.
|
|
#[serde(default)]
|
|
pub sidebar_folded: bool,
|
|
/// The sidebar's Pinned section was folded away.
|
|
#[serde(default)]
|
|
pub pins_collapsed: bool,
|
|
/// The project that was open, reopened on the next start.
|
|
#[serde(default)]
|
|
pub last_project: Option<PathBuf>,
|
|
/// What was typed on Make and not started yet.
|
|
#[serde(default)]
|
|
pub make: Option<state::MakeDraft>,
|
|
/// What was typed in each project's box and not sent yet, by project folder.
|
|
#[serde(default)]
|
|
pub unsent: std::collections::BTreeMap<PathBuf, String>,
|
|
}
|
|
|
|
pub const PREFS_KEY: &str = "craft-anything-ui";
|
|
const LEDGER_VIEW_CAP: usize = 300;
|
|
|
|
pub struct CraftApp {
|
|
pub engine: Arc<Engine>,
|
|
pub hub: Arc<AiHub>,
|
|
pub state: UiState,
|
|
/// The UI's copy of the settings; pushed into the session (and saved) when changed.
|
|
pub settings: Settings,
|
|
/// Write settings to disk (off in tests and snapshots).
|
|
pub persist: bool,
|
|
/// Never open native dialogs (tests and snapshots).
|
|
pub headless: bool,
|
|
pub view: SessionView,
|
|
pub jobs: jobs::Jobs<Done>,
|
|
pub conversation: Option<Box<dyn Conversation>>,
|
|
pub backends: Vec<BackendConfig>,
|
|
/// The backend the person chose to build with (Settings → AI), if any.
|
|
pub builder: Option<String>,
|
|
/// The backend chosen to take a game's files out in Deep mode, if any.
|
|
pub extractor: Option<String>,
|
|
pub detected: Option<Vec<Detected>>,
|
|
pub detecting: bool,
|
|
pub log: Vec<LogLine>,
|
|
pub key_inputs: std::collections::BTreeMap<String, String>,
|
|
/// Every project's rebuild in this window: working, waiting their turn, or finished.
|
|
pub runs: craft_anything_ai::rebuild::RunSet,
|
|
/// What was typed for each note still waiting for a builder, by note id, to put back in the
|
|
/// prompt box when the note is taken back.
|
|
pub drafts: std::collections::HashMap<u64, project::Draft>,
|
|
/// Each project's building time and AI use, every build together (read from its usage.json).
|
|
pub usage: std::collections::HashMap<PathBuf, usage::ProjectClock>,
|
|
/// All AI use since the app opened: builds and chat.
|
|
pub session_usage: craft_anything_engine::workspace::ProjectUsage,
|
|
/// The project shown on the Project screen.
|
|
pub workspace: Option<craft_anything_engine::workspace::Workspace>,
|
|
/// Every project in the projects folder, newest first.
|
|
pub projects: Vec<craft_anything_engine::workspace::Workspace>,
|
|
/// A preview server for a project opened without a run.
|
|
pub preview: Option<String>,
|
|
/// Questions asked about each project's build this session, and their answers (`just_ask`).
|
|
pub asked: std::collections::HashMap<PathBuf, Vec<just_ask::Asked>>,
|
|
/// Every project's pinned builds, and whether they're running (read by `refresh_pins`).
|
|
pub pins: Vec<pins::PinRow>,
|
|
pub pins_read_at: Option<Instant>,
|
|
/// An address to open in the browser on the next frame (set from a worker's result).
|
|
open_url: Option<String>,
|
|
/// The open project's screenshot thumbnails (dropped when another project opens).
|
|
pub thumbs: std::collections::HashMap<PathBuf, project::Thumb>,
|
|
/// The kinds of thing this app can make; a fork registers its own here.
|
|
pub kinds: craft_anything_ai::rebuild::KindRegistry,
|
|
/// Game engines on this computer (`None` until the first look finishes).
|
|
pub game_engines: Option<craft_anything_engines::game::Inventory>,
|
|
/// Which asset providers have a key set up, by provider id (`None` until checked; the keys
|
|
/// themselves never come into the app).
|
|
pub asset_keys: Option<std::collections::BTreeMap<String, bool>>,
|
|
/// What the open project's files say (built result, pictures, assets, open questions), read
|
|
/// on a worker.
|
|
pub facts: project::Facts,
|
|
facts_reader: project::FactsReader,
|
|
/// Getting a game engine: downloads, installers opened, a project waiting to start.
|
|
pub engine_flow: engine_ui::Flow,
|
|
/// Getting Ghidra for Deep mode.
|
|
pub ghidra: engine_ui::GhidraFlow,
|
|
/// When each toast appeared, in the same order (confirmations hide after a few seconds).
|
|
toast_at: Vec<Instant>,
|
|
settings_dirty: bool,
|
|
/// When to write the settings to disk: a moment after the last change, so typing a path
|
|
/// saves once, not once per letter.
|
|
save_due: Option<Instant>,
|
|
/// The save being written (one at a time; a change meanwhile saves again after it).
|
|
saving: Option<u64>,
|
|
/// A new project being made (Start waits for it).
|
|
pub creating: Option<u64>,
|
|
/// Tools the UI started that wait for the person's consent, by the id on their card.
|
|
pending_consent: std::collections::HashMap<u64, (String, Value, Route)>,
|
|
next_consent: u64,
|
|
/// Projects whose build is waiting for the person's answer (a question or approval).
|
|
pub asking: std::collections::HashSet<PathBuf>,
|
|
/// A build asked something since the last frame: the taskbar flashes if the window is behind.
|
|
attention: bool,
|
|
/// Jobs whose result goes into the conversation (a first look, an answer without an AI):
|
|
/// what the Ask screen waits on and its Stop cancels.
|
|
chat_jobs: Vec<u64>,
|
|
view_dirty: bool,
|
|
last_view: Option<Instant>,
|
|
applied_theme: Option<(ThemeKind, bool)>,
|
|
started: bool,
|
|
}
|
|
|
|
impl CraftApp {
|
|
pub fn new(engine: Arc<Engine>, hub: Arc<AiHub>) -> Self {
|
|
// Wait for the engine if it's busy: defaults here would show Clean room and no AI, and the
|
|
// next settings change would save them over the person's own.
|
|
let settings = engine.session().settings.clone();
|
|
i18n::set_language(&settings.language);
|
|
CraftApp {
|
|
engine,
|
|
hub,
|
|
state: UiState::default(),
|
|
settings,
|
|
persist: true,
|
|
headless: false,
|
|
view: SessionView::default(),
|
|
jobs: jobs::Jobs::default(),
|
|
conversation: None,
|
|
backends: Vec::new(),
|
|
builder: None,
|
|
extractor: None,
|
|
detected: None,
|
|
detecting: false,
|
|
log: Vec::new(),
|
|
key_inputs: Default::default(),
|
|
runs: craft_anything_ai::rebuild::RunSet::new(craft_anything_engine::settings::BuildSettings::default().parallel),
|
|
drafts: Default::default(),
|
|
usage: Default::default(),
|
|
session_usage: Default::default(),
|
|
workspace: None,
|
|
projects: Vec::new(),
|
|
preview: None,
|
|
pins: Vec::new(),
|
|
asked: Default::default(),
|
|
pins_read_at: None,
|
|
open_url: None,
|
|
thumbs: Default::default(),
|
|
kinds: craft_anything_ai::rebuild::KindRegistry::built_in(),
|
|
game_engines: None,
|
|
asset_keys: None,
|
|
facts: Default::default(),
|
|
facts_reader: Default::default(),
|
|
engine_flow: Default::default(),
|
|
ghidra: Default::default(),
|
|
toast_at: Vec::new(),
|
|
settings_dirty: false,
|
|
save_due: None,
|
|
saving: None,
|
|
creating: None,
|
|
chat_jobs: Vec::new(),
|
|
asking: Default::default(),
|
|
pending_consent: Default::default(),
|
|
next_consent: 1,
|
|
attention: false,
|
|
view_dirty: true,
|
|
last_view: None,
|
|
applied_theme: None,
|
|
started: false,
|
|
}
|
|
}
|
|
|
|
/// Fonts and image loaders; call once with the egui context.
|
|
pub fn setup_context(ctx: &egui::Context) {
|
|
theme::install_fonts(ctx);
|
|
egui_extras::install_image_loaders(ctx);
|
|
}
|
|
|
|
/// Restore remembered preferences.
|
|
pub fn load_prefs(&mut self, storage: Option<&dyn eframe::Storage>) {
|
|
let Some(text) = storage.and_then(|s| s.get_string(PREFS_KEY)) else { return };
|
|
let Ok(p) = serde_json::from_str::<Prefs>(&text) else { return };
|
|
self.state.purpose = p.purpose;
|
|
self.state.backend = p.backend;
|
|
if let Some(v) = p.explain_new {
|
|
self.state.explain_new = v;
|
|
}
|
|
if let Some(v) = p.rail {
|
|
self.state.rail = v;
|
|
}
|
|
if let Some(v) = p.center {
|
|
self.state.center = v;
|
|
}
|
|
if let Some(v) = p.bottom_open {
|
|
self.state.bottom_open = v;
|
|
}
|
|
self.state.sidebar_folded = p.sidebar_folded;
|
|
self.state.pins_collapsed = p.pins_collapsed;
|
|
// Typed drafts come back, so a restart doesn't lose them.
|
|
if let Some(d) = p.make {
|
|
self.state.make = d;
|
|
}
|
|
self.state.unsent = p.unsent.into_iter().map(|(root, text)| (root, (text, Vec::new()))).collect();
|
|
// Back to the project that was open, with its conversation, if it's still there.
|
|
if let Some(ws) = p.last_project.and_then(|root| craft_anything_engine::workspace::Workspace::open(&root).ok()) {
|
|
self.open_workspace(ws);
|
|
}
|
|
}
|
|
|
|
pub fn prefs(&self) -> Prefs {
|
|
let mut unsent: std::collections::BTreeMap<PathBuf, String> =
|
|
self.state.unsent.iter().filter(|(_, (t, _))| !t.trim().is_empty()).map(|(r, (t, _))| (r.clone(), t.clone())).collect();
|
|
if let Some(ws) = &self.workspace
|
|
&& !self.state.project_input.trim().is_empty()
|
|
{
|
|
unsent.insert(ws.root.clone(), self.state.project_input.clone());
|
|
}
|
|
let m = &self.state.make;
|
|
let make = (!m.request.trim().is_empty() || !m.url.trim().is_empty() || !m.pictures.is_empty()).then(|| m.clone());
|
|
Prefs {
|
|
make,
|
|
unsent,
|
|
sidebar_folded: self.state.sidebar_folded,
|
|
pins_collapsed: self.state.pins_collapsed,
|
|
purpose: self.state.purpose,
|
|
backend: self.state.backend.clone(),
|
|
explain_new: Some(self.state.explain_new),
|
|
rail: Some(self.state.rail),
|
|
center: Some(self.state.center),
|
|
bottom_open: Some(self.state.bottom_open),
|
|
last_project: (self.state.screen == Screen::Project).then(|| self.workspace.as_ref().map(|w| w.root.clone())).flatten(),
|
|
}
|
|
}
|
|
|
|
pub fn mode(&self) -> Mode {
|
|
self.settings.mode
|
|
}
|
|
|
|
pub fn beginner(&self) -> bool {
|
|
self.settings.mode == Mode::Beginner
|
|
}
|
|
|
|
pub fn theme_kind(&self) -> ThemeKind {
|
|
ThemeKind::from_id(&self.settings.theme)
|
|
}
|
|
|
|
pub fn log(&mut self, error: bool, text: impl Into<String>) {
|
|
let text = text.into();
|
|
if error {
|
|
log::warn!("{text}");
|
|
} else {
|
|
log::info!("{text}");
|
|
}
|
|
self.log.push(LogLine { at: Instant::now(), error, text });
|
|
if self.log.len() > 500 {
|
|
self.log.drain(..100);
|
|
}
|
|
}
|
|
|
|
pub fn toast(&mut self, text: impl Into<String>, error: bool) {
|
|
const KEPT: usize = 4;
|
|
let text = text.into();
|
|
// The same message again moves to the front instead of stacking up.
|
|
if let Some(i) = self.state.toasts.iter().position(|(t, _)| *t == text) {
|
|
self.state.toasts.remove(i);
|
|
if i < self.toast_at.len() {
|
|
self.toast_at.remove(i);
|
|
}
|
|
}
|
|
self.state.toasts.push((text, error));
|
|
self.toast_at.push(Instant::now());
|
|
// The oldest confirmations go first when there are too many; errors are kept longest.
|
|
while self.state.toasts.len() > KEPT {
|
|
let i = self.state.toasts.iter().position(|(_, e)| !e).unwrap_or(0);
|
|
self.state.toasts.remove(i);
|
|
if i < self.toast_at.len() {
|
|
self.toast_at.remove(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn has_tool(&self, id: &str) -> bool {
|
|
self.engine.registry().get(id).is_some()
|
|
}
|
|
|
|
/// The first registered tool among `ids`.
|
|
pub fn first_tool(&self, ids: &[&'static str]) -> Option<&'static str> {
|
|
ids.iter().copied().find(|id| self.has_tool(id))
|
|
}
|
|
|
|
// ---- settings -------------------------------------------------------------------------------
|
|
|
|
/// Mark settings changed: they are pushed into the session and saved on a worker.
|
|
pub fn settings_changed(&mut self) {
|
|
self.settings_dirty = true;
|
|
}
|
|
|
|
/// Changed settings go into the session at once, and to disk a moment after the last change.
|
|
fn flush_settings(&mut self, ctx: &egui::Context) {
|
|
const QUIET: Duration = Duration::from_millis(600);
|
|
if self.settings_dirty
|
|
&& let Some(mut s) = self.engine.try_session()
|
|
{
|
|
// The Ghidra folder changed: look for it again.
|
|
if s.settings.engines.ghidra.install_dir != self.settings.engines.ghidra.install_dir {
|
|
self.ghidra.ready = None;
|
|
}
|
|
// The `ai` table belongs to the ai crate (it edits it in the session); keep its copy.
|
|
self.settings.ai = s.settings.ai.clone();
|
|
s.settings = self.settings.clone();
|
|
drop(s);
|
|
self.settings_dirty = false;
|
|
self.view_dirty = true;
|
|
if self.persist {
|
|
self.save_due = Some(Instant::now() + QUIET);
|
|
}
|
|
}
|
|
let Some(due) = self.save_due.filter(|_| self.saving.is_none()) else { return };
|
|
let wait = due.saturating_duration_since(Instant::now());
|
|
if !wait.is_zero() {
|
|
ctx.request_repaint_after(wait);
|
|
return;
|
|
}
|
|
self.save_due = None;
|
|
let engine = self.engine.clone();
|
|
// What's in the session when it runs, so the newest settings are the ones written.
|
|
self.saving = Some(self.jobs.spawn("Save settings", false, move |_| {
|
|
let settings = engine.session().settings.clone();
|
|
settings.save().map_err(|e| e.message)?;
|
|
Ok(Done::Nothing)
|
|
}));
|
|
}
|
|
|
|
pub fn set_mode(&mut self, mode: Mode) {
|
|
if self.settings.mode != mode {
|
|
self.settings.mode = mode;
|
|
self.settings_changed();
|
|
if mode == Mode::Experienced {
|
|
self.state.screen = Screen::Workbench;
|
|
self.ensure_rail_loaded();
|
|
} else if self.state.screen == Screen::Workbench {
|
|
self.state.screen = Screen::Home;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn set_deep(&mut self, on: bool) {
|
|
if self.settings.deep_mode != on {
|
|
self.settings.deep_mode = on;
|
|
self.settings_changed();
|
|
self.log(false, if on { "Deep mode on" } else { "Clean room on" });
|
|
// Deep mode wants Ghidra: offer to get it now rather than quietly going without.
|
|
if on && self.ghidra_missing() && self.ghidra.job.is_none() {
|
|
self.state.modal = Some(state::Modal::Ghidra);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn finish_first_run(&mut self) {
|
|
self.settings.first_run_done = true;
|
|
self.settings_changed();
|
|
self.state.screen = Screen::Home;
|
|
}
|
|
|
|
// ---- session snapshot -------------------------------------------------------------------
|
|
|
|
fn refresh_view(&mut self) {
|
|
let stale = self.last_view.is_none_or(|t| t.elapsed() > Duration::from_millis(1500));
|
|
if !self.view_dirty && !stale {
|
|
return;
|
|
}
|
|
let Some(s) = self.engine.try_session() else { return };
|
|
let targets = s
|
|
.targets()
|
|
.map(|t| TargetInfo {
|
|
id: t.target.id.clone(),
|
|
name: t.target.name.clone(),
|
|
kind: t.target.kind,
|
|
path: t.target.path.clone(),
|
|
sha256: t.target.sha256.clone(),
|
|
bytes: t.target.bytes.clone(),
|
|
})
|
|
.collect();
|
|
let view = SessionView {
|
|
targets,
|
|
active: s.active_id().cloned(),
|
|
clean_room: s.clean_room(),
|
|
ledger: if self.view_dirty || self.view.ledger_len != s.ledger_len() {
|
|
s.ledger().take(LEDGER_VIEW_CAP).cloned().collect()
|
|
} else {
|
|
std::mem::take(&mut self.view.ledger)
|
|
},
|
|
ledger_len: s.ledger_len(),
|
|
unknowns: s.unknowns().take(500).cloned().collect(),
|
|
project: s.project.as_ref().map(|p| p.meta.name.clone()),
|
|
project_allows: s.project.as_ref().map(|p| p.meta.allowed_tools.clone()).unwrap_or_default(),
|
|
};
|
|
drop(s);
|
|
self.view = view;
|
|
self.view_dirty = false;
|
|
self.last_view = Some(Instant::now());
|
|
self.resolve_chat_evidence();
|
|
}
|
|
|
|
/// Fill in proof cards for agent tool calls once their evidence is in the ledger.
|
|
fn resolve_chat_evidence(&mut self) {
|
|
for item in &mut self.state.chat {
|
|
if let ChatItem::Tool { evidence_id: Some(id), evidence: ev @ None, .. } = item
|
|
&& let Some(e) = self.view.ledger.iter().find(|e| &e.id == id)
|
|
{
|
|
*ev = Some(Box::new(e.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn mark_dirty(&mut self) {
|
|
self.view_dirty = true;
|
|
}
|
|
|
|
// ---- tools ------------------------------------------------------------------------------
|
|
|
|
/// Build params for a tool from what its schema accepts (so `deny_unknown_fields` tools never
|
|
/// get a field they don't know).
|
|
pub fn params_for(&self, tool: &str, wanted: &[(&str, Value)]) -> Value {
|
|
let mut out = serde_json::Map::new();
|
|
let Some(spec) = self.engine.registry().get(tool) else { return Value::Object(out) };
|
|
let schema = self.engine.registry().input_schema(spec);
|
|
let props = schema.get("properties").and_then(Value::as_object);
|
|
for (k, v) in wanted {
|
|
if props.is_some_and(|p| p.contains_key(*k)) {
|
|
out.insert((*k).to_string(), v.clone());
|
|
}
|
|
}
|
|
Value::Object(out)
|
|
}
|
|
|
|
/// Run a tool on a worker; the result goes to `route`.
|
|
pub fn run_tool(&mut self, tool: &str, params: Value, route: Route, consent: Consent) {
|
|
let Some(spec) = self.engine.registry().get(tool).copied() else {
|
|
self.log(true, format!("No tool named {tool}"));
|
|
return;
|
|
};
|
|
match &route {
|
|
Route::Rail(k) => {
|
|
self.state.lists.insert(*k, Fill::Loading);
|
|
}
|
|
Route::View(t) => {
|
|
self.state.views.insert(*t, Fill::Loading);
|
|
}
|
|
Route::Palette => {
|
|
self.state.views.insert(CenterTab::Result, Fill::Loading);
|
|
self.state.center = CenterTab::Result;
|
|
}
|
|
_ => {}
|
|
}
|
|
let engine = self.engine.clone();
|
|
let id = spec.id.to_string();
|
|
let title = spec.title.to_string();
|
|
self.log(false, format!("Running {id}"));
|
|
let chat = matches!(route, Route::Chat { .. });
|
|
let job = self.jobs.spawn(title, true, move |j| {
|
|
let progress = j.reporter();
|
|
let opts = CallOptions::new(Caller::Ui).with_consent(consent).with_cancel(j.cancel.clone()).with_progress(&progress);
|
|
let result = engine.call(&id, params.clone(), opts);
|
|
Ok(Done::Tool { tool: id, params, route, result })
|
|
});
|
|
if chat {
|
|
self.chat_jobs.push(job);
|
|
}
|
|
}
|
|
|
|
/// Run a tool for a button on a worker (play, preview, open a folder…); the result comes back
|
|
/// as `Done::Acted` and is shown by `acted`. A click is the person's consent.
|
|
pub fn act(&mut self, tool: &'static str, params: Value, title: String) {
|
|
let engine = self.engine.clone();
|
|
self.jobs.spawn(title, false, move |j| {
|
|
let progress = j.reporter();
|
|
let opts = CallOptions::new(Caller::Ui).with_consent(Consent::Granted).with_cancel(j.cancel.clone()).with_progress(&progress);
|
|
let result = engine.call(tool, params, opts).map(|o| o.to_json().get("result").cloned().unwrap_or(Value::Null));
|
|
Ok(Done::Acted { tool, result })
|
|
});
|
|
}
|
|
|
|
fn acted(&mut self, tool: &str, result: Result<Value, ToolError>) {
|
|
match (tool, result) {
|
|
("project_preview", Ok(v)) => {
|
|
let url = v.get("url").and_then(Value::as_str).map(str::to_string);
|
|
self.preview.clone_from(&url);
|
|
self.open_url = url;
|
|
}
|
|
("project_play", Ok(v)) if v["already_running"].as_bool() == Some(true) => self.toast(tr("It's already open."), false),
|
|
("project_play", Ok(v)) if !v["closed_at_once"].is_null() => self.toast(pins::quit_at_once(&v), true),
|
|
("project_play", Ok(_)) => self.toast(tr("Starting… it opens in its own window."), false),
|
|
("project_play", Err(e)) => self.toast(format!("{} {}", tr("Couldn't start it."), e.for_people()), true),
|
|
("project_assets_budget" | "project_assets_allow", Ok(_)) => self.assets_changed(),
|
|
("rebuild_question_ask", r) => self.question_done(r),
|
|
("project_questions_set", Ok(v)) => {
|
|
// What was saved (the person's other changes on disk come along).
|
|
if let Some(ws) = self.workspace.as_mut().filter(|w| v["project"].as_str().is_some_and(|p| w.root == std::path::Path::new(p))) {
|
|
let _ = ws.reload();
|
|
}
|
|
let decide = v["questions"].as_str() == Some("decide");
|
|
self.toast(
|
|
tr(if decide { "The builder will decide its questions and tell you what it chose." } else { "The builder's questions will come to you." }),
|
|
false,
|
|
);
|
|
}
|
|
("project_pins_list" | "project_pin_create" | "project_pin_open" | "project_pin_stop" | "project_pin_rename" | "project_pin_delete", r) => {
|
|
self.pin_done(tool, r)
|
|
}
|
|
("project_rename" | "project_duplicate" | "project_delete", r) => self.managed(tool, r),
|
|
// The tool changed the session's plugin list; take it, so saving settings keeps it.
|
|
("plugins_add" | "plugins_remove", Ok(_)) => {
|
|
if let Some(s) = self.engine.try_session() {
|
|
self.settings.plugins = s.settings.plugins.clone();
|
|
}
|
|
self.toast(tr(if tool == "plugins_add" { "Plugin added. Read its tools to start using them." } else { "Plugin removed." }), false);
|
|
}
|
|
("plugins_refresh", Ok(v)) => {
|
|
let failed: Vec<String> = v["refreshed"]
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|r| r.get("error").and_then(Value::as_str).map(|e| format!("{}: {e}", r["id"].as_str().unwrap_or("?"))))
|
|
.collect();
|
|
if failed.is_empty() {
|
|
self.toast(tr("Plugins read. Their new tools are offered from the next start."), false);
|
|
} else {
|
|
self.toast(format!("{} {}", tr("Some plugins couldn't be read:"), failed.join("; ")), true);
|
|
}
|
|
}
|
|
("project_open_folder", Err(e)) => self.toast(format!("{} {}", tr("Couldn't open the folder."), e.message), true),
|
|
(
|
|
"game_archives_list"
|
|
| "game_pak_list"
|
|
| "game_unity_list"
|
|
| "game_iostore_list"
|
|
| "game_unity_bundle_list"
|
|
| "game_unreal_texture_export"
|
|
| "game_unity_asset_export",
|
|
r,
|
|
) => game_files::tool_done(self, tool, r),
|
|
// The Game files screen's look at a picked game.
|
|
("core_target_triage", r) => game_files::tool_done(self, tool, r),
|
|
(_, Ok(_)) => {}
|
|
(_, Err(e)) if clean_room::is_refusal(&e) => self.state.modal = Some(state::Modal::DeepMode),
|
|
(_, Err(e)) => self.toast(e.for_people(), true),
|
|
}
|
|
}
|
|
|
|
/// Open a file or folder as the active target.
|
|
pub fn open_path(&mut self, path: PathBuf) {
|
|
let path = if path.is_absolute() { path } else { std::env::current_dir().map(|d| d.join(&path)).unwrap_or(path) };
|
|
let engine = self.engine.clone();
|
|
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string());
|
|
self.toast(i18n::trf("Opening {name}…", &[("name", &name)]), false);
|
|
self.jobs.spawn(i18n::trf("Open {name}", &[("name", &name)]), true, move |j| {
|
|
let params = json!({"path": path.to_string_lossy()});
|
|
let result = engine.call("core_target_open", params, CallOptions::new(Caller::Ui).with_cancel(j.cancel.clone())).map(|o| match o {
|
|
ToolOutput::Value(v) => v,
|
|
ToolOutput::Evidence(e) => serde_json::to_value(*e).unwrap_or(Value::Null),
|
|
});
|
|
Ok(Done::Opened { path, result })
|
|
});
|
|
}
|
|
|
|
/// Show a native file dialog on a worker thread.
|
|
pub fn pick(&mut self, pick: Pick) {
|
|
if self.headless {
|
|
return;
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
let suggested = game_files::suggested_name(self);
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
self.jobs.spawn("Choose a file", false, move |_| {
|
|
let d = rfd::AsyncFileDialog::new();
|
|
if matches!(pick, Pick::Pictures | Pick::Attach) {
|
|
let title = if pick == Pick::Pictures { tr("Pictures of the original") } else { tr("Attach pictures") };
|
|
let files = pollster::block_on(d.set_title(title).add_filter(tr("Pictures"), &["png", "jpg", "jpeg", "webp", "gif"]).pick_files());
|
|
let files: Vec<PathBuf> = files.unwrap_or_default().iter().map(|h| h.path().to_path_buf()).collect();
|
|
return Ok(if pick == Pick::Pictures { Done::Pictures(files) } else { Done::ToAttach(files) });
|
|
}
|
|
let handle = match pick {
|
|
Pick::OpenFile => pollster::block_on(d.set_title(tr("Open an app or file")).pick_file()),
|
|
Pick::OpenFolder => pollster::block_on(d.set_title(tr("Open a folder or app bundle")).pick_folder()),
|
|
Pick::GhidraDir => pollster::block_on(d.set_title(tr("Where is Ghidra installed?")).pick_folder()),
|
|
Pick::TargetFile => pollster::block_on(d.set_title(tr("Pick the app, game or file to rebuild")).pick_file()),
|
|
Pick::TargetFolder => pollster::block_on(d.set_title(tr("Pick the app or game folder")).pick_folder()),
|
|
Pick::ProjectsDir => pollster::block_on(d.set_title(tr("Where should new projects go?")).pick_folder()),
|
|
Pick::ProjectFolder => pollster::block_on(d.set_title(tr("Pick the folder for this project")).pick_folder()),
|
|
Pick::ExtraProjectsDir => pollster::block_on(d.set_title(tr("Pick a folder with projects in it")).pick_folder()),
|
|
Pick::GameFolder => pollster::block_on(d.set_title(tr("Pick the game's folder")).pick_folder()),
|
|
Pick::SavePicture => pollster::block_on(d.set_title(tr("Save the picture")).add_filter("PNG", &["png"]).set_file_name(&suggested).save_file()),
|
|
Pick::Pictures | Pick::Attach => None,
|
|
};
|
|
Ok(Done::Picked { pick, path: handle.map(|h| h.path().to_path_buf()) })
|
|
});
|
|
}
|
|
|
|
/// The overview tool used for a "first look" and for answering without an AI.
|
|
pub fn first_look_tool(&self) -> Option<&'static str> {
|
|
self.first_tool(&["core_target_triage", "core_overview_show", "native_overview", "core_session_show"])
|
|
}
|
|
|
|
pub fn first_look(&mut self, intro: &str, cta: bool) {
|
|
if let Some(tool) = self.first_look_tool() {
|
|
self.run_tool(tool, json!({}), Route::Chat { intro: intro.to_string(), cta }, Consent::NotAsked);
|
|
}
|
|
}
|
|
|
|
/// Load the current rail list if it hasn't been.
|
|
pub fn ensure_rail_loaded(&mut self) {
|
|
let kind = self.state.rail;
|
|
if kind == RailKind::Targets || self.view.active.is_none() {
|
|
return;
|
|
}
|
|
if matches!(self.state.lists.get(&kind), None | Some(Fill::Idle)) {
|
|
self.load_rail(kind);
|
|
}
|
|
}
|
|
|
|
pub fn load_rail(&mut self, kind: RailKind) {
|
|
let target = self.view.active_target().map(|t| (t.kind, t.path.display().to_string()));
|
|
let Some(tool) = self.first_tool(kind.tools(target.as_ref().map(|t| t.0))) else {
|
|
self.state.lists.insert(kind, Fill::Unavailable(format!("{} isn't available yet in this build.", tr(kind.title()))));
|
|
return;
|
|
};
|
|
if let Some(blocked) = self.blocked(tool) {
|
|
self.state.lists.insert(kind, blocked);
|
|
return;
|
|
}
|
|
let path = target.map(|t| t.1).unwrap_or_default();
|
|
let params = self.params_for(tool, &[("offset", json!(0)), ("limit", json!(2000)), ("input_path", json!(path))]);
|
|
self.run_tool(tool, params, Route::Rail(kind), Consent::NotAsked);
|
|
}
|
|
|
|
/// `Some(Fill::Blocked)` when a tool can't run right now (checked without waiting).
|
|
pub fn blocked(&self, tool: &str) -> Option<Fill> {
|
|
let spec = self.engine.registry().get(tool)?;
|
|
let s = self.engine.try_session()?;
|
|
let a = self.engine.availability(spec, &s);
|
|
let deep_needed = s.clean_room() && !clean_room::allows(tool);
|
|
(!a.available).then(|| if deep_needed { Fill::DeepNeeded } else { Fill::Blocked(a.reason.unwrap_or_default(), a.remediation) })
|
|
}
|
|
|
|
/// Fill a centre view for the current selection.
|
|
pub fn load_view(&mut self, tab: CenterTab) {
|
|
let Some(tool) = self.first_tool(tab.tools()) else {
|
|
self.state.views.insert(tab, Fill::Unavailable(format!("The {} view isn't available yet in this build.", tr(tab.title()))));
|
|
return;
|
|
};
|
|
let sel = self.state.selection.clone().unwrap_or(Value::Null);
|
|
let address = workbench::row_address(&sel);
|
|
let name = workbench::row_name(&sel);
|
|
let mut wanted: Vec<(&str, Value)> = vec![("offset", json!(0)), ("limit", json!(4096))];
|
|
if let Some(a) = address.as_ref().or(name.as_ref()) {
|
|
wanted.extend([("address", json!(a)), ("function", json!(a))]);
|
|
}
|
|
if let Some(n) = &name {
|
|
wanted.extend([("name", json!(n)), ("module", json!(n)), ("path", json!(n))]);
|
|
}
|
|
let params = self.params_for(tool, &wanted);
|
|
self.run_tool(tool, params, Route::View(tab), Consent::NotAsked);
|
|
}
|
|
|
|
// ---- AI ---------------------------------------------------------------------------------
|
|
|
|
pub fn refresh_backends(&mut self) {
|
|
let hub = self.hub.clone();
|
|
self.jobs.spawn("Read AI settings", false, move |_| Ok(backends(&hub)));
|
|
}
|
|
|
|
/// Choose the backend that builds or extracts (`None`: its default).
|
|
pub fn set_role(&mut self, role: craft_anything_ai::Role, name: Option<String>) {
|
|
let hub = self.hub.clone();
|
|
self.jobs.spawn(tr("Choose an AI's job"), true, move |_| {
|
|
hub.set_role(role, name.as_deref()).map_err(|e| e.message)?;
|
|
Ok(backends(&hub))
|
|
});
|
|
}
|
|
|
|
pub fn detect_backends(&mut self) {
|
|
if self.detecting {
|
|
return;
|
|
}
|
|
self.detecting = true;
|
|
let hub = self.hub.clone();
|
|
self.jobs.spawn("Look for AI on this computer", true, move |_| Ok(Done::Detected(hub.detect())));
|
|
}
|
|
|
|
pub fn add_backend(&mut self, config: BackendConfig, key: Option<String>) {
|
|
let hub = self.hub.clone();
|
|
let name = config.name.clone();
|
|
self.jobs.spawn(i18n::trf("Connect {name}", &[("name", &name)]), true, move |_| {
|
|
let result = hub.add_backend(config, key);
|
|
Ok(Done::BackendAdded { name, result })
|
|
});
|
|
}
|
|
|
|
pub fn choose_backend(&mut self, name: Option<String>) {
|
|
if self.state.backend != name {
|
|
self.state.backend = name;
|
|
if let Some(mut c) = self.conversation.take() {
|
|
c.stop();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send a question: to the AI when one is set up, otherwise answered by the built-in engines.
|
|
pub fn send(&mut self, text: &str) {
|
|
let text = text.trim().to_string();
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
self.state.chat.push(ChatItem::User(text.clone()));
|
|
if self.state.backend.is_some() && self.conversation.is_none() {
|
|
match self.hub.start(self.state.backend.as_deref(), self.beginner()) {
|
|
Ok(c) => self.conversation = Some(c),
|
|
Err(e) => self.state.chat.push(ChatItem::Error { message: e.message, remediation: e.remediation }),
|
|
}
|
|
}
|
|
if let Some(c) = self.conversation.as_mut() {
|
|
let sent = if self.settings.mode == Mode::Beginner && self.state.explain_new {
|
|
format!("{text}\n\n(Explain like I'm new to this: plain words, short sentences, no jargon.)")
|
|
} else {
|
|
text
|
|
};
|
|
c.send(&sent);
|
|
return;
|
|
}
|
|
if self.view.active.is_none() {
|
|
self.state.chat.push(ChatItem::Assistant {
|
|
text: tr("Open something first: drop a file or app here, or choose \"Just look inside\" on New project. Then I can look inside it."),
|
|
backend: None,
|
|
streaming: false,
|
|
});
|
|
return;
|
|
}
|
|
self.first_look(&tr("There's no AI brain set up yet, so here's what the built-in engines found:"), true);
|
|
}
|
|
|
|
/// Stop the answer being worked on: the AI's, and tools working for the conversation. Builds,
|
|
/// downloads and other jobs carry on.
|
|
pub fn stop(&mut self) {
|
|
if let Some(c) = self.conversation.as_mut() {
|
|
c.stop();
|
|
}
|
|
for id in &self.chat_jobs {
|
|
self.jobs.cancel(*id);
|
|
}
|
|
}
|
|
|
|
/// An answer is being worked on (the Ask screen's "Working on your answer" and Stop).
|
|
pub fn answering(&self) -> bool {
|
|
self.conversation.as_ref().is_some_and(|c| c.is_busy()) || self.jobs.running().any(|j| self.chat_jobs.contains(&j.id))
|
|
}
|
|
|
|
pub fn busy(&self) -> bool {
|
|
self.conversation.as_ref().is_some_and(|c| c.is_busy()) || self.jobs.busy() || self.runs.any_busy()
|
|
}
|
|
|
|
fn poll_conversation(&mut self) {
|
|
let Some(c) = self.conversation.as_mut() else { return };
|
|
let events = c.poll();
|
|
if events.is_empty() {
|
|
return;
|
|
}
|
|
for e in events {
|
|
chat::apply_event(&mut self.state.chat, e.clone());
|
|
match e {
|
|
AgentEvent::ToolFinished { .. } => self.view_dirty = true,
|
|
AgentEvent::TurnDone { backend, input_tokens, output_tokens, cost_usd } => {
|
|
self.session_usage.add_turn(input_tokens, output_tokens, cost_usd);
|
|
let cost = cost_usd.map(|c| format!(", ${c:.4}")).unwrap_or_default();
|
|
self.log(false, format!("{backend}: {input_tokens} in / {output_tokens} out{cost}"));
|
|
}
|
|
AgentEvent::Error { message, .. } => self.log(true, message),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Answer a consent card (from the agent or from a UI-started tool).
|
|
pub fn answer_consent(&mut self, call_id: &str, answer: ConsentAnswer) {
|
|
let mut tool = None;
|
|
for item in &mut self.state.chat {
|
|
if let ChatItem::Consent { call_id: id, answer: a @ None, tool: t, .. } = item
|
|
&& id == call_id
|
|
{
|
|
*a = Some(answer);
|
|
tool = Some(t.clone());
|
|
}
|
|
}
|
|
let allow = answer != ConsentAnswer::Deny;
|
|
if answer == ConsentAnswer::AlwaysForProject
|
|
&& let Some(t) = &tool
|
|
{
|
|
self.allow_for_project(t.clone());
|
|
}
|
|
if let Some(rest) = call_id.strip_prefix("ui:") {
|
|
// A tool the UI started: run it again with consent.
|
|
let call = rest.parse().ok().and_then(|id| self.pending_consent.remove(&id));
|
|
if allow && let Some((tool, params, route)) = call {
|
|
self.run_tool(&tool, params, route, Consent::Granted);
|
|
}
|
|
} else if let Some(c) = self.conversation.as_mut() {
|
|
c.answer_consent(call_id, allow);
|
|
}
|
|
}
|
|
|
|
fn allow_for_project(&mut self, tool: String) {
|
|
let engine = self.engine.clone();
|
|
self.jobs.spawn("Remember permission", false, move |_| {
|
|
let mut s = engine.session();
|
|
if let Some(p) = s.project.as_mut() {
|
|
if !p.meta.allowed_tools.contains(&tool) {
|
|
p.meta.allowed_tools.push(tool);
|
|
}
|
|
p.save_meta().map_err(|e| e.message)?;
|
|
}
|
|
Ok(Done::Nothing)
|
|
});
|
|
}
|
|
|
|
// ---- report -----------------------------------------------------------------------------
|
|
|
|
pub fn export_report(&mut self) {
|
|
let md = report::markdown(&self.state.chat, self.view.active_target(), self.view.clean_room);
|
|
if self.headless {
|
|
return;
|
|
}
|
|
let name = self.view.active_target().map(|t| format!("{} report.md", t.name)).unwrap_or_else(|| "Craft Anything report.md".into());
|
|
let engine = self.engine.clone();
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
self.jobs.spawn("Export report", true, move |_| {
|
|
let picked = pollster::block_on(
|
|
rfd::AsyncFileDialog::new().set_title(tr("Save the report")).set_file_name(&name).add_filter("Markdown", &["md"]).save_file(),
|
|
);
|
|
let Some(h) = picked else { return Ok(Done::Wrote { what: "report".into(), path: None }) };
|
|
let path = h.path().to_path_buf();
|
|
// Written by the core_report_save tool, as any other client would.
|
|
let params = json!({"path": path.display().to_string(), "text": md});
|
|
engine.call("core_report_save", params, CallOptions::new(Caller::Ui)).map_err(|e| e.message)?;
|
|
Ok(Done::Wrote { what: "report".into(), path: Some(path) })
|
|
});
|
|
}
|
|
|
|
// ---- results ----------------------------------------------------------------------------
|
|
|
|
fn handle_done(&mut self, done: jobs::Finished<Done>) {
|
|
let job = done.job;
|
|
if self.saving == Some(job) {
|
|
self.saving = None;
|
|
}
|
|
if self.creating == Some(job) {
|
|
self.creating = None;
|
|
}
|
|
self.chat_jobs.retain(|id| *id != job);
|
|
let done = match done.result {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
self.detecting = false;
|
|
self.facts_read(job, None);
|
|
// A failed or cancelled engine download frees the button to try again.
|
|
if self.engine_flow.job.is_some_and(|(_, id)| id == job) {
|
|
self.engine_flow.job = None;
|
|
}
|
|
if self.ghidra.job == Some(job) {
|
|
self.ghidra.job = None;
|
|
}
|
|
self.log(true, e.clone());
|
|
self.toast(e, true);
|
|
return;
|
|
}
|
|
};
|
|
match done {
|
|
Done::Nothing => {}
|
|
Done::Projects(p) => {
|
|
self.projects = p;
|
|
self.refresh_pins();
|
|
}
|
|
Done::Facts(f) => self.facts_read(job, Some(f)),
|
|
Done::PicturesAdded(added) => self.pictures_added(added),
|
|
Done::Created { ws, request, backend, pictures } => self.created(*ws, request, backend, pictures),
|
|
Done::Thumb { path, image } => self.thumb_ready(path, image),
|
|
Done::Tool { tool, params, route, result } => self.tool_done(tool, params, route, result),
|
|
Done::Acted { tool, result } => self.acted(tool, result),
|
|
Done::Opened { path, result } => match result {
|
|
Ok(v) => {
|
|
// Another target: a new conversation, so answers about two things don't mix.
|
|
if self.view.active_target().is_some_and(|t| t.path != path) {
|
|
self.state.chat.clear();
|
|
if let Some(mut c) = self.conversation.take() {
|
|
c.stop();
|
|
}
|
|
}
|
|
self.settings.recent.retain(|p| p != &path);
|
|
self.settings.recent.insert(0, path.clone());
|
|
self.settings.recent.truncate(12);
|
|
self.settings_changed();
|
|
self.state.lists.clear();
|
|
self.state.views.clear();
|
|
self.state.selection = None;
|
|
self.view_dirty = true;
|
|
let name = v.get("name").and_then(Value::as_str).unwrap_or("it").to_string();
|
|
self.toast(i18n::trf("Opened {name}", &[("name", &name)]), false);
|
|
self.log(false, format!("Opened {}", path.display()));
|
|
if self.beginner() {
|
|
self.state.screen = Screen::Ask;
|
|
self.state.chat.push(ChatItem::Status(i18n::trf("Opened {name}. Taking a first look…", &[("name", &name)])));
|
|
self.first_look(&tr("Here's a first look:"), false);
|
|
} else {
|
|
self.state.screen = Screen::Workbench;
|
|
self.state.rail = if self.state.rail == RailKind::Targets { RailKind::Functions } else { self.state.rail };
|
|
self.load_rail(self.state.rail);
|
|
}
|
|
}
|
|
Err(e) if clean_room::is_refusal(&e) => self.state.modal = Some(state::Modal::DeepMode),
|
|
Err(e) => {
|
|
self.log(true, format!("Couldn't open {}: {}", path.display(), e.message));
|
|
self.toast(e.for_people(), true);
|
|
}
|
|
},
|
|
Done::Detected(d) => {
|
|
self.detecting = false;
|
|
self.log(false, format!("Found {} AI option(s) on this computer", d.len()));
|
|
self.detected = Some(d);
|
|
}
|
|
Done::Backends(b, builder, extractor) => {
|
|
if self.state.backend.as_ref().is_some_and(|n| !b.iter().any(|c| &c.name == n)) {
|
|
self.state.backend = None;
|
|
}
|
|
self.backends = b;
|
|
self.builder = builder;
|
|
self.extractor = extractor;
|
|
}
|
|
Done::BackendAdded { name, result } => match result {
|
|
Ok(()) => {
|
|
self.toast(i18n::trf("{name} is ready", &[("name", &name)]), false);
|
|
self.choose_backend(Some(name));
|
|
self.refresh_backends();
|
|
}
|
|
Err(e) => self.toast(e.for_people(), true),
|
|
},
|
|
Done::Picked { pick, path } => {
|
|
if let Some(path) = path {
|
|
match pick {
|
|
Pick::OpenFile | Pick::OpenFolder => self.open_path(path),
|
|
Pick::GhidraDir => {
|
|
self.settings.engines.ghidra.install_dir = Some(path);
|
|
self.settings_changed();
|
|
}
|
|
Pick::TargetFile | Pick::TargetFolder => {
|
|
self.state.make.target = craft_anything_engine::workspace::TargetRef::Local { path: path.clone() };
|
|
self.state.make.game = pick == Pick::TargetFolder;
|
|
self.state.make.editing_url = false;
|
|
self.state.make.builder = None;
|
|
self.detect_game_engine(path);
|
|
}
|
|
Pick::ProjectsDir => {
|
|
self.settings.projects_dir = Some(path);
|
|
self.settings_changed();
|
|
self.refresh_projects();
|
|
}
|
|
Pick::ProjectFolder => folders::picked(self, path),
|
|
Pick::ExtraProjectsDir => self.add_project_folder(path),
|
|
Pick::GameFolder => game_files::folder_picked(self, path),
|
|
Pick::SavePicture => game_files::save_to(self, path),
|
|
// Several files: these come back as Done::Pictures / Done::ToAttach.
|
|
Pick::Pictures | Pick::Attach => {}
|
|
}
|
|
}
|
|
}
|
|
Done::Pictures(files) => self.add_pictures(files),
|
|
Done::ToAttach(files) => self.attach(files),
|
|
Done::Attached { root, text, attachments, files } => self.deliver(root, text, attachments, files),
|
|
Done::GameEngines(inv) => self.engines_found(inv),
|
|
Done::GameFound { path, found } => {
|
|
// Only if that game is still the one picked.
|
|
if self.state.make.target == (craft_anything_engine::workspace::TargetRef::Local { path }) {
|
|
self.state.make.found = found;
|
|
}
|
|
}
|
|
Done::EngineGot { builder, installer } => self.engine_got(builder, installer),
|
|
Done::GhidraGot => self.ghidra_got(),
|
|
Done::AssetKeys(keys) => self.asset_keys = Some(keys),
|
|
Done::Wrote { what, path } => {
|
|
if let Some(p) = path {
|
|
self.toast(format!("{} {}", tr("Saved"), p.display()), false);
|
|
self.log(false, format!("Wrote {what} to {}", p.display()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn tool_done(&mut self, tool: String, params: Value, route: Route, result: Result<ToolOutput, ToolError>) {
|
|
self.view_dirty = true;
|
|
let fill = match &result {
|
|
Ok(ToolOutput::Evidence(e)) => Fill::Ready(e.clone()),
|
|
Ok(ToolOutput::Value(v)) => Fill::Value(v.clone()),
|
|
Err(e) if e.code == ErrorCode::ConsentRequired => Fill::Blocked(e.message.clone(), e.remediation.clone()),
|
|
Err(e) if clean_room::is_refusal(e) => Fill::DeepNeeded,
|
|
Err(e) => Fill::Error(e.message.clone(), e.remediation.clone()),
|
|
};
|
|
match &result {
|
|
Ok(_) => self.log(false, format!("{tool} finished")),
|
|
Err(e) => self.log(true, format!("{tool}: {}", e.message)),
|
|
}
|
|
match route {
|
|
Route::Rail(k) => {
|
|
self.state.lists.insert(k, fill);
|
|
}
|
|
Route::View(t) => {
|
|
self.state.views.insert(t, fill);
|
|
}
|
|
Route::Palette => {
|
|
if let Fill::Ready(e) = &fill {
|
|
self.state.selected_evidence = Some(e.id.clone());
|
|
}
|
|
self.state.views.insert(CenterTab::Result, fill);
|
|
self.state.center = CenterTab::Result;
|
|
}
|
|
Route::Silent => {}
|
|
Route::Chat { intro, cta } => {
|
|
match result {
|
|
Ok(ToolOutput::Evidence(e)) => self.state.chat.push(ChatItem::Proof { intro, evidence: e }),
|
|
Ok(ToolOutput::Value(v)) => {
|
|
self.state.chat.push(ChatItem::Assistant { text: format!("{intro}\n\n{}", chat::describe_value(&v)), backend: None, streaming: false })
|
|
}
|
|
Err(e) if e.code == ErrorCode::ConsentRequired => {
|
|
let title = self.engine.registry().get(&tool).map(|s| s.title.to_string()).unwrap_or_else(|| tool.clone());
|
|
// The card carries a short id; the call it stands for waits here.
|
|
let id = self.next_consent;
|
|
self.next_consent += 1;
|
|
self.pending_consent.insert(id, (tool.clone(), params, Route::Chat { intro, cta }));
|
|
self.state.chat.push(ChatItem::Consent { call_id: format!("ui:{id}"), tool, title, explanation: e.message, answer: None });
|
|
return;
|
|
}
|
|
Err(e) if clean_room::is_refusal(&e) => {
|
|
self.state.modal = Some(state::Modal::DeepMode);
|
|
return;
|
|
}
|
|
Err(e) => self.state.chat.push(ChatItem::Error { message: e.message, remediation: e.remediation }),
|
|
}
|
|
if cta {
|
|
self.state.chat.push(ChatItem::AddBrain);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- frame ------------------------------------------------------------------------------
|
|
|
|
/// Per-frame housekeeping: results, events, snapshot, theme. Never blocks.
|
|
pub fn tick(&mut self, ctx: &egui::Context) {
|
|
if !self.started {
|
|
self.started = true;
|
|
// Advanced mode opens on the workbench, unless a project was open.
|
|
if !self.beginner() && self.state.screen == Screen::Home {
|
|
self.state.screen = Screen::Workbench;
|
|
}
|
|
self.jobs.set_repaint(ctx);
|
|
if !self.headless {
|
|
self.refresh_backends();
|
|
self.refresh_projects();
|
|
self.refresh_game_engines();
|
|
self.check_asset_keys();
|
|
if !self.settings.first_run_done {
|
|
self.detect_backends();
|
|
}
|
|
}
|
|
}
|
|
let want = (self.theme_kind(), self.beginner());
|
|
if self.applied_theme != Some(want) {
|
|
theme::apply(ctx, want.0, want.1);
|
|
self.applied_theme = Some(want);
|
|
}
|
|
for f in self.jobs.poll() {
|
|
guard("job result", || self.handle_done(f));
|
|
}
|
|
if let Some(url) = self.open_url.take() {
|
|
ctx.open_url(egui::OpenUrl::new_tab(url));
|
|
}
|
|
guard("conversation", || self.poll_conversation());
|
|
guard("rebuild", || self.poll_run());
|
|
guard("pins", || self.poll_pins(ctx));
|
|
// Back from another window: projects may have been added, moved or removed meanwhile.
|
|
if !self.headless && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(true)))) {
|
|
self.refresh_projects();
|
|
self.refresh_side_panel();
|
|
}
|
|
// Another project shown (or none read yet): read its files.
|
|
if let Some(root) = self.workspace.as_ref().map(|w| &w.root).filter(|r| **r != self.facts.root) {
|
|
self.facts = project::Facts { root: root.clone(), ..Default::default() };
|
|
self.thumbs.clear();
|
|
self.refresh_side_panel();
|
|
}
|
|
self.read_facts(ctx);
|
|
self.flush_settings(ctx);
|
|
// A build asked something while the window is behind others: flash it in the taskbar.
|
|
if std::mem::take(&mut self.attention) && !ctx.input(|i| i.viewport().focused.unwrap_or(true)) {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
|
|
}
|
|
self.refresh_view();
|
|
// A conversation and jobs are checked on a timer; runs repaint the window themselves
|
|
// (`RunOptions::wake`), so only a slow check covers them.
|
|
if self.conversation.as_ref().is_some_and(|c| c.is_busy()) || self.jobs.busy() {
|
|
ctx.request_repaint_after(Duration::from_millis(100));
|
|
} else if self.runs.any_busy() {
|
|
ctx.request_repaint_after(Duration::from_secs(1));
|
|
}
|
|
// Dropped pictures go to the prompt box on Make or a project; anything else opens.
|
|
let dropped: Vec<PathBuf> = ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_path_buf()).filter(|p| !p.as_os_str().is_empty()).collect());
|
|
let pictures = !dropped.is_empty() && dropped.iter().all(|p| craft_anything_engine::workspace::is_picture(p));
|
|
match self.state.screen {
|
|
Screen::Project if pictures => self.attach(dropped),
|
|
Screen::Home if pictures => self.add_pictures(dropped),
|
|
_ => {
|
|
if let Some(p) = dropped.into_iter().next() {
|
|
self.open_path(p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Draw the whole interface.
|
|
pub fn show(&mut self, ui: &mut egui::Ui) {
|
|
let ctx = ui.ctx().clone();
|
|
self.tick(&ctx);
|
|
self.shortcuts(&ctx);
|
|
let t = theme::Tokens::get(&ctx);
|
|
// The title bar is on every screen: it is how the window moves, resizes and closes.
|
|
egui::Panel::top("titlebar").exact_size(titlebar::HEIGHT).frame(egui::Frame::new().fill(t.chrome)).show(ui, |ui| {
|
|
guard_ui(ui, "title bar", |ui| titlebar::show(self, ui));
|
|
});
|
|
if !self.settings.first_run_done {
|
|
egui::CentralPanel::default().frame(egui::Frame::new().fill(t.canvas)).show(ui, |ui| guard_ui(ui, "first run", |ui| first_run::show(self, ui)));
|
|
titlebar::window_edge(&ctx);
|
|
titlebar::resize_edges(&ctx);
|
|
return;
|
|
}
|
|
|
|
if self.beginner() || self.state.screen != Screen::Workbench {
|
|
egui::Panel::left("app-sidebar")
|
|
.exact_size(sidebar::width(self, &ctx))
|
|
.resizable(false)
|
|
.frame(egui::Frame::new().fill(t.chrome).inner_margin(egui::Margin::symmetric(8, 0)))
|
|
.show(ui, |ui| guard_ui(ui, "sidebar", |ui| sidebar::show(self, ui)));
|
|
egui::CentralPanel::default().frame(egui::Frame::new().fill(t.canvas)).show(ui, |ui| {
|
|
guard_ui(ui, "simple", |ui| match self.state.screen {
|
|
Screen::Home | Screen::Workbench => home::show(self, ui),
|
|
Screen::Ask => chat::ask_screen(self, ui),
|
|
Screen::Project => project::show(self, ui),
|
|
Screen::GameFiles => game_files::show(self, ui),
|
|
})
|
|
});
|
|
} else {
|
|
guard_ui(ui, "workbench", |ui| workbench::show(self, ui));
|
|
}
|
|
palette::show(self, &ctx);
|
|
settings_ui::show(self, &ctx);
|
|
home::modals(self, &ctx);
|
|
self.toasts(&ctx);
|
|
self.drop_overlay(&ctx);
|
|
titlebar::window_edge(&ctx);
|
|
titlebar::resize_edges(&ctx);
|
|
}
|
|
|
|
/// Messages float over the bottom of the window, newest at the bottom: the screen under them
|
|
/// doesn't move when one comes or goes.
|
|
fn toasts(&mut self, ctx: &egui::Context) {
|
|
const CONFIRMATION: Duration = Duration::from_secs(4);
|
|
// Times and messages stay in step even if something cleared the messages directly.
|
|
self.toast_at.resize(self.state.toasts.len(), Instant::now());
|
|
let mut i = 0;
|
|
while i < self.state.toasts.len() {
|
|
let gone = !self.state.toasts[i].1 && self.toast_at.get(i).is_some_and(|t| t.elapsed() >= CONFIRMATION);
|
|
if gone {
|
|
self.state.toasts.remove(i);
|
|
self.toast_at.remove(i);
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
if let Some(next) = self.state.toasts.iter().zip(&self.toast_at).filter(|((_, e), _)| !e).map(|(_, t)| CONFIRMATION.saturating_sub(t.elapsed())).min() {
|
|
ctx.request_repaint_after(next);
|
|
}
|
|
if self.state.toasts.is_empty() {
|
|
return;
|
|
}
|
|
let t = theme::Tokens::get(ctx);
|
|
let width = (ctx.content_rect().width() - 32.0).clamp(200.0, 640.0);
|
|
let mut dismiss = None;
|
|
egui::Area::new(egui::Id::new("toasts")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_BOTTOM, egui::vec2(0.0, -16.0)).show(ctx, |ui| {
|
|
ui.set_width(width);
|
|
for (i, (text, error)) in self.state.toasts.iter().enumerate() {
|
|
egui::Frame::new()
|
|
.fill(t.raised)
|
|
.stroke(egui::Stroke::new(1.0, if *error { t.danger.gamma_multiply(0.6) } else { t.card_border }))
|
|
.corner_radius(t.r_lg())
|
|
.shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow })
|
|
.inner_margin(egui::Margin::symmetric(14, 8))
|
|
.show(ui, |ui| {
|
|
ui.set_width(width - 28.0);
|
|
ui.horizontal(|ui| {
|
|
let (icon, col) = if *error { ("triangle-alert", t.danger) } else { ("check", t.success) };
|
|
icons::show(ui, icon, 15.0, col);
|
|
ui.add(egui::Label::new(egui::RichText::new(text).color(if *error { t.text } else { t.text_dim })).wrap());
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if icons::button(ui, "x", 28.0, false, "Dismiss").clicked() {
|
|
dismiss = Some(i);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
ui.add_space(6.0);
|
|
}
|
|
});
|
|
if let Some(i) = dismiss {
|
|
self.state.toasts.remove(i);
|
|
if i < self.toast_at.len() {
|
|
self.toast_at.remove(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn drop_overlay(&self, ctx: &egui::Context) {
|
|
if !ctx.input(|i| !i.raw.hovered_files.is_empty()) {
|
|
return;
|
|
}
|
|
let t = theme::Tokens::get(ctx);
|
|
let rect = ctx.content_rect();
|
|
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("drop")));
|
|
painter.rect_filled(rect, 0.0, t.shadow);
|
|
let inner = rect.shrink(40.0);
|
|
painter.rect_stroke(inner, t.radius_lg, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
|
// Pictures dropped on Make or a project are attached; anything else opens.
|
|
let pictures = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(craft_anything_engine::workspace::is_picture)));
|
|
let attach = pictures && matches!(self.state.screen, Screen::Home | Screen::Project);
|
|
let g = theme::heading_galley(&painter, tr(if attach { "Drop to attach" } else { "Drop to open it" }), theme::TITLE, t.text, f32::INFINITY);
|
|
painter.galley(inner.center() - g.size() / 2.0, g, t.text);
|
|
}
|
|
|
|
fn shortcuts(&mut self, ctx: &egui::Context) {
|
|
let (palette, esc) = ctx.input_mut(|i| (i.consume_key(egui::Modifiers::COMMAND, egui::Key::K), i.key_pressed(egui::Key::Escape)));
|
|
if palette && self.settings.first_run_done {
|
|
self.state.palette.open = !self.state.palette.open;
|
|
self.state.palette.query.clear();
|
|
self.state.palette.selected = 0;
|
|
self.state.palette.form_tool = None;
|
|
}
|
|
if esc {
|
|
if self.state.palette.open {
|
|
self.state.palette.open = false;
|
|
} else if self.state.modal.is_some() {
|
|
home::dismiss(self);
|
|
} else if self.state.settings_open {
|
|
self.state.settings_open = false;
|
|
} else if !matches!(self.state.screen, Screen::Home | Screen::Workbench) && ctx.memory(|m| m.focused().is_none()) {
|
|
// Back to Home, unless Esc was meant for a text field.
|
|
self.state.screen = Screen::Home;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The 256 px app icon.
|
|
pub static APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.craft-anything.png");
|
|
/// The app icon's master SVG, drawn at the exact pixel size wherever the app shows its icon.
|
|
pub static APP_ICON_SVG: &[u8] = include_bytes!("../../../assets/app-icon/craft-anything.svg");
|
|
|
|
/// The app icon at `size` points.
|
|
pub fn app_icon(size: f32) -> egui::Image<'static> {
|
|
egui::Image::from_bytes("bytes://app-icon.svg", APP_ICON_SVG).fit_to_exact_size(egui::Vec2::splat(size))
|
|
}
|
|
|
|
/// Run an action; a panic is logged and swallowed so one bad action never takes the app down.
|
|
/// The configured backends and the ones chosen to build and to extract (read off the UI thread).
|
|
pub(crate) fn backends(hub: &AiHub) -> Done {
|
|
Done::Backends(hub.backends(), hub.chosen(craft_anything_ai::Role::Builder), hub.chosen(craft_anything_ai::Role::Extractor))
|
|
}
|
|
|
|
pub fn guard<R>(what: &str, f: impl FnOnce() -> R) -> Option<R> {
|
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
|
Ok(r) => Some(r),
|
|
Err(p) => {
|
|
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
|
log::error!("{what} panicked: {msg}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Draw part of the UI; a panic shows a calm notice in its place instead of crashing.
|
|
pub fn guard_ui(ui: &mut egui::Ui, what: &str, f: impl FnOnce(&mut egui::Ui)) {
|
|
let ok = guard(what, || f(ui)).is_some();
|
|
if !ok {
|
|
let t = theme::Tokens::get(ui.ctx());
|
|
ui.colored_label(t.danger, tr("Part of this screen hit a bug and was skipped. Your work is safe."));
|
|
}
|
|
}
|
|
|
|
impl eframe::App for CraftApp {
|
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
|
self.show(ui);
|
|
}
|
|
|
|
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
|
if let Ok(s) = serde_json::to_string(&self.prefs()) {
|
|
storage.set_string(PREFS_KEY, s);
|
|
}
|
|
}
|
|
|
|
fn on_exit(&mut self) {
|
|
// A running build saves the project's conversation itself (rebuild::feed).
|
|
if let Some(c) = self.conversation.as_mut() {
|
|
c.stop();
|
|
}
|
|
self.runs.stop_all();
|
|
for j in self.jobs.running() {
|
|
j.cancel.cancel();
|
|
}
|
|
// A change not written yet (or one being written as the window closed) is written now.
|
|
if self.persist && (self.settings_dirty || self.save_due.is_some() || self.saving.is_some()) {
|
|
let _ = self.settings.save();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Short display of a path for recents.
|
|
pub fn short_path(p: &Path) -> String {
|
|
let s = p.display().to_string();
|
|
// Windows keeps the home folder in USERPROFILE.
|
|
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
|
|
&& let Ok(rest) = p.strip_prefix(&home)
|
|
{
|
|
return format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display());
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Human byte size.
|
|
pub fn human_bytes(n: usize) -> String {
|
|
craft_anything_engine::evidence::human_bytes(n as u64)
|
|
}
|
|
|
|
/// Icon for a target kind.
|
|
pub fn kind_icon(k: TargetKind) -> &'static str {
|
|
match k {
|
|
TargetKind::Elf | TargetKind::Pe | TargetKind::MachO | TargetKind::MachOFat | TargetKind::DosCom | TargetKind::RawBinary => "cpu",
|
|
TargetKind::Managed | TargetKind::Jar | TargetKind::JavaClass => "boxes",
|
|
TargetKind::Wasm => "binary",
|
|
TargetKind::Apk | TargetKind::Dex => "smartphone",
|
|
TargetKind::Zip | TargetKind::Asar => "package",
|
|
TargetKind::Directory => "folder",
|
|
TargetKind::Script => "file-code",
|
|
TargetKind::NetworkCapture => "network",
|
|
TargetKind::Plist => "list-tree",
|
|
TargetKind::Evm => "zap",
|
|
TargetKind::Sqlite => "database",
|
|
TargetKind::Unknown => "file",
|
|
}
|
|
}
|