makepad/tools/makepad_builder/src/tui.rs

2551 lines
109 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The setup process owns blocking work. On Windows MpTerm hosts this process
//! and all of its children; Unix bootstraps use the matching terminal menu.
//!
//! One full-screen view in the terminal's own colours: SETUP, YOUR APPS,
//! MAKEPAD EXPERIMENTS and CODING AGENTS rows, one status line where
//! questions, progress and results appear, and a key-hint footer. The file
//! reads top to bottom: startup, the main menu, then one section per row
//! group, then helpers and terminal input. Drawing lives in tui_view.rs.
use crate::{
catalog::{self, Release},
progress,
rustc,
runtime::{self, Dependency, Environment, RustChoice},
};
use std::{
env, fs,
io::{self, IsTerminal, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
sync::{Arc, Mutex},
};
// ---- Texts and links -------------------------------------------------------
/// App states and their status/action texts: `state|status|action`.
const MENU: &str = include_str!("../menu.txt");
/// The subtitle under the header.
const ABOUT: &str = include_str!("../about.txt");
/// License agreements, opened in the browser from the Agreements page and
/// the consent screens.
const MAKEPAD_LICENSE_URL: &str = "https://makepad.nl/commercial-license";
const BUILD_TOOLS_LICENSE_URL: &str = "https://visualstudio.microsoft.com/license-terms/vs2022-ga-diagnosticbuildtools/";
const WINDOWS_SDK_LICENSE_URL: &str = "https://learn.microsoft.com/legal/windows-sdk/windows-sdk-license";
const RUST_LICENSE_URL: &str = "https://www.rust-lang.org/policies/licenses";
const CUDA_LICENSE_URL: &str = "https://docs.nvidia.com/cuda/eula/";
/// Present once the GPU notice was read ("I understand") in this folder.
const GPU_NOTICE_READ: &str = "graphics-notice-read";
/// The GPU driver notice (Windows and Linux, until it is read).
const GPU_NOTICE: &str = "Makepad heavily relies on your GPU to draw its UI and implement AI functionality. Old hardware and broken drivers can cause your computer to reboot unexpectedly.";
/// Windows Defender and similar tools briefly lock a freshly unpacked rustc.
const STILL_SCANNING: &str = "Windows security software is still scanning the staged Rust compiler. Select Build tools to check again.";
/// Returned as an error by sub-screens when the person pressed q.
const QUIT: &str = "\u{1}quit";
#[path = "tui_view.rs"]
mod view;
pub use view::with_progress;
use view::{activity, done, text, Item, Nav, Row, Screen, View, DIM, OK, PLAIN, WARN};
// ---- Startup: log in, graphics, build tools, then the main menu ------------
/// `makepad-builder tui`: the whole session, from the first screen to quit.
pub fn run() -> Result<(), String> {
let root = crate::validate_install_root(&crate::default_root())?;
let bootstrap = match makepad_loader_bundle::load_from(&root)? {
Some(bootstrap) => Some(bootstrap),
None => makepad_loader_bundle::load()?.map(|(_, b)| b),
};
fs::create_dir_all(&root).map_err(|e| e.to_string())?;
let root = root.canonicalize().map_err(|e| e.to_string())?;
let _lock = Lock::take(&root)?;
let email = env::var("MAKEPAD_LOADER_EMAIL").ok().or_else(|| bootstrap.map(|b| b.email));
let mut setup = Setup::open(root, email)?;
view::set_log(setup.root.join("builder.log"));
// The host window's palette is for this TUI only, not for the shells,
// agents and apps started from it.
env::remove_var("MAKEPAD_TERMINAL_COLORS");
let screen = Screen::enter();
// The email comes first (downloads are not personalized): asked once per
// folder, kept in its makepad-builder.json and never asked again.
if setup.email.is_empty() && !setup.root.join(".login-asked").exists() {
setup.log_in_screen()?;
let _ = fs::write(setup.root.join(".login-asked"), "");
}
setup.gpu_read = gpu_warning_acknowledged(&setup.root, &setup.shown_email())?;
if !setup.gpu_read {
drop(screen);
println!("Setup cancelled. Nothing was downloaded or installed.");
return Ok(());
}
show_menu(&mut setup)?;
drop(screen);
if io::stdout().is_terminal() { print!("\x1b[0m"); }
println!("Makepad Builder closed.");
Ok(())
}
fn show_menu(setup: &mut Setup) -> Result<(), String> {
fs::write(setup.root.join("selected-app"), &setup.app).map_err(|e| e.to_string())?;
setup.measure_disk();
// Every start refreshes the licenses and sets up missing build tools,
// then stops at the menu with the first app selected: which app to
// build and open is the person's choice. Success, cancellation and
// failure all return to the menu; never restart the sequence on redraw.
let startup = (|| -> Result<(), String> {
// Setup screens follow the Graphics screen directly: no main menu
// or license check is drawn before the build tools consent; the menu
// first appears after Agree (busy install) or Cancel.
let ready = setup.ready();
let mut tools_ready = ready.compiler();
if !tools_ready || setup.cuda_wanted() {
match setup.setup_tools() {
Err(error) if error == QUIT => return Err(error),
Err(error) => setup.report(error),
Ok(()) => {}
}
let ready = setup.ready();
tools_ready = ready.compiler();
}
// The log-in screen may have checked already.
if setup.licenses.is_none() {
if let Err(error) = setup.check_licenses() {
setup.report(error);
}
}
view::set_view(setup.main_view());
Ok(())
})();
match startup {
Err(error) if error == QUIT => return Ok(()),
Err(error) => setup.report(error),
Ok(()) => {}
}
// The menu opens on the first of YOUR APPS, also after the start-up
// setup ran (which would otherwise select Build tools).
view::forget_worked_on();
let mut selected = setup.first_license_row(&setup.main_view());
loop {
let view = setup.main_view();
let nav = view::menu(view, &mut selected, &|| setup.disk_changed())?;
match nav {
Nav::Refresh => {}
Nav::Back | Nav::Quit => break,
Nav::Select(id) => match setup.select(&id) {
Err(error) if error == QUIT => break,
Err(error) => setup.report(error),
Ok(()) => {}
},
}
}
Ok(())
}
/// Linux and Windows show the GPU driver notice before anything is probed,
/// refreshed, downloaded or compiled, until the person chooses "I
/// understand"; that answer is kept in the folder (`graphics-notice-read`) and
/// the notice is not shown on later starts. macOS does not show it. Cancel,
/// Escape or closed input quit setup.
fn gpu_warning_acknowledged(root: &Path, email: &str) -> Result<bool, String> {
if cfg!(target_os = "macos") || root.join(GPU_NOTICE_READ).is_file() {
return Ok(true);
}
if !gpu_notice(email)? {
return Ok(false);
}
record_gpu_notice(root)?;
Ok(true)
}
/// Written through a temporary name, like the other records.
fn record_gpu_notice(root: &Path) -> Result<(), String> {
let next = root.join(format!("{GPU_NOTICE_READ}.next"));
fs::write(&next, "read\n").map_err(|e| e.to_string())?;
fs::rename(&next, root.join(GPU_NOTICE_READ)).map_err(|e| e.to_string())
}
/// The GPU driver notice as a screen of its own: "I understand" (selected)
/// or Cancel; Escape and closed input cancel.
fn gpu_notice(email: &str) -> Result<bool, String> {
let mut rows = vec![Row::Note(Vec::new())];
rows.extend(wrap(GPU_NOTICE, 74).into_iter().map(|line| Row::Note(text(line, PLAIN))));
rows.push(item("continue", "I understand", "", Vec::new(), ""));
rows.push(item("cancel", "Cancel", "", Vec::new(), ""));
let view = View {
crumb: " › Graphics".into(),
subtitle: "Before you continue.".into(),
email: email.into(),
rows,
back: true,
footer: Some("↑↓ move ⏎ select esc cancel"),
..View::default()
};
let mut selected = 0;
loop {
match view::menu(view.clone(), &mut selected, &|| false)? {
Nav::Select(id) => return Ok(id == "continue"),
Nav::Back | Nav::Quit => return Ok(false),
Nav::Refresh => {}
}
}
}
/// One Builder per folder: `.setup-lock/` holding the owner's pid.
struct Lock(PathBuf);
impl Lock {
fn take(root: &Path) -> Result<Self, String> {
let lock = root.join(".setup-lock");
// A closed terminal can end this process before Drop runs. Reclaim only
// our own lock format with a provably exited owner, never an active setup.
if let Ok(pid) = fs::read_to_string(lock.join("pid")) {
if pid.trim().parse::<u32>().is_ok_and(|p| p > 0 && !console::alive(p)) {
release_lock(&lock);
}
}
fs::create_dir(&lock).map_err(|_| {
"Makepad Builder is already using this folder. Close it before starting another.".to_owned()
})?;
let lock = Lock(lock);
fs::write(lock.0.join("pid"), std::process::id().to_string()).map_err(|e| e.to_string())?;
Ok(lock)
}
}
impl Drop for Lock {
fn drop(&mut self) {
release_lock(&self.0);
}
}
/// The lock is a folder holding only our pid file; remove_dir refuses
/// anything else.
fn release_lock(lock: &Path) {
if let Some(root) = lock.parent() {
let _ = crate::remove_inside(root, &lock.join("pid"));
}
let _ = fs::remove_dir(lock);
}
// ---- Setup state -----------------------------------------------------------
#[derive(Clone, Copy)]
struct Ready {
tools: bool,
rust: bool,
}
impl Ready {
fn compiler(self) -> bool {
self.tools && self.rust
}
}
/// Last validation of a recorded installed Rust (macOS/Linux). Redraws only
/// confirm the binaries still exist; builds re-validate through `selected_rust`.
#[derive(Clone)]
struct RustCheck {
recorded: String,
version: String,
sysroot: Option<PathBuf>,
}
#[derive(Clone)]
struct SourceCheck {
built: std::time::SystemTime,
at: std::time::Instant,
changed: bool,
}
/// The source files of a Cargo dep-info file (`target: a.rs b\ c.rs`): paths
/// after the first `: `, split on spaces not escaped with a backslash; a
/// backslash before a newline continues the line.
fn dep_file_sources(text: &str) -> Vec<String> {
let Some(first) = text.lines().next() else { return Vec::new() };
let Some((_, deps)) = first.split_once(": ") else { return Vec::new() };
let mut sources = Vec::new();
let mut current = String::new();
let mut chars = deps.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' if chars.peek() == Some(&' ') => {
current.push(' ');
chars.next();
}
' ' => {
if !current.is_empty() {
sources.push(std::mem::take(&mut current));
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
sources.push(current);
}
sources
}
#[cfg(test)]
mod dep_file_tests {
use super::dep_file_sources;
#[test]
fn dep_files_list_their_sources_with_escaped_spaces_and_drive_letters() {
assert_eq!(dep_file_sources("/t/release/app: /s/a.rs /s/my\\ dir/b.rs\n\n/s/a.rs:\n"), vec!["/s/a.rs", "/s/my dir/b.rs"]);
assert_eq!(
dep_file_sources("C:\\Users\\p\\target\\release\\scope.exe: C:\\Users\\p\\src\\main.rs C:\\Users\\p\\makepad-builder\\ (2)\\lib.rs"),
vec!["C:\\Users\\p\\src\\main.rs", "C:\\Users\\p\\makepad-builder (2)\\lib.rs"]
);
}
}
#[derive(Clone)]
struct Setup {
root: PathBuf,
project: PathBuf,
service: String,
email: String,
app: String,
release: Option<Release>,
cuda: bool,
compiler_retry: bool,
rust_check: std::cell::RefCell<Option<RustCheck>>,
/// Per app: whether its sources changed after its last build, rechecked
/// when the built app changes or after a few seconds.
source_checks: std::cell::RefCell<std::collections::HashMap<String, SourceCheck>>,
/// Licensed releases from the last catalog check; None before one succeeded.
licenses: Option<Vec<Release>>,
license_error: Option<String>,
/// The newest public Makepad release known (for the free apps).
public: Option<Release>,
/// The last update check or build: (downloaded updates, "HH:MM").
checked: Option<(bool, String)>,
disk: Arc<Mutex<(u64, Option<(u64, u64)>)>>,
disk_seen: std::cell::Cell<u64>,
/// Installed coding agents: (command, title).
agents: Vec<(&'static str, &'static str)>,
free_selected: usize,
/// The GPU driver notice was acknowledged in this session.
gpu_read: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum State {
New,
Partial,
Compile,
Update,
Ready,
Merge,
}
impl State {
fn key(self) -> &'static str {
match self {
State::New => "new",
State::Partial => "partial",
State::Compile => "compile",
State::Update => "update",
State::Ready => "ready",
State::Merge => "merge",
}
}
/// Status and action texts from menu.txt.
/// The status is the action for every state but ready and merge; those
/// rows get only a trailing ⏎ when selected (action "⏎"). `bytes` is
/// the download size when known.
fn texts(self, bytes: Option<u64>) -> (view::Text, String) {
let (status, action) = MENU
.lines()
.filter_map(|line| line.split_once('|'))
.find(|(key, _)| *key == self.key())
.and_then(|(_, rest)| rest.split_once('|'))
.map(|(s, a)| (s.to_owned(), a.to_owned()))
.unwrap_or_else(|| (self.key().to_owned(), "run".to_owned()));
let status = match bytes {
Some(bytes) => status.replace("{mb}", &bytes.div_ceil(1048576).to_string()),
None => status.replace(" {mb} MB", ""),
};
let action = if action.is_empty() { "⏎".to_owned() } else { action };
let status = match self {
State::New => text(status, DIM),
State::Ready => done(status),
State::Compile => text(status, PLAIN),
_ => text(status, WARN),
};
(status, action)
}
}
/// Opening a session.
impl Setup {
/// The session state for `root`, from what earlier runs left there.
fn open(root: PathBuf, email: Option<String>) -> Result<Self, String> {
let load = |path: &str| load_release(&root.join(path));
Ok(Setup {
project: env::var_os("MAKEPAD_LOADER_PROJECT")
.map(PathBuf::from)
.unwrap_or_else(|| root.clone())
.canonicalize()
.map_err(|e| e.to_string())?,
service: env::var("MAKEPAD_LOADER_SERVICE").unwrap_or_else(|_| catalog::DEFAULT_SERVICE.into()),
email: match email {
Some(email) if !email.is_empty() => catalog::email(&email)?,
_ => String::new(),
},
app: "scope".into(),
release: load("available/scope.json")
.or_else(|| load("latest.json").filter(|r| r.id == "scope"))
.or_else(|| load("installed/scope.json")),
public: fs::read_dir(root.join("available"))
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| load_release(&entry.path()))
.filter(|release| release.public)
.max_by(|a, b| a.release.cmp(&b.release)),
cuda: crate::cuda::build_with(&root),
compiler_retry: false,
rust_check: Default::default(),
source_checks: Default::default(),
licenses: None,
license_error: None,
checked: None,
disk: Arc::new(Mutex::new((0, None))),
disk_seen: std::cell::Cell::new(0),
agents: [("claude", "Claude Code"), ("codex", "Codex"), ("grok", "Grok")]
.into_iter()
.filter(|(command, _)| on_path(command))
.collect(),
free_selected: 0,
gpu_read: false,
root,
})
}
}
/// The main menu: rows, and what Return does on each.
impl Setup {
fn main_view(&self) -> View {
let ready = self.ready();
let mut rows = vec![Row::Head("SETUP".into())];
rows.push(self.account_row());
if !cfg!(target_os = "macos") {
rows.push(self.graphics_row());
}
rows.push(self.agreements_row());
rows.extend(self.compiler_rows(ready));
if crate::cuda::supported() {
rows.push(self.cuda_row());
}
rows.push(self.disk_row());
rows.push(match &self.checked {
None => item("updates", "Update", "", text("check for updates", DIM), "⏎"),
Some((false, time)) => item("updates", "Update", "", done(format!("up to date · {time}")), "check again"),
Some((true, time)) => item("updates", "Update", "", text(format!("updates downloaded · {time}"), PLAIN), "check again"),
});
rows.push(Row::Head("YOUR APPS".into()));
rows.extend(self.license_rows());
let free = free_apps().unwrap_or_default();
let built = free.iter().filter(|(id, _)| self.app_state(id, self.app_release(id).as_ref()) == State::Ready).count();
rows.push(Row::Head("MAKEPAD EXPERIMENTS".into()));
rows.push(item("free", "Experiments", "free", text(format!("{} · {built} ready", free.len()), PLAIN), "open list"));
rows.push(Row::Head("CODING AGENTS · each one knows how to change and rebuild these apps".into()));
for (command, title) in &self.agents {
rows.push(item(format!("agent-{command}"), *title, "", Vec::new(), "open"));
}
rows.push(item("agent-shell", "Shell", "", text("with this folder's Rust on PATH", DIM), "open"));
View {
subtitle: ABOUT.trim().into(),
email: self.shown_email(),
rows,
..View::default()
}
}
fn account_row(&self) -> Row {
if self.email.is_empty() {
item("account", "Account", "", text("not logged in", WARN), "log in")
} else {
// The email proves itself by loading its license catalog.
let status = match (&self.licenses, &self.license_error) {
(Some(_), _) => done(self.email.clone()),
(None, Some(error)) => vec![
view::Span(format!("{} ", self.email), PLAIN),
view::Span(format!("· {}", short_reason(error)), WARN),
],
(None, None) => vec![view::Span(format!("{} ", self.email), PLAIN), view::Span("checking…".into(), DIM)],
};
item("account", "Account", "", status, "switch email")
}
}
fn graphics_row(&self) -> Row {
if self.gpu_read {
item("gpu", "Graphics", "", done("driver notice read"), "")
} else {
item("gpu", "Graphics", "", text("read the driver notice", WARN), "read")
}
}
/// ✓ once every agreement the build tools need is accepted (on the
/// Agreements screen or a consent screen); NVIDIA joins when CUDA's is.
fn agreements_row(&self) -> Row {
let accepted = self.accepted_agreements();
let required = required_agreements();
let status = if required.iter().all(|id| accepted.iter().any(|a| a == id)) {
let mut names = vec!["Makepad"];
if cfg!(windows) {
names.push("Microsoft");
}
names.push("Rust");
if accepted.iter().any(|a| a == "cuda") {
names.push("NVIDIA");
}
vec![view::Span("✓".into(), OK), view::Span(" accepted ".into(), PLAIN), view::Span(format!("· {}", names.join(", ")), DIM)]
} else {
text("not accepted", WARN)
};
item("terms", "Agreements", "", status, "read")
}
/// Windows: one Build tools row. macOS/Linux: Rust, then Apple's tools or
/// the distribution's packages.
fn compiler_rows(&self, ready: Ready) -> Vec<Row> {
let version = self.pinned_rust().unwrap_or_default();
if cfg!(windows) {
let (status, action) = if self.compiler_retry {
(text("Windows security is still checking Rust", WARN), "check again")
} else if ready.compiler() {
(done(format!("Visual Studio Build Tools, Windows SDK, Rust {version}")), "recheck")
} else if ready.tools && !version.is_empty() {
(text(format!("Rust {version} not installed"), WARN), "install")
} else {
(text("not installed", WARN), "install")
};
return vec![item("tools", "Build tools", "", status, action)];
}
let (rust_status, rust_action) = if !ready.rust {
(text("not set up", WARN), "set up")
} else if let Ok(RustChoice::External(sysroot)) = runtime::rust_choice(&self.root) {
(done(format!("installed Rust {}", short_path(Path::new(&sysroot)))), "change")
} else {
let status = vec![
view::Span("✓".into(), OK),
view::Span(format!(" private {version} "), PLAIN),
view::Span(format!("in {}", short_path(&self.root)), DIM),
];
(status, "change")
};
let macos = cfg!(target_os = "macos");
let (tools_status, tools_action) = if ready.tools {
(done(if macos { "clang, SDK, git" } else { "compiler, linker, git" }), "recheck")
} else if macos && xcode_license_pending() {
(text("license not accepted", WARN), "accept")
} else if macos {
(text("not installed", WARN), "install")
} else {
(text("missing", WARN), "set up")
};
vec![
item("rust", "Rust", "", rust_status, rust_action),
item("system", if macos { "Xcode tools" } else { "System packages" }, "", tools_status, tools_action),
]
}
fn cuda_row(&self) -> Row {
let (status, action) = if !crate::cuda::gpu_present() {
(text("needs an NVIDIA GPU", DIM), "")
} else if self.cuda_installed() {
let status = vec![
view::Span("✓".into(), OK),
view::Span(format!(" CUDA {} ", crate::cuda::CUDA_VERSION), PLAIN),
view::Span("· AI features in Makepad Amp".into(), DIM),
];
(status, "")
} else {
(text("not installed · AI features in Makepad Amp", WARN), "install")
};
item("cuda", "CUDA", "", status, action)
}
fn license_rows(&self) -> Vec<Row> {
if self.email.is_empty() {
return vec![item("login", "Log in with your email address", "", Vec::new(), "to see your licenses")];
}
match (&self.licenses, &self.license_error) {
(Some(list), _) if list.is_empty() => vec![Row::Note(text("none on this email yet · buy or request beta access at makepad.nl", DIM))],
(Some(list), _) => list.iter().map(|release| self.app_row(release)).collect(),
(None, None) => vec![Row::Note(text(format!("checking licenses for {}…", self.email), DIM))],
(None, Some(error)) => {
// Offline: the apps cached from the last check still run.
let mut rows = vec![Row::Note(text(format!("licenses not checked: {}", clean(error.lines().next().unwrap_or_default())), WARN))];
let cached = fs::read_dir(self.root.join("available")).into_iter().flatten().flatten()
.filter_map(|e| load_release(&e.path()))
.filter(|r| !r.public);
rows.extend(cached.map(|release| self.app_row(&release)));
rows
}
}
}
fn app_row(&self, release: &Release) -> Row {
let state = self.app_state(&release.id, Some(release));
let (mut status, mut action) = state.texts(Some(release.repositories.iter().map(|r| r.bytes).sum()));
if !release.supported() {
status = text("not available for this platform yet", DIM);
action = String::new();
}
let license = if release.license == "beta" { "beta" } else { "commercial" };
item(format!("app:{}", release.id), release.title.clone(), license, status, action)
}
fn disk_row(&self) -> Row {
// Folder total, then what "clear build data" can delete.
match self.disk.lock().ok().and_then(|slot| slot.1) {
None => item("disk", "Disk", "", text("measuring…", DIM), ""),
Some((total, 0)) => item("disk", "Disk", "", text(format!("{} GB used", gb(total)), PLAIN), ""),
Some((total, build)) => item(
"disk",
"Disk",
"",
vec![view::Span(format!("{} GB used ", gb(total)), PLAIN), view::Span(format!("· build {} GB", gb(build)), DIM)],
"clear build data",
),
}
}
/// Index of the first licensed app row, where the menu starts.
fn first_license_row(&self, view: &View) -> usize {
let mut index = 0;
let mut in_licenses = false;
for row in &view.rows {
match row {
Row::Head(title) => in_licenses = title == "YOUR APPS",
Row::Item(_) if in_licenses => return index,
Row::Item(_) => index += 1,
Row::Note(_) => {}
}
}
0
}
fn select(&mut self, id: &str) -> Result<(), String> {
match id {
"account" | "login" => self.switch_account(),
"gpu" => self.graphics_screen(),
"tools" | "rust" => self.setup_tools(),
"system" => self.setup_system_tools(),
"cuda" => self.setup_cuda(),
"updates" => self.check_updates(),
"disk" => self.clear_build(),
"terms" => self.agreements_screen(),
"free" => self.experiments_screen(),
"agent-shell" => self.shell(),
agent if agent.starts_with("agent-") => self.launch_agent(&agent["agent-".len()..], None, None),
app => self.open_app(app.trim_start_matches("app:")),
}
}
fn report(&self, error: String) {
let error = if self.email.is_empty() { error } else { error.replace(&self.email, "[email]") };
view::warn(&error);
}
/// The header's right side.
fn shown_email(&self) -> String {
if self.email.is_empty() { "not logged in".into() } else { self.email.clone() }
}
}
/// Account and licenses.
impl Setup {
/// First run: the Log in screen with an inline email editor. Empty
/// continues with the open source experiments.
fn log_in_screen(&mut self) -> Result<(), String> {
let mut rows = vec![Row::Note(Vec::new())];
rows.extend(wrap("Enter the email address you bought a Makepad app with, such as Scope or Amp, or that has beta access. It shows your licenses and stays in this folder only.", 74).into_iter().map(|line| Row::Note(text(line, PLAIN))));
rows.push(Row::Note(Vec::new()));
rows.extend(wrap("Leave it empty to continue with the open source experiments; you can log in later from the menu.", 74).into_iter().map(|line| Row::Note(text(line, DIM))));
let mut hint = Vec::new();
let mut typed = String::new();
loop {
// Redrawn each time: the license check in between shows its busy line.
view::set_view(View {
crumb: " › Log in".into(),
subtitle: "Welcome to the Makepad Builder.".into(),
email: self.shown_email(),
rows: rows.clone(),
back: true,
..View::default()
});
let Some(value) = view::edit("Email:", &typed, hint, "type your email ⏎ continue")? else { return Ok(()) };
if value.is_empty() {
return Ok(());
}
typed = value.clone();
let Ok(email) = catalog::email(&value) else {
hint = text("That does not look like an email address.", WARN);
continue;
};
// Check it now, on this page: a typo comes straight back to the editor.
self.email = email;
match self.check_licenses_on(false) {
Err(error) if email_rejected(&error) => {
self.email.clear();
hint = rejected_hint(&value, &error);
}
_ => {
self.save_email();
return Ok(());
}
}
}
}
/// The license check refused the email (unknown or not enabled): keep the
/// typed text in the editor with the reason in yellow above the keys, and
/// check again on Return. Escape keeps the email that was there before.
fn correct_rejected_email(&mut self, error: String, keep: String) -> Result<(), String> {
let mut typed = self.email.clone();
let mut error = error;
loop {
let hint = rejected_hint(&typed, &error);
let Some(value) = view::edit("Email for your licenses:", &typed, hint, "⏎ check again esc keep")? else {
// Back to the email that was there; its own check shows its state.
if self.email != keep {
self.email = keep;
let _ = self.refresh_licenses();
}
return Ok(());
};
typed = value.clone();
let Ok(email) = catalog::email(&value) else {
error = "That does not look like an email address.".into();
continue;
};
self.email = email;
match self.refresh_licenses() {
Err(next) if email_rejected(&next) => error = next,
Err(next) => return Err(next),
Ok(()) => {
self.save_email();
let count = self.licenses.as_ref().map_or(0, Vec::len);
view::message(done(format!("Logged in as {} · {count} license{}.", self.email, if count == 1 { "" } else { "s" })));
return Ok(());
}
}
}
}
/// Check the licenses; a refused email opens the editor to correct it.
fn check_licenses(&mut self) -> Result<(), String> {
match self.refresh_licenses() {
Err(error) if email_rejected(&error) => {
let keep = self.email.clone();
self.correct_rejected_email(error, keep)
}
other => other,
}
}
fn switch_account(&mut self) -> Result<(), String> {
let (prompt, hint) = if self.email.is_empty() {
("Email you bought a Makepad app with:", Vec::new())
} else {
("Email for your licenses:", text(format!("⏎ keeps {}", self.email), DIM))
};
let Some(value) = view::edit(prompt, "", hint, "⏎ log in esc back")? else { return Ok(()) };
if value.is_empty() || value == self.email {
return Ok(());
}
let before = std::mem::replace(&mut self.email, catalog::email(&value).map_err(|_| "That does not look like an email address.".to_owned())?);
self.release = None;
if let Err(error) = self.refresh_licenses() {
if !email_rejected(&error) {
return Err(error);
}
return self.correct_rejected_email(error, before);
}
self.save_email();
let count = self.licenses.as_ref().map_or(0, Vec::len);
view::message(done(format!("Switched to {} · {count} license{}.", self.email, if count == 1 { "" } else { "s" })));
Ok(())
}
fn ask_email(&mut self) -> Result<bool, String> {
let Some(value) = view::edit("Email you bought a Makepad app with:", "", Vec::new(), "⏎ log in esc back")? else { return Ok(false) };
if value.is_empty() {
return Ok(false);
}
self.email = catalog::email(&value).map_err(|_| "That does not look like an email address.".to_owned())?;
self.save_email();
Ok(true)
}
/// Keep a switched email for the next start, in the folder's own bootstrap file.
fn save_email(&self) {
let app = makepad_loader_bundle::load_from(&self.root).ok().flatten().map_or_else(|| self.app.clone(), |b| b.app);
match makepad_loader_bundle::Bootstrap::new(&self.email, &app) {
Ok(bootstrap) => {
if let Err(error) = fs::write(self.root.join(makepad_loader_bundle::BOOTSTRAP_FILE), bootstrap.encode()) {
activity(&format!("Email not saved for the next start: {error}"));
}
}
Err(error) => activity(&format!("Email not saved for the next start: {error}")),
}
}
/// The licensed apps for this email, from the catalog. Their releases
/// are cached in `available/` for launches.
fn refresh_licenses(&mut self) -> Result<(), String> {
self.check_licenses_on(true)
}
/// `on_menu`: show the check on the main menu. The log-in screen checks
/// on its own page, so the menu never flashes up between log-in and setup.
fn check_licenses_on(&mut self, on_menu: bool) -> Result<(), String> {
if self.email.is_empty() {
self.licenses = Some(Vec::new());
return Ok(());
}
self.licenses = None;
self.license_error = None;
if on_menu {
view::set_view(self.main_view());
}
view::busy(&format!("Checking licenses for {}", self.email));
let releases = match with_progress(|| catalog::fetch(&self.service, &self.email)) {
Ok(releases) => releases,
Err(error) => {
self.license_error = Some(error.clone());
return Err(error);
}
};
let licensed: Vec<Release> = releases.into_iter().filter(|r| !r.public).collect();
fs::create_dir_all(self.root.join("available")).map_err(|e| e.to_string())?;
for release in &licensed {
release.save(&self.root.join("available").join(format!("{}.json", release.id)))?;
}
self.release = licensed.iter().find(|r| r.id == self.app).cloned();
if let Some(release) = &self.release {
release.save(&self.root.join("latest.json"))?;
}
self.licenses = Some(licensed);
Ok(())
}
fn licensed(&self, app: &str) -> bool {
self.licenses.as_ref().is_some_and(|list| list.iter().any(|r| r.id == app))
}
fn release(&mut self) -> Result<Release, String> {
if let Some(release) = &self.release {
return Ok(release.clone());
}
self.refresh()
}
fn refresh(&mut self) -> Result<Release, String> {
let public = is_public(&self.app);
if !public && self.email.is_empty() && !self.ask_email()? {
return Err("Log in with the email that has this app's license.".into());
}
let release = with_progress(|| {
progress::stage(
"Checking sources",
"The source service may refresh its Git cache while we wait",
0.0,
);
if !public {
catalog::fetch(&self.service, &self.email)?.into_iter().find(|r| r.id == self.app).ok_or("This app is not available for this email".into())
} else {
let app = catalog::apps()?.into_iter().find(|a| a.get("id").and_then(makepad_strict_json::Value::as_str) == Some(&self.app)).ok_or("Unknown public app")?;
catalog::fetch_public(&self.service)?.for_app(&app)
}
})?;
if !release.supported() {
return Err("No release for this platform yet".into());
}
fs::create_dir_all(self.root.join("available")).map_err(|e| e.to_string())?;
release.save(&self.root.join("available").join(format!("{}.json", self.app)))?;
release.save(&self.root.join("latest.json"))?;
self.release = Some(release.clone());
Ok(release)
}
}
/// Graphics notice, license agreements and consent.
impl Setup {
/// The Graphics row: the GPU driver notice, read again on request.
fn graphics_screen(&mut self) -> Result<(), String> {
// Reading it again changes nothing unless it was never read; Cancel
// does not un-read it.
if gpu_notice(&self.shown_email())? && !self.gpu_read {
record_gpu_notice(&self.root)?;
self.gpu_read = true;
}
Ok(())
}
/// The Agreements page: each agreement with its state (Return opens it),
/// then Agree to all and Disagree. Disagreeing only withdraws the record:
/// nothing installed is removed, and the next install or update asks again.
fn agreements_screen(&self) -> Result<(), String> {
let mut selected = 0;
loop {
let accepted = self.accepted_agreements();
let mut rows: Vec<Row> = agreements()
.into_iter()
.map(|(id, name, url)| {
let state = if accepted.iter().any(|a| a == id) {
vec![view::Span("✓".into(), OK), view::Span(" accepted ".into(), PLAIN), view::Span(host(url).into(), DIM)]
} else {
vec![view::Span("not accepted ".into(), WARN), view::Span(host(url).into(), DIM)]
};
item(format!("url:{id}"), format!("{name:<36}"), "", state, "open in browser")
})
.collect();
rows.push(Row::Note(Vec::new()));
rows.push(item("agree", "Agree to all", "", Vec::new(), "accept every agreement above"));
rows.push(item("disagree", "Disagree", "", Vec::new(), "withdraw acceptance"));
let view = View {
crumb: " › License agreements".into(),
subtitle: "Return opens an agreement in your browser.".into(),
email: self.shown_email(),
rows,
back: true,
..View::default()
};
match view::menu(view, &mut selected, &|| false)? {
Nav::Select(id) if id == "agree" => {
let ids: Vec<&str> = agreements().iter().map(|a| a.0).collect();
self.record_agreements(&ids)?;
view::message(done("Agreements accepted."));
}
Nav::Select(id) if id == "disagree" => {
self.record_agreements(&[])?;
view::message(text("Agreements withdrawn: installing or updating build tools will ask again.", WARN));
}
Nav::Select(id) => open_agreement(&id),
Nav::Back => return Ok(()),
Nav::Quit => return Err(QUIT.into()),
Nav::Refresh => {}
}
}
}
/// A consent screen of its own for the licenses a step needs: what it
/// installs and where, each agreement by name (Return opens it), then
/// "Agree to all" (selected at the start) and Cancel. Escape cancels.
/// Agreements already accepted are not asked again; agreeing records them.
fn consent(&self, title: &str, intro: &str, detail: &str, ids: &[&str], action: &str) -> Result<bool, String> {
let accepted = self.accepted_agreements();
if ids.iter().all(|id| accepted.iter().any(|a| a == id)) {
return Ok(true);
}
let mut selected = agreement_rows(Some(ids)).len();
loop {
let mut rows = vec![Row::Note(Vec::new())];
rows.extend(wrap(intro, 74).into_iter().map(|line| Row::Note(text(line, PLAIN))));
if !detail.is_empty() {
rows.push(Row::Note(text(detail, DIM)));
}
rows.push(Row::Head("READ THE AGREEMENTS".into()));
rows.extend(agreement_rows(Some(ids)));
rows.push(Row::Note(Vec::new()));
rows.push(item("agree", "Agree to all", "", Vec::new(), action));
rows.push(item("cancel", "Cancel", "", Vec::new(), ""));
let view = View {
crumb: format!(" › {title}"),
subtitle: "Please read what you are agreeing to.".into(),
email: self.shown_email(),
rows,
back: true,
footer: Some("↑↓ move ⏎ select esc cancel"),
..View::default()
};
match view::menu(view, &mut selected, &|| false)? {
Nav::Select(id) if id == "agree" => {
let mut all: Vec<&str> = accepted.iter().map(String::as_str).collect();
all.extend(ids.iter().filter(|id| !accepted.iter().any(|a| a == *id)));
self.record_agreements(&all)?;
return Ok(true);
}
Nav::Select(id) if id == "cancel" => return Ok(false),
Nav::Select(id) => open_agreement(&id),
Nav::Back | Nav::Quit => return Ok(false),
Nav::Refresh => {}
}
}
}
/// Accepted agreement ids, from `agreements-accepted` in the folder.
/// Folders set up before that record existed accepted the build tools'
/// agreements when installing them (and NVIDIA's with CUDA).
fn accepted_agreements(&self) -> Vec<String> {
match fs::read_to_string(self.root.join("agreements-accepted")) {
Ok(record) => record.split_whitespace().map(str::to_owned).collect(),
Err(_) => {
let mut ids = Vec::new();
if self.ready().compiler() {
ids.extend(required_agreements().iter().map(|id| id.to_string()));
}
if self.cuda_installed() {
ids.push("cuda".into());
}
ids
}
}
}
/// Replace the record; an empty list means withdrawn.
fn record_agreements(&self, ids: &[&str]) -> Result<(), String> {
let next = self.root.join("agreements-accepted.next");
fs::write(&next, ids.join("\n") + "\n").map_err(|e| e.to_string())?;
fs::rename(&next, self.root.join("agreements-accepted")).map_err(|e| e.to_string())
}
}
/// Compiler and platform tools.
impl Setup {
/// What compiling needs, checked on disk: the platform tools (Microsoft
/// tools and SDK on Windows, Apple's or the distribution's elsewhere) and
/// the pinned Rust.
fn ready(&self) -> Ready {
let tools = if cfg!(windows) {
crate::msvc::ready(&self.root.join("toolchain/msvc"))
} else {
runtime::system_tools_ready().is_ok()
};
let rust = self.pinned_rust().is_some_and(|version| self.rust_ready(&version));
Ready { tools, rust }
}
fn pinned_rust(&self) -> Option<String> {
self.release.as_ref().or(self.public.as_ref()).map(|r| r.rust.clone())
}
/// The release that pins the compiler: the primary app's, else the public one.
fn compiler_release(&mut self) -> Result<Release, String> {
if let Some(release) = self.release.clone().or_else(|| self.public.clone()) {
return Ok(release);
}
// Small and silent: this can run before any menu is drawn.
let public = catalog::fetch_public(&self.service)?;
self.public = Some(public.clone());
Ok(public)
}
/// The Build tools row (Windows) or Rust row (macOS/Linux).
fn setup_tools(&mut self) -> Result<(), String> {
let ready = self.ready();
if ready.compiler() && !self.compiler_retry && !self.cuda_wanted() {
if cfg!(windows) {
view::message(done("Build tools, Windows SDK and Rust are ready."));
return Ok(());
}
return self.change_rust();
}
let release = self.compiler_release()?;
if self.install_compiler(&release)? {
self.measure_disk();
view::message(if cfg!(windows) {
done(format!("Build tools{} and Rust {} installed; nothing outside this folder changed.", if self.cuda { ", CUDA" } else { "" }, release.rust))
} else {
done(format!("Rust {} is ready. Caches and builds stay in {}.", release.rust, short_path(&self.root)))
});
}
Ok(())
}
/// Everything compiling needs, set up in order: the consent screen for
/// what is missing, Apple's tools on macOS, then the downloads. True when
/// the compiler is ready; false when the person cancelled.
fn install_compiler(&mut self, release: &Release) -> Result<bool, String> {
if self.compiler_retry {
return self.retry_compiler(release);
}
let mut ready = self.ready();
if !cfg!(windows) && !ready.rust && self.choose_rust(&release.rust)? {
ready = self.ready();
}
let cuda = self.cuda_wanted();
if ready.compiler() && !cuda {
activity("Compiler is ready.");
return Ok(true);
}
if !self.compiler_consent(ready, release, cuda)? {
return Ok(self.nothing_installed());
}
if !cfg!(windows) && !ready.tools {
if cfg!(target_os = "macos") {
// Apple's tools come right after the agreements.
if !self.xcode_screen()? {
return Ok(self.nothing_installed());
}
} else {
let _pause = Screen::pause();
runtime::setup_system_tools()?;
}
}
// The components that actually run, one line each on the work page.
let mut steps = Vec::new();
if cfg!(windows) && !ready.tools {
steps.extend(["Build tools", "Windows SDK"]);
}
if !ready.rust {
steps.push("Rust");
}
if cuda {
steps.push("CUDA");
}
let row = if cfg!(windows) { "tools" } else { "rust" };
view::work_begin("Install build tools", "This takes a few minutes; everything goes into this folder.", &self.shown_email(), row, &steps);
let result: Result<bool, String> = with_progress(|| {
progress::plan(&steps);
if cfg!(windows) && !ready.tools {
runtime::dependency(&self.root, release, Dependency::Msvc)?;
}
if !ready.rust {
runtime::dependency(&self.root, release, Dependency::Rust)?;
}
if cuda {
runtime::dependency(&self.root, release, Dependency::Cuda)?;
}
Ok(true)
});
self.cuda = crate::cuda::build_with(&self.root);
let result = match result {
Err(error) if rustc::needs_compiler_retry(&error) => {
self.compiler_retry = true;
Err(STILL_SCANNING.into())
}
other => other,
};
view::work_end(result)
}
fn nothing_installed(&self) -> bool {
activity("Compiler installation cancelled.");
view::set_view(self.main_view());
view::message(text("Nothing was installed.", DIM));
false
}
/// Windows security software can hold a freshly unpacked rustc for a
/// while; the staged copy is checked again without downloading.
fn retry_compiler(&mut self, release: &Release) -> Result<bool, String> {
activity("Retrying the staged Rust compiler check; no download is needed.");
view::busy("Checking the staged Rust compiler");
match rustc::retry_staged(&runtime::rust_dir(&self.root, &release.rust), &release.rust) {
Ok(()) => {
self.compiler_retry = false;
activity("Compiler is ready.");
Ok(true)
}
Err(error) if rustc::needs_compiler_retry(&error) => Err(STILL_SCANNING.into()),
Err(error) => {
self.compiler_retry = false;
Err(error)
}
}
}
/// One consent screen covers every missing piece and its licenses.
fn compiler_consent(&self, ready: Ready, release: &Release, cuda: bool) -> Result<bool, String> {
let mut parts = Vec::new();
if cfg!(windows) && !ready.tools {
parts.push("Microsoft C++ Build Tools, Windows SDK".to_owned());
}
if !ready.rust {
parts.push(format!("Rust {}", release.rust));
}
if cuda {
parts.push(format!("NVIDIA CUDA Toolkit {} for your NVIDIA card (AI features in Makepad Amp)", crate::cuda::CUDA_VERSION));
}
let intro = match (cfg!(windows) || ready.tools, cfg!(target_os = "macos")) {
(true, _) => "Makepad compiles from source, so it needs a compiler.",
(false, true) => "Makepad compiles from source, so it needs a compiler. Apple's developer tools come from Apple's installer, which shows its own license.",
(false, false) => "Makepad compiles from source, so it needs a compiler. Development packages come from your distribution's package manager.",
};
let detail = if parts.is_empty() { String::new() } else { format!("Installed in this folder only: {}", parts.join(", ")) };
let mut ids = required_agreements().to_vec();
if cuda {
ids.push("cuda");
}
let action = match (!ready.tools, !ready.rust, cuda) {
(true, _, true) => "install the build tools, Rust and CUDA",
(true, _, false) => "install the build tools and Rust",
(false, true, true) => "install Rust and CUDA",
(false, true, false) => "install Rust",
(false, false, _) => "install CUDA",
};
self.consent("Install build tools", intro, &detail, &ids, action)
}
fn rust_ready(&self, version: &str) -> bool {
// Windows never reads the choice file: private pinned Rust only.
if cfg!(windows) {
return runtime::rust_ready(&self.root, version);
}
let recorded = fs::read_to_string(self.root.join("selected-rust")).unwrap_or_default().trim().to_owned();
if !recorded.starts_with('/') {
self.rust_check.borrow_mut().take();
return runtime::rust_ready(&self.root, version);
}
let mut cache = self.rust_check.borrow_mut();
if let Some(check) = cache.as_ref().filter(|c| c.recorded == recorded && c.version == version) {
return check.sysroot.as_ref().is_some_and(|s| {
s.join("bin").join(runtime::exe("cargo")).is_file() && s.join("bin").join(runtime::exe("rustc")).is_file()
});
}
let sysroot = runtime::validate_rust(version, &recorded).ok();
let ready = sysroot.is_some();
*cache = Some(RustCheck { recorded, version: version.to_owned(), sysroot });
ready
}
/// macOS/Linux only: repair a recorded installed Rust that stopped
/// validating, or make the same one-time offer as the bootstrap. Every
/// change of the recorded choice is an explicit answer; backing out keeps
/// the private default. Returns true when the record changed. Windows
/// never reaches this.
fn choose_rust(&self, version: &str) -> Result<bool, String> {
let stale = match runtime::rust_choice(&self.root)? {
RustChoice::Private => return Ok(false),
RustChoice::External(recorded) => match runtime::validate_rust(version, &recorded) {
Ok(_) => return Ok(false),
Err(reason) => Some((recorded, reason)),
},
RustChoice::Undecided => None,
};
if let Some((recorded, reason)) = &stale {
activity(&format!("The selected Rust at {recorded} cannot be used: {reason}"));
}
match runtime::probe_rust(version) {
Ok(candidate) => {
let sysroot = candidate.to_string_lossy().into_owned();
let question = format!("Use your installed Rust, or a private Rust {version} in this folder?");
let note = format!("installed: {} (not modified)", short_path(&candidate));
let choice = match view::choose(&question, &note, &["private", "installed"], 0)?.as_deref() {
Some("installed") => {
activity("Using the installed Rust.");
RustChoice::External(sysroot)
}
Some(_) => {
activity("Using a private Rust in this folder.");
RustChoice::Private
}
None => return Ok(false),
};
runtime::record_rust_choice(&self.root, &choice)?;
Ok(true)
}
Err(reason) => {
let Some((recorded, _)) = stale else {
// Nothing to offer and nothing recorded: the private flow
// continues and the offer returns once a compatible Rust exists.
activity(&format!("Installed Rust not used: {reason}"));
return Ok(false);
};
let question = format!("The selected Rust at {recorded} cannot be used. Switch to a private Rust in this folder?");
if view::choose(&question, "", &["switch", "keep"], 0)?.as_deref() == Some("switch") {
runtime::record_rust_choice(&self.root, &RustChoice::Private)?;
activity("Switching to a private Rust in this folder.");
Ok(true)
} else {
Err(format!("Kept the selected Rust at {recorded}. Make it available again, or choose Rust to switch."))
}
}
}
}
/// macOS/Linux: switch explicitly between the private Rust and a
/// compatible installed one.
fn change_rust(&mut self) -> Result<(), String> {
let Some(version) = self.pinned_rust() else { return Ok(()) };
let candidate = match runtime::probe_rust(&version) {
Ok(candidate) => candidate,
Err(reason) => {
view::message(text(format!("No other compatible installed Rust was found: {reason}"), DIM));
return Ok(());
}
};
let current = matches!(runtime::rust_choice(&self.root)?, RustChoice::External(_));
let question = format!("Build with a private Rust {version} in this folder, or your installed Rust?");
let note = format!("installed: {} (not modified)", short_path(&candidate));
let choice = match view::choose(&question, &note, &["private", "installed"], usize::from(current))?.as_deref() {
Some("installed") => RustChoice::External(candidate.to_string_lossy().into_owned()),
Some(_) => RustChoice::Private,
None => return Ok(()),
};
runtime::record_rust_choice(&self.root, &choice)?;
self.rust_check.borrow_mut().take();
if self.ready().rust {
view::message(done("Rust changed; the next compile uses it."));
Ok(())
} else {
self.setup_tools()
}
}
fn setup_system_tools(&mut self) -> Result<(), String> {
if runtime::system_tools_ready().is_ok() {
view::message(done("Compiler, SDK, linker and git are ready."));
return Ok(());
}
if cfg!(target_os = "macos") {
if self.xcode_screen()? {
view::message(done("Apple developer tools are ready."));
}
return Ok(());
}
let release = self.compiler_release()?;
if self.install_compiler(&release)? {
view::message(done("Developer tools are ready."));
}
Ok(())
}
/// macOS: the "› Apple developer tools" screen when clang, the SDK or
/// git are missing, or Xcode's license is not accepted. Installing opens
/// Apple's installer and waits for it; the license runs Apple's own
/// `sudo xcodebuild -license` on the real terminal. Never accepts on the
/// person's behalf. True once the tools are ready.
fn xcode_screen(&mut self) -> Result<bool, String> {
let mut selected = 0;
loop {
if runtime::system_tools_ready().is_ok() {
return Ok(true);
}
let license = xcode_license_pending();
let (intro, note, first) = if license {
(
"Xcode is installed, but its license has not been accepted yet, so Apple's compiler will not run.",
"Apple shows the license here in the terminal and asks for your password; type agree at the end to accept it.",
item("license", format!("{:<34}", "Read and accept the Xcode license"), "", Vec::new(), "sudo xcodebuild -license"),
)
} else {
(
"Makepad compiles with Apple's command line developer tools: clang, the macOS SDK and git. They are not installed on this Mac yet.",
"Apple's installer opens in its own window (about 1 GB); come back here when it has finished.",
item("install", format!("{:<34}", "Install the developer tools"), "", Vec::new(), "opens Apple's installer"),
)
};
let mut rows = vec![Row::Note(Vec::new())];
rows.extend(wrap(intro, 74).into_iter().map(|line| Row::Note(text(line, PLAIN))));
rows.push(Row::Note(Vec::new()));
rows.extend(wrap(note, 74).into_iter().map(|line| Row::Note(text(line, DIM))));
rows.push(Row::Note(Vec::new()));
rows.push(first);
rows.push(item("check", "Check again", "", Vec::new(), ""));
rows.push(item("cancel", "Cancel", "", Vec::new(), ""));
let view = View {
crumb: " › Apple developer tools".into(),
subtitle: "Needed to compile on macOS.".into(),
email: self.shown_email(),
rows,
back: true,
footer: Some("↑↓ move ⏎ select esc cancel"),
..View::default()
};
match view::menu(view, &mut selected, &|| false)? {
Nav::Select(id) if id == "install" => {
let _ = Command::new("/usr/bin/xcode-select").arg("--install").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status();
wait_for_apple_installer()?;
}
Nav::Select(id) if id == "license" => {
let _pause = Screen::pause();
println!("Apple's Xcode license follows. Type agree at the end to accept it.\n");
let _ = Command::new("sudo").args(["/usr/bin/xcodebuild", "-license"]).status();
}
Nav::Select(id) if id == "check" => {
view::busy("Checking clang, the macOS SDK, the linker and git");
if runtime::system_tools_ready().is_err() {
view::message(text("Still not ready.", WARN));
}
}
Nav::Select(_) | Nav::Back | Nav::Quit => return Ok(false),
Nav::Refresh => {}
}
}
}
/// An NVIDIA card without the toolkit: CUDA joins the build tools.
fn cuda_wanted(&self) -> bool {
crate::cuda::gpu_present() && !self.cuda_installed()
}
fn cuda_installed(&self) -> bool {
self.root.join("toolchain/cuda/bin").join(runtime::exe("nvcc")).is_file()
}
fn setup_cuda(&mut self) -> Result<(), String> {
if !crate::cuda::gpu_present() {
view::message(text("CUDA needs an NVIDIA graphics card; none was found.", DIM));
return Ok(());
}
if self.cuda_installed() {
view::message(done("CUDA is installed; apps build with it on this NVIDIA card."));
return Ok(());
}
let detail = format!("Installed in this folder only: NVIDIA CUDA Toolkit {}", crate::cuda::CUDA_VERSION);
if !self.consent("Install CUDA", "CUDA adds the AI features in Makepad Amp.", &detail, &["cuda"], "install CUDA")? {
activity("CUDA installation cancelled.");
return Ok(());
}
let release = self.compiler_release()?;
view::work_begin("Install CUDA", "Into this folder only; Makepad Amp uses it for its AI features.", &self.shown_email(), "cuda", &["CUDA"]);
view::work_end(with_progress(|| {
progress::plan(&["CUDA"]);
runtime::dependency(&self.root, &release, Dependency::Cuda)
}))?;
self.cuda = crate::cuda::build_with(&self.root);
self.measure_disk();
view::message(done("CUDA installed. Makepad Amp gets its AI features on its next compile."));
Ok(())
}
}
/// Apps: their state, and downloading, compiling and running them.
impl Setup {
/// Makepad apps: one scrolling list; each compiles on first run.
fn experiments_screen(&mut self) -> Result<(), String> {
let list = free_apps()?;
loop {
let rows: Vec<Row> = list
.iter()
.map(|(id, title)| {
// One shared Makepad download: rows show only readiness;
// the action appears on the selected row.
let (status, action) = match self.app_state(id, self.app_release(id).as_ref()) {
State::Ready => (done("ready"), "run"),
State::Compile => (Vec::new(), "compile and run"),
State::Merge => (text("updated · your changes to merge", WARN), "merge with agent"),
_ => (Vec::new(), "download source, compile and run"),
};
item(format!("app:{id}"), title.clone(), "", status, action)
})
.collect();
let view = View {
crumb: " › Makepad experiments".into(),
subtitle: "From our open source repository: experiments, not finished applications.".into(),
email: self.shown_email(),
rows: {
let note = match self.app_release("wm").filter(|r| !r.installed(&self.root)) {
Some(release) => format!(
"One {} MB download covers them all; each compiles on first run.",
release.repositories.iter().map(|r| r.bytes).sum::<u64>().div_ceil(1048576)
),
None => "Each one compiles the first time you run it.".into(),
};
let mut all = vec![Row::Note(Vec::new()), Row::Note(text(note, DIM))];
all.extend(rows);
all
},
back: true,
..View::default()
};
let mut selected = self.free_selected;
let nav = view::menu(view, &mut selected, &|| self.disk_changed())?;
self.free_selected = selected;
match nav {
Nav::Select(id) => {
// The app returns here when it has been launched (or could
// not be), with the row still selected.
if let Err(error) = self.open_app(id.trim_start_matches("app:")) {
if error == QUIT {
return Err(error);
}
self.report(error);
}
}
Nav::Back => return Ok(()),
Nav::Quit => return Err(QUIT.into()),
Nav::Refresh => {}
}
}
}
/// The release an app's row refers to, without writing anything:
/// cached, installed, or projected from the known public release.
fn app_release(&self, app: &str) -> Option<Release> {
if let Some(release) = load_release(&self.root.join("available").join(format!("{app}.json")))
.or_else(|| load_release(&self.root.join("installed").join(format!("{app}.json"))))
{
return Some(release);
}
if app == self.app {
return self.release.clone();
}
let definition = catalog::apps().ok()?.into_iter().find(|entry| entry.get("id").and_then(makepad_strict_json::Value::as_str) == Some(app))?;
self.public.as_ref().or(self.release.as_ref())?.for_app(&definition).ok()
}
fn app_state(&self, app: &str, release: Option<&Release>) -> State {
if self.merge_marker(app).is_file() {
return State::Merge;
}
let Some(release) = release else { return State::New };
let installed = load_release(&self.root.join("installed").join(format!("{app}.json")));
let binary = self.root.join(if cfg!(windows) { runtime::exe(&release.binary) } else { format!("{}.bin", release.binary) });
if installed.as_ref().is_some_and(|i| i.release == release.release) && binary.is_file() {
// Built from these sources; edited since (a coding agent, the
// person) means it compiles again.
if self.sources_changed(app, &release.binary, &binary) { State::Compile } else { State::Ready }
} else if release.installed(&self.root) {
State::Compile
} else if installed.is_some() {
State::Update
} else if fs::read_dir(release.directory(&self.root).join(".builder-repositories")).is_ok_and(|mut d| d.next().is_some()) {
State::Partial
} else {
State::New
}
}
/// Whether any source file Cargo used for the built app (its
/// target/release/<binary>.d) is newer than the app, or gone. Unknown
/// (no .d yet) counts as unchanged. Cached: this reads a thousand or so
/// file dates and the menu redraws often.
fn sources_changed(&self, app: &str, binary_name: &str, binary: &Path) -> bool {
let Ok(built) = fs::metadata(binary).and_then(|m| m.modified()) else { return false };
if let Some(check) = self.source_checks.borrow().get(app) {
if check.built == built && check.at.elapsed() < std::time::Duration::from_secs(5) {
return check.changed;
}
}
let deps = self.root.join("target").join("release").join(format!("{binary_name}.d"));
let changed = fs::read_to_string(&deps).is_ok_and(|text| {
dep_file_sources(&text).iter().any(|source| fs::metadata(source).and_then(|m| m.modified()).map_or(true, |t| t > built))
});
self.source_checks.borrow_mut().insert(app.to_owned(), SourceCheck { built, at: std::time::Instant::now(), changed });
changed
}
fn app_setup(&self, app: &str) -> Result<Self, String> {
let mut selected = self.clone();
selected.app = app.into();
selected.release = self.app_release(app);
if let Some(release) = &selected.release {
let available = self.root.join("available");
fs::create_dir_all(&available).map_err(|e| e.to_string())?;
release.save(&available.join(format!("{app}.json")))?;
}
Ok(selected)
}
/// A row's action: merge saved changes, run a built app, or download,
/// compile and run it.
fn open_app(&mut self, app: &str) -> Result<(), String> {
let release = self.app_release(app);
if self.app_state(app, release.as_ref()) == State::Merge {
return self.merge_changes(app);
}
self.run_app(app)
}
fn run_app(&mut self, app: &str) -> Result<(), String> {
let mut selected = self.app_setup(app)?;
let state = self.app_state(app, selected.release.as_ref());
fs::write(self.root.join("selected-app"), app).map_err(|e| e.to_string())?;
// A built app just opens; anything else gets the work page.
if state == State::Ready {
view::working(&format!("app:{app}"), "opening…");
}
let result = if state == State::Ready {
selected.launch().map(|title| launched(&title))
} else {
selected.install_and_run()
};
fs::write(self.root.join("selected-app"), &self.app).map_err(|e| e.to_string())?;
self.adopt(app, selected);
self.measure_disk();
result
}
/// An app runs on a copy of this session pointed at it; carry back what
/// that copy learned (compiler state, releases, a new login).
fn adopt(&mut self, app: &str, selected: Setup) {
if selected.checked.is_some() {
self.checked = selected.checked;
}
self.compiler_retry = selected.compiler_retry;
self.cuda = selected.cuda;
if selected.public.is_some() {
self.public = selected.public;
}
if self.email.is_empty() && !selected.email.is_empty() {
self.email = selected.email;
}
if app == self.app {
self.release = selected.release;
}
}
fn install_and_run(&mut self) -> Result<(), String> {
// Resolve the release once so compiler setup and the following build
// cannot disagree if a newer release appears while installing tools.
let release = if self.compiler_retry {
self.release.clone().ok_or("The compiler retry has no release metadata; select Build tools to start again")?
} else {
self.release()?
};
if !self.install_compiler(&release)? {
return Ok(());
}
activity("Compiler ready. Continuing to download, compile and open the app.");
self.build_release(release)
}
/// Download (when needed), compile and open an app on its work page.
fn build_release(&mut self, release: Release) -> Result<(), String> {
if !self.ready().compiler() {
return Err(format!("The latest source requires Rust {}. Select Build tools first.", release.rust));
}
let mut steps = Vec::new();
if !release.installed(&self.root) {
steps.push("Download source");
}
steps.extend(["Compile", "Open"]);
let subtitle = if steps[0] == "Download source" { "Download the source, compile it, then run it." } else { "Compile the source, then run it." };
view::work_begin(&release.title, subtitle, &self.shown_email(), &format!("app:{}", release.id), &steps);
let title = view::work_end(self.compile_and_open(&release))?;
activity(&format!("{title} is running; Builder remains open."));
if self.merge_marker(&release.id).is_file() {
view::message(text(format!("{title} is updated. Your edits are in changes/; select it to merge."), WARN));
} else {
launched(&title);
}
Ok(())
}
fn compile_and_open(&mut self, release: &Release) -> Result<String, String> {
// Say which snapshot builds, before anything downloads: shared
// sources mean shared artifacts; a differing Makepad commit means
// the dependencies compile again, and that is expected.
activity(&release.describe_sources(&self.root));
self.keep_edits(release);
with_progress(|| {
view::work_step("Download source");
catalog::checkout(&self.service, &self.email, &self.root, release)?;
view::work_step("Compile");
let environment = Environment::prepare(&self.root, release, self.cuda)?;
// Scope's release pins the Makepad tree the Builder itself is
// compiled from. A Builder that fails to compile from the new
// tree does not withhold the app; the next start tries again.
if release.id == "scope" {
match runtime::update_builder(&environment, release) {
Ok(Some(note)) => activity(&note),
Ok(None) => {}
Err(error) => activity(&format!("Builder not updated: {error}")),
}
}
environment.build(release)
})?;
fs::create_dir_all(self.root.join("installed")).map_err(|e| e.to_string())?;
release.save(&self.root.join("installed").join(format!("{}.json", release.id)))?;
release.save(&self.root.join("installed-release.json"))?;
match catalog::prune_snapshots(&self.root, std::slice::from_ref(release)) {
Ok(removed) => {
for label in removed {
activity(&format!("Removed the unused sources {label}; nothing was built from them any more and they held no edits."));
}
}
Err(error) => activity(&format!("Unused sources kept: {error}")),
}
// Just downloaded and built: that is up to date.
self.checked = Some((false, clock()));
activity(&format!("Build complete. Opening {}.", release.title));
view::work_step("Open");
self.launch()
}
/// Present while an app's saved edits wait for an agent to merge them.
fn merge_marker(&self, app: &str) -> PathBuf {
self.root.join("changes").join(format!("{app}.merge"))
}
/// An update is about to move `release.id` off the snapshot it was built
/// from: save the edits made there first (see `catalog::save_changes`).
fn keep_edits(&self, release: &Release) {
let Some(previous) = load_release(&self.root.join("installed").join(format!("{}.json", release.id))) else { return };
if previous.release == release.release
|| previous.directory(&self.root) == release.directory(&self.root)
|| self.merge_marker(&release.id).is_file()
{
return;
}
let (year, month, day, _, _) = local_time();
match catalog::save_changes(&self.root, &release.id, &previous, &format!("{year:04}-{month:02}-{day:02}")) {
Ok(Some(name)) => activity(&format!("Saved your changes to {} in changes/{name} before updating; the edited source stays in {}.", release.title, previous.directory(&self.root).display())),
Ok(None) => {}
Err(error) => activity(&format!("Could not save a diff of your changes ({error}); the edited source stays in {}.", previous.directory(&self.root).display())),
}
}
/// Launch the app recorded under installed/; returns its title.
fn launch(&self) -> Result<String, String> {
let release = load_release(&self.root.join("installed").join(format!("{}.json", self.app))).ok_or("Build the app first")?;
let environment = Environment::prepare(&self.root, &release, self.cuda)?;
let project = if self.project == self.root {
release
.repositories
.iter()
.find(|repo| repo.name == "makepad")
.map(|repo| release.directory(&self.root).join(&repo.path))
.unwrap_or_else(|| release.source(&self.root))
} else {
self.project.clone()
};
#[cfg(target_os = "macos")]
let executable = crate::desktop::prepare(&self.root, &release, &project)?;
#[cfg(not(target_os = "macos"))]
let executable = environment.app_binary(&release);
let mut app = Command::new(executable);
#[cfg(target_os = "macos")]
if release.id == "scope" {
// This explicit Builder launch should open in front. Ordinary
// Makepad windows retain their default non-activating startup.
app.arg("--focus");
}
// The app is a GUI child of Builder. Give it its own process group (or
// detached Windows process) so closing the terminal that hosts Builder
// does not terminate an already launched app. Its output is redirected
// below, so no child console or terminal ownership is needed.
detach_application(&mut app);
let log_path = environment.build.join(format!("{}-app.log", release.binary));
let log = fs::File::create(&log_path).map_err(|e| e.to_string())?;
view::busy(&format!("Opening {}", release.title));
activity(&format!("Running {}", release.title));
let child = app
.args(["--cwd", &project.to_string_lossy()])
.current_dir(&project)
.envs(environment.vars)
.env_remove("MAKEPAD_LOADER_EMAIL")
.env_remove("RUSTUP_TOOLCHAIN")
.stdin(Stdio::null())
.stderr(log.try_clone().map_err(|e| e.to_string())?)
.stdout(log)
.spawn()
.map_err(|e| e.to_string())?;
let title = release.title.clone();
RUNNING_APPS.with(|apps| apps.borrow_mut().push(RunningApp {
title: release.title.clone(), child, log: log_path,
}));
#[cfg(target_os = "macos")]
note_older_bundle(&self.root, &release);
Ok(title)
}
/// After an update saved an app's edits in changes/, a coding agent
/// reapplies them onto the new source.
fn merge_changes(&mut self, app: &str) -> Result<(), String> {
let marker = self.merge_marker(app);
let diff = fs::read_to_string(&marker).unwrap_or_default().trim().to_owned();
let agent = match self.agents.iter().filter(|a| a.0 != "grok").collect::<Vec<_>>().as_slice() {
[] => {
view::message(text(format!("Install Claude Code or Codex to merge, or apply changes/{diff} yourself."), WARN));
return Ok(());
}
[one] => **one,
several => {
let names: Vec<&str> = several.iter().map(|a| a.0).collect();
let Some(chosen) = view::choose("Merge your changes with which agent?", "", &names, 0)? else { return Ok(()) };
**several.iter().find(|a| a.0 == chosen).ok_or("Unknown agent")?
}
};
let task = format!(
"Reapply my changes saved in changes/{diff} in the installation root onto the new {app} source. Keep the intent of each change where the new release moved code, rebuild it until it compiles, and report any change that no longer applies."
);
self.launch_agent(agent.0, Some(app), Some(&task))?;
let _ = crate::remove_inside(&self.root, &marker);
view::message(done(format!("{} finished merging. Select the app to run it.", agent.1)));
Ok(())
}
}
/// Coding agents and the shell.
impl Setup {
/// Coding agents and the shell work in an app whose source is here.
fn agent_release(&self, app: Option<&str>) -> Option<Release> {
match app {
Some(app) => self.app_release(app),
None => [self.release.clone(), self.app_release("wm")].into_iter().flatten().find(|r| r.installed(&self.root)),
}
}
fn environment(&self, command: &mut Command) -> Result<(), String> {
if let Some(release) = self.agent_release(None) {
let environment = Environment::prepare(&self.root, &release, self.cuda)?;
let context = crate::agent::write_context(&release, &environment)?;
command.envs(environment.vars).env("MAKEPAD_AGENT_CONTEXT", context);
}
runtime::isolate(command);
command.current_dir(&self.project);
Ok(())
}
fn shell(&self) -> Result<(), String> {
let mut command = if cfg!(windows) {
let mut c = Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
c.args(["/d", "/v:off"]);
c
} else {
Command::new(env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()))
};
self.environment(&mut command)?;
let _pause = Screen::pause();
println!(
"Project: {}\nThis folder's Rust is on PATH. Type exit to return to Makepad.\n",
self.project.display()
);
let status = command
.status()
.map_err(|e| format!("Could not start the shell: {e}"))?;
if !status.success() {
activity(&format!("Shell exited {status}"));
}
Ok(())
}
fn launch_agent(&self, name: &str, app: Option<&str>, task: Option<&str>) -> Result<(), String> {
let release = self.agent_release(app).filter(|r| r.installed(&self.root)).ok_or(
"Download an app first; coding agents work in its source.",
)?;
let tools = if cfg!(windows) { crate::msvc::ready(&self.root.join("toolchain/msvc")) } else { runtime::system_tools_ready().is_ok() };
if !tools || !self.rust_ready(&release.rust) {
return Err("Set up the build tools first, so the agent can rebuild apps.".into());
}
let environment = Environment::prepare(&self.root, &release, self.cuda)?;
let mut command = crate::agent::command(name, &release, &environment, task)?;
fs::write(self.root.join("selected-app"), &release.id).map_err(|e| e.to_string())?;
let status = {
let _pause = Screen::pause();
command.status().map_err(|e| format!("Could not start installed {name}: {e}"))
};
let _ = fs::write(self.root.join("selected-app"), &self.app);
let status = status?;
if !status.success() {
return Err(format!("{name} exited {status}. It must already be installed and signed in."));
}
Ok(())
}
}
/// Update and disk.
impl Setup {
/// Updates: licenses first (new purchases appear, expired ones go), then
/// every installed app is compared with its newest release. An app with
/// a newer release gets clean new sources; edits made to its old source
/// are saved in changes/ first and it waits for an agent to merge them.
fn check_updates(&mut self) -> Result<(), String> {
// Updates download Makepad sources; withdrawn agreements ask again.
if !self.consent("Update", "Updates download the newest sources of your apps.", "", &["makepad"], "check for updates")? {
view::set_view(self.main_view());
view::message(text("Nothing was downloaded.", DIM));
return Ok(());
}
self.check_licenses()?;
view::work_begin("Update", "Newer sources replace unchanged ones; your edits are kept as a diff.", &self.shown_email(), "updates", &["Check for updates", "Download updates"]);
let updated = view::work_end(self.pull_updates())?;
self.report_updates(updated);
Ok(())
}
/// Record the newest releases and download clean sources for installed
/// apps that have one. Returns the updated titles and whether edits wait.
fn pull_updates(&mut self) -> Result<(Vec<String>, bool), String> {
view::work_step("Check for updates");
// TODO: send the releases this folder has (the `X-Makepad-Have`
// header) once the catalog service defines it; today the full catalog
// is compared here.
let public = with_progress(|| catalog::fetch_public(&self.service))?;
self.public = Some(public.clone());
let available = self.root.join("available");
fs::create_dir_all(&available).map_err(|e| e.to_string())?;
for definition in catalog::apps()? {
let Some(id) = definition.get("id").and_then(makepad_strict_json::Value::as_str) else { continue };
if available.join(format!("{id}.json")).is_file() || self.root.join("installed").join(format!("{id}.json")).is_file() {
public.for_app(&definition)?.save(&available.join(format!("{id}.json")))?;
}
}
let mut updated = Vec::new();
let mut merges = false;
for entry in fs::read_dir(self.root.join("installed")).into_iter().flatten().flatten() {
let Some(installed) = load_release(&entry.path()) else { continue };
let Some(latest) = load_release(&available.join(format!("{}.json", installed.id))) else { continue };
if latest.release == installed.release || !latest.supported() || (!latest.public && !self.licensed(&latest.id)) {
continue;
}
self.keep_edits(&latest);
merges |= self.merge_marker(&latest.id).is_file();
activity(&latest.describe_sources(&self.root));
view::work_step("Download updates");
with_progress(|| catalog::checkout(&self.service, &self.email, &self.root, &latest))?;
updated.push(latest.title.clone());
}
if let Some(release) = self.release.as_ref().map(|r| r.id.clone()).and_then(|id| load_release(&available.join(format!("{id}.json")))) {
self.release = Some(release);
}
Ok((updated, merges))
}
fn report_updates(&mut self, (updated, merges): (Vec<String>, bool)) {
self.checked = Some((!updated.is_empty(), clock()));
self.measure_disk();
view::message(if updated.is_empty() {
done("Licenses checked; everything is up to date.")
} else if merges {
done(format!("Updated {}. Your edits are saved in changes/; select to merge.", updated.join(", ")))
} else {
done(format!("Updated {}. Each compiles on its next run.", updated.join(", ")))
});
}
fn measure_disk(&self) {
let root = self.root.clone();
let disk = self.disk.clone();
std::thread::spawn(move || {
let measured = measure(&root);
if let Ok(mut slot) = disk.lock() {
slot.0 += 1;
slot.1 = Some(measured);
}
});
}
fn disk_changed(&self) -> bool {
let generation = self.disk.lock().map(|slot| slot.0).unwrap_or(0);
generation != self.disk_seen.replace(generation)
}
fn clear_build(&mut self) -> Result<(), String> {
let target = self.root.join("target");
if !target.exists() {
view::message(text("There is no build data to delete.", DIM));
return Ok(());
}
let question = if cfg!(windows) { "Delete the build data in target\\?" } else { "Delete the build data in target/?" };
if view::choose(question, "your apps keep working; the next compile starts from scratch", &["keep", "delete"], 0)?.as_deref() != Some("delete") {
return Ok(());
}
// Cargo removes its own build directory (CARGO_TARGET_DIR = target/)
// with this installation's compiler environment: no hand-written
// deletes. Published executables live beside Builder and keep working.
let Some(release) = self.agent_release(None) else {
view::message(text("Nothing to clean yet: no app has been compiled here.", DIM));
return Ok(());
};
let environment = Environment::prepare(&self.root, &release, self.cuda)?;
let cargo = environment.vars.get("CARGO").ok_or("Missing Cargo path")?;
let mut command = Command::new(cargo);
// The target folder is named explicitly: cargo clean never guesses.
command.current_dir(&environment.cwd).envs(&environment.vars).arg("clean").arg("--target-dir").arg(&environment.build);
runtime::isolate(&mut command);
view::working("disk", "cleaning…");
view::busy("cargo clean");
let result = command.output().map_err(|e| e.to_string()).and_then(|out| {
if out.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&out.stderr).trim().to_owned()) }
});
self.measure_disk();
match result {
Ok(()) => view::message(done("Build data cleaned. Built apps are unchanged.")),
Err(error) => {
activity(&format!("cargo clean: {error}"));
view::message(text("Build data is in use by a running app; close it and try again.", WARN));
}
}
Ok(())
}
}
// ---- Row helpers -----------------------------------------------------------
fn item(id: impl Into<String>, name: impl Into<String>, license: &'static str, status: view::Text, action: impl Into<String>) -> Row {
Row::Item(Item { id: id.into(), name: name.into(), license, status, action: action.into() })
}
/// The agreements involved on this platform: (id, name, URL).
fn agreements() -> Vec<(&'static str, &'static str, &'static str)> {
let mut list = vec![("makepad", "Makepad commercial license", MAKEPAD_LICENSE_URL)];
if cfg!(windows) {
list.push(("vs", "Microsoft Visual Studio Build Tools", BUILD_TOOLS_LICENSE_URL));
list.push(("sdk", "Microsoft Windows SDK", WINDOWS_SDK_LICENSE_URL));
}
list.push(("rust", "Rust", RUST_LICENSE_URL));
if crate::cuda::supported() {
list.push(("cuda", "NVIDIA CUDA Toolkit", CUDA_LICENSE_URL));
}
list
}
/// The agreements installing the build tools needs on this platform.
fn required_agreements() -> &'static [&'static str] {
if cfg!(windows) { &["makepad", "vs", "sdk", "rust"] } else { &["makepad", "rust"] }
}
/// Agreement rows: full name and host; Return opens the link.
fn agreement_rows(ids: Option<&[&str]>) -> Vec<Row> {
agreements()
.into_iter()
.filter(|(id, _, _)| ids.is_none_or(|ids| ids.contains(id)))
.map(|(id, name, url)| item(format!("url:{id}"), format!("{name:<36}"), "", text(host(url), DIM), "open in browser"))
.collect()
}
/// Return on an agreement row: open it and confirm on the status line.
fn open_agreement(id: &str) {
let Some((_, _, url)) = agreements().into_iter().find(|(key, _, _)| Some(*key) == id.strip_prefix("url:")) else { return };
match open_url(url) {
Ok(()) => view::message(done(format!("Opened {} in your browser.", url.trim_start_matches("https://")))),
Err(error) => view::warn(&error),
}
}
fn host(url: &str) -> &str {
url.split_once("://").map_or(url, |(_, rest)| rest.split('/').next().unwrap_or(rest))
}
/// Makepad WM, then every registry app marked `menu: other`.
fn free_apps() -> Result<Vec<(String, String)>, String> {
let mut list = Vec::new();
let mut wm = None;
for app in catalog::apps()? {
let field = |name| app.get(name).and_then(makepad_strict_json::Value::as_str).map(str::to_owned);
let (Some(id), Some(title)) = (field("id"), field("title")) else { return Err("Invalid app registry".into()) };
match field("menu").as_deref() {
Some("other") => list.push((id, title)),
_ if id == "wm" => wm = Some((id, title)),
_ => {}
}
}
list.splice(0..0, wm);
Ok(list)
}
fn is_public(app: &str) -> bool {
catalog::apps().is_ok_and(|apps| apps.iter().any(|a| a.get("id").and_then(makepad_strict_json::Value::as_str) == Some(app)))
}
/// The catalog refused the email itself (unknown or not enabled), rather
/// than the network or the server failing.
fn email_rejected(error: &str) -> bool {
let lower = error.to_lowercase();
["(http 401)", "(http 403)", "http 401 ", "http 403 ", "not enabled", "unknown email"].iter().any(|s| lower.contains(s))
}
/// The yellow line over a reopened email editor.
fn rejected_hint(typed: &str, _error: &str) -> view::Text {
// The typed email stays in the editor; this only says it was not found.
text(format!("Email not recognised: {typed} · fix any typo, then ⏎"), WARN)
}
/// The first line of an error, short enough for a row.
fn short_reason(error: &str) -> String {
let line = clean(error.lines().next().unwrap_or_default());
let line = line.split(" (HTTP").next().unwrap_or_default().to_owned();
if line.chars().count() > 40 { line.chars().take(39).collect::<String>() + "…" } else { line }
}
thread_local! {
/// Said once after a launch: an older bundle named after the binary.
static OLDER_BUNDLE: std::cell::Cell<Option<String>> = const { std::cell::Cell::new(None) };
}
/// Bundles are now named after the title ("Makepad Scope.app"). An older
/// "Scope.app" is never deleted; it is mentioned once per folder.
#[cfg(target_os = "macos")]
fn note_older_bundle(root: &Path, release: &Release) {
let Some(old) = crate::desktop::older_bundle(root, release) else { return };
let noted = root.join("installed").join(format!("{}.older-bundle-noted", release.binary));
if noted.exists() {
return;
}
let _ = fs::write(&noted, "");
let name = old.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
activity(&format!("{name} from an earlier build is left in place; the app is now {}.app.", crate::desktop::bundle_name(release)));
OLDER_BUNDLE.with(|n| n.set(Some(format!("The older {name} is still here; you can remove it."))));
}
/// The success line after launching an app.
fn launched(title: &str) {
if let Some(note) = OLDER_BUNDLE.with(|n| n.take()) {
view::message(vec![view::Span("✓".into(), OK), view::Span(format!(" {title} is running. "), PLAIN), view::Span(note, WARN)]);
return;
}
let hint = if cfg!(target_os = "macos") {
"Right-click its Dock icon › Options › Keep in Dock."
} else if cfg!(windows) {
"Right-click its taskbar icon › Pin to taskbar."
} else {
"Run it again from here or from its command."
};
view::message(vec![
view::Span("✓".into(), OK),
view::Span(format!(" {title} is running. "), PLAIN),
view::Span(hint.into(), DIM),
]);
}
fn short_path(path: &Path) -> String {
let home = env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from);
match home.and_then(|home| path.strip_prefix(&home).ok().map(Path::to_path_buf)) {
Some(rest) if !cfg!(windows) => format!("~/{}", rest.display()),
_ => path.display().to_string(),
}
}
fn wrap(value: &str, width: usize) -> Vec<String> {
let mut lines = vec![String::new()];
for word in value.split_whitespace() {
let last = lines.last_mut().unwrap();
if !last.is_empty() && last.chars().count() + 1 + word.chars().count() > width {
lines.push(word.to_owned());
} else {
if !last.is_empty() { last.push(' '); }
last.push_str(word);
}
}
lines
}
fn clean(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
fn line(prompt: &str) -> Result<String, String> {
print!("{prompt}");
io::stdout().flush().map_err(|e| e.to_string())?;
let mut value = String::new();
if io::stdin()
.read_line(&mut value)
.map_err(|e| e.to_string())?
== 0
{
return Err("Input closed".into());
}
Ok(value.trim().to_owned())
}
// ---- Launched apps ---------------------------------------------------------
struct RunningApp {
title: String,
child: std::process::Child,
log: PathBuf,
}
thread_local! {
static RUNNING_APPS: std::cell::RefCell<Vec<RunningApp>> = const { std::cell::RefCell::new(Vec::new()) };
}
// Menu polling owns child bookkeeping; no blocking waiter or per-app thread.
fn reap_apps() -> bool {
RUNNING_APPS.with(|apps| {
let mut changed = false;
apps.borrow_mut().retain_mut(|app| match app.child.try_wait() {
Ok(None) => true,
Ok(Some(status)) => {
if status.success() {
activity(&format!("{} closed", app.title));
} else {
view::warn(&format!("{} exited {status}; see {}", app.title, app.log.display()));
}
changed = true;
false
}
Err(error) => {
view::warn(&format!("{}: {error}", app.title));
changed = true;
false
}
});
changed
})
}
fn detach_application(command: &mut Command) {
#[cfg(windows)] {
use std::os::windows::process::CommandExt;
// DETACHED_PROCESS prevents an accidental console window; the new
// process group keeps Ctrl+C and console teardown scoped to Builder.
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
command.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
// Create a new session before exec. This removes the controlling
// terminal as well as its process group, so the app is independent of
// the shell hosting Builder on both macOS and Linux.
unsafe extern "C" {
fn setsid() -> i32;
}
command.pre_exec(|| {
if setsid() == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
#[cfg(not(any(windows, unix)))] {
let _ = command;
}
}
fn load_release(path: &Path) -> Option<Release> {
Release::parse(&makepad_strict_json::parse(&fs::read(path).ok()?).ok()?).ok()
}
// ---- Platform helpers ------------------------------------------------------
/// Open a link in the default browser without a console window.
fn open_url(url: &str) -> Result<(), String> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
#[link(name = "shell32")]
unsafe extern "system" {
fn ShellExecuteW(window: *mut std::ffi::c_void, operation: *const u16, file: *const u16, parameters: *const u16, directory: *const u16, show: i32) -> isize;
}
let wide = |s: &str| std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect::<Vec<u16>>();
let (operation, file) = (wide("open"), wide(url));
let result = unsafe { ShellExecuteW(std::ptr::null_mut(), operation.as_ptr(), file.as_ptr(), std::ptr::null(), std::ptr::null(), 1) };
if result > 32 { Ok(()) } else { Err(format!("Could not open {url} (error {result})")) }
}
#[cfg(not(windows))]
{
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
Command::new(opener)
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.map_err(|e| format!("Could not open {url}: {e}"))
}
}
/// An executable of this name is on PATH (with a PATHEXT extension on Windows).
fn on_path(name: &str) -> bool {
let Some(path) = env::var_os("PATH") else { return false };
let extensions: Vec<String> = if cfg!(windows) {
env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into()).split(';').filter(|e| !e.is_empty()).map(str::to_lowercase).collect()
} else {
vec![String::new()]
};
env::split_paths(&path).any(|directory| extensions.iter().any(|extension| directory.join(format!("{name}{extension}")).is_file()))
}
/// The local time as "HH:MM".
fn clock() -> String {
let (_, _, _, hour, minute) = local_time();
format!("{hour:02}:{minute:02}")
}
/// Local (year, month, day, hour, minute).
fn local_time() -> (i32, u32, u32, u32, u32) {
#[cfg(windows)]
{
#[link(name = "kernel32")]
unsafe extern "system" { fn GetLocalTime(time: *mut [u16; 8]); }
let mut t = [0u16; 8];
unsafe { GetLocalTime(&mut t) };
(t[0] as i32, t[1] as u32, t[3] as u32, t[4] as u32, t[5] as u32)
}
#[cfg(not(windows))]
{
unsafe extern "C" {
fn time(out: *mut i64) -> i64;
fn localtime_r(time: *const i64, out: *mut [i64; 8]) -> *mut [i64; 8];
}
// struct tm starts with int sec, min, hour, mday, mon, year.
let mut tm = [0i64; 8];
let now = unsafe { time(std::ptr::null_mut()) };
if unsafe { localtime_r(&now, &mut tm) }.is_null() {
return (1970, 1, 1, 0, 0);
}
let f = unsafe { std::slice::from_raw_parts(tm.as_ptr() as *const i32, 6) };
(f[5] + 1900, f[4] as u32 + 1, f[3] as u32, f[2] as u32, f[1] as u32)
}
}
fn tree_size(path: &Path) -> u64 {
match fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() => fs::read_dir(path).map(|entries| entries.flatten().map(|e| tree_size(&e.path())).sum()).unwrap_or(0),
Ok(meta) => meta.len(),
Err(_) => 0,
}
}
/// (everything this folder uses, the build data "clear build data" deletes: target/ only).
fn measure(root: &Path) -> (u64, u64) {
(tree_size(root), tree_size(&root.join("target")))
}
fn gb(bytes: u64) -> String {
format!("{:.1}", bytes as f64 / 1073741824.)
}
/// Poll until Apple's installer has finished; Escape stops waiting. Each
/// probe runs the tools, so they are checked every few seconds.
fn wait_for_apple_installer() -> Result<(), String> {
let _input = console::Input::enter()?;
let mut polls = 0;
loop {
view::busy("Waiting for Apple's installer to finish · esc stops waiting");
if matches!(console::key()?, Key::Back | Key::Quit) {
return Ok(());
}
polls += 1;
if polls % 15 == 0 && runtime::system_tools_ready().is_ok() {
return Ok(());
}
}
}
/// macOS: Apple's compiler refuses to run until Xcode's license is accepted,
/// and says so. Only reads; never accepts.
fn xcode_license_pending() -> bool {
if !cfg!(target_os = "macos") {
return false;
}
Command::new("/usr/bin/xcrun")
.args(["--sdk", "macosx", "clang", "--version"])
.stdin(Stdio::null())
.output()
.is_ok_and(|output| !output.status.success() && String::from_utf8_lossy(&output.stderr).to_lowercase().contains("license"))
}
// ---- Terminal input --------------------------------------------------------
enum Key {
Left,
Right,
Up,
Down,
Enter,
PageUp,
PageDown,
Home,
End,
#[cfg(not(windows))]
WheelUp,
#[cfg(not(windows))]
WheelDown,
/// Escape: back out of a screen or question.
Back,
Backspace,
/// Ctrl-C or closed input.
Quit,
Char(char),
/// No key within the poll interval: redraw, reap apps.
Other,
}
/// A GUI-subsystem executable also serves as its ConPTY setup child.
pub fn attach_parent_console() {
#[cfg(windows)] unsafe {
#[link(name = "kernel32")]
unsafe extern "system" { fn AttachConsole(pid: u32) -> i32; }
let _ = AttachConsole(u32::MAX);
}
}
#[cfg(windows)]
mod console {
use super::*;
use std::ffi::c_void;
#[repr(C)]
#[derive(Default)]
struct Coord {
x: i16,
y: i16,
}
#[repr(C)]
#[derive(Default)]
struct Rect {
left: i16,
top: i16,
right: i16,
bottom: i16,
}
#[repr(C)]
#[derive(Default)]
struct Info {
size: Coord,
cursor: Coord,
attrs: u16,
window: Rect,
maximum: Coord,
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetStdHandle(kind: u32) -> *mut c_void;
fn GetConsoleMode(handle: *mut c_void, mode: *mut u32) -> i32;
fn SetConsoleMode(handle: *mut c_void, mode: u32) -> i32;
fn GetConsoleScreenBufferInfo(handle: *mut c_void, info: *mut Info) -> i32;
fn SetConsoleOutputCP(code_page: u32) -> i32;
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut c_void;
fn WaitForSingleObject(handle: *mut c_void, timeout: u32) -> u32;
fn CloseHandle(handle: *mut c_void) -> i32;
}
unsafe extern "C" {
fn _getwch() -> u16;
fn _kbhit() -> i32;
}
pub fn enable() {
unsafe {
let h = GetStdHandle(-11i32 as u32);
let mut mode = 0;
if GetConsoleMode(h, &mut mode) != 0 {
SetConsoleMode(h, mode | 4);
}
SetConsoleOutputCP(65001);
}
}
pub fn size() -> (usize, usize) {
unsafe {
let mut info = Info::default();
if GetConsoleScreenBufferInfo(GetStdHandle(-11i32 as u32), &mut info) != 0 {
(
(info.window.right - info.window.left + 1).max(1) as usize,
(info.window.bottom - info.window.top + 1).max(1) as usize,
)
} else {
(100, 32)
}
}
}
pub fn alive(pid: u32) -> bool {
unsafe {
let h = OpenProcess(0x00100000, 0, pid);
if h.is_null() {
return false;
}
let active = WaitForSingleObject(h, 0) == 258;
CloseHandle(h);
active
}
}
pub struct Input;
impl Input {
pub fn enter() -> Result<Self, String> {
Ok(Self)
}
}
pub fn key() -> Result<Key, String> {
// Returning periodically lets the caller redraw after a ConPTY resize.
for _ in 0..10 {
if unsafe { _kbhit() } != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
if unsafe { _kbhit() } == 0 {
return Ok(Key::Other);
}
let value = unsafe { _getwch() };
Ok(match value {
0 | 224 => match unsafe { _getwch() } {
72 => Key::Up,
80 => Key::Down,
75 => Key::Left,
77 => Key::Right,
73 => Key::PageUp,
81 => Key::PageDown,
71 => Key::Home,
79 => Key::End,
68 => Key::Quit,
_ => Key::Other,
},
13 => Key::Enter,
9 => Key::Right,
8 => Key::Backspace,
27 => Key::Back,
3 => Key::Quit,
_ => char::from_u32(value as u32)
.map(Key::Char)
.unwrap_or(Key::Other),
})
}
}
#[cfg(not(windows))]
mod console {
use super::*;
pub fn enable() {}
#[repr(C)]
#[derive(Default)]
struct Size {
rows: u16,
cols: u16,
x: u16,
y: u16,
}
#[repr(C)]
struct PollFd {
fd: i32,
events: i16,
revents: i16,
}
#[cfg(target_os = "macos")]
type NFds = u32;
#[cfg(not(target_os = "macos"))]
type NFds = std::ffi::c_ulong;
unsafe extern "C" {
fn ioctl(fd: i32, request: usize, ...) -> i32;
fn kill(pid: i32, signal: i32) -> i32;
fn poll(fds: *mut PollFd, count: NFds, timeout: i32) -> i32;
fn read(fd: i32, buffer: *mut u8, count: usize) -> isize;
}
pub fn size() -> (usize, usize) {
let mut size = Size::default();
let request = if cfg!(target_os = "macos") {
0x40087468
} else {
0x5413
};
if unsafe { ioctl(0, request, &mut size) } == 0 && size.cols > 0 && size.rows > 0 {
(size.cols as usize, size.rows as usize)
} else {
(100, 32)
}
}
pub fn alive(pid: u32) -> bool {
unsafe { kill(pid as i32, 0) == 0 }
}
pub struct Input(String);
impl Input {
pub fn enter() -> Result<Self, String> {
let saved = Command::new("stty")
.arg("-g")
.stdin(std::process::Stdio::inherit())
.output()
.map_err(|e| e.to_string())?;
let saved = String::from_utf8_lossy(&saved.stdout).trim().to_owned();
if !saved.is_empty()
&& Command::new("stty")
.args(["-icanon", "-echo", "-isig", "min", "1", "time", "0"])
.status()
.map_err(|e| e.to_string())?
.success()
{
// Report mouse input only while a menu is consuming it with
// echo disabled, never during builds or while an app runs.
print!("\x1b[?1000h\x1b[?1006h");
let _ = io::stdout().flush();
Ok(Self(saved))
} else {
Err("Cannot read terminal keys".into())
}
}
}
impl Drop for Input {
fn drop(&mut self) {
print!("\x1b[?1000l\x1b[?1006l");
let _ = io::stdout().flush();
let _ = Command::new("stty").arg(&self.0).status();
}
}
/// Input is waiting on the terminal within `ms` milliseconds.
fn waiting(ms: i32) -> bool {
let mut fd = PollFd { fd: 0, events: 1, revents: 0 };
unsafe { poll(&mut fd, 1, ms) > 0 }
}
fn byte() -> Option<u8> {
let mut value = 0u8;
(unsafe { read(0, &mut value, 1) } == 1).then_some(value)
}
pub fn key() -> Result<Key, String> {
// Poll, so a quiet terminal returns Other for redraws and child
// reaping; a readable terminal that yields nothing is closed input.
if !waiting(200) {
return Ok(Key::Other);
}
let Some(first) = byte() else {
// Closed input is never an answer: leave instead of spinning or
// accepting a default.
return Ok(Key::Quit);
};
Ok(match first {
b'\r' | b'\n' => Key::Enter,
9 => Key::Right,
3 => Key::Quit,
8 | 127 => Key::Backspace,
27 => {
let mut sequence = Vec::new();
while sequence.len() < 32 && waiting(30) {
let Some(value) = byte() else { break };
sequence.push(value);
let introducer = sequence.len() == 1 && (value == b'[' || value == b'O');
if !introducer && (value.is_ascii_alphabetic() || value == b'~') {
break;
}
}
match sequence.as_slice() {
b"" => Key::Back,
b"[A" | b"OA" => Key::Up,
b"[B" | b"OB" => Key::Down,
b"[C" | b"OC" => Key::Right,
b"[D" | b"OD" => Key::Left,
b"[5~" => Key::PageUp,
b"[6~" => Key::PageDown,
b"[H" | b"OH" | b"[1~" | b"[7~" => Key::Home,
b"[F" | b"OF" | b"[4~" | b"[8~" => Key::End,
s if s.starts_with(b"[<64;") && s.ends_with(b"M") => Key::WheelUp,
s if s.starts_with(b"[<65;") && s.ends_with(b"M") => Key::WheelDown,
b"[21~" => Key::Quit,
_ => Key::Other,
}
}
c if c.is_ascii() => Key::Char(c as char),
_ => Key::Other,
})
}
}